diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -359,6 +359,8 @@ 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** · InDesign's hidden stroke styles: a document stroke style named Feet, Happy, Lights, Woof or Bounce (Dash) or Rainbow or Rasta (Stripe), capitals included, draws InDesign's pattern (footprints, smileys, a string of lights, paw prints, bouncing dashes, rainbow and Rasta bands) on screen and in PDF, with its fixed colours or the stroke's and the gap colour under it. Found in the Learn Skills Daily course, chapter 42. 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,26 @@ 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,
+    /// One of InDesign's hidden stroke styles: what a document stroke style of that name resolves
+    /// to ([`HiddenStroke::of`]); never stored.
+    Hidden {
+        style: HiddenStroke,
+    },
     /// A custom striped stroke: bands (start, width) as fractions of the weight from the left edge.
     Stripes {
         bands: Vec<(f64, f64)>,
@@ -170,11 +190,58 @@ 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::Hidden { .. } => "Custom",
             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 +264,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,23 +279,81 @@ 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,
         })
     }
 
-    /// The filled area for types drawn as fills (stripes, wavy, hash); `None` for solid, dashed
-    /// and dotted strokes (and unresolved named styles).
+    /// The parts of a hidden stroke style and their colours (`None`: the stroke's colour); `None`
+    /// for every other type.
+    pub fn painted(&self, bp: &designcraft_geom::BezPath, weight: f64, tol: f64) -> Option<Vec<designcraft_geom::stroke_style::Part>> {
+        use designcraft_geom::stroke_style as ss;
+        let StrokeType::Hidden { style } = self else { return None };
+        Some(match style {
+            HiddenStroke::Rainbow => ss::colour_bands(bp, weight, &ss::RAINBOW, tol),
+            HiddenStroke::Rasta => ss::colour_bands(bp, weight, &ss::RASTA, tol),
+            HiddenStroke::Feet => ss::feet(bp, weight),
+            HiddenStroke::Woof => ss::woof(bp, weight),
+            HiddenStroke::Happy => ss::happy(bp, weight, tol),
+            HiddenStroke::Lights => ss::lights(bp, weight, tol),
+            HiddenStroke::Bounce => ss::bounce(bp, weight),
+        })
+    }
+
+    /// The filled area for types drawn as fills (stripes, wavy, hash, and a hidden style's parts
+    /// together); `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;
+        if let Some(parts) = self.painted(bp, weight, tol) {
+            let mut all = designcraft_geom::BezPath::new();
+            for el in parts.iter().flat_map(|(p, _)| p.elements()) {
+                all.push(*el);
+            }
+            return Some(all);
+        }
         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)),
         }
     }
 }
 
+/// InDesign's hidden stroke styles: a document stroke style named exactly one of these (a Dash
+/// style, or a Stripe style for Rainbow and Rasta) draws a set pattern instead of its own.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum HiddenStroke {
+    Feet,
+    Happy,
+    Lights,
+    Woof,
+    Bounce,
+    Rainbow,
+    Rasta,
+}
+
+impl HiddenStroke {
+    /// The hidden style a stroke style named `name` of `kind` is, if any. The name is matched as
+    /// InDesign matches it, capitals included.
+    pub fn of(name: &str, kind: &StrokeType) -> Option<HiddenStroke> {
+        Some(match (name, kind) {
+            ("Feet", StrokeType::Dashed { .. }) => HiddenStroke::Feet,
+            ("Happy", StrokeType::Dashed { .. }) => HiddenStroke::Happy,
+            ("Lights", StrokeType::Dashed { .. }) => HiddenStroke::Lights,
+            ("Woof", StrokeType::Dashed { .. }) => HiddenStroke::Woof,
+            ("Bounce", StrokeType::Dashed { .. }) => HiddenStroke::Bounce,
+            ("Rainbow", StrokeType::Stripes { .. }) => HiddenStroke::Rainbow,
+            ("Rasta", StrokeType::Stripes { .. }) => HiddenStroke::Rasta,
+            _ => return None,
+        })
+    }
+}
+
 /// A named custom stroke style (Stroke Styles dialog): dashes, dots or stripes.
 #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
 #[serde(rename_all = "camelCase")]
