use oxitext_core::PositionedGlyph;
pub const MAX_TCY_RUN_LEN: usize = 4;
#[derive(Debug, Clone)]
pub struct GlyphEntry<'a> {
pub glyph: &'a PositionedGlyph,
pub codepoint: char,
}
impl<'a> GlyphEntry<'a> {
pub fn new(glyph: &'a PositionedGlyph, codepoint: char) -> Self {
Self { glyph, codepoint }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TateChuYokoRun {
pub start_glyph: usize,
pub end_glyph: usize,
pub combined_advance: f32,
}
fn is_tcy_char(c: char) -> bool {
matches!(c,
'0'..='9'
| 'A'..='Z'
| 'a'..='z'
)
}
pub fn detect_runs(entries: &[GlyphEntry<'_>], em_size: f32) -> Vec<TateChuYokoRun> {
let mut runs = Vec::new();
let mut i = 0;
while i < entries.len() {
let run_start = i;
let mut j = i;
while j < entries.len()
&& (j - run_start) < MAX_TCY_RUN_LEN
&& is_tcy_char(entries[j].codepoint)
{
j += 1;
}
let run_len = j - run_start;
if run_len >= 1 {
let combined_advance = if run_len == 1 {
em_size
} else {
let y_first = entries[run_start].glyph.pos.1;
let y_last = entries[run_start + run_len - 1].glyph.pos.1;
let span = (y_last - y_first).abs();
if span > 0.0 {
span + em_size
} else {
em_size
}
};
runs.push(TateChuYokoRun {
start_glyph: run_start,
end_glyph: run_start + run_len,
combined_advance,
});
i = run_start + run_len;
} else {
i += 1;
}
}
runs
}
pub fn tcy_combined_advance(
face_data: &[u8],
glyph_ids: &[u16],
em_size: f32,
units_per_em: u16,
) -> f32 {
let first_gid = match glyph_ids.first() {
Some(&id) => id,
None => return em_size,
};
if units_per_em == 0 {
return em_size;
}
crate::vertical::vmtx_advance_for_glyph(face_data, first_gid, em_size)
}
#[cfg(test)]
mod tests {
use super::*;
use oxitext_core::PositionedGlyph;
use std::sync::Arc;
fn pg(y: f32) -> PositionedGlyph {
PositionedGlyph {
gid: 1,
font_data: Arc::from(&[][..]),
pos: (0.0, y),
font_size: 16.0,
advance_x: 16.0,
cluster: 0,
}
}
#[test]
fn detects_two_digit_run() {
let g0 = pg(0.0);
let g1 = pg(16.0);
let g2 = pg(32.0);
let entries = [
GlyphEntry::new(&g0, '2'),
GlyphEntry::new(&g1, '4'),
GlyphEntry::new(&g2, '日'), ];
let runs = detect_runs(&entries, 16.0);
assert_eq!(runs.len(), 1, "expected exactly one TCY run");
let run = &runs[0];
assert_eq!(run.start_glyph, 0);
assert_eq!(run.end_glyph, 2, "run should cover the 2 digit glyphs");
assert!(
run.combined_advance > 0.0,
"combined_advance must be positive"
);
}
#[test]
fn single_digit_run() {
let g0 = pg(0.0);
let g1 = pg(16.0);
let entries = [GlyphEntry::new(&g0, '5'), GlyphEntry::new(&g1, '日')];
let runs = detect_runs(&entries, 16.0);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].start_glyph, 0);
assert_eq!(runs[0].end_glyph, 1);
}
#[test]
fn no_run_for_cjk_only() {
let g0 = pg(0.0);
let g1 = pg(16.0);
let g2 = pg(32.0);
let entries = [
GlyphEntry::new(&g0, '日'),
GlyphEntry::new(&g1, '本'),
GlyphEntry::new(&g2, '語'),
];
let runs = detect_runs(&entries, 16.0);
assert_eq!(runs.len(), 0, "CJK-only text should yield no TCY runs");
}
#[test]
fn run_capped_at_max() {
let glyphs: Vec<PositionedGlyph> = (0..6).map(|i| pg(i as f32 * 16.0)).collect();
let chars = ['H', 'e', 'l', 'l', 'o', '日'];
let entries: Vec<GlyphEntry<'_>> = glyphs
.iter()
.zip(chars.iter())
.map(|(g, &c)| GlyphEntry::new(g, c))
.collect();
let runs = detect_runs(&entries, 16.0);
assert!(!runs.is_empty());
assert_eq!(runs[0].end_glyph - runs[0].start_glyph, MAX_TCY_RUN_LEN);
assert_eq!(runs.len(), 2);
assert_eq!(runs[1].start_glyph, 4);
assert_eq!(runs[1].end_glyph, 5);
}
#[test]
fn empty_entries_yields_no_runs() {
let runs = detect_runs(&[], 16.0);
assert!(runs.is_empty());
}
#[test]
fn combined_advance_uses_em_for_single_glyph() {
let em = 20.0_f32;
let g0 = pg(0.0);
let g1 = pg(em);
let entries = [GlyphEntry::new(&g0, '9'), GlyphEntry::new(&g1, '日')];
let runs = detect_runs(&entries, em);
assert_eq!(runs.len(), 1);
assert!(
(runs[0].combined_advance - em).abs() < f32::EPSILON,
"single-glyph run should have combined_advance == em_size"
);
}
#[test]
fn ascii_letters_are_tcy() {
let g0 = pg(0.0);
let g1 = pg(16.0);
let entries = [GlyphEntry::new(&g0, 'A'), GlyphEntry::new(&g1, 'B')];
let runs = detect_runs(&entries, 16.0);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].end_glyph - runs[0].start_glyph, 2);
}
#[test]
fn mixed_cjk_digits_cjk() {
let glyphs: Vec<PositionedGlyph> = (0..4).map(|i| pg(i as f32 * 16.0)).collect();
let chars = ['日', '2', '3', '本'];
let entries: Vec<GlyphEntry<'_>> = glyphs
.iter()
.zip(chars.iter())
.map(|(g, &c)| GlyphEntry::new(g, c))
.collect();
let runs = detect_runs(&entries, 16.0);
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].start_glyph, 1);
assert_eq!(runs[0].end_glyph, 3);
}
#[test]
fn detects_2024_in_vertical_cjk() {
let text = "縦書き2024年";
let chars: Vec<char> = text.chars().collect();
let glyphs: Vec<PositionedGlyph> = chars
.iter()
.enumerate()
.map(|(i, _)| pg(i as f32 * 16.0))
.collect();
let entries: Vec<GlyphEntry<'_>> = glyphs
.iter()
.zip(chars.iter())
.map(|(g, &c)| GlyphEntry::new(g, c))
.collect();
let runs = detect_runs(&entries, 16.0);
let tcy_chars: Vec<char> = runs
.iter()
.flat_map(|r| chars[r.start_glyph..r.end_glyph].iter().copied())
.collect();
let tcy_str: String = tcy_chars.iter().collect();
assert!(
tcy_str.contains("2024"),
"Expected '2024' to be covered by a TCY run, got: {tcy_str:?}"
);
assert!(
!tcy_chars.contains(&'縦'),
"CJK char '縦' must not be in a TCY run"
);
assert!(
!tcy_chars.contains(&'書'),
"CJK char '書' must not be in a TCY run"
);
assert!(
!tcy_chars.contains(&'年'),
"CJK char '年' must not be in a TCY run"
);
}
}