1use 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 pub const INLINE_STYLE_MALFORMED: DwCode = DwCode::new("DW0975", ExitTier::Build);
38}
39
40crate::dw_code! {
41 pub const INLINE_STYLE_UNMATCHED: DwCode = DwCode::new("DW0976", ExitTier::Build);
46}
47
48pub const OPEN: &str = "[[";
50pub const CLOSE: &str = "]]";
52pub const SEP: char = '|';
54
55pub const FLAGS: [&str; 5] = [
57 "bold",
58 "italic",
59 "underlined",
60 "strikethrough",
61 "obfuscated",
62];
63
64pub 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
85pub fn span_key(line_key: &str, index: usize) -> String {
88 format!("{line_key}.span.{index}")
89}
90
91#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
93pub struct SpanStyle {
94 pub flags: Vec<&'static str>,
96 pub color: Option<String>,
98}
99
100impl SpanStyle {
101 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 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#[derive(Clone, Debug, PartialEq, Eq)]
127pub enum Segment<'a> {
128 Text(&'a str),
130 Span(SpanStyle, &'a str),
132}
133
134#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct MarkupError {
137 pub at: usize,
139 pub what: String,
141}
142
143fn char_at(s: &str, byte: usize) -> usize {
144 s[..byte].chars().count()
145}
146
147pub 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
190pub 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
257pub 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
276pub 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
302pub 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#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct Lowered {
322 pub format: String,
325 pub spans: Vec<(SpanStyle, String)>,
328}
329
330fn escape_percent(s: &str) -> String {
331 s.replace('%', "%%")
332}
333
334pub 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
376pub 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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
433pub struct InlineStyleBinding {
434 pub lines: usize,
436 pub styled: usize,
438 pub spans: usize,
440 pub sidecar_rows: usize,
442}
443
444impl InlineStyleBinding {
445 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 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
480pub 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 #[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 #[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 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}