lini 0.22.0

Pretty diagrams, charts, and technical drawings from plain text, with fine-grained control. Compiles to clean, themeable SVG.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
//! The per-family rule sub-builders `build` appends in cascade order: the root
//! inherited-text rule and canvas plate, per-shape paint defaults, the
//! structural text / sequence / gutter rules, template + define looks, the link
//! defaults, marker rules, and the link-label rules. Each pushes onto the shared
//! `rules` Vec; kept in one place so their emission order stays byte-identical.

use super::super::rules::{Rule, ensure_dash_none};
use super::super::values::{css_value, dash_pattern, format_value, num};
use super::paint_props;
use crate::Options;
use crate::layout::LaidOut;
use crate::resolve::{AttrMap, NodeKind, ResolvedValue, VarTable};
use std::collections::{BTreeSet, HashMap};

/// The root inherited-text baseline (`.lini`) and the scene background plate
/// (`.lini-canvas`).
pub(super) fn build_frame_rules(
    rules: &mut Vec<Rule>,
    laid: &LaidOut,
    vars: &VarTable,
    opts: &Options,
) {
    // Root rule: the inherited-text baseline, stated once. `font-family` /
    // `font-weight` / `color` default to their themeable var, but a global override
    // (in `root_text`) wins; `font-size` is the baked literal.
    let font_size = laid.sheet.root_font_size;
    let rt = &laid.sheet.root_text;
    let global = |attr: &str, var: &str| match rt.get(attr) {
        Some(v) => css_value(attr, v, vars, opts),
        None => live(var, vars, opts),
    };
    let mut root_props = vec![
        ("font-family".into(), global("font-family", "font-family")),
        ("font-size".into(), format!("{}px", num(font_size))),
        ("font-weight".into(), global("font-weight", "font-weight")),
        ("color".into(), global("color", "text-color")),
    ];
    // The rest ride `.lini` only when globally set — live CSS with no default.
    for attr in [
        "font-style",
        "text-transform",
        "text-decoration",
        "text-shadow",
    ] {
        if let Some(v) = rt.get(attr) {
            root_props.push((attr.to_string(), css_value(attr, v, vars, opts)));
        }
    }
    rules.push(Rule {
        class: "lini".into(),
        props: root_props,
    });

    // The scene background plate: `.lini-canvas` fills with `--lini-bg`, stated as
    // a CSS rule (not a presentation attr, where `var()` is invalid) so it switches
    // live and bakes to a literal for resvg/email [SPEC 17].
    rules.push(Rule {
        class: "lini-canvas".into(),
        props: vec![("fill".into(), live("bg", vars, opts))],
    });
}

/// Per-node paint defaults (only for shapes present), plus the structural
/// drawing-anatomy, text, and sequence-text rules.
pub(super) fn build_shape_rules(
    rules: &mut Vec<Rule>,
    laid: &LaidOut,
    present: &BTreeSet<&str>,
    vars: &VarTable,
    opts: &Options,
) {
    // Per-node paint, sourced from the generated `.lini-*` class defs — desugar
    // folded the bundles + element rules into them. Geometry stays baked; only
    // the paint subset rides CSS. Closed primitives and `line` mask
    // `stroke-dasharray` so a container's `line:` can't bleed into children.
    let class_map: HashMap<&str, &AttrMap> = laid
        .sheet
        .class_rules
        .iter()
        .map(|(n, a)| (n.as_str(), a))
        .collect();
    let shape_paint = |class: &str| -> Vec<(String, String)> {
        class_map
            .get(class)
            .map(|a| paint_props(a, vars, opts))
            .unwrap_or_default()
    };

    const CLOSED: &[NodeKind] = &[
        NodeKind::Block,
        NodeKind::Oval,
        NodeKind::Hex,
        NodeKind::Slant,
        NodeKind::Cyl,
        NodeKind::Diamond,
        NodeKind::Poly,
        NodeKind::Path,
        NodeKind::Sketch,
    ];
    for kind in CLOSED {
        if present.contains(kind.as_str()) {
            let class = format!("lini-{}", kind.as_str());
            let mut props = shape_paint(&class);
            ensure_dash_none(&mut props);
            rules.push(Rule { class, props });
        }
    }
    if present.contains("line") {
        let mut props = shape_paint("lini-line");
        ensure_dash_none(&mut props);
        rules.push(Rule {
            class: "lini-line".into(),
            props,
        });
    }
    // The drawing dimension anatomy [SPEC 15.6] states its constant paint
    // once: dimension / leader linework at the drafting thin weight, and the
    // extension lines a step lighter (`--lini-stroke-light`) so the geometry
    // reads first. After the shape rules, so they win the same-specificity tie.
    if present.contains("dim-line") {
        rules.push(Rule {
            class: "lini-dim-line".into(),
            props: vec![
                ("fill".into(), "none".into()),
                ("stroke".into(), live("stroke-dark", vars, opts)),
                ("stroke-width".into(), "1".into()),
            ],
        });
    }
    if present.contains("ext-line") {
        rules.push(Rule {
            class: "lini-ext-line".into(),
            props: vec![
                ("fill".into(), "none".into()),
                ("stroke".into(), live("stroke-light", vars, opts)),
                ("stroke-width".into(), "1".into()),
            ],
        });
    }
    // Annotation text reads at the caption size [SPEC 15.6/17]: stated once
    // here, so no dimension / leader / callout leaf inlines it (only a `tol:`
    // deviation or a restyled link overrides).
    if present.contains("dim-text") {
        rules.push(Rule {
            class: "lini-dim-text".into(),
            props: vec![
                (
                    "font-size".into(),
                    format!("{}px", num(crate::ledger::consts::DRAWING_LINK_FONT_SIZE)),
                ),
                ("font-weight".into(), "normal".into()),
            ],
        });
    }
    if present.contains("text") {
        // A bare `<text class="lini-text">` [SPEC 17]. `fill: currentColor` ties
        // the glyph colour to the inherited `color`; `stroke: none` keeps a
        // container's stroke off the glyphs; `text-anchor: middle` centres it
        // on x. Vertical centring is a baked `dy` on the element — cap-height
        // optical centring [SPEC 5], not `dominant-baseline` (renderers
        // disagree on it; a baked offset is faithful everywhere).
        rules.push(Rule {
            class: "lini-text".into(),
            props: vec![
                ("fill".into(), "currentColor".into()),
                ("stroke".into(), "none".into()),
                ("text-anchor".into(), "middle".into()),
            ],
        });
    }
    if present.contains("icon") {
        let mut props = shape_paint("lini-icon");
        // Mask `stroke-dasharray` so a dashed container's stroke can't bleed onto
        // the icon's lines (its strokes are element-level, but dash inherits).
        ensure_dash_none(&mut props);
        rules.push(Rule {
            class: "lini-icon".into(),
            props,
        });
    }
}

