1use crate::prelude::*;
24
25use alloc::collections::{BTreeMap, BTreeSet};
26use alloc::rc::Rc;
27
28use serde::{Deserialize, Serialize};
29
30use crate::error::ContentError;
31use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
32use crate::image::png_size;
33use crate::lang::{Lang, LangCode, TextAt, TextPart};
34use crate::ron::parse_ron;
35
36impl AssetKind for FontAtlasDef {
37 const NAME: &'static str = "font atlas";
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct FontAtlasRon {
46 required: String,
47}
48
49impl FontAtlasRon {
50 pub fn requiring(required: impl Into<String>) -> Self {
52 Self {
53 required: required.into(),
54 }
55 }
56}
57
58impl Format for FontAtlasRon {
59 type Asset = FontAtlasDef;
60
61 fn id(&self) -> &'static str {
62 "font-atlas"
63 }
64
65 fn extensions(&self) -> &'static [&'static str] {
66 &["ron"]
67 }
68
69 fn max_bytes(&self) -> usize {
70 TEXT_MAX_BYTES
71 }
72
73 fn decode(
74 &self,
75 file: &AssetPath,
76 bytes: &[u8],
77 cx: &DecodeCx<'_>,
78 ) -> Result<FontAtlasDef, Vec<ContentError>> {
79 let source = text(file, bytes)?;
80 let png = file.with_extension("png");
81 FontAtlasDef::from_source(
82 file.shown(),
83 source,
84 &cx.shown(&png),
85 cx.bytes(&png),
86 &self.required,
87 )
88 }
89}
90
91pub const FALLBACK_GLYPH: char = '?';
93
94pub const WIDE_PUNCTUATION: [char; 6] = [
99 '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '\u{2026}', '\u{2014}',
100];
101
102pub fn is_wide_punctuation(c: char) -> bool {
104 WIDE_PUNCTUATION.contains(&c)
105}
106
107pub type WideSet = BTreeMap<char, u32>;
109
110#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
112pub struct FontAtlasDef {
113 pub cell_w: u32,
115 pub cell_h: u32,
117 pub columns: u32,
119 pub glyphs: BTreeMap<char, u32>,
121 #[serde(default)]
127 pub wide: WideSet,
128 #[serde(default)]
133 pub wide_sets: BTreeMap<String, WideSet>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct AtlasRect {
139 pub x: u32,
141 pub y: u32,
143 pub w: u32,
145 pub h: u32,
147}
148
149impl FontAtlasDef {
150 pub fn cell_rect(&self, index: u32) -> AtlasRect {
153 AtlasRect {
154 x: index % self.columns * self.cell_w,
155 y: index / self.columns * self.cell_h,
156 w: self.cell_w,
157 h: self.cell_h,
158 }
159 }
160
161 pub fn glyph_rect(&self, c: char) -> Option<AtlasRect> {
164 self.glyphs.get(&c).map(|&i| self.cell_rect(i))
165 }
166
167 pub fn wide_rect(&self, c: char) -> Option<AtlasRect> {
171 self.wide_rect_in(None, c)
172 }
173
174 pub fn wide_rect_in(&self, set: Option<&str>, c: char) -> Option<AtlasRect> {
178 self.wide_cell(set, c).map(|i| {
179 let rect = self.cell_rect(i);
180 AtlasRect {
181 w: rect.w * 2,
182 ..rect
183 }
184 })
185 }
186
187 pub fn wide_cell(&self, set: Option<&str>, c: char) -> Option<u32> {
192 let named = set.and_then(|name| self.wide_sets.get(name));
193 named
194 .and_then(|glyphs| glyphs.get(&c))
195 .or_else(|| self.wide.get(&c))
196 .or_else(|| self.wide_sets.values().find_map(|glyphs| glyphs.get(&c)))
197 .copied()
198 }
199
200 pub fn wide_set(&self, name: Option<&str>) -> Option<&WideSet> {
203 match name {
204 None => Some(&self.wide),
205 Some(name) => self.wide_sets.get(name),
206 }
207 }
208
209 pub fn all_wide_sets(&self) -> impl Iterator<Item = (Option<&str>, &WideSet)> + '_ {
212 let named = self
213 .wide_sets
214 .iter()
215 .map(|(name, glyphs)| (Some(name.as_str()), glyphs));
216 core::iter::once((None, &self.wide)).chain(named)
217 }
218
219 pub fn wide_chars(&self) -> BTreeSet<char> {
223 self.all_wide_sets()
224 .flat_map(|(_, glyphs)| glyphs.keys().copied())
225 .filter(|&c| !(is_wide_punctuation(c) && self.glyphs.contains_key(&c)))
226 .collect()
227 }
228
229 pub fn has_wide(&self, c: char) -> bool {
231 self.all_wide_sets()
232 .any(|(_, glyphs)| glyphs.contains_key(&c))
233 }
234
235 pub fn draws(&self, c: char) -> bool {
240 self.glyphs.contains_key(&c) || self.has_wide(c)
241 }
242
243 pub fn drawn(&self) -> impl Iterator<Item = char> + '_ {
246 let wide = self.all_wide_sets().flat_map(|(_, glyphs)| glyphs.keys());
247 let all: BTreeSet<char> = self.glyphs.keys().chain(wide).copied().collect();
248 all.into_iter()
249 }
250
251 pub fn cell_end(&self) -> u32 {
254 self.cells()
255 .map(|(i, _)| i.saturating_add(1))
256 .max()
257 .unwrap_or(0)
258 }
259
260 pub fn from_source(
264 file: &str,
265 source: &str,
266 png_file: &str,
267 png: Option<&[u8]>,
268 required: &str,
269 ) -> Result<Self, Vec<ContentError>> {
270 let def: Self = parse_ron(file, source).map_err(|e| vec![e])?;
271 let mut errors: Vec<ContentError> = def
272 .problems(required)
273 .into_iter()
274 .map(|m| ContentError::new(file, m))
275 .collect();
276 match png.map(png_size) {
277 None => errors.push(ContentError::not_found(png_file)),
278 Some(None) => errors.push(ContentError::new(png_file, "not a valid PNG file")),
279 Some(Some((w, h))) => errors.extend(
280 def.image_problems(w, h)
281 .into_iter()
282 .map(|m| ContentError::new(png_file, m)),
283 ),
284 }
285 if errors.is_empty() {
286 Ok(def)
287 } else {
288 Err(errors)
289 }
290 }
291
292 fn cells(&self) -> impl Iterator<Item = (u32, char)> + '_ {
295 let narrow = self.glyphs.iter().map(|(&c, &i)| (i, c));
296 let wide = self
297 .all_wide_sets()
298 .flat_map(|(_, glyphs)| glyphs)
299 .flat_map(|(&c, &i)| [(i, c), (i.saturating_add(1), c)]);
300 narrow.chain(wide)
301 }
302
303 pub fn problems(&self, required: &str) -> Vec<String> {
308 let mut out = Vec::new();
309 for (name, v) in [
310 ("cell_w", self.cell_w),
311 ("cell_h", self.cell_h),
312 ("columns", self.columns),
313 ] {
314 if v == 0 {
315 out.push(format!("{name} must be greater than 0"));
316 }
317 }
318 let missing: Vec<String> = required
319 .chars()
320 .filter(|&c| !self.draws(c))
321 .map(describe)
322 .collect();
323 if !missing.is_empty() {
324 out.push(format!("missing required glyphs: {}", missing.join(", ")));
325 }
326 if self.wide_sets.keys().any(|name| name.trim().is_empty()) {
327 out.push("a wide set's name must not be empty".to_owned());
328 }
329 for (name, glyphs) in self.all_wide_sets() {
330 let set = in_set(name);
331 let both = glyphs
332 .keys()
333 .filter(|&&c| self.glyphs.contains_key(&c) && !is_wide_punctuation(c));
334 for &c in both {
335 out.push(format!("{} is both narrow and wide{set}", describe(c)));
336 }
337 if self.columns == 0 {
338 continue;
339 }
340 for (&c, &i) in glyphs {
341 if i % self.columns == self.columns - 1 {
342 out.push(format!(
343 "wide {}{set} has cell index {i}, the last of its row: its right half \
344 would start the next row",
345 describe(c)
346 ));
347 }
348 }
349 }
350 let mut owner: BTreeMap<u32, char> = BTreeMap::new();
351 for (i, c) in self.cells() {
352 if let Some(&first) = owner.get(&i) {
353 if first != c {
354 out.push(format!(
355 "{} and {} share cell index {i}",
356 describe(first),
357 describe(c)
358 ));
359 }
360 } else {
361 owner.insert(i, c);
362 }
363 }
364 out
365 }
366
367 pub fn image_problems(&self, width: u32, height: u32) -> Vec<String> {
369 if self.cell_w == 0 || self.cell_h == 0 || self.columns == 0 {
370 return Vec::new(); }
372 let mut out = Vec::new();
373 let expected_w = u64::from(self.columns) * u64::from(self.cell_w);
374 if u64::from(width) != expected_w {
375 out.push(format!(
376 "image is {width} px wide, expected {expected_w} (columns × cell_w)"
377 ));
378 }
379 if !height.is_multiple_of(self.cell_h) {
380 out.push(format!(
381 "image height {height} px is not a multiple of cell_h {}",
382 self.cell_h
383 ));
384 }
385 let cells = u64::from(self.columns) * u64::from(height / self.cell_h);
386 if let Some((i, c)) = self.cells().find(|&(i, _)| u64::from(i) >= cells) {
387 out.push(format!(
388 "{} has cell index {i}, but the image only holds {cells} cells",
389 describe(c)
390 ));
391 }
392 out
393 }
394}
395
396#[derive(Debug, Clone, Default, PartialEq, Eq)]
406pub struct CellWidths {
407 wide: Option<Rc<BTreeSet<char>>>,
409 punctuation: bool,
411 set: Option<Rc<str>>,
413}
414
415impl CellWidths {
416 pub const NARROW: Self = Self {
418 wide: None,
419 punctuation: false,
420 set: None,
421 };
422
423 pub fn of(atlas: &FontAtlasDef) -> Self {
428 Self::wide(atlas.wide_chars())
429 }
430
431 pub fn wide(wide: impl IntoIterator<Item = char>) -> Self {
433 let wide: BTreeSet<char> = wide.into_iter().collect();
434 Self {
435 wide: (!wide.is_empty()).then(|| Rc::new(wide)),
436 ..Self::NARROW
437 }
438 }
439
440 #[must_use]
445 pub fn in_lang(self, wide_font: Option<&str>, wide_punctuation: bool) -> Self {
446 Self {
447 punctuation: wide_punctuation,
448 set: wide_font.map(Rc::from),
449 ..self
450 }
451 }
452
453 pub fn wide_font(&self) -> Option<&str> {
456 self.set.as_deref()
457 }
458
459 pub fn wide_punctuation(&self) -> bool {
461 self.punctuation
462 }
463
464 pub fn is_wide(&self, c: char) -> bool {
466 self.wide.as_ref().is_some_and(|wide| wide.contains(&c))
467 || (self.punctuation && is_wide_punctuation(c))
468 }
469
470 pub fn of_char(&self, c: char) -> usize {
472 if self.is_wide(c) { 2 } else { 1 }
473 }
474}
475
476fn in_set(name: Option<&str>) -> String {
479 name.map(|name| format!(" in wide set {name:?}"))
480 .unwrap_or_default()
481}
482
483pub fn describe(c: char) -> String {
485 format!("'{}' (U+{:04X})", c.escape_debug(), u32::from(c))
486}
487
488pub fn is_combining_mark(c: char) -> bool {
492 ('\u{300}'..='\u{36f}').contains(&c)
493}
494
495pub fn ascii_spelling(c: char) -> Option<&'static str> {
498 match c {
499 '\u{2018}' | '\u{2019}' => Some("'"),
500 '\u{201C}' | '\u{201D}' => Some("\""),
501 '\u{2026}' => Some("..."),
502 '\u{2014}' => Some("--"),
503 '\u{2013}' => Some("-"),
504 _ => None,
505 }
506}
507
508struct Undrawn<'a> {
511 c: char,
512 first: TextAt<'a>,
513 keys: BTreeSet<(TextPart, &'a str)>,
514 set: Option<&'a str>,
517}
518
519impl Undrawn<'_> {
520 fn error(&self) -> ContentError {
521 let more = self.keys.len() - 1;
522 let keys = match more {
523 0 => self.first.key.to_owned(),
524 1 => format!("{} and 1 more key", self.first.key),
525 _ => format!("{} and {more} more keys", self.first.key),
526 };
527 let c = describe(self.c);
528 let message = if is_combining_mark(self.c) {
529 format!(
530 "{c} is a combining mark, which a cell draws beside its letter, not over it \
531 ({keys}); write the accented letter as one character, as 'é' (U+00E9) is"
532 )
533 } else if let Some(set) = self.set {
534 format!(
535 "{c} isn't in the font atlas's wide set {set:?}, which the language draws from \
536 ({keys}); remake the atlas with font-atlas --wide {set}=<font.bdf> from a font \
537 that has it"
538 )
539 } else {
540 let instead = ascii_spelling(self.c)
541 .map(|ascii| format!(", or write {ascii} instead"))
542 .unwrap_or_default();
543 format!(
544 "{c} isn't in the font atlas ({keys}); remake the atlas with font-atlas from a \
545 font that has it{instead}"
546 )
547 };
548 ContentError::new(self.first.path(), message)
549 }
550}
551
552#[derive(Clone, Copy)]
554enum SetOf<'a> {
555 Any,
557 Named(&'a str, &'a WideSet),
559 Missing,
562}
563
564struct LangFont<'a> {
567 widths: CellWidths,
568 set: SetOf<'a>,
569}
570
571impl<'a> LangFont<'a> {
572 fn draws(&self, atlas: &FontAtlasDef, c: char) -> bool {
576 if !self.widths.is_wide(c) {
577 return atlas.glyphs.contains_key(&c);
578 }
579 match self.set {
580 SetOf::Any => atlas.has_wide(c),
581 SetOf::Named(_, glyphs) => glyphs.contains_key(&c),
582 SetOf::Missing => true,
583 }
584 }
585
586 fn set_for(&self, c: char) -> Option<&'a str> {
588 match self.set {
589 SetOf::Named(name, _) if self.widths.is_wide(c) => Some(name),
590 _ => None,
591 }
592 }
593}
594
595pub fn undrawn(atlas: &FontAtlasDef, lang: &Lang) -> Vec<ContentError> {
608 let base = CellWidths::of(atlas);
609 let mut errors = Vec::new();
610 let mut fonts: BTreeMap<Option<&LangCode>, LangFont<'_>> = BTreeMap::new();
611 let mut found: Vec<Undrawn<'_>> = Vec::new();
612 let mut index: BTreeMap<(Option<&LangCode>, char), usize> = BTreeMap::new();
613 for (at, text) in lang.texts() {
614 let font = fonts.entry(at.lang).or_insert_with(|| {
615 let source = &lang.source_lang().code;
616 let widths = lang.widths(at.lang.unwrap_or(source), &base);
617 let set = match widths.wide_font() {
618 None => SetOf::Any,
619 Some(name) => {
620 if let Some((name, glyphs)) = atlas.wide_sets.get_key_value(name) {
621 SetOf::Named(name, glyphs)
622 } else {
623 let (path, whose) = match at.lang {
626 Some(code) => (format!("{code}/{}", crate::lang::INFO_FILE), ""),
627 None => (source.to_string(), "the source language's "),
628 };
629 errors.push(ContentError::new(
630 path,
631 format!(
632 "{whose}wide_font names {name:?}, a wide set the font atlas \
633 doesn't have; remake the atlas with font-atlas --wide \
634 {name}=<font.bdf>"
635 ),
636 ));
637 SetOf::Missing
638 }
639 }
640 };
641 LangFont { widths, set }
642 });
643 for c in text.chars() {
644 if c.is_control() || (font.draws(atlas, c) && !is_combining_mark(c)) {
645 continue;
646 }
647 let set = font.set_for(c);
648 let i = *index.entry((at.lang, c)).or_insert_with(|| {
649 found.push(Undrawn {
650 c,
651 first: at,
652 keys: BTreeSet::new(),
653 set,
654 });
655 found.len() - 1
656 });
657 found[i].keys.insert((at.part, at.key));
658 }
659 }
660 errors.extend(found.iter().map(Undrawn::error));
661 errors
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use crate::image::png_header;
668
669 const REQUIRED: &str = " !'?ABCabc\u{ad}♣─";
671
672 fn required_only() -> FontAtlasDef {
674 FontAtlasDef {
675 cell_w: 8,
676 cell_h: 16,
677 columns: 16,
678 glyphs: REQUIRED.chars().zip(0..).collect(),
679 ..FontAtlasDef::default()
680 }
681 }
682
683 fn rows_for(def: &FontAtlasDef) -> u32 {
684 let n = u32::try_from(def.glyphs.len()).unwrap_or(u32::MAX);
685 n.div_ceil(def.columns)
686 }
687
688 fn from_source(source: &str, png: Option<&[u8]>) -> Result<FontAtlasDef, Vec<ContentError>> {
689 FontAtlasDef::from_source("a.ron", source, "base/fonts/atlas.png", png, REQUIRED)
690 }
691
692 #[test]
693 fn describe_names_the_glyph_and_its_code_point() {
694 assert_eq!(describe('a'), "'a' (U+0061)");
695 assert_eq!(describe('\''), "'\\'' (U+0027)");
696 assert_eq!(describe('\u{ad}'), "'\\u{ad}' (U+00AD)");
697 assert_eq!(describe('♣'), "'♣' (U+2663)");
698 assert!(REQUIRED.contains(FALLBACK_GLYPH));
699 }
700
701 #[test]
702 fn cell_and_glyph_rects() {
703 let def = FontAtlasDef {
704 cell_w: 8,
705 cell_h: 16,
706 columns: 32,
707 glyphs: [('a', 0), ('b', 31), ('c', 32), ('d', 70)].into(),
708 wide: [('あ', 33)].into(),
709 wide_sets: BTreeMap::new(),
710 };
711 let r = |x, y| Some(AtlasRect { x, y, w: 8, h: 16 });
712 assert_eq!(def.glyph_rect('a'), r(0, 0));
713 assert_eq!(def.glyph_rect('b'), r(248, 0));
714 assert_eq!(def.glyph_rect('c'), r(0, 16));
715 assert_eq!(def.glyph_rect('d'), r(48, 32));
716 assert_eq!(def.glyph_rect('z'), None);
717 let wide = Some(AtlasRect {
719 x: 8,
720 y: 16,
721 w: 16,
722 h: 16,
723 });
724 assert_eq!(def.wide_rect('あ'), wide);
725 assert_eq!(def.glyph_rect('あ'), None);
726 assert_eq!(def.wide_rect('a'), None);
727 }
728
729 fn with_wide() -> FontAtlasDef {
732 FontAtlasDef {
733 wide: [('あ', 16), ('い', 18)].into(),
734 ..required_only()
735 }
736 }
737
738 #[test]
739 fn a_wide_glyph_is_drawn_and_listed_among_the_narrow_ones() {
740 let def = with_wide();
741 assert_eq!(def.problems(REQUIRED), Vec::<String>::new());
742 assert_eq!(def.image_problems(128, 32), Vec::<String>::new());
743 assert!(def.draws('あ') && def.draws('A') && !def.draws('う'));
744 let drawn: Vec<char> = def.drawn().collect();
745 let mut sorted = drawn.clone();
746 sorted.sort_unstable();
747 assert_eq!(drawn, sorted, "in code-point order");
748 assert_eq!(drawn.len(), REQUIRED.chars().count() + 2);
749 assert_eq!(def.problems("あ"), Vec::<String>::new());
751 let mixed = FontAtlasDef {
753 glyphs: [('b', 0), ('d', 1)].into(),
754 wide: [('a', 2), ('c', 4), ('e', 6)].into(),
755 ..FontAtlasDef::default()
756 };
757 assert_eq!(mixed.drawn().collect::<String>(), "abcde");
758 }
759
760 #[test]
761 fn wide_glyph_problems() {
762 let mut def = with_wide();
764 def.glyphs.insert('あ', 30);
765 assert_eq!(
766 def.problems(REQUIRED),
767 ["'あ' (U+3042) is both narrow and wide"]
768 );
769 let mut def = with_wide();
771 def.wide.insert('う', 12);
772 assert_eq!(
773 def.problems(REQUIRED),
774 ["'─' (U+2500) and 'う' (U+3046) share cell index 12"]
775 );
776 let mut def = with_wide();
779 def.wide.insert('う', 31);
780 assert_eq!(
781 def.problems(REQUIRED),
782 [
783 "wide 'う' (U+3046) has cell index 31, the last of its row: its right half \
784 would start the next row"
785 ]
786 );
787 assert_eq!(
788 def.image_problems(128, 32),
789 ["'う' (U+3046) has cell index 32, but the image only holds 32 cells"]
790 );
791 }
792
793 fn with_sets() -> FontAtlasDef {
797 let mut def = required_only();
798 def.glyphs.extend([('“', 28), ('…', 29)]);
799 def.wide_sets = [
800 ("ja".to_owned(), [('直', 16), ('あ', 18)].into()),
801 (
802 "zh-Hans".to_owned(),
803 [('直', 20), ('简', 22), ('“', 24), ('…', 26)].into(),
804 ),
805 ]
806 .into();
807 def
808 }
809
810 #[test]
811 fn a_wide_glyph_is_looked_up_in_the_languages_set_first_then_any_other() {
812 let def = with_sets();
813 assert_eq!(def.problems(REQUIRED), Vec::<String>::new());
814 assert_eq!(def.image_problems(128, 32), Vec::<String>::new());
815 assert_eq!(def.wide_cell(Some("ja"), '直'), Some(16));
817 assert_eq!(def.wide_cell(Some("zh-Hans"), '直'), Some(20));
818 assert_eq!(def.wide_cell(Some("ja"), '简'), Some(22));
820 assert_eq!(def.wide_cell(Some("zh-Hans"), 'あ'), Some(18));
821 assert_eq!(def.wide_cell(None, '直'), Some(16));
824 assert_eq!(def.wide_cell(Some("ko"), '直'), Some(16));
825 let mut unnamed = with_sets();
826 unnamed.wide.insert('直', 30);
827 assert_eq!(unnamed.wide_cell(None, '直'), Some(30));
828 assert_eq!(unnamed.wide_cell(Some("zh-Hans"), '直'), Some(20));
829 assert_eq!(def.wide_cell(Some("ja"), 'A'), None);
830 let rect = def.wide_rect_in(Some("zh-Hans"), '直');
831 assert_eq!(rect.map(|r| (r.x, r.y, r.w)), Some((32, 16, 16)));
832 assert_eq!(def.wide_rect('直'), def.wide_rect_in(Some("ja"), '直'));
833 assert!(def.draws('简') && def.draws('あ') && !def.draws('骨'));
835 assert_eq!(def.drawn().filter(|&c| c == '直').count(), 1);
836 assert_eq!(def.wide_set(Some("ja")).map(BTreeMap::len), Some(2));
837 assert_eq!(def.wide_set(None), Some(&BTreeMap::new()));
838 assert_eq!(def.wide_set(Some("ko")), None);
839 assert_eq!(def.cell_end(), 30);
840 }
841
842 #[test]
843 fn wide_set_problems() {
844 let mut def = with_sets();
846 def.glyphs.insert('简', 30);
847 assert_eq!(
848 def.problems(REQUIRED),
849 ["'简' (U+7B80) is both narrow and wide in wide set \"zh-Hans\""]
850 );
851 let mut def = with_sets();
853 def.wide_sets.get_mut("ja").unwrap().insert('骨', 31);
854 assert_eq!(
855 def.problems(REQUIRED),
856 [
857 "wide '骨' (U+9AA8) in wide set \"ja\" has cell index 31, the last of its row: \
858 its right half would start the next row"
859 ]
860 );
861 assert_eq!(
862 def.image_problems(128, 32),
863 ["'骨' (U+9AA8) has cell index 32, but the image only holds 32 cells"]
864 );
865 let mut def = with_sets();
867 def.wide_sets.get_mut("ja").unwrap().insert('骨', 20);
868 def.wide_sets.insert(" ".to_owned(), BTreeMap::new());
869 assert_eq!(
870 def.problems(REQUIRED),
871 [
872 "a wide set's name must not be empty",
873 "'骨' (U+9AA8) and '直' (U+76F4) share cell index 20",
874 "'骨' (U+9AA8) and '直' (U+76F4) share cell index 21",
875 ]
876 );
877 let src = "(cell_w: 8, cell_h: 16, columns: 16, glyphs: {}, wide: {'あ': 0})";
879 let def: FontAtlasDef = ron::from_str(src).unwrap();
880 assert!(def.wide_sets.is_empty());
881 let src =
882 "(cell_w: 8, cell_h: 16, columns: 16, glyphs: {}, wide_sets: {\"ja\": {'あ': 0}})";
883 let def: FontAtlasDef = ron::from_str(src).unwrap();
884 assert_eq!(def.wide_cell(Some("ja"), 'あ'), Some(0));
885 }
886
887 #[test]
888 fn widths_by_language_make_six_punctuation_marks_wide() {
889 let base = CellWidths::of(&with_sets());
890 assert!(base.is_wide('直') && base.is_wide('简') && base.is_wide('あ'));
893 assert!(!base.is_wide('“') && !base.is_wide('…'));
894 assert_eq!(base.wide_font(), None);
895 let chinese = base.clone().in_lang(Some("zh-Hans"), true);
896 assert_eq!(chinese.wide_font(), Some("zh-Hans"));
897 assert!(chinese.wide_punctuation());
898 for c in WIDE_PUNCTUATION {
899 assert!(chinese.is_wide(c), "{c}");
900 assert!(!base.is_wide(c), "{c}");
901 }
902 let width = |text: &str, widths: &CellWidths| crate::lang::text_width(text, widths);
903 assert_eq!(width("“直”", &chinese), 6);
904 assert_eq!(width("……", &chinese), 4);
905 assert_eq!(width("“ok”", &base), 4);
906 assert_eq!(width("“ok”", &base.clone().in_lang(Some("ja"), false)), 4);
907 let mut only_wide = with_sets();
909 only_wide.glyphs.remove(&'…');
910 assert!(CellWidths::of(&only_wide).is_wide('…'));
911 assert!(!CellWidths::NARROW.wide_punctuation());
912 }
913
914 #[test]
915 fn widths_count_a_wide_glyph_twice() {
916 let widths = CellWidths::of(&with_wide());
917 assert!(widths.is_wide('あ') && !widths.is_wide('A'));
918 assert_eq!((widths.of_char('い'), widths.of_char('A')), (2, 1));
919 assert_eq!(CellWidths::of(&required_only()), CellWidths::NARROW);
920 assert_eq!(CellWidths::wide([]), CellWidths::default());
921 assert!(!CellWidths::NARROW.is_wide('あ'));
922 }
923
924 #[test]
925 fn valid_atlas_has_no_problems() {
926 let def = required_only();
927 assert_eq!(def.problems(REQUIRED), Vec::<String>::new());
928 let rows = rows_for(&def);
929 assert_eq!(def.image_problems(128, rows * 16), Vec::<String>::new());
930 }
931
932 #[test]
933 fn zero_sizes_are_problems() {
934 let mut def = required_only();
935 def.cell_w = 0;
936 def.columns = 0;
937 assert_eq!(
938 def.problems(REQUIRED),
939 [
940 "cell_w must be greater than 0",
941 "columns must be greater than 0"
942 ]
943 );
944 let got = def.image_problems(0, 0);
945 assert!(got.is_empty(), "{got:?}");
946 let mut def = required_only();
947 def.cell_h = 0;
948 assert_eq!(def.problems(REQUIRED), ["cell_h must be greater than 0"]);
949 let got = def.image_problems(0, 0);
950 assert!(got.is_empty(), "{got:?}");
951 let mut def = with_wide();
953 def.columns = 0;
954 assert_eq!(def.problems(REQUIRED), ["columns must be greater than 0"]);
955 }
956
957 #[test]
958 fn missing_required_glyphs_listed_together() {
959 let mut def = required_only();
960 def.glyphs.remove(&'♣');
961 def.glyphs.remove(&'\'');
962 assert_eq!(
963 def.problems(REQUIRED),
964 ["missing required glyphs: '\\'' (U+0027), '♣' (U+2663)"]
965 );
966 assert_eq!(def.problems(""), Vec::<String>::new());
968 }
969
970 #[test]
971 fn shared_index_is_problem() {
972 let mut def = required_only();
973 def.glyphs.insert('\u{2603}', 0);
974 assert_eq!(
975 def.problems(REQUIRED),
976 ["' ' (U+0020) and '☃' (U+2603) share cell index 0"]
977 );
978 }
979
980 #[test]
981 fn image_size_problems() {
982 let def = required_only();
983 let rows = rows_for(&def);
984 assert_eq!(
985 def.image_problems(120, rows * 16),
986 ["image is 120 px wide, expected 128 (columns × cell_w)"]
987 );
988 assert_eq!(
989 def.image_problems(128, rows * 16 + 3),
990 [format!(
991 "image height {} px is not a multiple of cell_h 16",
992 rows * 16 + 3
993 )]
994 );
995 let narrow = FontAtlasDef {
997 cell_w: 8,
998 cell_h: 16,
999 columns: 2,
1000 glyphs: [('a', 5)].into(),
1001 ..FontAtlasDef::default()
1002 };
1003 assert_eq!(narrow.image_problems(16, 48), Vec::<String>::new());
1004 assert_eq!(narrow.image_problems(16, 32).len(), 1);
1005 let mut def = required_only();
1007 def.glyphs.insert('z', rows * 16);
1008 let problems = def.image_problems(128, rows * 16);
1009 assert_eq!(
1010 problems,
1011 [format!(
1012 "'z' (U+007A) has cell index {}, but the image only holds {} cells",
1013 rows * 16,
1014 rows * 16
1015 )]
1016 );
1017 }
1018
1019 #[test]
1020 fn from_source_reports_file_and_image_errors() {
1021 let src = "(cell_w: 8, cell_h: 16, columns: 0, glyphs: {'a': 0})";
1022 let errs = from_source(src, None).err().unwrap_or_default();
1023 let text: Vec<String> = errs.iter().map(ToString::to_string).collect();
1024 assert_eq!(text.len(), 3, "{text:?}");
1025 assert_eq!(text[0], "a.ron: columns must be greater than 0");
1026 assert!(text[1].starts_with("a.ron: missing required glyphs: ' ' (U+0020)"));
1027 assert_eq!(
1028 text[2],
1029 "base/fonts/atlas.png: file not found in asset bundle"
1030 );
1031 let errs = from_source(src, Some(b"nope")).err().unwrap_or_default();
1032 assert_eq!(
1033 errs.last().map(ToString::to_string).as_deref(),
1034 Some("base/fonts/atlas.png: not a valid PNG file")
1035 );
1036 }
1037
1038 #[test]
1039 fn draws_exactly_the_characters_drawn_lists() {
1040 let def = required_only();
1041 let drawn: Vec<char> = def.drawn().collect();
1042 let mut sorted = drawn.clone();
1043 sorted.sort_unstable();
1044 assert_eq!(drawn, sorted, "in code-point order");
1045 assert_eq!(drawn.len(), REQUIRED.chars().count());
1046 assert!(drawn.iter().all(|&c| def.draws(c)));
1047 assert!(!def.draws('Ж') && !def.draws('z'));
1048 assert_eq!(FontAtlasDef::default().drawn().count(), 0);
1049 }
1050
1051 mod undrawn {
1052 use alloc::collections::BTreeMap;
1053
1054 use super::super::*;
1055 use crate::lang::{PackRules, pack_from_sources};
1056
1057 const RULES: PackRules<'static> = PackRules {
1058 data_problem: |_, _| None,
1059 tokens: &crate::dialogue::NoTokens,
1060 limits: crate::dialogue::tests::LIMITS,
1061 widths: &crate::font::CellWidths::NARROW,
1062 variants: &[],
1063 };
1064
1065 fn ascii_and(extra: &str) -> FontAtlasDef {
1067 FontAtlasDef {
1068 cell_w: 8,
1069 cell_h: 16,
1070 columns: 16,
1071 glyphs: (' '..='~').chain(extra.chars()).zip(0..).collect(),
1072 ..FontAtlasDef::default()
1073 }
1074 }
1075
1076 fn owned(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
1077 pairs
1078 .iter()
1079 .map(|&(k, v)| (k.to_owned(), v.to_owned()))
1080 .collect()
1081 }
1082
1083 fn lang(english: &[(&str, &str)], packs: &[(&str, &[(&str, &str)])]) -> Lang {
1086 let english = owned(english);
1087 let packs = packs
1088 .iter()
1089 .map(|&(code, entries)| {
1090 let ui: Vec<String> = entries
1091 .iter()
1092 .map(|(key, text)| {
1093 let source = &english[*key];
1094 format!("(key: {key:?}, source: {source:?}, text: {text:?})")
1095 })
1096 .collect();
1097 let ui = format!("[{}]", ui.join(", "));
1098 let info = r#"(name: "Test", made_by: Machine)"#;
1099 let pack =
1100 pack_from_sources(("lang.ron", info), ("ui.ron", &ui), &english, &RULES);
1101 (LangCode::new(code).unwrap(), pack.unwrap())
1102 })
1103 .collect();
1104 Lang::new(english, packs)
1105 }
1106
1107 fn errors(atlas: &FontAtlasDef, lang: &Lang) -> Vec<String> {
1108 undrawn(atlas, lang)
1109 .iter()
1110 .map(ToString::to_string)
1111 .collect()
1112 }
1113
1114 #[test]
1115 fn a_character_the_atlas_lacks_names_its_file_its_key_and_what_to_do() {
1116 let lang = lang(
1117 &[("menu.title", "Start")],
1118 &[("ru", &[("menu.title", "Жa")])],
1119 );
1120 let found = undrawn(&ascii_and(""), &lang);
1121 assert_eq!(found.len(), 1, "{found:?}");
1122 assert_eq!(found[0].file, "ru/ui.ron");
1123 assert_eq!(found[0].line, None);
1124 assert_eq!(
1125 found[0].message,
1126 "'Ж' (U+0416) isn't in the font atlas (menu.title); remake the atlas with \
1127 font-atlas from a font that has it"
1128 );
1129 assert_eq!(errors(&ascii_and("Ж"), &lang), Vec::<String>::new());
1130 }
1131
1132 #[test]
1133 fn a_wide_glyph_counts_as_drawn() {
1134 let lang = lang(
1135 &[("menu.title", "Start")],
1136 &[("ja", &[("menu.title", "あA")])],
1137 );
1138 let found = errors(&ascii_and(""), &lang);
1139 assert_eq!(found.len(), 1, "{found:?}");
1140 assert!(found[0].starts_with("ja/ui.ron: 'あ' (U+3042) isn't in the font atlas"));
1141 let mut atlas = ascii_and("");
1142 atlas.wide.insert('あ', 96);
1143 assert_eq!(errors(&atlas, &lang), Vec::<String>::new());
1144 }
1145
1146 fn lang_with_info(packs: &[(&str, &str, &str)]) -> Lang {
1149 let english = owned(&[("menu.title", "Start")]);
1150 let packs = packs
1151 .iter()
1152 .map(|&(code, extra, text)| {
1153 let ui = format!(r#"[(key: "menu.title", source: "Start", text: {text:?})]"#);
1154 let info = format!(r#"(name: "Test", made_by: Machine{extra})"#);
1155 let pack =
1156 pack_from_sources(("lang.ron", &info), ("ui.ron", &ui), &english, &RULES);
1157 (LangCode::new(code).unwrap(), pack.unwrap())
1158 })
1159 .collect();
1160 Lang::new(english, packs)
1161 }
1162
1163 fn two_sets() -> FontAtlasDef {
1166 let mut atlas = ascii_and("“”");
1167 atlas.wide_sets = [
1168 ("ja".to_owned(), [('直', 100), ('あ', 102)].into()),
1169 (
1170 "zh-Hans".to_owned(),
1171 [('直', 104), ('简', 106), ('“', 108), ('”', 110)].into(),
1172 ),
1173 ]
1174 .into();
1175 atlas
1176 }
1177
1178 #[test]
1179 fn a_packs_wide_characters_must_be_in_the_set_it_names() {
1180 let ja = r#", wide_font: Some("ja")"#;
1181 let zh = r#", wide_font: Some("zh-Hans"), wide_punctuation: true"#;
1182 let fine = lang_with_info(&[("ja", ja, "直あ"), ("zh-Hans", zh, "“直简”")]);
1184 assert_eq!(errors(&two_sets(), &fine), Vec::<String>::new());
1185 let lacks = lang_with_info(&[("ja", ja, "直简")]);
1187 assert_eq!(
1188 errors(&two_sets(), &lacks),
1189 [
1190 "ja/ui.ron: '简' (U+7B80) isn't in the font atlas's wide set \"ja\", which \
1191 the language draws from (menu.title); remake the atlas with font-atlas \
1192 --wide ja=<font.bdf> from a font that has it"
1193 ]
1194 );
1195 let narrow_missing = lang_with_info(&[("ja", ja, "直Ж")]);
1197 assert_eq!(
1198 errors(&two_sets(), &narrow_missing),
1199 [
1200 "ja/ui.ron: 'Ж' (U+0416) isn't in the font atlas (menu.title); remake the \
1201 atlas with font-atlas from a font that has it"
1202 ]
1203 );
1204 let mut no_quotes = two_sets();
1206 no_quotes.wide_sets.get_mut("zh-Hans").unwrap().remove(&'“');
1207 let found = errors(&no_quotes, &fine);
1208 assert_eq!(found.len(), 1, "{found:?}");
1209 assert!(
1210 found[0]
1211 .starts_with("zh-Hans/ui.ron: '“' (U+201C) isn't in the font atlas's wide set")
1212 );
1213 let narrow = lang_with_info(&[("ja", ja, "“直”")]);
1214 assert_eq!(errors(&no_quotes, &narrow), Vec::<String>::new());
1215 let any = lang_with_info(&[("ko", "", "直简あ")]);
1217 assert_eq!(errors(&two_sets(), &any), Vec::<String>::new());
1218 let ko = r#", wide_font: Some("ko")"#;
1220 let missing = lang_with_info(&[("ko", ko, "直简Ж")]);
1221 let found = errors(&two_sets(), &missing);
1222 assert_eq!(
1223 found[0],
1224 "ko/lang.ron: wide_font names \"ko\", a wide set the font atlas doesn't have; \
1225 remake the atlas with font-atlas --wide ko=<font.bdf>"
1226 );
1227 assert_eq!(found.len(), 2, "{found:?}");
1228 assert!(found[1].starts_with("ko/ui.ron: 'Ж' (U+0416) isn't in the font atlas ("));
1229 }
1230
1231 #[test]
1232 fn the_sources_wide_characters_must_be_in_the_set_it_names() {
1233 use crate::lang::SourceLang;
1234 let source = |set: &str| {
1235 let code = LangCode::new("zh-Hans").unwrap();
1236 SourceLang::new(code, "中文")
1237 .with_wide_font(set)
1238 .with_wide_punctuation(true)
1239 };
1240 let written = |text: &str, set: &str| {
1241 lang(&[("menu.title", text)], &[]).with_source_lang(source(set))
1242 };
1243 assert_eq!(
1244 errors(&two_sets(), &written("“直简”", "zh-Hans")),
1245 Vec::<String>::new()
1246 );
1247 assert_eq!(
1249 errors(&two_sets(), &written("直简", "ja")),
1250 [
1251 "zh-Hans: '简' (U+7B80) isn't in the font atlas's wide set \"ja\", which the \
1252 language draws from (menu.title); remake the atlas with font-atlas --wide \
1253 ja=<font.bdf> from a font that has it"
1254 ]
1255 );
1256 let mut no_quotes = two_sets();
1258 no_quotes.wide_sets.get_mut("zh-Hans").unwrap().remove(&'“');
1259 let found = errors(&no_quotes, &written("“直”", "zh-Hans"));
1260 assert_eq!(found.len(), 1, "{found:?}");
1261 assert!(
1262 found[0].starts_with("zh-Hans: '“' (U+201C) isn't in the font atlas's wide set")
1263 );
1264 assert_eq!(
1266 errors(&two_sets(), &written("直", "ko")),
1267 [
1268 "zh-Hans: the source language's wide_font names \"ko\", a wide set the font \
1269 atlas doesn't have; remake the atlas with font-atlas --wide ko=<font.bdf>"
1270 ]
1271 );
1272 }
1273
1274 #[test]
1275 fn one_error_a_language_and_character_counting_its_other_keys() {
1276 let english = [("a.one", "One"), ("a.three", "Three"), ("a.two", "Two")];
1277 let ru: &[(&str, &str)] = &[("a.one", "Ж"), ("a.two", "ЖЖ"), ("a.three", "Жи")];
1278 let uk: &[(&str, &str)] = &[("a.two", "Ж")];
1279 let both = lang(&english, &[("ru", ru), ("uk", uk)]);
1280 let lacks = "isn't in the font atlas";
1281 let fix = "remake the atlas with font-atlas from a font that has it";
1282 assert_eq!(
1283 errors(&ascii_and(""), &both),
1284 [
1285 format!("ru/ui.ron: 'Ж' (U+0416) {lacks} (a.one and 2 more keys); {fix}"),
1286 format!("ru/ui.ron: 'и' (U+0438) {lacks} (a.three); {fix}"),
1287 format!("uk/ui.ron: 'Ж' (U+0416) {lacks} (a.two); {fix}"),
1288 ]
1289 );
1290 let two = lang(&english, &[("ru", &[("a.one", "Ж"), ("a.two", "Ж")])]);
1291 assert!(errors(&ascii_and(""), &two)[0].contains("(a.one and 1 more key)"));
1292 }
1293
1294 #[test]
1295 fn the_english_text_is_checked_too() {
1296 let lang = lang(&[("menu.title", "Café")], &[]);
1297 let found = undrawn(&ascii_and(""), &lang);
1298 assert_eq!(found.len(), 1);
1299 assert_eq!(found[0].file, "en");
1300 assert!(
1301 found[0]
1302 .message
1303 .starts_with("'é' (U+00E9) isn't in the font atlas")
1304 );
1305 }
1306
1307 #[test]
1308 fn punctuation_names_its_ascii_spelling_and_a_newline_is_never_an_error() {
1309 let lang = lang(&[("a.one", "It’s\nhere…")], &[]);
1310 let found = errors(&ascii_and(""), &lang);
1311 assert_eq!(found.len(), 2, "{found:?}");
1312 assert!(found[0].starts_with("en: '’' (U+2019)"), "{found:?}");
1313 assert!(found[0].ends_with(", or write ' instead"), "{found:?}");
1314 assert!(found[1].ends_with(", or write ... instead"), "{found:?}");
1315 for c in ['\u{2018}', '\u{201C}', '\u{201D}', '\u{2014}', '\u{2013}'] {
1316 assert!(ascii_spelling(c).is_some(), "{c:?}");
1317 }
1318 assert_eq!(ascii_spelling('a'), None);
1319 }
1320
1321 #[test]
1322 fn a_combining_mark_is_an_error_even_with_its_glyph() {
1323 let lang = lang(&[("a.one", "Cafe\u{301}")], &[]);
1324 let found = errors(&ascii_and("\u{301}"), &lang);
1325 assert_eq!(
1326 found,
1327 [
1328 "en: '\\u{301}' (U+0301) is a combining mark, which a cell draws beside its \
1329 letter, not over it (a.one); write the accented letter as one character, as \
1330 'é' (U+00E9) is"
1331 ]
1332 );
1333 assert!(is_combining_mark('\u{300}') && is_combining_mark('\u{36f}'));
1334 assert!(!is_combining_mark('\u{2ff}') && !is_combining_mark('\u{370}'));
1335 }
1336
1337 proptest::proptest! {
1338 #[test]
1342 fn undrawn_is_empty_exactly_when_the_atlas_draws_the_text(
1343 texts in proptest::collection::vec("\\PC{0,6}|[a\\n\u{301}é]{0,4}", 1..4),
1344 extra in "[\u{301}éЖ]{0,3}",
1345 ) {
1346 let english: Vec<(String, String)> = texts
1347 .iter()
1348 .enumerate()
1349 .map(|(i, text)| (format!("k.{i}"), text.clone()))
1350 .collect();
1351 let lang = Lang::new(english.into_iter().collect(), BTreeMap::new());
1352 let atlas = ascii_and(&extra);
1353 let clean = texts.iter().flat_map(|t| t.chars()).all(|c| {
1354 c.is_control() || (atlas.draws(c) && !is_combining_mark(c))
1355 });
1356 proptest::prop_assert_eq!(undrawn(&atlas, &lang).is_empty(), clean);
1357 }
1358 }
1359 }
1360
1361 #[test]
1362 fn from_source_ok_and_syntax_error() {
1363 let def = required_only();
1364 let src = ron::to_string(&def).unwrap_or_default();
1365 let png = png_header(128, rows_for(&def) * 16);
1366 assert_eq!(from_source(&src, Some(&png)), Ok(def));
1367 let png = png_header(64, rows_for(&required_only()) * 16);
1368 let errs = from_source(&src, Some(&png)).err().unwrap_or_default();
1369 assert_eq!(errs.len(), 1);
1370 assert_eq!(errs[0].file, "base/fonts/atlas.png");
1371 let errs = from_source("(cell_w: ", None).err().unwrap_or_default();
1372 assert_eq!(errs.len(), 1);
1373 assert!(errs[0].line.is_some());
1374 }
1375}