Skip to main content

delvewright_dsl/
textstyle.rs

1//! Inline text styles (spec-0096): a span of any player-facing line carries a
2//! vanilla text style.
3//!
4//! A span is written inside the line as `[[<styles>|<text>]]`, where `<styles>`
5//! is one or more of `obfuscated`, `bold`, `italic`, `underlined`,
6//! `strikethrough` and `color=<colour>`, comma-separated, each at most once.
7//! A doubled square bracket occurs in no prose and in no other DSL surface, so a
8//! line carrying one is markup or a defect, never ambiguous; a single bracket is
9//! ordinary prose.
10//!
11//! The span belongs to the **player-facing string**, the object class
12//! [`crate::l10n::each_string`] defines, so this module is the one rule every
13//! reader of such a string shares:
14//!
15//! - [`parse`] — the grammar, refused by [`validate_inline_styles`] (`DW0975`);
16//! - [`visible`] — what a reader that does not draw the line sees;
17//! - [`lower`] / [`lower_aligned`] — the format string and span list the emitter
18//!   writes as a `translate` component with `with` arguments, and the language
19//!   files write under `<key>` and `<key>.span.<i>`;
20//! - [`validate_inline_styles`] — the English and every sidecar, and a
21//!   translation whose spans are not the English's (`DW0976`).
22
23use std::borrow::Cow;
24use std::collections::BTreeMap;
25
26use crate::diagnostic::{Diagnostic, DwCode, ExitTier};
27use crate::envelope::Campaign;
28use crate::l10n::{L10nDoc, inventory};
29
30crate::dw_code! {
31    /// (spec-0096) **Style markup that does not parse.** A player-visible
32    /// string — authored English or any sidecar row — carries a `[[` or `]]`
33    /// that is not one well-formed span `[[<styles>|<text>]]`: an unclosed or
34    /// unopened span, no `|`, no style, an unknown or repeated style, a colour
35    /// that is neither a vanilla name nor `#rrggbb`, empty text, or a nested
36    /// span ([`parse`]).
37    pub const INLINE_STYLE_MALFORMED: DwCode = DwCode::new("DW0975", ExitTier::Build);
38}
39
40crate::dw_code! {
41    /// (spec-0096) **A translation whose styled spans are not the English's.**
42    /// A sidecar row drops, adds or restyles a span of the English line it
43    /// renders ([`lower_aligned`]): the style rides on the component, the
44    /// translation only places and words each span.
45    pub const INLINE_STYLE_UNMATCHED: DwCode = DwCode::new("DW0976", ExitTier::Build);
46}
47
48/// Opens a span.
49pub const OPEN: &str = "[[";
50/// Closes a span.
51pub const CLOSE: &str = "]]";
52/// Separates a span's styles from its text.
53pub const SEP: char = '|';
54
55/// The five vanilla text-component flags, in the order a signature lists them.
56pub const FLAGS: [&str; 5] = [
57    "bold",
58    "italic",
59    "underlined",
60    "strikethrough",
61    "obfuscated",
62];
63
64/// The sixteen named text colours of the pinned game (Minecraft Wiki, *Text
65/// component format*: `color`).
66pub const NAMED_COLORS: [&str; 16] = [
67    "black",
68    "dark_blue",
69    "dark_green",
70    "dark_aqua",
71    "dark_red",
72    "dark_purple",
73    "gold",
74    "gray",
75    "dark_gray",
76    "blue",
77    "green",
78    "aqua",
79    "red",
80    "light_purple",
81    "yellow",
82    "white",
83];
84
85/// The suffix the language files key a span's own text under:
86/// `<line key>.span.<index>`.
87pub fn span_key(line_key: &str, index: usize) -> String {
88    format!("{line_key}.span.{index}")
89}
90
91/// The style a span sets on top of the line it sits in.
92#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
93pub struct SpanStyle {
94    /// The flags set, in [`FLAGS`] order.
95    pub flags: Vec<&'static str>,
96    /// `color`, lower-case: a vanilla name or `#rrggbb`.
97    pub color: Option<String>,
98}
99
100impl SpanStyle {
101    /// The component keys this style sets, as `(key, value)`.
102    pub fn fields(&self) -> Vec<(&'static str, serde_json::Value)> {
103        let mut out: Vec<(&'static str, serde_json::Value)> = self
104            .flags
105            .iter()
106            .map(|f| (*f, serde_json::Value::Bool(true)))
107            .collect();
108        if let Some(c) = &self.color {
109            out.push(("color", serde_json::Value::String(c.clone())));
110        }
111        out
112    }
113
114    /// The canonical spelling of this style: flags in [`FLAGS`] order, then
115    /// `color=<c>`. Two spans have the same style iff their signatures are equal.
116    pub fn signature(&self) -> String {
117        let mut parts: Vec<String> = self.flags.iter().map(|f| (*f).to_string()).collect();
118        if let Some(c) = &self.color {
119            parts.push(format!("color={c}"));
120        }
121        parts.join(",")
122    }
123}
124
125/// One piece of a parsed line.
126#[derive(Clone, Debug, PartialEq, Eq)]
127pub enum Segment<'a> {
128    /// Unstyled prose.
129    Text(&'a str),
130    /// A styled span.
131    Span(SpanStyle, &'a str),
132}
133
134/// Why a line's markup does not parse: the character offset and what was found.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct MarkupError {
137    /// Character offset into the line.
138    pub at: usize,
139    /// What is wrong, in a clause.
140    pub what: String,
141}
142
143fn char_at(s: &str, byte: usize) -> usize {
144    s[..byte].chars().count()
145}
146
147/// Whether `s` contains any markup delimiter at all — the fast path every reader
148/// takes for the common line with no span.
149pub fn has_markup(s: &str) -> bool {
150    s.contains(OPEN) || s.contains(CLOSE)
151}
152
153fn parse_styles(spec: &str) -> Result<SpanStyle, String> {
154    if spec.is_empty() {
155        return Err("a span names no style before its `|`".to_string());
156    }
157    let mut style = SpanStyle::default();
158    for part in spec.split(',') {
159        if let Some(c) = part.strip_prefix("color=") {
160            if style.color.is_some() {
161                return Err("`color` is written twice in one span".to_string());
162            }
163            let lower = c.to_ascii_lowercase();
164            if !(NAMED_COLORS.contains(&c) || crate::color::is_hex(c)) {
165                return Err(format!(
166                    "`{c}` is not a text colour: write one of {} or `#rrggbb`",
167                    NAMED_COLORS.join(", ")
168                ));
169            }
170            style.color = Some(lower);
171        } else if let Some(f) = FLAGS.iter().find(|f| **f == part) {
172            if style.flags.contains(f) {
173                return Err(format!("`{part}` is written twice in one span"));
174            }
175            style.flags.push(f);
176        } else {
177            return Err(format!(
178                "`{part}` is not a style: write {} or `color=<colour>`, comma-separated, \
179                 with no spaces",
180                FLAGS.join(", ")
181            ));
182        }
183    }
184    style
185        .flags
186        .sort_by_key(|f| FLAGS.iter().position(|g| g == f));
187    Ok(style)
188}
189
190/// Parse `s` into its segments (spec-0096 §3.1). A line with no markup is one
191/// [`Segment::Text`] (or none, if empty).
192pub fn parse(s: &str) -> Result<Vec<Segment<'_>>, MarkupError> {
193    let mut out = Vec::new();
194    let mut rest = 0usize;
195    loop {
196        let tail = &s[rest..];
197        let open = tail.find(OPEN);
198        let close = tail.find(CLOSE);
199        match (open, close) {
200            (None, None) => {
201                if !tail.is_empty() {
202                    out.push(Segment::Text(tail));
203                }
204                return Ok(out);
205            }
206            (o, Some(c)) if o.is_none_or(|o| c < o) => {
207                return Err(MarkupError {
208                    at: char_at(s, rest + c),
209                    what: "`]]` closes no span".to_string(),
210                });
211            }
212            (Some(o), _) => {
213                if o > 0 {
214                    out.push(Segment::Text(&tail[..o]));
215                }
216                let start = rest + o;
217                let body_at = start + OPEN.len();
218                let body = &s[body_at..];
219                let Some(end) = body.find(CLOSE) else {
220                    return Err(MarkupError {
221                        at: char_at(s, start),
222                        what: "`[[` opens a span that is never closed with `]]`".to_string(),
223                    });
224                };
225                let inner = &body[..end];
226                if let Some(nested) = inner.find(OPEN) {
227                    return Err(MarkupError {
228                        at: char_at(s, body_at + nested),
229                        what: "`[[` inside a span: spans do not nest".to_string(),
230                    });
231                }
232                let Some(bar) = inner.find(SEP) else {
233                    return Err(MarkupError {
234                        at: char_at(s, start),
235                        what: "a span has no `|` between its styles and its text".to_string(),
236                    });
237                };
238                let style = parse_styles(&inner[..bar]).map_err(|what| MarkupError {
239                    at: char_at(s, body_at),
240                    what,
241                })?;
242                let text = &inner[bar + 1..];
243                if text.trim().is_empty() {
244                    return Err(MarkupError {
245                        at: char_at(s, body_at + bar + 1),
246                        what: "a span's text is empty".to_string(),
247                    });
248                }
249                out.push(Segment::Span(style, text));
250                rest = body_at + end + CLOSE.len();
251            }
252            (None, Some(_)) => unreachable!("covered by the close-first arm"),
253        }
254    }
255}
256
257/// The text a reader sees when it does not draw the line: each span replaced by
258/// its text. A line that does not parse is returned unchanged (`DW0975` is the
259/// refusal; a reader is not where it is raised).
260pub fn visible(s: &str) -> Cow<'_, str> {
261    if !has_markup(s) {
262        return Cow::Borrowed(s);
263    }
264    match parse(s) {
265        Ok(segs) => Cow::Owned(
266            segs.iter()
267                .map(|g| match g {
268                    Segment::Text(t) | Segment::Span(_, t) => *t,
269                })
270                .collect(),
271        ),
272        Err(_) => Cow::Borrowed(s),
273    }
274}
275
276/// The text a player can **read** in the line: [`visible`], except that an
277/// `obfuscated` span's text is not legible — the client draws each of its glyphs
278/// as a random glyph of the same width — so each of its characters is replaced
279/// by U+FFFD (spaces kept, so the words around it stay words). A reader that
280/// asks what the player was told reads this; one that asks what the line says
281/// reads [`visible`]. A line that does not parse is returned unchanged.
282pub fn legible(s: &str) -> Cow<'_, str> {
283    if !has_markup(s) {
284        return Cow::Borrowed(s);
285    }
286    match parse(s) {
287        Ok(segs) => Cow::Owned(
288            segs.iter()
289                .map(|g| match g {
290                    Segment::Span(st, t) if st.flags.contains(&"obfuscated") => t
291                        .chars()
292                        .map(|c| if c.is_whitespace() { c } else { '\u{FFFD}' })
293                        .collect::<String>(),
294                    Segment::Text(t) | Segment::Span(_, t) => (*t).to_string(),
295                })
296                .collect(),
297        ),
298        Err(_) => Cow::Borrowed(s),
299    }
300}
301
302/// The span signatures of `s`, sorted — the multiset `DW0976` compares. Empty
303/// for a line with no span or one that does not parse.
304pub fn signatures(s: &str) -> Vec<String> {
305    let mut out: Vec<String> = parse(s)
306        .map(|segs| {
307            segs.iter()
308                .filter_map(|g| match g {
309                    Segment::Span(st, _) => Some(st.signature()),
310                    Segment::Text(_) => None,
311                })
312                .collect()
313        })
314        .unwrap_or_default();
315    out.sort();
316    out
317}
318
319/// A styled line lowered for a `translate` component (spec-0096 §3.3).
320#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct Lowered {
322    /// The line with span `i` replaced by `%<i+1>$s` and every other `%`
323    /// doubled: the component's `fallback` and the language file's value.
324    pub format: String,
325    /// Each span's style and its text (every `%` doubled), by index: the
326    /// `with` arguments, and the language file's `<key>.span.<i>` values.
327    pub spans: Vec<(SpanStyle, String)>,
328}
329
330fn escape_percent(s: &str) -> String {
331    s.replace('%', "%%")
332}
333
334/// Lower `s`, its spans indexed in its own order. `None` for a line with no
335/// span, or one that does not parse (validation refuses it before emission).
336pub fn lower(s: &str) -> Option<Lowered> {
337    if !has_markup(s) {
338        return None;
339    }
340    let segs = parse(s).ok()?;
341    let order: Vec<usize> = (0..segs.len()).collect();
342    lower_in(&segs, &order)
343}
344
345fn lower_in(segs: &[Segment<'_>], index_of: &[usize]) -> Option<Lowered> {
346    let n = segs
347        .iter()
348        .filter(|g| matches!(g, Segment::Span(..)))
349        .count();
350    if n == 0 {
351        return None;
352    }
353    let mut format = String::new();
354    let mut spans: Vec<Option<(SpanStyle, String)>> = vec![None; n];
355    let mut k = 0usize;
356    for g in segs {
357        match g {
358            Segment::Text(t) => format.push_str(&escape_percent(t)),
359            Segment::Span(st, t) => {
360                let i = index_of[k];
361                format.push_str(&format!("%{}$s", i + 1));
362                spans[i] = Some((st.clone(), escape_percent(t)));
363                k += 1;
364            }
365        }
366    }
367    Some(Lowered {
368        format,
369        spans: spans
370            .into_iter()
371            .map(|s| s.expect("every index filled"))
372            .collect(),
373    })
374}
375
376/// Lower a translation `translated` with each span written under the index of
377/// the English span it answers (spec-0096 §3.3): spans are matched by style, in
378/// order of appearance among spans of that style. `Ok(None)` when neither line
379/// carries a span; `Err` (the `DW0976` reason) when the two span multisets
380/// differ or either line does not parse.
381pub fn lower_aligned(english: &str, translated: &str) -> Result<Option<Lowered>, String> {
382    let en = parse(english).map_err(|e| format!("the English does not parse: {}", e.what))?;
383    let tr =
384        parse(translated).map_err(|e| format!("the translation does not parse: {}", e.what))?;
385    let en_sig: Vec<String> = en
386        .iter()
387        .filter_map(|g| match g {
388            Segment::Span(st, _) => Some(st.signature()),
389            Segment::Text(_) => None,
390        })
391        .collect();
392    let tr_sig: Vec<String> = tr
393        .iter()
394        .filter_map(|g| match g {
395            Segment::Span(st, _) => Some(st.signature()),
396            Segment::Text(_) => None,
397        })
398        .collect();
399    let (mut a, mut b) = (en_sig.clone(), tr_sig.clone());
400    a.sort();
401    b.sort();
402    if a != b {
403        return Err(format!(
404            "the English carries span(s) [{}] and the translation [{}]",
405            en_sig.join("; "),
406            tr_sig.join("; ")
407        ));
408    }
409    if en_sig.is_empty() {
410        return Ok(None);
411    }
412    // The k-th span of a style in the translation answers the k-th span of that
413    // style in the English.
414    let mut taken: BTreeMap<&str, usize> = BTreeMap::new();
415    let mut index_of = Vec::with_capacity(tr_sig.len());
416    for sig in &tr_sig {
417        let nth = taken.entry(sig.as_str()).or_insert(0);
418        let i = en_sig
419            .iter()
420            .enumerate()
421            .filter(|(_, s)| *s == sig)
422            .nth(*nth)
423            .map(|(i, _)| i)
424            .expect("equal multisets");
425        *nth += 1;
426        index_of.push(i);
427    }
428    Ok(lower_in(&tr, &index_of))
429}
430
431/// What [`validate_inline_styles`] examined, for the binding line.
432#[derive(Clone, Debug, Default, PartialEq, Eq)]
433pub struct InlineStyleBinding {
434    /// English lines in the inventory.
435    pub lines: usize,
436    /// English lines carrying at least one span.
437    pub styled: usize,
438    /// Spans across the English.
439    pub spans: usize,
440    /// Sidecar rows checked against their English.
441    pub sidecar_rows: usize,
442}
443
444impl InlineStyleBinding {
445    /// Count what the checks bind over `c` and `sidecars`.
446    pub fn of(c: &Campaign, sidecars: &BTreeMap<String, L10nDoc>) -> Self {
447        let inv = inventory(c);
448        let mut b = InlineStyleBinding {
449            lines: inv.len(),
450            ..Default::default()
451        };
452        for v in inv.values() {
453            let n = signatures(v).len();
454            if n > 0 {
455                b.styled += 1;
456                b.spans += n;
457            }
458        }
459        b.sidecar_rows = sidecars
460            .values()
461            .map(|d| d.content.keys().filter(|k| inv.contains_key(*k)).count())
462            .sum();
463        b
464    }
465
466    /// The one-line binding statement `delvec validate` prints.
467    pub fn line(&self) -> String {
468        format!(
469            "inline-style binding: {} of {} player-facing line(s) carry {} span(s); {} sidecar \
470             row(s) held to their English's spans",
471            self.styled, self.lines, self.spans, self.sidecar_rows
472        )
473    }
474}
475
476const GRAMMAR: &str = "a span is `[[<styles>|<text>]]`, <styles> one or more of bold, italic, \
477     underlined, strikethrough, obfuscated, color=<name or #rrggbb>, comma-separated with no \
478     spaces";
479
480/// `DW0975` over every English line and every sidecar row, and `DW0976` over
481/// every sidecar row whose spans are not its English's (spec-0096 §4).
482pub fn validate_inline_styles(
483    c: &Campaign,
484    sidecars: &BTreeMap<String, L10nDoc>,
485) -> Vec<Diagnostic> {
486    let mut d = Vec::new();
487    let inv = inventory(c);
488    let malformed = |stage: &str, path: String, key: &str, text: &str, e: MarkupError| {
489        Diagnostic::error(
490            INLINE_STYLE_MALFORMED,
491            stage,
492            path,
493            format!(
494                "player-visible string `{key}` has malformed style markup at character {}: {} \
495                 — {GRAMMAR}. Correct the span, or remove the doubled brackets. Text: `{text}`",
496                e.at, e.what
497            ),
498        )
499    };
500    for (key, text) in &inv {
501        if !has_markup(text) {
502            continue;
503        }
504        if let Err(e) = parse(text) {
505            d.push(malformed("l10n", format!("#/{key}"), key, text, e));
506        }
507    }
508    for (lang, doc) in sidecars {
509        for (key, text) in &doc.content {
510            let path = format!("l10n/{lang}.json#/content/{key}");
511            if has_markup(text)
512                && let Err(e) = parse(text)
513            {
514                d.push(malformed("l10n", path, key, text, e));
515                continue;
516            }
517            let Some(en) = inv.get(key) else {
518                continue;
519            };
520            if parse(en).is_err() {
521                continue;
522            }
523            if !has_markup(text) && !has_markup(en) {
524                continue;
525            }
526            if let Err(why) = lower_aligned(en, text) {
527                d.push(Diagnostic::error(
528                    INLINE_STYLE_UNMATCHED,
529                    "l10n",
530                    path,
531                    format!(
532                        "`{lang}` row `{key}` does not carry the English's styled spans: {why}. \
533                         Keep every span of the English with its style unchanged and its text \
534                         transcreated; it may move within the line. English: `{en}`; \
535                         translation: `{text}`"
536                    ),
537                ));
538            }
539        }
540    }
541    d
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    /// An obfuscated span is drawn but not read: its words are not legible,
549    /// every other span's are, and a line with no markup is itself.
550    #[test]
551    fn an_obfuscated_span_is_not_legible() {
552        assert_eq!(legible("plain"), "plain");
553        assert_eq!(
554            legible("the [[obfuscated|old road]] and the [[bold|Keep]]"),
555            "the \u{FFFD}\u{FFFD}\u{FFFD} \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} and the Keep"
556        );
557        assert_eq!(legible("[[bold,obfuscated|x]]"), "\u{FFFD}");
558    }
559
560    const BARK: &str =
561        "The ledger is kept by [[obfuscated|someone else]] at [[italic,color=dark_purple|night]].";
562
563    #[test]
564    fn a_line_with_no_markup_is_one_text_segment() {
565        assert_eq!(
566            parse("Plain words.").unwrap(),
567            vec![Segment::Text("Plain words.")]
568        );
569        assert_eq!(parse("a [bracket] or two").unwrap().len(), 1);
570        assert!(parse("").unwrap().is_empty());
571        assert_eq!(lower("Plain words."), None);
572    }
573
574    #[test]
575    fn every_style_key_parses() {
576        for f in FLAGS {
577            let line = format!("x [[{f}|y]] z");
578            let segs = parse(&line).unwrap();
579            assert_eq!(
580                segs[1],
581                Segment::Span(
582                    SpanStyle {
583                        flags: vec![f],
584                        color: None
585                    },
586                    "y"
587                )
588            );
589        }
590        for c in NAMED_COLORS {
591            let line = format!("[[color={c}|y]]");
592            assert_eq!(signatures(&line), vec![format!("color={c}")]);
593        }
594        assert_eq!(
595            signatures("[[color=#AbCdEf|y]]"),
596            vec!["color=#abcdef".to_string()]
597        );
598        assert_eq!(
599            signatures("[[obfuscated,bold,color=red|y]]"),
600            vec!["bold,obfuscated,color=red".to_string()]
601        );
602    }
603
604    #[test]
605    fn each_refusal_shape_names_its_offset() {
606        for (line, at, needle) in [
607            ("ab [[bold|c", 3, "never closed"),
608            ("ab ]] c", 3, "closes no span"),
609            ("[[bold c]]", 0, "no `|`"),
610            ("[[|c]]", 2, "names no style"),
611            ("[[shiny|c]]", 2, "not a style"),
612            ("[[bold,bold|c]]", 2, "twice"),
613            ("[[color=crimson|c]]", 2, "not a text colour"),
614            ("[[color=red,color=blue|c]]", 2, "twice"),
615            ("[[bold|  ]]", 7, "empty"),
616            ("[[bold|a [[b]]", 9, "do not nest"),
617            ("[[bold, italic|c]]", 2, "not a style"),
618        ] {
619            let e = parse(line).expect_err(line);
620            assert_eq!(e.at, at, "{line}: {}", e.what);
621            assert!(e.what.contains(needle), "{line}: {}", e.what);
622        }
623    }
624
625    /// `l10n::plain` — what every named exclusion reads — is the visible text,
626    /// tagged or not (spec-0096 §3.4).
627    #[test]
628    fn plain_reads_the_visible_text() {
629        let tagged = crate::l10n::tag("cast.q.n.0.bark.2", BARK);
630        assert_eq!(
631            crate::l10n::plain(&tagged),
632            "The ledger is kept by someone else at night."
633        );
634        assert_eq!(
635            crate::l10n::plain(BARK),
636            "The ledger is kept by someone else at night."
637        );
638        assert!(!crate::l10n::plain(&tagged).contains("[["));
639    }
640
641    #[test]
642    fn the_visible_text_drops_the_markup() {
643        assert_eq!(
644            visible(BARK),
645            "The ledger is kept by someone else at night."
646        );
647        assert_eq!(visible("no spans"), "no spans");
648        assert_eq!(visible("[[bold|broken"), "[[bold|broken");
649    }
650
651    #[test]
652    fn lowering_numbers_the_spans_and_escapes_percent() {
653        let l = lower("A 50% [[bold|chance]] of [[obfuscated|100%]].").unwrap();
654        assert_eq!(l.format, "A 50%% %1$s of %2$s.");
655        assert_eq!(l.spans[0].1, "chance");
656        assert_eq!(l.spans[1].1, "100%%");
657        assert_eq!(l.spans[1].0.signature(), "obfuscated");
658    }
659
660    #[test]
661    fn a_translation_is_aligned_to_the_english_by_style() {
662        let zh = "[[italic,color=dark_purple|夜里]],账本由[[obfuscated|别的什么人]]记着。";
663        let l = lower_aligned(BARK, zh).unwrap().unwrap();
664        assert_eq!(l.format, "%2$s,账本由%1$s记着。");
665        assert_eq!(l.spans[0].1, "别的什么人");
666        assert_eq!(l.spans[0].0.signature(), "obfuscated");
667        assert_eq!(l.spans[1].1, "夜里");
668        // Same-style spans keep their relative order.
669        let l = lower_aligned("[[bold|a]] [[bold|b]]", "[[bold|甲]][[bold|乙]]")
670            .unwrap()
671            .unwrap();
672        assert_eq!(l.format, "%1$s%2$s");
673    }
674
675    #[test]
676    fn a_translation_with_other_spans_is_refused() {
677        assert!(lower_aligned(BARK, "账本由别的什么人记着。").is_err());
678        assert!(lower_aligned(BARK, "[[bold|夜里]],账本由[[obfuscated|某人]]记着。").is_err());
679        assert!(lower_aligned("plain", "[[bold|加粗]]").is_err());
680        assert_eq!(lower_aligned("plain", "平"), Ok(None));
681    }
682}