/// Sequence tab / guard text [SPEC 13] — size / weight stated once, never inline.
pub(super) fn build_sequence_text_rules(rules: &mut Vec<Rule>, present: &BTreeSet<&str>) {
    // Sequence tab / guard text [SPEC 13] takes its size / weight from these rules,
    // never an inline `style=`, so a diagram's many labels don't each repeat the font
    // props. (The message-label rule rides `has_seq_labels`, with the wire-label rules.)
    if present.contains("chart-title") {
        // A chart / pie title [SPEC 14.6]: stated once, so no title inlines
        // its font; semibold — the chrome weight, a step under bold.
        rules.push(Rule {
            class: "lini-chart-title".into(),
            props: vec![
                (
                    "font-size".into(),
                    format!("{}px", num(crate::layout::chart::metrics::TITLE_SIZE)),
                ),
                ("font-weight".into(), "600".into()),
            ],
        });
    }
    if present.contains("sequence-tab") {
        rules.push(Rule {
            class: "lini-sequence-tab".into(),
            props: vec![
                ("font-size".into(), "12px".into()),
                ("font-weight".into(), "600".into()),
            ],
        });
    }
    if present.contains("sequence-guard") {
        rules.push(Rule {
            class: "lini-sequence-guard".into(),
            props: vec![
                // A touch smaller than the bold tab keyword, so the guard reads as its
                // subordinate condition.
                ("font-size".into(), "11px".into()),
                ("font-weight".into(), "normal".into()),
            ],
        });
    }
}

/// A `gap-fill` gutter rect's `stroke: none`, stated once rather than per rect.
pub(super) fn build_gutter_rule(rules: &mut Vec<Rule>, has_gutters: bool) {
    // A `gap-fill` gutter rect states its fill inline (it varies per container) but
    // never a stroke — the container's border can't bleed onto it [SPEC 11]. Stated
    // once here, not `stroke="none"` on every gutter rect.
    if has_gutters {
        rules.push(Rule {
            class: "lini-gutter".into(),
            props: vec![("stroke".into(), "none".into())],
        });
    }
}

/// Template + define looks (a group's wash, a define's paint), in class-def order.
pub(super) fn build_template_rules(
    rules: &mut Vec<Rule>,
    laid: &LaidOut,
    present: &BTreeSet<&str>,
    vars: &VarTable,
    opts: &Options,
) {
    // Template + define looks (group's wash, a define's paint), in class-def order
    // (templates then defines). Element rules are already folded into these.
    for (name, attrs) in &laid.sheet.class_rules {
        if let Some(tn) = name.strip_prefix("lini-")
            && NodeKind::parse(tn).is_none()
            && present.contains(tn)
        {
            rules.push(Rule {
                class: name.clone(),
                props: paint_props(attrs, vars, opts),
            });
        }
    }
}