@@ -1661,6 +1792,86 @@ mod tests {
     use super::*;
     use designcraft_geom::shapes;
 
+    /// InDesign's hidden stroke styles: a document style named exactly Feet, Happy, Lights, Woof
+    /// or Bounce (Dash), or Rainbow or Rasta (Stripe), resolves to its pattern; another name, other
+    /// capitals or another type stay the style's own.
+    #[test]
+    fn hidden_stroke_styles_resolve_by_name_and_type() {
+        let dash = StrokeType::Dashed { pattern: vec![12.0, 4.0] };
+        let stripe = StrokeType::Stripes { bands: vec![(0.0, 0.5)] };
+        let mut d = crate::Document::new(&crate::build::NewDocument::default());
+        for (name, kind) in [("Feet", &dash), ("Rainbow", &stripe), ("feet", &dash), ("Rainbow", &dash), ("Lights", &stripe), ("Rasta", &stripe)] {
+            d.stroke_styles.retain(|x| x.name != name);
+            d.stroke_styles.push(StrokeStyleDef { name: name.into(), kind: kind.clone() });
+        }
+        let resolve = |d: &crate::Document, name: &str| d.stroke_kind(&StrokeType::Style { name: name.into() });
+        assert_eq!(resolve(&d, "Feet"), StrokeType::Hidden { style: HiddenStroke::Feet });
+        assert_eq!(resolve(&d, "Rasta"), StrokeType::Hidden { style: HiddenStroke::Rasta });
+        assert_eq!(resolve(&d, "feet"), dash, "capitals matter");
+        assert_eq!(resolve(&d, "Lights"), stripe, "Lights is a Dash style");
+        assert_eq!(HiddenStroke::of("Rainbow", &dash), None, "Rainbow is a Stripe style");
+        assert_eq!(HiddenStroke::of("Rainbow", &stripe), Some(HiddenStroke::Rainbow));
+        // Each draws, with colours where InDesign fixes them.
+        let line = designcraft_geom::BezPath::from_vec(vec![
+            designcraft_geom::PathEl::MoveTo((0.0, 0.0).into()),
+            designcraft_geom::PathEl::LineTo((200.0, 0.0).into()),
+        ]);
+        for style in [
+            HiddenStroke::Feet,
+            HiddenStroke::Happy,
+            HiddenStroke::Lights,
+            HiddenStroke::Woof,
+            HiddenStroke::Bounce,
+            HiddenStroke::Rainbow,
+            HiddenStroke::Rasta,
+        ] {
+            let parts = StrokeType::Hidden { style }.painted(&line, 20.0, 0.05).unwrap();
+            assert!(parts.iter().all(|(p, _)| !p.elements().is_empty()), "{style:?}");
+            let colours = parts.iter().filter(|(_, c)| c.is_some()).count();
+            let fixed = matches!(style, HiddenStroke::Happy | HiddenStroke::Lights | HiddenStroke::Rainbow | HiddenStroke::Rasta);
+            assert_eq!(colours > 0, fixed, "{style:?}: fixed colours");
+        }
+        assert_eq!(StrokeType::Hidden { style: HiddenStroke::Rainbow }.painted(&line, 20.0, 0.05).map(|p| p.len()), Some(7));
+        assert_eq!(StrokeType::Hidden { style: HiddenStroke::Rasta }.painted(&line, 20.0, 0.05).map(|p| p.len()), Some(3));
+    }
+
+    /// 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/doc/src/lib.rs b/crates/doc/src/lib.rs
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -200,7 +200,7 @@ impl Document {
                 .stroke_styles
                 .iter()
                 .find(|s| s.name == *name)
-                .map(|s| s.kind.clone())
+                .map(|s| crate::item::HiddenStroke::of(&s.name, &s.kind).map_or_else(|| s.kind.clone(), |style| StrokeType::Hidden { style }))
                 .filter(|k| !matches!(k, StrokeType::Style { .. }))
                 .unwrap_or(StrokeType::Solid),
             t => t.clone(),
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> = p
diff --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,239 @@ 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
+}
+
+/// A part of a hidden stroke style (below): its area and its colour (`None`: the stroke's own).
+pub type Part = (BezPath, Option<[u8; 3]>);
+
+const BLACK: [u8; 3] = [0, 0, 0];
+
+/// The Rainbow stroke style's bands, outside (left of travel) to inside.
+pub const RAINBOW: [[u8; 3]; 7] = [[229, 73, 47], [241, 152, 61], [255, 230, 72], [0, 140, 88], [0, 151, 231], [47, 55, 145], [225, 70, 137]];
+
+/// The Rasta stroke style's bands.
+pub const RASTA: [[u8; 3]; 3] = [[244, 5, 0], [254, 255, 0], [8, 251, 1]];
+
+/// The bulb colours of the Lights stroke style, in turn.
+pub const LIGHTS: [[u8; 3]; 5] = [[226, 52, 44], [250, 222, 40], [45, 70, 200], [240, 140, 40], [20, 160, 80]];
+
+/// Equal bands across the weight, each in its colour (Rainbow, Rasta). Neighbouring bands overlap
+/// a hair so no seam shows between them.
+pub fn colour_bands(bp: &BezPath, weight: f64, colours: &[[u8; 3]], tol: f64) -> Vec<Part> {
+    let w = 1.0 / colours.len() as f64;
+    colours
+        .iter()
+        .enumerate()
+        .map(|(i, c)| {
+            let start = (i as f64 * w - 0.002).max(0.0);
+            let width = (w + 0.004).min(1.0 - start);
+            (stripes(bp, weight, &[(start, width)], tol), Some(*c))
+        })
+        .collect()
+}
+
+/// `shape` drawn in a local frame (x along the path, y across it towards the right of travel)
+/// placed at `p` with the path's unit tangent `t`.
+fn placed(shape: &BezPath, p: Point, t: Vec2) -> BezPath {
+    kurbo::Affine::new([t.x, t.y, -t.y, t.x, p.x, p.y]) * shape.clone()
+}
+
+/// An ellipse centred at (`cx`, `cy`) with radii `rx` along and `ry` across.
+fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> BezPath {
+    use kurbo::Shape;
+    kurbo::Ellipse::new((cx, cy), (rx.max(0.01), ry.max(0.01)), 0.0).to_path(0.01)
+}
+
+fn extend(out: &mut BezPath, b: &BezPath) {
+    for el in b.elements() {
+        out.push(*el);
+    }
+}
+
+/// The Feet stroke style: footprints walking along the path, left and right in turn, toes
+/// forward, in the stroke colour.
+pub fn feet(bp: &BezPath, weight: f64) -> Vec<Part> {
+    let len = weight * 0.34;
+    let mut out = BezPath::new();
+    for (i, (p, t)) in samples(bp, (weight * 0.5).max(1.0)).into_iter().enumerate() {
+        // The left foot on the left of travel, the right foot on the right.
+        let side = if i % 2 == 0 { -1.0 } else { 1.0 };
+        let mut foot = BezPath::new();
+        // Heel and ball, then five toes across the front, the big toe on the inside.
+        extend(&mut foot, &ellipse(-len * 0.2, 0.0, len * 0.2, len * 0.15));
+        extend(&mut foot, &ellipse(len * 0.1, 0.0, len * 0.24, len * 0.19));
+        for k in 0..5 {
+            let f = k as f64 / 4.0;
+            let r = len * (0.085 - 0.035 * f);
+            let across = (f - 0.5) * len * 0.42 * -side;
+            extend(&mut foot, &ellipse(len * (0.44 - 0.07 * (2.0 * f - 1.0).abs()), across, r, r));
+        }
+        let at = p + Vec2::new(-t.y, t.x) * (side * weight * 0.2);
+        extend(&mut out, &placed(&foot, at, t));
+    }
+    vec![(out, None)]
+}
+
+/// The Woof stroke style: paw prints in two staggered rows, toes to the left of travel, in the
+/// stroke colour.
+pub fn woof(bp: &BezPath, weight: f64) -> Vec<Part> {
+    let size = weight * 0.36;
+    let mut out = BezPath::new();
+    for (i, (p, t)) in samples(bp, (weight * 0.42).max(1.0)).into_iter().enumerate() {
+        let side = if i % 2 == 0 { -1.0 } else { 1.0 };
+        let mut paw = BezPath::new();
+        // The pad, and four toes in an arc beyond it.
+        extend(&mut paw, &ellipse(0.0, size * 0.14, size * 0.3, size * 0.24));
+        for (dx, dy) in [(-0.36, -0.12), (-0.13, -0.32), (0.13, -0.32), (0.36, -0.12)] {
+            extend(&mut paw, &ellipse(size * dx, size * dy, size * 0.1, size * 0.12));
+        }
+        let at = p + Vec2::new(-t.y, t.x) * (side * weight * 0.22);
+        extend(&mut out, &placed(&paw, at, t));
+    }
+    vec![(out, None)]
+}
+
+/// The Happy stroke style: yellow smiling faces as wide as the weight, touching, looking along
+/// the path, outlined in black.
+pub fn happy(bp: &BezPath, weight: f64, tol: f64) -> Vec<Part> {
+    let r = weight * 0.47;
+    let line = Stroke::new((weight * 0.05).max(0.25)).with_caps(kurbo::Cap::Round);
+    let (mut faces, mut ink) = (BezPath::new(), BezPath::new());
+    for (p, t) in samples(bp, weight.max(1.0)) {
+        let disc = ellipse(0.0, 0.0, r, r);
+        extend(&mut faces, &placed(&disc, p, t));
+        let mut lines = disc.clone();
+        // The smile: an arc on the far side of the face from the eyes.
+        let mut smile = BezPath::new();
+        for k in 0..=12 {
+            let a = std::f64::consts::PI * (0.62 + 0.76 * k as f64 / 12.0);
+            let q = Point::new(r * 0.08 + r * 0.55 * a.cos(), r * 0.55 * a.sin());
+            if k == 0 { smile.move_to(q) } else { smile.line_to(q) }
+        }
+        extend(&mut lines, &smile);
+        let stroked = kurbo::stroke(lines.iter(), &line, &kurbo::StrokeOpts::default(), tol);
+        extend(&mut ink, &placed(&stroked, p, t));
+        // The eyes, towards the direction of travel.
+        for y in [-0.28, 0.28] {
+            extend(&mut ink, &placed(&ellipse(r * 0.3, r * y, r * 0.14, r * 0.09), p, t));
+        }
+    }
+    vec![(faces, Some([255, 232, 0])), (ink, Some(BLACK))]
+}
+
+/// The Lights stroke style: a black wire swinging along the path with a bulb hanging from each
+/// swing, on alternating sides, in turn red, yellow, blue, orange and green.
+pub fn lights(bp: &BezPath, weight: f64, tol: f64) -> Vec<Part> {
+    let wavelength = (weight * 1.6).max(2.0);
+    let amp = weight * 0.16;
+    let mut wire = BezPath::new();
+    let mut sockets = BezPath::new();
+    let mut bulbs: Vec<Part> = Vec::new();
+    for (i, (p, t)) in samples(bp, wavelength / 16.0).into_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() * amp);
+        if i == 0 {
+            wire.move_to(q)
+        } else {
+            wire.line_to(q)
+        }
+        // A bulb at each crest, hanging outwards from it on its socket.
+        if i % 8 == 4 {
+            let k = i / 8;
+            let out = normal * if k % 2 == 0 { 1.0 } else { -1.0 };
+            let dir = (t * 0.4 + out).normalize();
+            let socket_at = q + dir * (weight * 0.05);
+            extend(&mut sockets, &placed(&ellipse(0.0, 0.0, weight * 0.05, weight * 0.035), socket_at, dir));
+            let bulb_at = socket_at + dir * (weight * 0.16);
+            bulbs.push((placed(&ellipse(0.0, 0.0, weight * 0.13, weight * 0.07), bulb_at, dir), Some(LIGHTS[k % LIGHTS.len()])));
+        }
+    }
+    let s = Stroke::new((weight * 0.035).max(0.25)).with_join(kurbo::Join::Round);
+    let mut black = kurbo::stroke(wire.iter(), &s, &kurbo::StrokeOpts::default(), tol);
+    extend(&mut black, &sockets);
+    let mut parts = vec![(black, Some(BLACK))];
+    parts.extend(bulbs);
+    parts
+}
+
+/// The Bounce stroke style: dashes across the weight that are longest at the path's anchor points
+/// and shrink to slivers halfway between them, in the stroke colour.
+pub fn bounce(bp: &BezPath, weight: f64) -> Vec<Part> {
+    let h = weight / 2.0;
+    let gap = (weight * 0.22).max(0.5);
+    let mut out = BezPath::new();
+    let at = |seg: &PathSeg, s: f64| {
+        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();
+        let tan = if n > 1e-12 { d / n } else { Vec2::new(1.0, 0.0) };
+        (seg.eval(t), Vec2::new(-tan.y, tan.x) * h)
+    };
+    for seg in bp.segments() {
+        let len = seg.arclen(1e-3);
+        if len < 1e-6 {
+            continue;
+        }
+        let mut s = 0.0;
+        let mut n = 0;
+        while s < len && n < 100_000 {
+            let f = s / len;
+            let dash = weight * (0.15 + 0.95 * (1.0 - (std::f64::consts::PI * f).sin())).max(0.15);
+            let e = (s + dash).min(len);
+            let ((p0, n0), (p1, n1)) = (at(&seg, s), at(&seg, e));
+            out.move_to(p0 - n0);
+            out.line_to(p1 - n1);
+            out.line_to(p1 + n1);
+            out.line_to(p0 + n0);
+            out.close_path();
+            s = e + gap;
+            n += 1;
+        }
+    }
+    vec![(out, None)]
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -141,4 +435,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
@@ -335,7 +335,7 @@ 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::Stripes { .. } | StrokeType::Hidden { .. } => "StrokeStyle/$ID/Solid",
         StrokeType::Solid => "StrokeStyle/$ID/Solid",
         StrokeType::Dashed { .. } => "StrokeStyle/$ID/Dashed",
         StrokeType::Dotted => "StrokeStyle/$ID/Canned Dotted",
@@ -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
@@ -1158,6 +1158,32 @@ impl Exporter<'_> {
             designcraft_doc::Cap::Projecting => krilla::paint::LineCap::Square,
         };
         let kind = self.doc.stroke_kind(&st.kind);
