Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ clap-verbosity-flag = { version = "3", features = ["log"] }
fontheight = { version = "0.2", path = "../core" }
harfrust.workspace = true
harfshapedfa.workspace = true
humantime = "2.3.0"
log.workspace = true
maud = "0.27.0"
ordered-float.workspace = true
Expand Down
171 changes: 134 additions & 37 deletions cli/src/fmt/html.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap, hash_map::Entry},
collections::{BTreeMap, HashMap, HashSet, hash_map::Entry},
fmt,
fmt::Write,
ops::Neg,
rc::Rc,
time::SystemTime,
};

use anyhow::{Context, bail};
use fontheight::{Location, Report, VerticalExtremes};
use fontheight::{Location, Report, VerticalExtremes, WordExtremes};
use harfrust::{ShaperData, ShaperInstance, UnicodeBuffer};
use harfshapedfa::{
HarfRustShaperExt, ShapingMeta,
convert::{iso639_to_opentype, iso15924_to_opentype},
pens::BoundsPen,
};
use humantime::format_rfc3339;
use log::{debug, error};
use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, html};
use ordered_float::NotNan;
Expand All @@ -34,12 +36,28 @@ body {
font-family: sans-serif;
}

#timestamp {
position: fixed;
top: 0;
right: 0;
margin: 0;
background: white;
z-index: 1;
}

h1 {
text-align: center;
}

details {
margin: 4rem 0;
position: relative;
}

summary {
position: sticky;
Comment thread
belluzj marked this conversation as resolved.
top: 0;
background: white;
}

summary h2 {
Expand Down Expand Up @@ -363,6 +381,45 @@ impl ShapingAccumulator {
}
}

/// A report about a single word, for easier sorting
#[derive(Debug, Clone)]
struct WordReport<'a> {
/// The [`Location`] the exemplars were found at.
location: &'a Location,
/// The [`WordList`] that was shaped.
///
/// This will always be the full word list, even if only part of it was
/// tested.
word_list: &'a WordList,
/// One word that reached an extreme
extremes: WordExtremes<'a>,
}

impl<'a> WordReport<'a> {
/// Produce a list of flat reports, each about a single word, for easy
/// sorting for HTML reporting.
fn new(report: &'a Report<'a>) -> Vec<Self> {
// Deduplicate words at this location and word_list (the same word might
// be both hitting low and high extremes)
report
.exemplars
.lowest()
.iter()
.chain(report.exemplars.highest())
.copied()
.map(|word_extremes| (word_extremes.word, word_extremes))
.collect::<HashMap<_, _>>()
.values()
.copied()
.map(|word_extremes| WordReport {
location: report.location,
word_list: report.word_list,
extremes: word_extremes,
})
.collect()
}
}

fn draw_svg<'a>(
font_cache: Rc<RefCell<FontCache<'a>>>,
location_cache: Rc<RefCell<LocationCache>>,
Expand Down Expand Up @@ -554,33 +611,24 @@ fn draw_exemplar<'a>(
fn format_script_reports<'a>(
font_cache: Rc<RefCell<FontCache<'a>>>,
script: &str,
reports: &[&Report<'a>],
reports: &[WordReport<'a>],
) -> Markup {
html! {
details open {
summary { h2 { (script) } }
@for report in reports {
@let location_cache =
Rc::new(RefCell::new(LocationCache::new(font_cache.borrow().font, report.location)));
ul.drawn {
@for high_exemplar in report.exemplars.highest() {
(draw_exemplar(
font_cache.clone(),
location_cache.clone(),
high_exemplar.word,
report.word_list,
report.location,
))
}
@for low_exemplar in report.exemplars.lowest() {
(draw_exemplar(
font_cache.clone(),
location_cache.clone(),
low_exemplar.word,
report.word_list,
report.location,
))
}
ul.drawn {
@for report in reports {
// TODO: make location_cache be a hash by location?
// Generating the HTML report is fast enough like this though.
@let location_cache =
Rc::new(RefCell::new(LocationCache::new(font_cache.borrow().font, report.location)));
(draw_exemplar(
font_cache.clone(),
location_cache.clone(),
report.extremes.word,
report.word_list,
report.location,
))
}
}
}
Expand All @@ -591,26 +639,70 @@ pub fn format_all_reports(
reports: &[Report],
font: &FontRef,
) -> anyhow::Result<String> {
// Group on script and then present exemplars from word lists in order by
// name
let mut script_exemplars = BTreeMap::<&str, Vec<&Report>>::new();
reports.iter().for_each(|report| {
// Group on script and then present examplars by decreasing badness, with
// all locations and word lists of origin mixed together.
let mut script_exemplars = BTreeMap::<&str, Vec<WordReport>>::new();
reports.iter().flat_map(WordReport::new).for_each(|report| {
// ZWSP at the start of Unknown so it gets sorted last
let script = report.word_list.script().unwrap_or("\u{200B}Unknown");
script_exemplars.entry(script).or_default().push(report);
});
// Sort reports by name, then by location
script_exemplars.values_mut().for_each(|reports| {
reports.sort_unstable_by(|report_a, report_b| {
Ord::cmp(report_a.word_list.name(), report_b.word_list.name())
.then_with(|| {
PartialOrd::partial_cmp(
&report_a.location,
&report_b.location,
)
// Here we'll also be deduplicating words from different locations,
// keeping the badest one. Here the same word can show at most twice,
// once as demonstrating a highest extreme, and a second time for
// lowest. I was concerned that using the same dedup for high and low
// could miss a problem, if a word is highest in one location but not
// very low there, and lowest in another location (later in the list).
let mut dedup_high = HashSet::new();
let mut dedup_low = HashSet::new();
let mut highest = reports.clone();
highest.sort_unstable_by(|report_a, report_b| {
// b cmp a, because above we want the biggest values first (desc)
f64::partial_cmp(
&report_b.extremes.highest(),
&report_a.extremes.highest(),
)
.unwrap() // unwrap is safe because we know there are no NaNs
.then_with(|| {
Ord::cmp(report_a.word_list.name(), report_b.word_list.name())
})
.then_with(|| {
PartialOrd::partial_cmp(&report_a.location, &report_b.location)
.expect("fontheight produced unsortable locations")
})
})
});
let mut lowest = reports.clone();
lowest.sort_unstable_by(|report_a, report_b| {
// a cmp b, because below we want the smallest values first (asc)
f64::partial_cmp(
&report_a.extremes.lowest(),
&report_b.extremes.lowest(),
)
.unwrap() // unwrap is safe because we know there are no NaNs
.then_with(|| {
Ord::cmp(report_a.word_list.name(), report_b.word_list.name())
})
.then_with(|| {
PartialOrd::partial_cmp(&report_a.location, &report_b.location)
.expect("fontheight produced unsortable locations")
})
});
// Report high, low, high, low so that the first 2 words from each
// script are the worst high and low = makes skimming the report easy.
*reports = highest.into_iter().zip(lowest).fold(
Vec::new(),
|mut acc, (high, low)| {
if dedup_high.insert(high.extremes.word) {
acc.push(high);
}
if dedup_low.insert(low.extremes.word) {
acc.push(low)
}
acc
},
)
});

let font_cache = Rc::new(RefCell::new(FontCache::new(font)?));
Expand All @@ -624,6 +716,11 @@ pub fn format_all_reports(
style { (CSS) }
}
body {
// Timestamp the report to allow checking quickly that we're
// looking at the latest
p id="timestamp" {
(format_rfc3339(SystemTime::now()))
}
h1 { "Font Height report" }
h3 { "Lines legend" }
p {
Expand Down