/// The `|link|` defaults (`.lini-link`) and the per-operator dash styles.
pub(super) fn build_link_rules(
    rules: &mut Vec<Rule>,
    laid: &LaidOut,
    vars: &VarTable,
    opts: &Options,
) {
    // Links: the `|link|` defaults stated once. Emitted *before* the style
    // rules — it is the link's default layer (like a primitive rule for a node), so
    // a link's `.style` class overrides it in the cascade [SPEC 4].
    if !laid.links.is_empty() || !laid.strays.is_empty() {
        // The link path's paint, in a fixed order (fill, stroke, width, dash) so a
        // root `link:` that overrides only some props still emits a stable rule. Font
        // props from the defaults style labels, not the `<path>`, so they're dropped.
        let defaults = &laid.sheet.link_defaults;
        let dp = paint_props(defaults, vars, opts);
        let from_defaults = |p: &str| dp.iter().find(|(k, _)| k == p).map(|(_, v)| v.clone());
        let mut props = vec![
            ("fill".into(), "none".into()),
            (
                "stroke".into(),
                from_defaults("stroke").unwrap_or_else(|| live("stroke", vars, opts)),
            ),
            (
                "stroke-width".into(),
                from_defaults("stroke-width").unwrap_or_else(|| "2".into()),
            ),
            (
                "stroke-dasharray".into(),
                from_defaults("stroke-dasharray").unwrap_or_else(|| "none".into()),
            ),
        ];
        for (k, v) in &dp {
            if !k.starts_with("font")
                && !matches!(k.as_str(), "stroke" | "stroke-width" | "stroke-dasharray")
            {
                props.push((k.clone(), v.clone()));
            }
        }
        rules.push(Rule {
            class: "lini-link".into(),
            props,
        });

        // Line styles (`--` dashed / `..` dotted from the operator, or an
        // explicit `stroke-style:`) ride a `lini-link-{style}` class so the dash
        // pattern is stated once, not inlined on every link — exactly as a
        // shape's stroke rides its class. The pattern bakes the link default
        // `stroke-width`; a link that overrides the width inlines its own
        // pattern via the cascade diff in `render_link`.
        let link_width = laid
            .sheet
            .link_defaults
            .number("stroke-width")
            .unwrap_or(0.0);
        let mut link_styles: BTreeSet<&str> = BTreeSet::new();
        for w in &laid.links {
            if let Some(ResolvedValue::Ident(s)) = w.attrs.get("stroke-style")
                && (s == "dashed" || s == "dotted")
            {
                link_styles.insert(s.as_str());
            }
        }
        for style in link_styles {
            rules.push(Rule {
                class: format!("lini-link-{style}"),
                props: vec![("stroke-dasharray".into(), dash_pattern(style, link_width))],
            });
        }
    }
}

/// Link-label paint stated once per role (`.lini-link-label`,
/// `.lini-sequence-message`) plus the label-cut mask rects.
pub(super) fn build_link_label_rules(
    rules: &mut Vec<Rule>,
    laid: &LaidOut,
    has_link_labels: bool,
    has_seq_labels: bool,
    has_labels: bool,
    vars: &VarTable,
    opts: &Options,
) {
    // Link labels: the constant `<text>` paint stated once per role, plus the baked
    // font size — a diagram label rides on the wire (`.lini-link-label`, the baked
    // link size), a sequence message rides above the arrow like a heading
    // (`.lini-sequence-message`, the larger `messages::LABEL_SIZE`). Two rules so
    // both coexist in one file; a label that overrides one inlines the difference.
    if has_link_labels {
        let wfs = laid.sheet.link_defaults.number("font-size").unwrap_or(11.0);
        rules.push(Rule {
            class: "lini-link-label".into(),
            props: vec![
                ("fill".into(), "currentColor".into()),
                ("stroke".into(), "none".into()),
                ("text-anchor".into(), "middle".into()),
                ("font-size".into(), format!("{}px", num(wfs))),
                ("font-weight".into(), live("link-font-weight", vars, opts)),
            ],
        });
    }
    if has_seq_labels {
        rules.push(Rule {
            class: "lini-sequence-message".into(),
            props: vec![
                ("fill".into(), "currentColor".into()),
                ("stroke".into(), "none".into()),
                ("text-anchor".into(), "middle".into()),
                // Larger than the wire label so messages read on the time axis —
                // the size layout measured with.
                (
                    "font-size".into(),
                    format!("{}px", num(crate::layout::sequence::messages::LABEL_SIZE)),
                ),
                ("font-weight".into(), "normal".into()),
            ],
        });
    }
    if has_labels {
        // The label cut's mask rects state their fill/stroke as CSS, not inline —
        // so the link's own `stroke` can't bleed into the luminance mask, and the
        // SVG stays free of per-label paint attrs [SPEC 17]. White shows the
        // link, a black box per label punches the hole.
        rules.push(Rule {
            class: "lini-cut-bg".into(),
            props: vec![
                ("fill".into(), "white".into()),
                ("stroke".into(), "none".into()),
            ],
        });
        rules.push(Rule {
            class: "lini-cut".into(),
            props: vec![
                ("fill".into(), "black".into()),
                ("stroke".into(), "none".into()),
            ],
        });
    }
}

