1use std::collections::{HashMap, HashSet};
18use std::fs::{File, OpenOptions};
19use std::io::{self, BufRead, BufWriter, IsTerminal, Write};
20use std::process::ExitCode;
21
22use mongol_norm::{is_mongolian_word_char, FontShaper, Locale, Shaper, WrittenUnit};
23
24const USAGE: &str = "\
25usage: cargo run --release --example compare --features font -- [OPTIONS] [FILE ...]
26
27Compare FontShaper (the bundled mongfontbuilder Hudum font through harfrust) with Shaper (the
28ported rules) and report the words they shape differently. The report goes to stdout.
29
30input (one of):
31 FILE ... the Mongolian words of these text files ('-' is stdin)
32 (no FILE) the Mongolian words of stdin
33 --random N N random strings over the Hudum letters, FVS1-4, MVS, NNBSP, nirugu and ZWJ
34
35options:
36 --seed S seed of --random (default 1)
37 --max-len L longest random string (default 8)
38 --limit K differing words to list (default 50; 0 lists none)
39 --groups G difference groups to list (default 20; 0 lists none)
40 --tsv FILE also write every differing word to FILE (tab-separated, with a header)
41 --markdown FILE also append the report to FILE as Markdown (CI: $GITHUB_STEP_SUMMARY)
42 --github-warning also print a GitHub Actions annotation: a warning when any word differs
43 (an error with --fail-on-diff), a notice when none does
44 --fail-on-diff exit with status 1 when any word differs or is shaped by one side only
45 -h, --help show this help
46
47The exit status is 0 whatever the shapers do, unless --fail-on-diff is given and they differ
48(1). A usage or I/O error exits with 2.
49";
50
51struct Options {
52 files: Vec<String>,
53 random: Option<usize>,
54 seed: u64,
55 max_len: usize,
56 limit: usize,
57 groups: usize,
58 tsv: Option<String>,
59 markdown: Option<String>,
60 github_warning: bool,
61 fail_on_diff: bool,
62}
63
64fn parse_args() -> Result<Options, String> {
65 let mut options = Options {
66 files: Vec::new(),
67 random: None,
68 seed: 1,
69 max_len: 8,
70 limit: 50,
71 groups: 20,
72 tsv: None,
73 markdown: None,
74 github_warning: false,
75 fail_on_diff: false,
76 };
77 let mut args = std::env::args().skip(1);
78 while let Some(arg) = args.next() {
79 let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value"));
80 let number = |name: &str, text: String| {
81 text.parse::<u64>()
82 .map_err(|_| format!("{name}: not a number: {text}"))
83 };
84 match arg.as_str() {
85 "-h" | "--help" => return Err(String::new()),
86 "--random" => options.random = Some(number("--random", value("--random")?)? as usize),
87 "--seed" => options.seed = number("--seed", value("--seed")?)?,
88 "--max-len" => options.max_len = number("--max-len", value("--max-len")?)? as usize,
89 "--limit" => options.limit = number("--limit", value("--limit")?)? as usize,
90 "--groups" => options.groups = number("--groups", value("--groups")?)? as usize,
91 "--tsv" => options.tsv = Some(value("--tsv")?),
92 "--markdown" => options.markdown = Some(value("--markdown")?),
93 "--github-warning" => options.github_warning = true,
94 "--fail-on-diff" => options.fail_on_diff = true,
95 _ if arg.starts_with("--") => return Err(format!("unknown option {arg}")),
96 _ => options.files.push(arg),
97 }
98 }
99 if options.random.is_some() && !options.files.is_empty() {
100 return Err("--random takes no FILE".to_owned());
101 }
102 if options.max_len == 0 {
103 return Err("--max-len must be at least 1".to_owned());
104 }
105 Ok(options)
106}
107
108fn words_of(
110 reader: impl BufRead,
111 words: &mut Vec<String>,
112 seen: &mut HashSet<String>,
113) -> io::Result<()> {
114 for line in reader.lines() {
115 for word in line?.split(|c: char| !is_mongolian_word_char(c)) {
116 if !word.is_empty() && seen.insert(word.to_owned()) {
117 words.push(word.to_owned());
118 }
119 }
120 }
121 Ok(())
122}
123
124fn read_words(files: &[String]) -> io::Result<Vec<String>> {
125 let mut words = Vec::new();
126 let mut seen = HashSet::new();
127 if files.is_empty() {
128 if io::stdin().is_terminal() {
129 eprintln!("reading words from stdin (end with Ctrl-D); --help for the options");
130 }
131 words_of(io::stdin().lock(), &mut words, &mut seen)?;
132 }
133 for file in files {
134 if file == "-" {
135 words_of(io::stdin().lock(), &mut words, &mut seen)?;
136 } else {
137 let reader = io::BufReader::new(
138 File::open(file)
139 .map_err(|error| io::Error::new(error.kind(), format!("{file}: {error}")))?,
140 );
141 words_of(reader, &mut words, &mut seen)?;
142 }
143 }
144 Ok(words)
145}
146
147struct XorShift(u64);
149
150impl XorShift {
151 fn below(&mut self, n: usize) -> usize {
152 let mut x = self.0;
153 x ^= x << 13;
154 x ^= x >> 7;
155 x ^= x << 17;
156 self.0 = x;
157 (x % n as u64) as usize
158 }
159}
160
161fn random_words(count: usize, seed: u64, max_len: usize) -> Vec<String> {
162 let mut alphabet: Vec<char> = (0x1820..=0x1842).filter_map(char::from_u32).collect();
163 alphabet.extend([
164 '\u{180B}', '\u{180C}', '\u{180D}', '\u{180F}', '\u{180E}', '\u{202F}', '\u{180A}',
165 '\u{200D}',
166 ]);
167 let mut rng = XorShift(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
169 (0..count)
170 .map(|_| {
171 let len = 1 + rng.below(max_len);
172 (0..len)
173 .map(|_| alphabet[rng.below(alphabet.len())])
174 .collect()
175 })
176 .collect()
177}
178
179fn joined(units: &[WrittenUnit]) -> String {
180 if units.is_empty() {
181 return "∅".to_owned();
182 }
183 units
184 .iter()
185 .map(|unit| unit.as_str())
186 .collect::<Vec<_>>()
187 .join("+")
188}
189
190fn code_points(word: &str) -> String {
191 word.chars()
192 .map(|c| format!("{:04X}", c as u32))
193 .collect::<Vec<_>>()
194 .join(" ")
195}
196
197fn middle<'a>(
200 a: &'a [WrittenUnit],
201 b: &'a [WrittenUnit],
202) -> (&'a [WrittenUnit], &'a [WrittenUnit]) {
203 let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
204 let suffix = a[prefix..]
205 .iter()
206 .rev()
207 .zip(b[prefix..].iter().rev())
208 .take_while(|(x, y)| x == y)
209 .count();
210 (&a[prefix..a.len() - suffix], &b[prefix..b.len() - suffix])
211}
212
213fn pinned_commit() -> &'static str {
215 include_str!("../fonts/mongfontbuilder.json")
216 .split("\"commit\": \"")
217 .nth(1)
218 .and_then(|rest| rest.get(..12))
219 .unwrap_or("?")
220}
221
222struct Difference {
223 word: String,
224 font: String,
225 engine: String,
226 group: (String, String),
228}
229
230struct Rejected {
232 word: String,
233 side: &'static str,
234 error: String,
235}
236
237struct Group {
239 font: String,
240 engine: String,
241 count: usize,
242 example: String,
243}
244
245struct Report {
246 input: String,
247 total: usize,
248 differences: Vec<Difference>,
249 rejected: Vec<Rejected>,
250 groups: Vec<Group>,
252}
253
254impl Report {
255 fn same(&self) -> usize {
256 self.total - self.differences.len() - self.rejected.len()
257 }
258
259 fn percent(&self, n: usize) -> f64 {
260 100.0 * n as f64 / self.total.max(1) as f64
261 }
262}
263
264fn compare(options: &Options, words: &[String]) -> Report {
265 let font = FontShaper::new();
266 let engine = Shaper::new(Locale::Mng);
267 let mut differences = Vec::new();
268 let mut rejected = Vec::new();
269 for word in words {
270 match (font.shape(word), engine.shape(word)) {
271 (Ok(a), Ok(b)) if a == b => {}
272 (Ok(a), Ok(b)) => {
273 let (x, y) = middle(&a, &b);
274 differences.push(Difference {
275 word: word.clone(),
276 font: joined(&a),
277 engine: joined(&b),
278 group: (joined(x), joined(y)),
279 });
280 }
281 (Err(error), Ok(_)) => rejected.push(Rejected {
282 word: word.clone(),
283 side: "font",
284 error: error.to_string(),
285 }),
286 (Ok(_), Err(error)) => rejected.push(Rejected {
287 word: word.clone(),
288 side: "engine",
289 error: error.to_string(),
290 }),
291 (Err(_), Err(_)) => {}
292 }
293 }
294
295 let mut counts: HashMap<&(String, String), (usize, &str)> = HashMap::new();
296 for difference in &differences {
297 counts
298 .entry(&difference.group)
299 .or_insert((0, &difference.word))
300 .0 += 1;
301 }
302 let mut groups: Vec<Group> = counts
303 .into_iter()
304 .map(|((font, engine), (count, example))| Group {
305 font: font.clone(),
306 engine: engine.clone(),
307 count,
308 example: example.to_owned(),
309 })
310 .collect();
311 groups.sort_by(|a, b| {
312 b.count
313 .cmp(&a.count)
314 .then_with(|| (&a.font, &a.engine).cmp(&(&b.font, &b.engine)))
315 });
316
317 let input = match options.random {
318 Some(count) => format!(
319 "{count} random strings (seed {}, length 1-{})",
320 options.seed, options.max_len
321 ),
322 None => format!("{} distinct words", words.len()),
323 };
324 Report {
325 input,
326 total: words.len(),
327 differences,
328 rejected,
329 groups,
330 }
331}
332
333fn write_text(out: &mut impl Write, report: &Report, options: &Options) -> io::Result<()> {
334 writeln!(
335 out,
336 "FontShaper (fonts/hudum.otf, mongfontbuilder {}) vs Shaper (ported rules), public shape",
337 pinned_commit()
338 )?;
339 writeln!(out, "input: {}", report.input)?;
340 writeln!(
341 out,
342 "same: {} differ: {} ({:.2}%) shaped by one side only: {}",
343 report.same(),
344 report.differences.len(),
345 report.percent(report.differences.len()),
346 report.rejected.len()
347 )?;
348
349 if options.groups > 0 && !report.groups.is_empty() {
350 writeln!(
351 out,
352 "\nWhere they differ ({} groups; font vs engine, between the common prefix and suffix):",
353 report.groups.len()
354 )?;
355 let shown = &report.groups[..report.groups.len().min(options.groups)];
356 let width = shown
357 .iter()
358 .map(|group| group.font.chars().count())
359 .max()
360 .unwrap_or(0);
361 for group in shown {
362 writeln!(
363 out,
364 " {:>6} {:<width$} vs {:<12} e.g. {} ({})",
365 group.count,
366 group.font,
367 group.engine,
368 group.example,
369 code_points(&group.example)
370 )?;
371 }
372 if report.groups.len() > shown.len() {
373 writeln!(
374 out,
375 " … {} more groups (--groups N)",
376 report.groups.len() - shown.len()
377 )?;
378 }
379 }
380
381 if options.limit > 0 && !report.differences.is_empty() {
382 let shown = &report.differences[..report.differences.len().min(options.limit)];
383 writeln!(
384 out,
385 "\nDiffering words ({} of {}; --limit N for more, --tsv FILE for all):",
386 shown.len(),
387 report.differences.len()
388 )?;
389 let font_width = shown.iter().map(|d| d.font.len()).max().unwrap_or(0).max(4);
390 let engine_width = shown
391 .iter()
392 .map(|d| d.engine.len())
393 .max()
394 .unwrap_or(0)
395 .max(6);
396 writeln!(
397 out,
398 " {:<font_width$} {:<engine_width$} code points / word",
399 "font", "engine"
400 )?;
401 for difference in shown {
402 writeln!(
403 out,
404 " {:<font_width$} {:<engine_width$} {} {}",
405 difference.font,
406 difference.engine,
407 code_points(&difference.word),
408 difference.word
409 )?;
410 }
411 }
412
413 if !report.rejected.is_empty() {
414 writeln!(
415 out,
416 "\nShaped by one side only ({}):",
417 report.rejected.len()
418 )?;
419 for rejected in report.rejected.iter().take(options.limit.max(1)) {
420 writeln!(
421 out,
422 " {} rejects {} {}: {}",
423 rejected.side,
424 code_points(&rejected.word),
425 rejected.word,
426 rejected.error
427 )?;
428 }
429 }
430 Ok(())
431}
432
433fn write_markdown(out: &mut impl Write, report: &Report, options: &Options) -> io::Result<()> {
434 writeln!(out, "## FontShaper vs Shaper\n")?;
435 writeln!(
436 out,
437 "`fonts/hudum.otf` (mongfontbuilder `{}`) against the ported rules, public `shape`, on {}.\n",
438 pinned_commit(),
439 report.input
440 )?;
441 writeln!(out, "| words | same | differ | shaped by one side only |")?;
442 writeln!(out, "|---:|---:|---:|---:|")?;
443 writeln!(
444 out,
445 "| {} | {} | {} ({:.2}%) | {} |\n",
446 report.total,
447 report.same(),
448 report.differences.len(),
449 report.percent(report.differences.len()),
450 report.rejected.len()
451 )?;
452
453 if options.groups > 0 && !report.groups.is_empty() {
454 let shown = &report.groups[..report.groups.len().min(options.groups)];
455 writeln!(
456 out,
457 "### Where they differ: {} groups{}\n",
458 report.groups.len(),
459 if shown.len() < report.groups.len() {
460 format!(" (the {} largest)", shown.len())
461 } else {
462 String::new()
463 }
464 )?;
465 writeln!(
466 out,
467 "The units between the common prefix and suffix of the two shapes; `∅` is nothing.\n"
468 )?;
469 writeln!(out, "| words | font | engine | example | code points |")?;
470 writeln!(out, "|---:|---|---|---|---|")?;
471 for group in shown {
472 writeln!(
473 out,
474 "| {} | `{}` | `{}` | {} | `{}` |",
475 group.count,
476 group.font,
477 group.engine,
478 group.example,
479 code_points(&group.example)
480 )?;
481 }
482 writeln!(out)?;
483 }
484
485 if options.limit > 0 && !report.differences.is_empty() {
486 let shown = &report.differences[..report.differences.len().min(options.limit)];
487 writeln!(
488 out,
489 "<details><summary>The first {} of {} differing words</summary>\n",
490 shown.len(),
491 report.differences.len()
492 )?;
493 writeln!(out, "| font | engine | code points | word |")?;
494 writeln!(out, "|---|---|---|---|")?;
495 for difference in shown {
496 writeln!(
497 out,
498 "| `{}` | `{}` | `{}` | {} |",
499 difference.font,
500 difference.engine,
501 code_points(&difference.word),
502 difference.word
503 )?;
504 }
505 writeln!(out, "\n</details>\n")?;
506 }
507
508 if !report.rejected.is_empty() {
509 writeln!(
510 out,
511 "### Shaped by one side only: {}\n",
512 report.rejected.len()
513 )?;
514 writeln!(out, "| rejected by | code points | word | error |")?;
515 writeln!(out, "|---|---|---|---|")?;
516 for rejected in report.rejected.iter().take(options.limit.max(1)) {
517 writeln!(
518 out,
519 "| {} | `{}` | {} | {} |",
520 rejected.side,
521 code_points(&rejected.word),
522 rejected.word,
523 rejected.error
524 )?;
525 }
526 writeln!(out)?;
527 }
528 Ok(())
529}
530
531fn artifact_name(path: &str) -> Option<&str> {
533 std::path::Path::new(path).file_stem()?.to_str()
534}
535
536fn escape_command(text: &str, property: bool) -> String {
539 let mut out = String::with_capacity(text.len());
540 for c in text.chars() {
541 match c {
542 '%' => out.push_str("%25"),
543 '\r' => out.push_str("%0D"),
544 '\n' => out.push_str("%0A"),
545 ':' if property => out.push_str("%3A"),
546 ',' if property => out.push_str("%2C"),
547 _ => out.push(c),
548 }
549 }
550 out
551}
552
553fn github_annotation(report: &Report, fail_on_diff: bool, artifact: Option<&str>) -> String {
556 let title = format!(
557 "FontShaper vs Shaper: {} of {} differ ({:.2}%)",
558 report.differences.len(),
559 report.total,
560 report.percent(report.differences.len())
561 );
562 if report.differences.is_empty() && report.rejected.is_empty() {
563 return format!(
564 "::notice title={}::The two shapers agree on all {}.",
565 escape_command(&title, true),
566 report.input
567 );
568 }
569 let mut message = format!(
570 "Input: {}. {} groups; the largest (font vs engine):",
571 report.input,
572 report.groups.len()
573 );
574 for group in report.groups.iter().take(5) {
575 message.push_str(&format!(
576 "\n{}× {} vs {}, e.g. {} ({})",
577 group.count,
578 group.font,
579 group.engine,
580 group.example,
581 code_points(&group.example)
582 ));
583 }
584 if !report.rejected.is_empty() {
585 message.push_str(&format!(
586 "\n{} words are shaped by one side only.",
587 report.rejected.len()
588 ));
589 }
590 match artifact {
591 Some(name) => message.push_str(&format!(
592 "\nFull report: the job summary; every word: the {name} artifact."
593 )),
594 None => message.push_str("\nFull report: the job summary."),
595 }
596 let level = if fail_on_diff { "error" } else { "warning" };
597 format!(
598 "::{level} title={}::{}",
599 escape_command(&title, true),
600 escape_command(&message, false)
601 )
602}
603
604fn write_tsv(path: &str, report: &Report) -> io::Result<()> {
605 let mut tsv = BufWriter::new(File::create(path)?);
606 writeln!(
607 tsv,
608 "word\tcode_points\tfont\tengine\tfont_part\tengine_part"
609 )?;
610 for difference in &report.differences {
611 writeln!(
612 tsv,
613 "{}\t{}\t{}\t{}\t{}\t{}",
614 difference.word,
615 code_points(&difference.word),
616 difference.font,
617 difference.engine,
618 difference.group.0,
619 difference.group.1
620 )?;
621 }
622 for rejected in &report.rejected {
623 let error = format!("error: {}", rejected.error);
624 let (font, engine) = if rejected.side == "font" {
625 (error.as_str(), "")
626 } else {
627 ("", error.as_str())
628 };
629 writeln!(
630 tsv,
631 "{}\t{}\t{font}\t{engine}\t\t",
632 rejected.word,
633 code_points(&rejected.word)
634 )?;
635 }
636 tsv.flush()
637}
638
639fn run(options: &Options, words: &[String]) -> io::Result<bool> {
641 let report = compare(options, words);
642 let stdout = io::stdout();
643 let mut out = BufWriter::new(stdout.lock());
644 write_text(&mut out, &report, options)?;
645 if let Some(path) = &options.tsv {
646 write_tsv(path, &report)?;
647 writeln!(
648 out,
649 "\nwrote {} rows to {path}",
650 report.differences.len() + report.rejected.len()
651 )?;
652 }
653 if let Some(path) = &options.markdown {
654 let file = OpenOptions::new().create(true).append(true).open(path)?;
655 let mut markdown = BufWriter::new(file);
656 write_markdown(&mut markdown, &report, options)?;
657 markdown.flush()?;
658 writeln!(out, "appended the Markdown report to {path}")?;
659 }
660 if options.github_warning {
661 let artifact = options.tsv.as_deref().and_then(artifact_name);
662 writeln!(
663 out,
664 "{}",
665 github_annotation(&report, options.fail_on_diff, artifact)
666 )?;
667 }
668 out.flush()?;
669 Ok(report.differences.is_empty() && report.rejected.is_empty())
670}
671
672fn main() -> ExitCode {
673 let options = match parse_args() {
674 Ok(options) => options,
675 Err(message) if message.is_empty() => {
676 print!("{USAGE}");
677 return ExitCode::SUCCESS;
678 }
679 Err(message) => {
680 eprintln!("compare: {message}\n\n{USAGE}");
681 return ExitCode::from(2);
682 }
683 };
684 let words = match options.random {
685 Some(count) => random_words(count, options.seed, options.max_len),
686 None => match read_words(&options.files) {
687 Ok(words) => words,
688 Err(error) => {
689 eprintln!("compare: {error}");
690 return ExitCode::from(2);
691 }
692 },
693 };
694 match run(&options, &words) {
695 Ok(agree) if agree || !options.fail_on_diff => ExitCode::SUCCESS,
696 Ok(_) => ExitCode::from(1),
697 Err(error) => {
698 eprintln!("compare: {error}");
699 ExitCode::from(2)
700 }
701 }
702}