Skip to main content

diffenator3_lib/render/
mod.rs

1/// Rendering and comparison of fonts
2///
3/// The routines in this file handle the rendering and comparison of text
4/// strings; the actual rendering proper is done in the `renderer` module.
5mod cachedoutlines;
6pub(crate) mod colorpainter;
7pub(crate) mod colorrenderer;
8pub mod encodedglyphs;
9pub mod renderer;
10pub mod utils;
11pub mod wordlists;
12pub use crate::structs::{Difference, GlyphDiff};
13use crate::{
14    dfont::DFont,
15    render::{utils::count_differences, wordlists::direction_from_script},
16};
17use cfg_if::cfg_if;
18use colorrenderer::ColorRenderer;
19use harfrust::Script;
20use renderer::Renderer;
21use skrifa::raw::TableProvider;
22use static_lang_word_lists::WordList;
23use std::{
24    collections::{BTreeMap, HashSet},
25    str::FromStr,
26};
27
28cfg_if! {
29    if #[cfg(not(target_family = "wasm"))] {
30        use indicatif::ParallelProgressIterator;
31        use rayon::iter::ParallelIterator;
32        use thread_local::ThreadLocal;
33        use std::cell::RefCell;
34        use std::sync::RwLock;
35    }
36}
37
38pub const DEFAULT_WORDS_FONT_SIZE: f32 = 16.0;
39pub const DEFAULT_GLYPHS_FONT_SIZE: f32 = 32.0;
40/// Number of differing pixels after which two images are considered different
41///
42/// This is a count rather than a percentage, because a percentage would mean
43/// that significant differences could "hide" inside a long word. This should
44/// be adjusted to the size of the font and the expected differences.
45pub const DEFAULT_WORDS_THRESHOLD: usize = 8;
46pub const DEFAULT_GLYPHS_THRESHOLD: usize = 16;
47/// Gray pixels which differ by less than this amount are considered the same
48pub const DEFAULT_GRAY_FUZZ: u8 = 8;
49
50/// Returns true if the font has a COLR table.
51fn font_has_colr(dfont: &DFont) -> bool {
52    dfont.fontref().colr().is_ok()
53}
54
55/// Compare two fonts by rendering a list of words and comparing the images
56///
57/// Word lists are gathered for all scripts which are supported by both fonts.
58/// The return value is a BTreeMap where each key is a script tag and the
59/// value is a list of  [Difference] objects.
60pub fn test_font_words(
61    font_a: &DFont,
62    font_b: &DFont,
63    custom_inputs: &[WordList],
64) -> BTreeMap<String, Vec<Difference>> {
65    let mut map: BTreeMap<String, Vec<Difference>> = BTreeMap::new();
66    let mut jobs: Vec<&WordList> = vec![];
67
68    let shared_codepoints = font_a
69        .codepoints
70        .intersection(&font_b.codepoints)
71        .copied()
72        .collect();
73
74    let supported_a = font_a.supported_scripts();
75    let supported_b = font_b.supported_scripts();
76
77    // Create the jobs
78    for script in supported_a.intersection(&supported_b) {
79        if let Some(wordlist) = wordlists::get_wordlist(script) {
80            jobs.push(wordlist);
81        }
82    }
83    jobs.extend(custom_inputs.iter());
84    // Process the jobs
85    for job in jobs.iter_mut() {
86        let results = diff_many_words(
87            font_a,
88            font_b,
89            DEFAULT_WORDS_FONT_SIZE,
90            job,
91            Some(&shared_codepoints),
92            DEFAULT_WORDS_THRESHOLD,
93        );
94        if !results.is_empty() {
95            map.insert(job.name().to_string(), results);
96        }
97    }
98    map
99}
100
101impl From<Difference> for GlyphDiff {
102    fn from(diff: Difference) -> Self {
103        if let Some(c) = diff.word.chars().next() {
104            GlyphDiff {
105                string: diff.word,
106                name: unicode_names2::name(c)
107                    .map(|n| n.to_string())
108                    .unwrap_or_default(),
109                unicode: format!("U+{:04X}", c as i32),
110                differing_pixels: diff.differing_pixels,
111            }
112        } else {
113            GlyphDiff {
114                string: "".to_string(),
115                name: "".to_string(),
116                unicode: "".to_string(),
117                differing_pixels: 0,
118            }
119        }
120    }
121}
122
123// A fast but complicated version
124#[cfg(not(target_family = "wasm"))]
125/// Compare two fonts by rendering a list of words and comparing the images
126///
127/// This function is parallelized and uses rayon to speed up the process.
128pub(crate) fn diff_many_words(
129    font_a: &DFont,
130    font_b: &DFont,
131    font_size: f32,
132    wordlist: &WordList,
133    shared_codepoints: Option<&HashSet<u32>>,
134    threshold: usize,
135) -> Vec<Difference> {
136    let script = wordlist.script().and_then(|x| Script::from_str(x).ok());
137    let direction = script.and_then(direction_from_script);
138    let seen_glyphs = RwLock::new(HashSet::new());
139    let use_color = font_has_colr(font_a) || font_has_colr(font_b);
140
141    let differences: Vec<Option<Difference>> = if use_color {
142        let tl_a: ThreadLocal<RefCell<ColorRenderer>> = ThreadLocal::new();
143        let tl_b: ThreadLocal<RefCell<ColorRenderer>> = ThreadLocal::new();
144        wordlist
145            .par_iter()
146            .progress()
147            .filter(|word| {
148                shared_codepoints
149                    .as_ref()
150                    .is_none_or(|scp| word.chars().all(|c| scp.contains(&(c as u32))))
151            })
152            .map(|word| {
153                let renderer_a = tl_a.get_or(|| {
154                    RefCell::new(ColorRenderer::new(font_a, font_size, direction, script))
155                });
156                let renderer_b = tl_b.get_or(|| {
157                    RefCell::new(ColorRenderer::new(font_b, font_size, direction, script))
158                });
159
160                let (buffer_a, img_a) = renderer_a.borrow_mut().render_string(word)?;
161                if buffer_a
162                    .split('|')
163                    .all(|glyph| seen_glyphs.read().unwrap().contains(glyph))
164                {
165                    return None;
166                }
167                for glyph in buffer_a.split('|') {
168                    seen_glyphs.write().unwrap().insert(glyph.to_string());
169                }
170                let (buffer_b, img_b) = renderer_b.borrow_mut().render_string(word)?;
171                let differing_pixels = count_differences(img_a, img_b, DEFAULT_GRAY_FUZZ);
172                let buffers_same = buffer_a == buffer_b;
173
174                Some(Difference {
175                    word: word.to_string(),
176                    buffer_a,
177                    buffer_b: if buffers_same { None } else { Some(buffer_b) },
178                    differing_pixels,
179                    ot_features: "".to_string(),
180                    lang: "".to_string(),
181                })
182            })
183            .collect()
184    } else {
185        let tl_a: ThreadLocal<RefCell<Renderer>> = ThreadLocal::new();
186        let tl_b: ThreadLocal<RefCell<Renderer>> = ThreadLocal::new();
187        wordlist
188            .par_iter()
189            .progress()
190            .filter(|word| {
191                shared_codepoints
192                    .as_ref()
193                    .is_none_or(|scp| word.chars().all(|c| scp.contains(&(c as u32))))
194            })
195            .map(|word| {
196                let renderer_a = tl_a
197                    .get_or(|| RefCell::new(Renderer::new(font_a, font_size, direction, script)));
198                let renderer_b = tl_b
199                    .get_or(|| RefCell::new(Renderer::new(font_b, font_size, direction, script)));
200
201                let (buffer_a, commands_a) =
202                    renderer_a.borrow_mut().string_to_positioned_glyphs(word)?;
203                if buffer_a
204                    .split('|')
205                    .all(|glyph| seen_glyphs.read().unwrap().contains(glyph))
206                {
207                    return None;
208                }
209                for glyph in buffer_a.split('|') {
210                    seen_glyphs.write().unwrap().insert(glyph.to_string());
211                }
212                let (buffer_b, commands_b) =
213                    renderer_b.borrow_mut().string_to_positioned_glyphs(word)?;
214                if commands_a == commands_b {
215                    return None;
216                }
217                let img_a = renderer_a
218                    .borrow_mut()
219                    .render_positioned_glyphs(&commands_a);
220                let img_b = renderer_b
221                    .borrow_mut()
222                    .render_positioned_glyphs(&commands_b);
223                let differing_pixels = count_differences(img_a, img_b, DEFAULT_GRAY_FUZZ);
224                let buffers_same = buffer_a == buffer_b;
225
226                Some(Difference {
227                    word: word.to_string(),
228                    buffer_a,
229                    buffer_b: if buffers_same { None } else { Some(buffer_b) },
230                    differing_pixels,
231                    ot_features: "".to_string(),
232                    lang: "".to_string(),
233                })
234            })
235            .collect()
236    };
237
238    let mut diffs: Vec<Difference> = differences
239        .into_iter()
240        .flatten()
241        .filter(|diff| diff.differing_pixels > threshold)
242        .collect();
243    diffs.sort_by_key(|x| -(x.differing_pixels as i32));
244    diffs
245}
246
247// A slow and simple version
248#[cfg(target_family = "wasm")]
249pub(crate) fn diff_many_words(
250    font_a: &DFont,
251    font_b: &DFont,
252    font_size: f32,
253    wordlist: &WordList,
254    shared_codepoints: Option<&HashSet<u32>>,
255    threshold: usize,
256) -> Vec<Difference> {
257    let script = wordlist.script().and_then(|x| Script::from_str(x).ok());
258    let direction = script.and_then(|s| direction_from_script(s));
259    let use_color = font_has_colr(font_a) || font_has_colr(font_b);
260    let mut seen_glyphs: HashSet<String> = HashSet::new();
261    let mut differences: Vec<Difference> = vec![];
262
263    if use_color {
264        let mut renderer_a = ColorRenderer::new(font_a, font_size, direction, script);
265        let mut renderer_b = ColorRenderer::new(font_b, font_size, direction, script);
266
267        for word in wordlist.iter() {
268            if let Some(scp) = shared_codepoints {
269                if !word.chars().all(|c| scp.contains(&(c as u32))) {
270                    continue;
271                }
272            }
273            let Some((buffer_a, img_a)) = renderer_a.render_string(&word) else {
274                continue;
275            };
276            if buffer_a.split('|').all(|glyph| seen_glyphs.contains(glyph)) {
277                continue;
278            }
279            for glyph in buffer_a.split('|') {
280                seen_glyphs.insert(glyph.to_string());
281            }
282            let Some((buffer_b, img_b)) = renderer_b.render_string(&word) else {
283                continue;
284            };
285            let buffers_same = buffer_a == buffer_b;
286            let differing_pixels = count_differences(img_a, img_b, DEFAULT_GRAY_FUZZ);
287            if differing_pixels > threshold {
288                differences.push(Difference {
289                    word: word.to_string(),
290                    buffer_a,
291                    buffer_b: if buffers_same { None } else { Some(buffer_b) },
292                    ot_features: "".to_string(),
293                    lang: "".to_string(),
294                    differing_pixels,
295                });
296            }
297        }
298    } else {
299        let mut renderer_a = Renderer::new(font_a, font_size, direction, script);
300        let mut renderer_b = Renderer::new(font_b, font_size, direction, script);
301
302        for word in wordlist.iter() {
303            if let Some(scp) = shared_codepoints {
304                if !word.chars().all(|c| scp.contains(&(c as u32))) {
305                    continue;
306                }
307            }
308            let Some((buffer_a, commands_a)) = renderer_a.string_to_positioned_glyphs(&word) else {
309                continue;
310            };
311            if buffer_a.split('|').all(|glyph| seen_glyphs.contains(glyph)) {
312                continue;
313            }
314            for glyph in buffer_a.split('|') {
315                seen_glyphs.insert(glyph.to_string());
316            }
317            let Some((buffer_b, commands_b)) = renderer_b.string_to_positioned_glyphs(&word) else {
318                continue;
319            };
320            if commands_a == commands_b {
321                continue;
322            }
323            let buffers_same = buffer_a == buffer_b;
324            let img_a = renderer_a.render_positioned_glyphs(&commands_a);
325            let img_b = renderer_b.render_positioned_glyphs(&commands_b);
326            let differing_pixels = count_differences(img_a, img_b, DEFAULT_GRAY_FUZZ);
327            if differing_pixels > threshold {
328                differences.push(Difference {
329                    word: word.to_string(),
330                    buffer_a,
331                    buffer_b: if buffers_same { None } else { Some(buffer_b) },
332                    ot_features: "".to_string(),
333                    lang: "".to_string(),
334                    differing_pixels,
335                });
336            }
337        }
338    }
339
340    differences.sort_by_key(|x| -(x.differing_pixels as i32));
341    differences
342}