use std::collections::HashMap;
use rustyfi_backend::{FontKey, GraphicsElem, PureHorzBox, TabularBox, VertBox};
const GLUE_EPSILON_PT: f64 = 0.05;
pub(crate) fn is_cjk(c: char) -> bool {
matches!(c as u32,
0x1100..=0x11FF | 0x2E80..=0x2EFF | 0x3000..=0x303F | 0x3040..=0x30FF | 0x3100..=0x312F | 0x3190..=0x319F | 0x31F0..=0x31FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6
| 0x20000..=0x2FA1F )
}
pub(crate) fn wants_space(prev: Option<char>, next: Option<char>, natural_pt: f64) -> bool {
if natural_pt <= GLUE_EPSILON_PT {
return false;
}
let Some(p) = prev else { return false };
!(is_cjk(p) && next.is_some_and(is_cjk))
}
pub(crate) fn has_visible_content(html: &str) -> bool {
let mut rest = html;
loop {
rest = rest.trim_start();
let Some(after) = rest.strip_prefix("<span class=\"hskip\"") else {
return !rest.is_empty();
};
match after.find("></span>") {
Some(end) => rest = &after[end + "></span>".len()..],
None => return true,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct BodyStyle {
pub(crate) font: Option<FontKey>,
pub(crate) size: f64,
pub(crate) cjk_ratio: f64,
pub(crate) image_uses: HashMap<usize, usize>,
}
impl Default for BodyStyle {
fn default() -> Self {
BodyStyle {
font: None,
size: 12.0,
cjk_ratio: 0.0,
image_uses: HashMap::new(),
}
}
}
impl BodyStyle {
pub fn dominant(source: Option<&[VertBox]>) -> BodyStyle {
let mut t = Tally::default();
if let Some(vboxes) = source {
t.vboxes(vboxes);
}
let best = t.styles.iter().max_by_key(|(&(font, size), &n)| {
(n, std::cmp::Reverse(size), std::cmp::Reverse(font))
});
let (font, size) = match best {
Some((&(font, size_hundredths), _)) => {
(Some(FontKey(font)), size_hundredths as f64 / 100.0)
}
None => (None, 12.0),
};
BodyStyle {
font,
size,
cjk_ratio: if t.total == 0 {
0.0
} else {
t.cjk as f64 / t.total as f64
},
image_uses: t.images,
}
}
pub(crate) fn matches(&self, font: FontKey, size: f64) -> bool {
self.font == Some(font) && (size - self.size).abs() < 0.005
}
}
#[derive(Default)]
struct Tally {
styles: HashMap<(u16, i64), usize>,
cjk: usize,
total: usize,
images: HashMap<usize, usize>,
}
impl Tally {
fn vboxes(&mut self, vboxes: &[VertBox]) {
for vb in vboxes {
if let VertBox::Line { contents, .. } = vb {
for (_, bx) in contents {
self.hbox(bx);
}
}
}
}
fn hbox(&mut self, bx: &PureHorzBox) {
match bx {
PureHorzBox::InnerString { info, text, .. } => {
let n = text.chars().count();
if n == 0 {
return;
}
*self
.styles
.entry((info.font.0, (info.size.0 * 100.0).round() as i64))
.or_default() += n;
self.total += n;
self.cjk += text.chars().filter(|c| is_cjk(*c)).count();
}
PureHorzBox::Image { image, .. } => *self.images.entry(image.0).or_default() += 1,
PureHorzBox::Frame { contents, .. } => {
for (_, c) in contents {
self.hbox(c);
}
}
PureHorzBox::EmbeddedBlock { block, .. } | PureHorzBox::Footnote { block } => {
self.vboxes(block)
}
PureHorzBox::Tabular(tab) => self.tabular(tab),
PureHorzBox::Graphics { elems, .. } => self.elems(elems),
PureHorzBox::Discretionary { no_break, .. } => {
for c in no_break {
self.hbox(c);
}
}
_ => {}
}
}
fn tabular(&mut self, tab: &TabularBox) {
for cell in &tab.cells {
for (_, bx) in &cell.contents {
self.hbox(bx);
}
}
}
fn elems(&mut self, elems: &[GraphicsElem]) {
for e in elems {
match e {
GraphicsElem::Text { contents, .. } => {
for (_, bx) in contents {
self.hbox(bx);
}
}
GraphicsElem::Group(inner) | GraphicsElem::Clip(_, inner) => self.elems(inner),
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_width_glue_is_never_a_space() {
assert!(!wants_space(Some('n'), Some('t'), 0.0));
assert!(!wants_space(Some('研'), Some('究'), 0.0));
}
#[test]
fn cjk_pair_never_takes_a_space_even_when_the_glue_is_wide() {
assert!(!wants_space(Some('。'), Some('研'), 5.28));
}
#[test]
fn a_script_boundary_and_a_word_space_both_take_one() {
assert!(wants_space(Some('を'), Some('L'), 2.64));
assert!(wants_space(Some('X'), Some('を'), 2.64));
assert!(wants_space(Some('o'), Some('w'), 3.5));
}
#[test]
fn a_negative_kern_is_not_a_space() {
assert!(!wants_space(Some('L'), Some('A'), -1.5));
}
#[test]
fn a_paragraph_edge_emits_nothing() {
assert!(!wants_space(None, Some('a'), 3.5));
}
#[test]
fn cjk_ranges_cover_kana_han_and_fullwidth_but_not_latin() {
for c in ['研', 'ひ', 'カ', '。', ' ', 'A', '한'] {
assert!(is_cjk(c), "{c} should be CJK");
}
for c in ['a', 'Z', '1', '.', ' ', 'é', 'α'] {
assert!(!is_cjk(c), "{c} should not be CJK");
}
}
}