+        // A hidden stroke style: its parts, each in its colour or the stroke's (gap colour under them).
+        let hidden_arrows = matches!(kind, StrokeType::Hidden { .. }).then(|| designcraft_doc::arrow::apply(bp, st, closed)).flatten();
+        let hidden_bp = hidden_arrows.as_ref().map_or(bp, |a| &a.0);
+        if let Some(parts) = kind.painted(hidden_bp, st.weight, 0.05) {
+            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);
+            }
+            for (part, colour) in &parts {
+                let Some(p) = to_path(part) else { continue };
+                let paint: krilla::paint::Paint = match colour {
+                    Some([r, g, b]) => device(&Color::Rgb { r: *r as f32 / 255.0, g: *g as f32 / 255.0, b: *b as f32 / 255.0 }, self.rgb_only).into(),
+                    None => paint.clone(),
+                };
+                s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
+                s.draw_path(&p);
+            }
+            s.set_fill(None);
+            draw_arrowheads(s, hidden_arrows.iter().flat_map(|a| &a.1), &paint);
+            return;
+        }
         // 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)
@@ -1178,27 +1204,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;
@@ -1242,21 +1278,7 @@ impl Exporter<'_> {
         for _ in 0..pushes {
             s.pop();
         }
-        for h in arrows.iter().flat_map(|a| &a.1) {
-            let Some(p) = to_path(&h.path) else { continue };
-            match h.outline {
-                Some(w) => {
-                    s.set_stroke(Some(Stroke { paint: paint.clone(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
-                    s.draw_path(&p);
-                    s.set_stroke(None);
-                }
-                None => {
-                    s.set_fill(Some(krilla::paint::Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
-                    s.draw_path(&p);
-                    s.set_fill(None);
-                }
-            }
-        }
+        draw_arrowheads(s, arrows.iter().flat_map(|a| &a.1), &paint);
     }
 
     /// A placed PDF as krilla embeds it. krilla refuses a PDF newer than the file it writes (the
@@ -2216,3 +2238,22 @@ mod eps_tests {
         assert_eq!(civil(i64::MIN), (-292277022657, 1, 27, 8, 29, 52));
     }
 }
+
+/// A stroke's arrowheads in its paint: outlined ones stroked, solid ones filled.
+fn draw_arrowheads<'a>(s: &mut Surface, heads: impl Iterator<Item = &'a designcraft_doc::arrow::ArrowShape>, paint: &krilla::paint::Paint) {
+    for h in heads {
+        let Some(p) = to_path(&h.path) else { continue };
+        match h.outline {
+            Some(w) => {
+                s.set_stroke(Some(Stroke { paint: paint.clone(), width: w as f32, opacity: NormalizedF32::ONE, ..Default::default() }));
+                s.draw_path(&p);
+                s.set_stroke(None);
+            }
+            None => {
+                s.set_fill(Some(krilla::paint::Fill { paint: paint.clone(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
+                s.draw_path(&p);
+                s.set_fill(None);
+            }
+        }
+    }
+}
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,19 @@ 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)
+        });
+        // Hidden styles are painted on the centre line whatever the alignment, over the whole gap
+        // colour (as in the PDF).
         if let Some(g) = gap
-            && !matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside if closed)
+            && (matches!(kind, StrokeType::Hidden { .. })
+                || 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,12 +790,37 @@ 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();
             }
             set_fill_paint(ctx, f.doc, &paint, bounds);
         }
+        // A hidden stroke style: its parts, each in its colour or the stroke's.
+        if let Some(parts) = kind.painted(bp, st.weight, 0.05 * f.px) {
+            for (part, colour) in &parts {
+                match colour {
+                    // As ink coverage under Separations Preview, like every other colour.
+                    Some(rgb) => ctx.set_paint(screen_color(*rgb, 255)),
+                    None => {
+                        set_fill_paint(ctx, f.doc, &paint, bounds);
+                    }
+                }
+                ctx.fill_path(part);
+            }
+            // The arrowheads, in the stroke's paint: the path was shortened under them.
+            set_fill_paint(ctx, f.doc, &paint, bounds);
+            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;
+        }
         // 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);
@@ -801,6 +836,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);
@@ -1773,6 +1813,36 @@ mod tests {
         assert!(gap[0] < 80 && gap[2] > 150, "gap {gap:?}");
     }
 
+    /// A hidden style on a closed frame aligned inside still has its gap colour under it.
+    #[test]
+    fn hidden_stroke_keeps_its_gap_colour_when_aligned() {
+        let mut d = Document::new(&NewDocument::default());
+        let lid = d.default_layer();
+        let id = designcraft_doc::ItemId(d.alloc());
+        let mut frame =
+            Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)));
+        frame.stroke = designcraft_doc::Stroke {
+            swatch: designcraft_color::swatch::BLACK.into(),
+            weight: 12.0,
+            align: StrokeAlign::Inside,
+            kind: StrokeType::Hidden { style: designcraft_doc::HiddenStroke::Feet },
+            gap_swatch: "C=100 M=0 Y=0 K=0".into(),
+            ..Default::default()
+        };
+        d.insert_item(SpreadRef::Doc(0), frame, None).unwrap();
+        let cache = Cache::new();
+        let mut r = Renderer::new();
+        r.threads = 0;
+        let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap();
+        let cyan = (100..300).filter(|x| {
+            (94..106).any(|y| {
+                let p = img.pixel(*x, y);
+                p[0] < 80 && p[2] > 150
+            })
+        });
+        assert!(cyan.count() > 20, "the gap colour shows between the feet");
+    }
+
     #[test]
     fn overprint_preview_mixes_inks() {
         let mut d = Document::new(&NewDocument::default());
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() {