/// The base marker paint (`.lini-marker`), the drafting-head variants, and the
/// chart-label pointer-events rule.
pub(super) fn build_marker_rules(
    rules: &mut Vec<Rule>,
    present: &BTreeSet<&str>,
    has_markers: bool,
    vars: &VarTable,
    opts: &Options,
) {
    // Markers: fill follows the link stroke (the common default stated once),
    // `stroke: none` for the filled heads. The crow flips this below. A
    // drawing's dimension arrows are marker-classed nodes ([SPEC 15.6]), so
    // their presence pulls the rule in too.
    if has_markers || present.contains("marker") {
        rules.push(Rule {
            class: "lini-marker".into(),
            props: vec![
                ("fill".into(), live("stroke", vars, opts)),
                ("stroke".into(), "none".into()),
            ],
        });
    }
    // The drafting heads read the geometry tone [SPEC 10.1]: the slender dim
    // arrow and the datum triangle at full black/white, after `.lini-marker`
    // so the variant wins the same-specificity tie.
    for variant in ["marker-dim", "marker-datum"] {
        if present.contains(variant) {
            rules.push(Rule {
                class: format!("lini-{variant}"),
                props: vec![("fill".into(), live("stroke-dark", vars, opts))],
            });
        }
    }

    // Inline chart labels [SPEC 14.8] take no pointer events, so hovering a labelled
    // point still reveals the point's card through the label sitting over it.
    if present.contains("chart-label") {
        rules.push(Rule {
            class: "lini-chart-label".into(),
            props: vec![("pointer-events".into(), "none".into())],
        });
    }
}

/// The `.style` class rules in source order, plus the filled-marker descendant
/// rule each stroke-bearing style contributes.
pub(super) fn build_style_class_rules(
    rules: &mut Vec<Rule>,
    laid: &LaidOut,
    used_styles: &BTreeSet<&str>,
    has_markers: bool,
    vars: &VarTable,
    opts: &Options,
) {
    // Class rules in source order — the stylesheet's `.name { }` rules shipped
    // as CSS. After the shape/link default rules, so a class overrides a default.
    for (name, attrs) in &laid.sheet.class_rules {
        if name.starts_with("lini-") || !used_styles.contains(name.as_str()) {
            continue;
        }
        // One paint vocabulary: a `.style` class states `stroke` whether it dresses a
        // node's outline or a link's wire, so its `.lini-style-*` rule paints both
        // with no per-link inline [SPEC 17].
        rules.push(Rule {
            class: format!("lini-style-{}", name),
            props: paint_props(attrs, vars, opts),
        });
        // A filled marker inside an element carrying this class fills with the
        // class's stroke — one descendant rule (3 classes, so it beats the base
        // `.lini-marker`), no per-marker inline. The crow is stroked, not
        // filled, so it resolves its colour inline instead (`emit_marker`).
        if has_markers && let Some(v) = attrs.get("stroke") {
            rules.push(Rule {
                class: format!("lini-style-{} .lini-marker", name),
                props: vec![("fill".into(), format_value(v, vars, opts))],
            });
        }
    }
}

/// The open ER markers (`.lini-marker.lini-marker-open`) — stroked, not filled.
pub(super) fn build_open_marker_rule(rules: &mut Vec<Rule>, has_open: bool) {
    // The open ER markers (crow's-foot, bars, rings) are stroked, not filled — the
    // opposite of the other heads. A compound selector (specificity ties the
    // `.lini-style-* .lini-marker` fill descendants and wins by coming last) flips the
    // base `.lini-marker` paint; `stroke: inherit` pulls the line's colour and width off
    // the enclosing `<g>`, so an open marker's paint never needs inlining.
    if has_open {
        rules.push(Rule {
            class: "lini-marker.lini-marker-open".into(),
            props: vec![
                ("fill".into(), "none".into()),
                ("stroke".into(), "inherit".into()),
                ("stroke-linecap".into(), "round".into()),
                ("stroke-dasharray".into(), "none".into()),
            ],
        });
    }
}

/// A live-var reference formatted for CSS — `var(--lini-name)` live, its baked
/// literal for resvg / email.
fn live(name: &str, vars: &VarTable, opts: &Options) -> String {
    format_value(
        &ResolvedValue::LiveVar {
            name: name.to_string(),
            raw: false,
        },
        vars,
        opts,
    )
}