collective

Browse / github.com/storytold/designcraft / patch

Stroke types: InDesign's Triple, canned dashes, slant hashes, White Diamond and Japanese Dots

Stroke types: InDesign's Triple, canned dashes, slant hashes, White Diamond and Japanese Dots Found while replicating the *InDesign Full Course Tutorial* (Learn Skills Daily), chapter 42, "Custom Strokes". The lesson walks through the Stroke panel's Type list on a picture frame: - Straight Hash; - Dotted, then Japanese Dots, aligned inside and outside; - Japanese Dots with a pink gap colour; - a dashed arrow with a green gap. InDesign's Type list has 18 built-in types. DesignCraft had 11. It was missing **Triple, Dashed (3 and 2), Dashed (4 and 4), Left Slant Hash, Right Slant Hash, White Diamond and Japanese Dots**. IDML import read them as the nearest type it had: Japanese Dot

Status
open
Score
0.00
Adopters
0 other operators
Author
moppeeer (imported from pull request #1610, open)
Base
4be45a713af087057273b5dccee35c444fcd23bb
Size
+498 −80 in 28 files
Review
pending
Shared
2026-10-28
Id
e149845b292f02d8a11971d6

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/compose/src/parabox.rs crates/doc/src/item.rs crates/engine/src/cmd/object.rs crates/engine/src/cmd/table.rs crates/geom/src/stroke_style.rs crates/idml/src/names.rs crates/idml/src/tests.rs crates/pdf/src/export.rs crates/pdf/src/text.rs crates/pptx/src/slide.rs crates/pptx/src/table.rs crates/render/src/lib.rs crates/render/src/text.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/i18n/ar.rs crates/ui-egui/src/i18n/cs.rs crates/ui-egui/src/i18n/de.rs crates/ui-egui/src/i18n/it.rs crates/ui-egui/src/i18n/ja.rs crates/ui-egui/src/i18n/ka.rs crates/ui-egui/src/i18n/pl.rs crates/ui-egui/src/i18n/pt_br.rs crates/ui-egui/src/i18n/ru.rs crates/ui-egui/src/i18n/uk.rs crates/ui-egui/src/panels/cell_stroke.rs crates/ui-egui/src/panels/properties.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -359,6 +359,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first.  - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.+- **2026-10-22** · Stroke types as InDesign's: Triple, Dashed (3 and 2), Dashed (4 and 4), Left and Right Slant Hash, White Diamond and Japanese Dots join the Type lists (Stroke panel, cell strokes, paragraph borders), which follow InDesign's order; they draw on screen, in PDF and in paragraph borders, keep InDesign's ids in IDML and map to PowerPoint. Dotted strokes draw on screen again (a zero-length dash wasn't drawn) with InDesign's spacing, and round dots aligned inside or outside a frame stay whole. Found in the Learn Skills Daily course, chapter 42. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.diff --git a/crates/compose/src/parabox.rs b/crates/compose/src/parabox.rs--- a/crates/compose/src/parabox.rs+++ b/crates/compose/src/parabox.rs@@ -353,11 +353,12 @@ fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Joi             let p: Vec<f64> = pattern.iter().copied().filter(|v| v.is_finite() && *v >= 0.0).collect();             Some(if p.iter().sum::<f64>() > 1e-3 { p } else { vec![3.0 * w, 2.0 * w] })         }-        StrokeType::Dotted => {-            st = st.with_caps(kurbo::Cap::Round);-            Some(vec![0.0, 2.0 * w])-        }-        _ => None,+        k => k.dashes(w).map(|(p, dots)| {+            if dots {+                st = st.clone().with_caps(kurbo::Cap::Round);+            }+            p+        }),     };     if let Some(d) = dashes {         let period: f64 = d.iter().sum();diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs--- a/crates/doc/src/item.rs+++ b/crates/doc/src/item.rs@@ -146,6 +146,21 @@ pub enum StrokeType {     ThickThinThick,     Wavy,     Hashed,+    /// Three lines of the same width.+    Triple,+    /// Dashes three weights long, gaps two weights (InDesign's Dashed (3 and 2)).+    #[serde(rename = "dashed3x2")]+    Dashed3x2,+    /// Dashes and gaps four weights long (Dashed (4 and 4)).+    #[serde(rename = "dashed4x4")]+    Dashed4x4,+    /// Hash ticks leaning back and forward.+    LeftSlantHash,+    RightSlantHash,+    /// A chain of hollow diamonds, tip to tip.+    WhiteDiamond,+    /// Dots closer together than Dotted's.+    JapaneseDots,     /// A custom striped stroke: bands (start, width) as fractions of the weight from the left edge.     Stripes {         bands: Vec<(f64, f64)>,@@ -170,11 +185,57 @@ impl StrokeType {             StrokeType::ThickThinThick => "Thick - Thin - Thick",             StrokeType::Wavy => "Wavy",             StrokeType::Hashed => "Straight Hash",+            StrokeType::Triple => "Triple",+            StrokeType::Dashed3x2 => "Dashed (3 and 2)",+            StrokeType::Dashed4x4 => "Dashed (4 and 4)",+            StrokeType::LeftSlantHash => "Left Slant Hash",+            StrokeType::RightSlantHash => "Right Slant Hash",+            StrokeType::WhiteDiamond => "White Diamond",+            StrokeType::JapaneseDots => "Japanese Dots",             StrokeType::Stripes { .. } => "Stripes",             StrokeType::Style { .. } => "Custom",         }     } +    /// The built-in types (serialized `kind`s) in the order of InDesign's Type menu, plain Dashed+    /// last, after its separator.+    pub const BUILT_IN: [&'static str; 18] = [+        "solid",+        "thickThick",+        "thickThin",+        "thickThinThick",+        "thinThick",+        "thinThickThin",+        "thinThin",+        "triple",+        "dashed3x2",+        "dashed4x4",+        "leftSlantHash",+        "rightSlantHash",+        "hashed",+        "dotted",+        "wavy",+        "whiteDiamond",+        "japaneseDots",+        "dashed",+    ];++    /// The dash pattern of the dashed and dotted types for a stroke of `weight` (points), and+    /// whether its dashes are round dots (very short dashes with round caps: a zero-length dash+    /// isn't drawn by every stroker). Dotted spaces its dots three weights apart and Japanese Dots+    /// two, as InDesign draws them.+    pub fn dashes(&self, weight: f64) -> Option<(Vec<f64>, bool)> {+        let dot = |spacing: f64| (vec![weight * 1e-3, weight * (spacing - 1e-3)], true);+        Some(match self {+            StrokeType::Dashed { pattern } => (pattern.clone(), false),+            StrokeType::Dashed3x2 => (vec![3.0 * weight, 2.0 * weight], false),+            StrokeType::Dashed4x4 => (vec![4.0 * weight, 4.0 * weight], false),+            StrokeType::Dotted => dot(3.0),+            StrokeType::JapaneseDots => dot(2.0),+            _ => return None,+        })+    }+     /// 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 {@@ -197,6 +258,12 @@ impl StrokeType {             && length / period <= MAX_DASH_PERIODS     } +    /// Whether this type draws round dots along a path at most `length` points long: a dotted+    /// type whose pattern expands there (otherwise it draws solid, see [`Self::expandable_dashes`]).+    pub fn draws_dots(&self, weight: f64, length: f64) -> bool {+        self.dashes(weight).is_some_and(|(pattern, dots)| dots && Self::expandable_dashes(&pattern, length))+    }+     /// The bands of a striped type (start, width as fractions of the weight).     pub fn bands(&self) -> Option<Vec<(f64, f64)>> {         Some(match self {@@ -206,6 +273,7 @@ impl StrokeType {             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::Triple => vec![(0.0, 0.2), (0.4, 0.2), (0.8, 0.2)],             StrokeType::Stripes { bands } => bands.clone(),             _ => return None,         })@@ -218,6 +286,9 @@ impl StrokeType {         match self {             StrokeType::Wavy => Some(ss::wavy(bp, weight, tol)),             StrokeType::Hashed => Some(ss::hashed(bp, weight, tol)),+            StrokeType::LeftSlantHash => Some(ss::slant_hash(bp, weight, -1.0, tol)),+            StrokeType::RightSlantHash => Some(ss::slant_hash(bp, weight, 1.0, tol)),+            StrokeType::WhiteDiamond => Some(ss::diamonds(bp, weight, tol)),             t => t.bands().map(|b| ss::stripes(bp, weight, &b, tol)),         }     }@@ -1661,6 +1732,43 @@ mod tests {     use super::*;     use designcraft_geom::shapes; +    /// Dots are dots only where their pattern expands: on a path too long for it the stroke is solid.+    #[test]+    fn dots_draw_only_where_their_pattern_expands() {+        assert!(StrokeType::Dotted.draws_dots(1.0, 500.0));+        assert!(!StrokeType::Dotted.draws_dots(1.0, 1e9), "solid fallback");+        assert!(!StrokeType::Dashed3x2.draws_dots(1.0, 500.0), "dashes aren't dots");+        assert!(!StrokeType::Solid.draws_dots(1.0, 500.0));+    }++    /// The Type menu's built-in types: each once, each a type of its own with a label, and the+    /// dashed and dotted ones with their patterns.+    #[test]+    fn built_in_stroke_types_parse_and_dash_as_indesigns() {+        let mut seen = std::collections::HashSet::new();+        for k in StrokeType::BUILT_IN {+            assert!(seen.insert(k), "{k} twice");+            let t: StrokeType = serde_json::from_value(serde_json::json!({"kind": k, "pattern": []}))+                .or_else(|_| serde_json::from_value(serde_json::json!({"kind": k})))+                .unwrap_or_else(|e| panic!("{k}: {e}"));+            assert_eq!(serde_json::to_value(&t).unwrap()["kind"], k);+            assert!(!t.label().is_empty());+        }+        assert_eq!(StrokeType::Dashed3x2.dashes(2.0), Some((vec![6.0, 4.0], false)));+        assert_eq!(StrokeType::Dashed4x4.dashes(2.0), Some((vec![8.0, 8.0], false)));+        let period = |t: StrokeType| t.dashes(2.0).map(|(p, dots)| (p.iter().sum::<f64>(), dots));+        assert_eq!(period(StrokeType::Dotted), Some((6.0, true)));+        assert_eq!(period(StrokeType::JapaneseDots), Some((4.0, true)));+        assert_eq!(StrokeType::Triple.bands().map(|b| b.len()), Some(3));+        let line = designcraft_geom::BezPath::from_vec(vec![+            designcraft_geom::PathEl::MoveTo((0.0, 0.0).into()),+            designcraft_geom::PathEl::LineTo((100.0, 0.0).into()),+        ]);+        for t in [StrokeType::LeftSlantHash, StrokeType::RightSlantHash, StrokeType::WhiteDiamond, StrokeType::Triple] {+            assert!(t.outline(&line, 8.0, 0.05).is_some(), "{t:?} draws as a fill");+        }+    }+     #[test]     fn graphics_without_a_clipping_path_load_and_save_as_before() {         let g = Graphic {diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs--- a/crates/engine/src/cmd/object.rs+++ b/crates/engine/src/cmd/object.rs@@ -259,7 +259,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Stroke",             [],             None,-            "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed|stripes|style, pattern?: [dash, gap, …] (dashed: required, points), bands?: [[start, width], …] (stripes: required, fractions of stroke weight), name?: string (style: required, document stroke style name)} (other kinds take no extra fields; unknown type fields are errors), cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",+            "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed|triple|dashed3x2|dashed4x4|leftSlantHash|rightSlantHash|whiteDiamond|japaneseDots|stripes|style, pattern?: [dash, gap, …] (dashed: required, points), bands?: [[start, width], …] (stripes: required, fractions of stroke weight), name?: string (style: required, document stroke style name)} (other kinds take no extra fields; unknown type fields are errors), cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",             has_doc,             |s, p| {                 let kind: Option<designcraft_doc::StrokeType> = pdiff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs--- a/crates/engine/src/cmd/table.rs+++ b/crates/engine/src/cmd/table.rs@@ -859,9 +859,8 @@ fn swatch_in(doc: &Document, v: &Value, keys: &[&str], cmd: &str) -> Result<Opti     Ok(Some(name.to_string())) } -/// The cell stroke type names of `type`.-const CELL_STROKE_TYPES: &[&str] =-    &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"];+/// The cell stroke type names of `type`: every built-in type.+const CELL_STROKE_TYPES: &[&str] = &StrokeType::BUILT_IN;  /// The attributes a stroke edit sets. A plain `"dashed"` gets a pattern scaled to the /// resulting weight of each edge.diff --git a/crates/geom/src/stroke_style.rs b/crates/geom/src/stroke_style.rs--- a/crates/geom/src/stroke_style.rs+++ b/crates/geom/src/stroke_style.rs@@ -33,6 +33,67 @@ pub fn offset_path(bp: &BezPath, d: f64, tol: f64) -> BezPath {     out } +/// A closed path moved `d` outwards (negative: inwards), whichever way it runs: the stroke line+/// of round dots aligned outside or inside the path, which stay whole rather than being clipped.+/// Each contour moves on its own, away from the shape's filled area (a hole's contour into the+/// hole), and stays a separate closed contour.+pub fn offset_outward(bp: &BezPath, d: f64, tol: f64) -> BezPath {+    use kurbo::Shape;+    let mut out = BezPath::new();+    for contour in contours(bp) {+        // Outside is the side away from the filled area: test a point just left of the+        // contour's travel, at the first place along it where it has a direction (a cubic can+        // stand still at one point, so other points and segments are tried).+        let Some((at, dir)) =+            contour.segments().flat_map(|seg| [0.5, 0.25, 0.75, 0.1, 0.9].map(|t| (seg.eval(t), seg.deriv_at(t)))).find(|(_, d)| d.hypot() > 1e-9)+        else {+            continue;+        };+        let left = Vec2::new(dir.y, -dir.x) / dir.hypot();+        let r = contour.bounding_box();+        let eps = (r.width().min(r.height()) * 1e-3).clamp(1e-6, 0.01);+        let left_inside = bp.winding(at + left * eps) != 0;+        let mut moved = offset_path(&contour, if left_inside { -d } else { d }, tol);+        // Closed, so the dots run round it with no seam or end caps.+        if contour.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)) {+            moved.close_path();+        }+        for el in moved.elements() {+            out.push(*el);+        }+    }+    out+}++/// The contours (sub-paths) of `bp`, each starting at its MoveTo.+fn contours(bp: &BezPath) -> Vec<BezPath> {+    let mut out: Vec<BezPath> = Vec::new();+    for el in bp.elements() {+        if matches!(el, kurbo::PathEl::MoveTo(_)) || out.is_empty() {+            out.push(BezPath::new());+        }+        if let Some(c) = out.last_mut() {+            c.push(*el);+        }+    }+    out+}++/// The derivative of a segment at `t`.+trait DerivAt {+    fn deriv_at(&self, t: f64) -> Vec2;+}++impl DerivAt for PathSeg {+    fn deriv_at(&self, t: f64) -> Vec2 {+        match self {+            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(),+        }+    }+}+ /// Bands (start, width) across a stroke, as fractions of the weight from its left edge. pub type Bands = [(f64, f64)]; @@ -109,6 +170,44 @@ pub fn hashed(bp: &BezPath, weight: f64, tol: f64) -> BezPath {     kurbo::stroke(ticks.iter(), &s, &kurbo::StrokeOpts::default(), tol) } +/// A slanted hash: ticks across the path at 45°, leaning back (`lean` −1, Left Slant Hash) or+/// forward (+1, Right Slant Hash), within the weight.+pub fn slant_hash(bp: &BezPath, weight: f64, lean: f64, tol: f64) -> BezPath {+    let mut ticks = BezPath::new();+    let h = weight / 2.0;+    for (p, t) in samples(bp, (weight * 0.6).max(1.0)) {+        let normal = Vec2::new(-t.y, t.x) * h;+        // The tick's top (left of travel) leans back or forward along the path.+        let along = t * (h * lean);+        ticks.move_to(p - normal + along);+        ticks.line_to(p + normal - along);+    }+    let s = Stroke::new((weight * 0.14).max(0.25)).with_caps(kurbo::Cap::Butt);+    kurbo::stroke(ticks.iter(), &s, &kurbo::StrokeOpts::default(), tol)+}++/// White Diamond: hollow diamonds, as tall as the weight and a third longer, tip to tip.+pub fn diamonds(bp: &BezPath, weight: f64, tol: f64) -> BezPath {+    let long = (weight * 1.3).max(1.0);+    let mut out = BezPath::new();+    let h = weight / 2.0;+    for (p, t) in samples(bp, long) {+        let normal = Vec2::new(-t.y, t.x) * h;+        let along = t * (long / 2.0);+        let mut d = BezPath::new();+        d.move_to(p - along);+        d.line_to(p - normal);+        d.line_to(p + along);+        d.line_to(p + normal);+        d.close_path();+        let s = Stroke::new((weight * 0.08).max(0.25)).with_join(kurbo::Join::Miter);+        for el in kurbo::stroke(d.iter(), &s, &kurbo::StrokeOpts::default(), tol).elements() {+            out.push(*el);+        }+    }+    out+}+ #[cfg(test)] mod tests {     use super::*;@@ -141,4 +240,78 @@ mod tests {         let h = hashed(&line(), 8.0, 0.01).bounding_box();         assert!((h.height() - 8.0).abs() < 0.5 && h.width() > 90.0, "{h:?}");     }++    #[test]+    fn offset_outward_moves_a_closed_path_out_or_in_either_way_it_runs() {+        use kurbo::ParamCurveNearest;+        let square = |cw: bool| {+            let mut pts = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];+            if !cw {+                pts.reverse();+            }+            let mut b = BezPath::new();+            b.move_to(pts[0]);+            for p in &pts[1..] {+                b.line_to(*p);+            }+            b.close_path();+            b+        };+        // Whether the path passes within 0.5 pt of `p`.+        let passes = |b: &BezPath, p: (f64, f64)| b.segments().any(|s| s.nearest(Point::new(p.0, p.1), 1e-6).distance_sq < 0.25);+        // A closed shape whose first cubic stands still halfway (zero derivative at t = 0.5)+        // keeps its dots: another point gives the direction.+        let mut lens = BezPath::new();+        lens.move_to((0.0, 0.0));+        lens.curve_to((100.0, 0.0), (0.0, 0.0), (100.0, 0.0));+        lens.curve_to((100.0, 60.0), (0.0, 60.0), (0.0, 0.0));+        lens.close_path();+        assert!(!offset_outward(&lens, 2.0, 0.01).elements().is_empty(), "the contour is kept");++        // A square with a square hole (the hole running the other way, or the same way): each+        // contour moves away from the filled ring, the hole's into the hole, and they stay apart.+        for hole_cw in [false, true] {+            let mut ring = square(true);+            let mut hole = [(30.0, 30.0), (70.0, 30.0), (70.0, 70.0), (30.0, 70.0)];+            if !hole_cw {+                hole.reverse();+            }+            ring.move_to(hole[0]);+            for p in &hole[1..] {+                ring.line_to(*p);+            }+            ring.close_path();+            let out = offset_outward(&ring, 5.0, 0.01);+            let moves = out.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count();+            assert_eq!(moves, 2, "{hole_cw}: two contours, not joined");+            assert!(passes(&out, (50.0, -5.0)), "{hole_cw}: the outer contour outside");+            assert!(passes(&out, (50.0, 35.0)), "{hole_cw}: the hole's contour into the hole");+            let inside = offset_outward(&ring, -5.0, 0.01);+            assert!(passes(&inside, (50.0, 5.0)) && passes(&inside, (50.0, 25.0)), "{hole_cw}: both into the ring");+        }+        for cw in [true, false] {+            let closed = |b: &BezPath| matches!(b.elements().last(), Some(kurbo::PathEl::ClosePath));+            assert!(closed(&offset_outward(&square(cw), 5.0, 0.01)) && closed(&offset_outward(&square(cw), -5.0, 0.01)), "{cw}: closed");+            assert!(passes(&offset_outward(&square(cw), 5.0, 0.01), (50.0, -5.0)), "{cw}: outside, above the top edge");+            assert!(passes(&offset_outward(&square(cw), -5.0, 0.01), (50.0, 5.0)), "{cw}: inside, below the top edge");+        }+    }++    #[test]+    fn slant_hashes_lean_their_ways_and_diamonds_are_hollow() {+        // Along y = 0 going right, "left of travel" is −y: a left slant's tick runs from up-left+        // to down-right (\), a right slant's from up-right to down-left (/).+        let left = slant_hash(&line(), 8.0, -1.0, 0.01);+        let right = slant_hash(&line(), 8.0, 1.0, 0.01);+        assert!(left.contains(Point::new(48.0 - 3.0, -3.0)) || left.contains(Point::new(50.4 - 3.0, -3.0)), "\\ top left");+        let lb = left.bounding_box();+        assert!((lb.height() - 8.0).abs() < 1.0, "{lb:?}");+        assert_ne!(format!("{:?}", left.elements().first()), format!("{:?}", right.elements().first()));+        let d = diamonds(&line(), 10.0, 0.01);+        let b = d.bounding_box();+        assert!((b.height() - 10.0).abs() < 1.5 && b.width() > 90.0, "{b:?}");+        // A diamond's centre is empty; its edge is drawn.+        assert!(!d.contains(Point::new(13.0, 0.0)), "hollow");+        assert!(d.contains(Point::new(13.0 + 6.5 / 2.0, -2.5)), "the edge between the side tip and the top");+    } }diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs--- a/crates/idml/src/names.rs+++ b/crates/idml/src/names.rs@@ -347,6 +347,13 @@ pub fn stroke_type_out(t: &StrokeType) -> String {         StrokeType::ThickThinThick => "StrokeStyle/$ID/ThickThinThick",         StrokeType::Wavy => "StrokeStyle/$ID/Wavy",         StrokeType::Hashed => "StrokeStyle/$ID/Straight Hash",+        StrokeType::Triple => "StrokeStyle/$ID/Triple_Stroke",+        StrokeType::Dashed3x2 => "StrokeStyle/$ID/Canned Dashed 3x2",+        StrokeType::Dashed4x4 => "StrokeStyle/$ID/Canned Dashed 4x4",+        StrokeType::LeftSlantHash => "StrokeStyle/$ID/Left Slant Hash",+        StrokeType::RightSlantHash => "StrokeStyle/$ID/Right Slant Hash",+        StrokeType::WhiteDiamond => "StrokeStyle/$ID/White Diamond",+        StrokeType::JapaneseDots => "StrokeStyle/$ID/Japanese Dots",     };     s.to_string() }@@ -356,10 +363,17 @@ pub fn stroke_type_in(s: &str) -> StrokeType {             return StrokeType::Style { name: name.to_string() };         }     }-    let n = s.rsplit('/').next().unwrap_or(s).replace([' ', '-'], "").to_ascii_lowercase();+    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![] },-        "dotted" | "japanesedots" | "canneddotted" => StrokeType::Dotted,+        "dashed" => StrokeType::Dashed { pattern: vec![] },+        "dashed(3and2)" | "canneddashed3x2" => StrokeType::Dashed3x2,+        "dashed(4and4)" | "canneddashed4x4" => StrokeType::Dashed4x4,+        "dotted" | "canneddotted" => StrokeType::Dotted,+        "japanesedots" => StrokeType::JapaneseDots,+        "triple" | "triplestroke" => StrokeType::Triple,+        "leftslanthash" => StrokeType::LeftSlantHash,+        "rightslanthash" => StrokeType::RightSlantHash,+        "whitediamond" => StrokeType::WhiteDiamond,         "thickthin" => StrokeType::ThickThin,         "thinthick" => StrokeType::ThinThick,         "thinthin" => StrokeType::ThinThin,@@ -367,7 +381,7 @@ pub fn stroke_type_in(s: &str) -> StrokeType {         "thinthickthin" => StrokeType::ThinThickThin,         "thickthinthick" => StrokeType::ThickThinThick,         "wavy" => StrokeType::Wavy,-        "straighthash" | "leftslanthash" | "rightslanthash" => StrokeType::Hashed,+        "straighthash" => StrokeType::Hashed,         _ => StrokeType::Solid,     } }diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs--- a/crates/idml/src/tests.rs+++ b/crates/idml/src/tests.rs@@ -4439,6 +4439,40 @@ fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_     assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); } +/// Every built-in stroke type exports under InDesign's id and imports as itself.+#[test]+fn stroke_types_round_trip() {+    use StrokeType::*;+    use designcraft_doc::StrokeType;+    let all = [+        Solid,+        ThickThick,+        ThickThin,+        ThickThinThick,+        ThinThick,+        ThinThickThin,+        ThinThin,+        Triple,+        Dashed3x2,+        Dashed4x4,+        LeftSlantHash,+        RightSlantHash,+        Hashed,+        Dotted,+        Wavy,+        WhiteDiamond,+        JapaneseDots,+        Dashed { pattern: vec![] },+    ];+    assert_eq!(all.len(), StrokeType::BUILT_IN.len());+    for t in all {+        let id = names::stroke_type_out(&t);+        assert_eq!(names::stroke_type_in(&id), t, "{id}");+    }+    assert_eq!(names::stroke_type_in("StrokeStyle/$ID/Japanese Dots"), StrokeType::JapaneseDots);+    assert_eq!(names::stroke_type_in("StrokeStyle/$ID/Triple_Stroke"), StrokeType::Triple);+}+ /// A graphic placed in a table cell is written into the cell, not dropped. #[test] fn exports_graphic_in_table_cell() {diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs--- a/crates/pdf/src/export.rs+++ b/crates/pdf/src/export.rs@@ -1178,27 +1178,37 @@ impl Exporter<'_> {         }         // Viewers expand dashes too: a degenerate pattern draws solid, as on screen.         let length = designcraft_doc::path_length_bound(bp);-        let dash = match &kind {-            StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => {+        let dash = match kind.dashes(st.weight) {+            Some((pattern, dots)) if StrokeType::expandable_dashes(&pattern, length) => {                 let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();                 if pat.len() % 2 == 1 {                     pat.extend(pat.clone());                 }+                if dots {+                    cap = krilla::paint::LineCap::Round;+                }                 Some(StrokeDash { array: pat, offset: 0.0 })             }-            StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => {-                cap = krilla::paint::LineCap::Round;-                Some(StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 })-            }             _ => None,         };         let arrows = designcraft_doc::arrow::apply(bp, st, closed);         let trimmed = arrows.as_ref().and_then(|a| to_path(&a.0));         let path = trimmed.as_ref().unwrap_or(path);-        let aligned = closed && st.align != StrokeAlign::Center;+        // Round dots aligned inside or outside stay whole: along the path moved half the weight+        // that way, at the stroke's own width and unclipped, as on screen.+        let dots = kind.draws_dots(st.weight, length);+        let moved = (closed && dots && st.align != StrokeAlign::Center)+            .then(|| {+                let d = st.weight / 2.0 * if st.align == StrokeAlign::Outside { 1.0 } else { -1.0 };+                to_path(&designcraft_geom::stroke_style::offset_outward(bp, d, 0.05))+            })+            .flatten();+        let path = moved.as_ref().unwrap_or(path);+        let aligned = closed && st.align != StrokeAlign::Center && moved.is_none();         let width = if aligned { st.weight * 2.0 } else { st.weight };         let mut pushes = 0;         match st.align {+            _ if moved.is_some() => {}             StrokeAlign::Inside if closed => {                 s.push_clip_path(path, &FillRule::NonZero);                 pushes += 1;diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs--- a/crates/pdf/src/text.rs+++ b/crates/pdf/src/text.rs@@ -374,20 +374,14 @@ impl Exporter<'_> {                     s.set_fill(None);                     continue;                 }-                let (cap, dash) = match &kind {-                    designcraft_doc::StrokeType::Dashed { pattern }-                        if designcraft_doc::StrokeType::expandable_dashes(pattern, seg.a.distance(seg.b)) =>-                    {+                let (cap, dash) = match kind.dashes(st.weight) {+                    Some((pattern, dots)) if designcraft_doc::StrokeType::expandable_dashes(&pattern, seg.a.distance(seg.b)) => {                         let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();                         if pat.len() % 2 == 1 {                             pat.extend(pat.clone());                         }-                        (krilla::paint::LineCap::Butt, Some(krilla::paint::StrokeDash { array: pat, offset: 0.0 }))-                    }-                    designcraft_doc::StrokeType::Dotted-                        if designcraft_doc::StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], seg.a.distance(seg.b)) =>-                    {-                        (krilla::paint::LineCap::Round, Some(krilla::paint::StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 }))+                        let cap = if dots { krilla::paint::LineCap::Round } else { krilla::paint::LineCap::Butt };+                        (cap, Some(krilla::paint::StrokeDash { array: pat, offset: 0.0 }))                     }                     _ => (krilla::paint::LineCap::Square, None),                 };diff --git a/crates/pptx/src/slide.rs b/crates/pptx/src/slide.rs--- a/crates/pptx/src/slide.rs+++ b/crates/pptx/src/slide.rs@@ -460,11 +460,20 @@ impl<'a> Exporter<'a> {             StrokeType::ThickThin => "thickThin",             StrokeType::ThinThick => "thinThick",             StrokeType::ThinThin | StrokeType::ThickThick => "dbl",-            StrokeType::ThinThickThin | StrokeType::ThickThinThick => "tri",+            StrokeType::ThinThickThin | StrokeType::ThickThinThick | StrokeType::Triple => "tri",             _ => "sng",         };-        if matches!(s.kind, StrokeType::Wavy | StrokeType::Hashed | StrokeType::Stripes { .. } | StrokeType::Style { .. }) {-            self.warn("wavy, hashed, striped and custom stroke styles are exported as solid lines");+        if matches!(+            s.kind,+            StrokeType::Wavy+                | StrokeType::Hashed+                | StrokeType::LeftSlantHash+                | StrokeType::RightSlantHash+                | StrokeType::WhiteDiamond+                | StrokeType::Stripes { .. }+                | StrokeType::Style { .. }+        ) {+            self.warn("wavy, hashed, diamond, striped and custom stroke styles are exported as solid lines");         }         let algn = match s.align {             StrokeAlign::Inside => " algn=\"in\"",@@ -490,7 +499,9 @@ impl<'a> Exporter<'a> {                 d.push_str("</a:custDash>");                 d             }-            StrokeType::Dotted => "<a:prstDash val=\"sysDot\"/>".into(),+            StrokeType::Dotted | StrokeType::JapaneseDots => "<a:prstDash val=\"sysDot\"/>".into(),+            StrokeType::Dashed3x2 => "<a:prstDash val=\"dash\"/>".into(),+            StrokeType::Dashed4x4 => "<a:prstDash val=\"lgDash\"/>".into(),             _ => "<a:prstDash val=\"solid\"/>".into(),         };         let join = match s.join {diff --git a/crates/pptx/src/table.rs b/crates/pptx/src/table.rs--- a/crates/pptx/src/table.rs+++ b/crates/pptx/src/table.rs@@ -56,8 +56,9 @@ fn border(ex: &mut Exporter, tag: &str, s: Option<&StrokeSeg>, k: f64, opacity:     let st = &s.stroke;     let Some(c) = ex.color(&st.color, st.tint, opacity) else { return format!("<a:{tag} w=\"0\"><a:noFill/></a:{tag}>") };     let dash = match st.kind {-        StrokeType::Dashed { .. } => "dash",-        StrokeType::Dotted => "sysDot",+        StrokeType::Dashed { .. } | StrokeType::Dashed3x2 => "dash",+        StrokeType::Dashed4x4 => "lgDash",+        StrokeType::Dotted | StrokeType::JapaneseDots => "sysDot",         _ => "solid",     };     format!(diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -745,14 +745,13 @@ impl Renderer {             .with_miter_limit(st.miter_limit);         let kind = f.doc.stroke_kind(&st.kind);         let length = designcraft_doc::path_length_bound(bp);-        match &kind {-            StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => {-                stroke = stroke.with_dashes(0.0, pattern.iter().copied())-            }-            StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, st.weight * 2.0], length) => {-                stroke = stroke.with_dashes(0.0, [0.0, st.weight * 2.0]).with_caps(kurbo::Cap::Round)+        if let Some((pattern, dots)) = kind.dashes(st.weight)+            && StrokeType::expandable_dashes(&pattern, length)+        {+            stroke = stroke.with_dashes(0.0, pattern);+            if dots {+                stroke = stroke.with_caps(kurbo::Cap::Round);             }-            _ => {}         }         ctx.set_transform(f.view * xf);         if !set_fill_paint(ctx, f.doc, &paint, bounds) {@@ -764,8 +763,15 @@ impl Renderer {         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!(kind, StrokeType::Solid) { None } else { f.doc.resolve_color(&st.gap_swatch, st.gap_tint) };+        // Round dots aligned inside or outside run along the path moved half the weight that way+        // (they stay whole), and so does their gap colour.+        let dots = kind.draws_dots(st.weight, length);+        let moved = (closed && dots && st.align != StrokeAlign::Center).then(|| {+            let d = st.weight / 2.0 * if st.align == StrokeAlign::Outside { 1.0 } else { -1.0 };+            designcraft_geom::stroke_style::offset_outward(bp, d, 0.05 * f.px)+        });         if let Some(g) = gap-            && !matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside if closed)+            && (moved.is_some() || !matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside if closed))         {             let op = overprints(f, &st.gap_swatch, st.gap_tint, st.gap_overprint);             if op {@@ -780,7 +786,7 @@ impl Renderer {                 end_cap: kurbo::Cap::Butt,                 ..stroke.clone()             });-            ctx.stroke_path(bp);+            ctx.stroke_path(moved.as_ref().unwrap_or(bp));             if op {                 ctx.pop_layer();             }@@ -801,6 +807,11 @@ impl Renderer {             return;         }         match st.align {+            // Round dots aligned inside or outside stay whole, as InDesign draws them.+            _ if moved.is_some() => {+                ctx.set_stroke(stroke);+                ctx.stroke_path(moved.as_ref().unwrap_or(bp));+            }             StrokeAlign::Inside if closed => {                 // Clip a double-width stroke to the path.                 ctx.push_clip_layer(bp);diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs--- a/crates/render/src/text.rs+++ b/crates/render/src/text.rs@@ -602,12 +602,9 @@ fn paint_glyphs( pub(crate) fn cell_stroke(kind: &designcraft_doc::StrokeType, weight: f64, length: f64) -> kurbo::Stroke {     use designcraft_doc::StrokeType;     let st = kurbo::Stroke::new(weight).with_caps(kurbo::Cap::Square);-    match kind {-        StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, length) => {-            st.with_caps(kurbo::Cap::Butt).with_dashes(0.0, pattern.iter().copied())-        }-        StrokeType::Dotted if StrokeType::expandable_dashes(&[0.0, weight * 2.0], length) => {-            st.with_dashes(0.0, [0.0, weight * 2.0]).with_caps(kurbo::Cap::Round)+    match kind.dashes(weight) {+        Some((pattern, dots)) if StrokeType::expandable_dashes(&pattern, length) => {+            st.with_caps(if dots { kurbo::Cap::Round } else { kurbo::Cap::Butt }).with_dashes(0.0, pattern)         }         _ => st,     }diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -4311,8 +4311,7 @@ fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui }  /// Built-in stroke types of the paragraph border's Type menu (serialized `StrokeType`s).-const BORDER_TYPES: [&str; 11] =-    ["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"];+const BORDER_TYPES: [&str; 18] = designcraft_doc::StrokeType::BUILT_IN;  /// The border's stroke type: a built-in type or one of the document's stroke styles. fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc: &designcraft_doc::Document) {@@ -4324,6 +4323,9 @@ fn border_type_menu(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, d     };     egui::ComboBox::from_id_salt("psbordertype").selected_text(crate::rtl::widget(ui, shown)).width(160.0).show_ui(ui, |ui| {         for k in BORDER_TYPES {+            if k == "dashed" {+                ui.separator();+            }             let ty = if k == "dashed" { json!({"kind": k, "pattern": []}) } else { json!({"kind": k}) };             let Ok(t) = serde_json::from_value::<StrokeType>(ty.clone()) else { continue };             let on = std::mem::discriminant(&t) == std::mem::discriminant(&cur);diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- a/crates/ui-egui/src/i18n.rs+++ b/crates/ui-egui/src/i18n.rs@@ -1075,6 +1075,13 @@ const TABLE: &[(&str, [&str; 5])] = &[         ],     ),     ("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]),+    ("Triple", ["Dreifach", "Triple", "Triple", "三重線", "三线"]),+    ("Dashed (3 and 2)", ["Gestrichelt (3 und 2)", "Tirets (3 et 2)", "Discontinua (3 y 2)", "破線(3 と 2)", "虚线(3 和 2)"]),+    ("Dashed (4 and 4)", ["Gestrichelt (4 und 4)", "Tirets (4 et 4)", "Discontinua (4 y 4)", "破線(4 と 4)", "虚线(4 和 4)"]),+    ("Left Slant Hash", ["Schraffur links", "Hachures obliques à gauche", "Rayado inclinado a la izquierda", "左斜線", "左斜线"]),+    ("Right Slant Hash", ["Schraffur rechts", "Hachures obliques à droite", "Rayado inclinado a la derecha", "右斜線", "右斜线"]),+    ("White Diamond", ["Weiße Raute", "Losange blanc", "Rombo blanco", "白抜きダイヤ", "空心菱形"]),+    ("Japanese Dots", ["Japanische Punkte", "Points japonais", "Puntos japoneses", "和文点線", "日式点线"]),     ("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]),     ("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]),     ("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]),diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs--- a/crates/ui-egui/src/i18n/ar.rs+++ b/crates/ui-egui/src/i18n/ar.rs@@ -30,6 +30,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "قص"),     ("Copy", "نسخ"),     ("Paste", "لصق"),+    ("Triple", "ثلاثي"),+    ("Dashed (3 and 2)", "متقطع (3 و2)"),+    ("Dashed (4 and 4)", "متقطع (4 و4)"),+    ("Left Slant Hash", "تهشير مائل لليسار"),+    ("Right Slant Hash", "تهشير مائل لليمين"),+    ("White Diamond", "معين أبيض"),+    ("Japanese Dots", "نقاط يابانية"),     ("Restart Numbering", "إعادة الترقيم"),     ("Continue Numbering", "متابعة الترقيم"),     ("Keep Options…", "خيارات الإبقاء…"),diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs--- a/crates/ui-egui/src/i18n/cs.rs+++ b/crates/ui-egui/src/i18n/cs.rs@@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "Vyjmout"),     ("Copy", "Kopírovat"),     ("Paste", "Vložit"),+    ("Triple", "Trojitá"),+    ("Dashed (3 and 2)", "Přerušovaná (3 a 2)"),+    ("Dashed (4 and 4)", "Přerušovaná (4 a 4)"),+    ("Left Slant Hash", "Šrafování šikmo vlevo"),+    ("Right Slant Hash", "Šrafování šikmo vpravo"),+    ("White Diamond", "Bílý kosočtverec"),+    ("Japanese Dots", "Japonské tečky"),     ("Restart Numbering", "Začít číslování znovu"),     ("Continue Numbering", "Pokračovat v číslování"),     ("Keep Options…", "Volby zachování…"),diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs--- a/crates/ui-egui/src/i18n/de.rs+++ b/crates/ui-egui/src/i18n/de.rs@@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "Ausschneiden"),     ("Copy", "Kopieren"),     ("Paste", "Einfügen"),+    ("Triple", "Dreifach"),+    ("Dashed (3 and 2)", "Gestrichelt (3 und 2)"),+    ("Dashed (4 and 4)", "Gestrichelt (4 und 4)"),+    ("Left Slant Hash", "Schraffur links"),+    ("Right Slant Hash", "Schraffur rechts"),+    ("White Diamond", "Weiße Raute"),+    ("Japanese Dots", "Japanische Punkte"),     ("Restart Numbering", "Nummerierung neu beginnen"),     ("Continue Numbering", "Nummerierung fortführen"),     ("Keep Options…", "Umbruchoptionen …"),diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs--- a/crates/ui-egui/src/i18n/it.rs+++ b/crates/ui-egui/src/i18n/it.rs@@ -28,6 +28,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "Taglia"),     ("Copy", "Copia"),     ("Paste", "Incolla"),+    ("Triple", "Tripla"),+    ("Dashed (3 and 2)", "Tratteggio (3 e 2)"),+    ("Dashed (4 and 4)", "Tratteggio (4 e 4)"),+    ("Left Slant Hash", "Tratteggio obliquo a sinistra"),+    ("Right Slant Hash", "Tratteggio obliquo a destra"),+    ("White Diamond", "Rombo bianco"),+    ("Japanese Dots", "Punti giapponesi"),     ("Restart Numbering", "Riavvia numerazione"),     ("Continue Numbering", "Continua numerazione"),     ("Keep Options…", "Opzioni di separazione…"),diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs--- a/crates/ui-egui/src/i18n/ja.rs+++ b/crates/ui-egui/src/i18n/ja.rs@@ -30,6 +30,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "カット"),     ("Copy", "コピー"),     ("Paste", "ペースト"),+    ("Triple", "三重線"),+    ("Dashed (3 and 2)", "破線(3 と 2)"),+    ("Dashed (4 and 4)", "破線(4 と 4)"),+    ("Left Slant Hash", "左斜線"),+    ("Right Slant Hash", "右斜線"),+    ("White Diamond", "白抜きダイヤ"),+    ("Japanese Dots", "和文点線"),     ("Restart Numbering", "番号の振り直し"),     ("Continue Numbering", "番号の継続"),     ("Keep Options…", "段落分離禁止オプション…"),diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs--- a/crates/ui-egui/src/i18n/ka.rs+++ b/crates/ui-egui/src/i18n/ka.rs@@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "ამოჭრა"),     ("Copy", "კოპირება"),     ("Paste", "ჩასმა"),+    ("Triple", "სამმაგი"),+    ("Dashed (3 and 2)", "წყვეტილი (3 და 2)"),+    ("Dashed (4 and 4)", "წყვეტილი (4 და 4)"),+    ("Left Slant Hash", "მარცხნივ დახრილი შტრიხი"),+    ("Right Slant Hash", "მარჯვნივ დახრილი შტრიხი"),+    ("White Diamond", "თეთრი რომბი"),+    ("Japanese Dots", "იაპონური წერტილები"),     ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"),     ("Continue Numbering", "ნუმერაციის გაგრძელება"),     ("Keep Options…", "შენარჩუნების პარამეტრები…"),diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs--- a/crates/ui-egui/src/i18n/pl.rs+++ b/crates/ui-egui/src/i18n/pl.rs@@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "Wytnij"),     ("Copy", "Kopiuj"),     ("Paste", "Wklej"),+    ("Triple", "Potrójna"),+    ("Dashed (3 and 2)", "Kreskowana (3 i 2)"),+    ("Dashed (4 and 4)", "Kreskowana (4 i 4)"),+    ("Left Slant Hash", "Kreskowanie ukośne w lewo"),+    ("Right Slant Hash", "Kreskowanie ukośne w prawo"),+    ("White Diamond", "Biały romb"),+    ("Japanese Dots", "Kropki japońskie"),     ("Restart Numbering", "Zacznij numerowanie od nowa"),     ("Continue Numbering", "Kontynuuj numerowanie"),     ("Keep Options…", "Opcje utrzymywania…"),diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs--- a/crates/ui-egui/src/i18n/pt_br.rs+++ b/crates/ui-egui/src/i18n/pt_br.rs@@ -31,6 +31,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "Recortar"),     ("Copy", "Copiar"),     ("Paste", "Colar"),+    ("Triple", "Tripla"),+    ("Dashed (3 and 2)", "Tracejada (3 e 2)"),+    ("Dashed (4 and 4)", "Tracejada (4 e 4)"),+    ("Left Slant Hash", "Hachura inclinada à esquerda"),+    ("Right Slant Hash", "Hachura inclinada à direita"),+    ("White Diamond", "Losango branco"),+    ("Japanese Dots", "Pontos japoneses"),     ("Restart Numbering", "Reiniciar numeração"),     ("Continue Numbering", "Continuar numeração"),     ("Keep Options…", "Opções de manutenção…"),diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs--- a/crates/ui-egui/src/i18n/ru.rs+++ b/crates/ui-egui/src/i18n/ru.rs@@ -33,6 +33,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "Вырезать"),     ("Copy", "Копировать"),     ("Paste", "Вставить"),+    ("Triple", "Тройная"),+    ("Dashed (3 and 2)", "Пунктир (3 и 2)"),+    ("Dashed (4 and 4)", "Пунктир (4 и 4)"),+    ("Left Slant Hash", "Штриховка влево"),+    ("Right Slant Hash", "Штриховка вправо"),+    ("White Diamond", "Белый ромб"),+    ("Japanese Dots", "Японские точки"),     ("Restart Numbering", "Начать нумерацию заново"),     ("Continue Numbering", "Продолжить нумерацию"),     ("Keep Options…", "Параметры удержания…"),diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs--- a/crates/ui-egui/src/i18n/uk.rs+++ b/crates/ui-egui/src/i18n/uk.rs@@ -29,6 +29,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[     ("Cut", "Вирізати"),     ("Copy", "Копіювати"),     ("Paste", "Вставити"),+    ("Triple", "Потрійна"),+    ("Dashed (3 and 2)", "Пунктир (3 і 2)"),+    ("Dashed (4 and 4)", "Пунктир (4 і 4)"),+    ("Left Slant Hash", "Штрихування вліво"),+    ("Right Slant Hash", "Штрихування вправо"),+    ("White Diamond", "Білий ромб"),+    ("Japanese Dots", "Японські крапки"),     ("Restart Numbering", "Почати нумерацію знову"),     ("Continue Numbering", "Продовжити нумерацію"),     ("Keep Options…", "Параметри утримання…"),diff --git a/crates/ui-egui/src/panels/cell_stroke.rs b/crates/ui-egui/src/panels/cell_stroke.rs--- a/crates/ui-egui/src/panels/cell_stroke.rs+++ b/crates/ui-egui/src/panels/cell_stroke.rs@@ -26,9 +26,8 @@ const PROXY_INSET: f32 = 8.0;  const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0]; -/// Stroke types offered for cell edges (all of them draw on table edges).-const TYPES: &[&str] =-    &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"];+/// Stroke types offered for cell edges (all of them draw on table edges), in InDesign's order.+const TYPES: &[&str] = &StrokeType::BUILT_IN;  /// Whether the selection is table cells (or the text cursor is in a cell). pub fn in_cells(app: &DesignApp) -> bool {@@ -154,16 +153,7 @@ pub fn edge_proxy(ui: &mut Ui, id: &str, edges: &mut Edges, lang: &str) -> bool fn builtin(kind: &str) -> StrokeType {     match kind {         "dashed" => StrokeType::Dashed { pattern: Vec::new() },-        "dotted" => StrokeType::Dotted,-        "thickThin" => StrokeType::ThickThin,-        "thinThick" => StrokeType::ThinThick,-        "thinThin" => StrokeType::ThinThin,-        "thickThick" => StrokeType::ThickThick,-        "thinThickThin" => StrokeType::ThinThickThin,-        "thickThinThick" => StrokeType::ThickThinThick,-        "wavy" => StrokeType::Wavy,-        "hashed" => StrokeType::Hashed,-        _ => StrokeType::Solid,+        k => serde_json::from_value(json!({ "kind": k })).unwrap_or_default(),     } } @@ -194,6 +184,9 @@ pub fn stroke_fields(app: &mut DesignApp, ui: &mut Ui, id: &str, v: &Value) -> O         let shown = type_label(&lang, &v["type"]);         egui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| {             for kind in TYPES {+                if *kind == "dashed" {+                    ui.separator();+                }                 let text = tr(&lang, builtin(kind).label());                 if ui.selectable_label(shown == text, crate::rtl::widget(ui, text)).clicked() {                     out = Some(("type", json!(kind)));diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs--- a/crates/ui-egui/src/panels/properties.rs+++ b/crates/ui-egui/src/panels/properties.rs@@ -2222,20 +2222,12 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) {             ))             .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 {+                // InDesign's order; plain Dashed after a separator, the document's styles after+                // another.+                for k in StrokeType::BUILT_IN {+                    if k == "dashed" {+                        ui.separator();+                    }                     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(shown == label, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {