Merge pull request #370 from imachus/pr/ja-wakan

Type: mojikumi — a quarter em between Japanese and Latin text

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Brandon Thomas
2026-10-07 17:47:44 -04:00
3 changed files with 93 additions and 5 deletions
+56 -3
View File
@@ -264,6 +264,53 @@ fn compress_punctuation(sg: &mut [SGlyph]) -> Vec<bool> {
lost_after
}
/// The space between Japanese and Latin letters or digits (JLREQ 3.2.2: a quarter em of the
/// Japanese characters' size), with Line-end Punctuation Half Width.
const WAKAN_AKI: f64 = 0.25;
/// A Japanese character for the space next to Latin text: kana and kanji (and full-width
/// letters), not punctuation or symbols.
fn is_japanese_letter(c: char) -> bool {
is_cjk(c)
&& !matches!(c as u32, 0x3000..=0x303F | 0xFF01..=0xFF0F | 0xFF1A..=0xFF20 | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65)
&& c != '・'
&& punct(c).is_none()
}
/// A Latin letter or digit (or another script's letter), set proportionally.
fn is_latin_letter(c: char) -> bool {
c.is_alphanumeric() && !is_cjk(c)
}
/// Mojikumi (JLREQ 3.2.2): a quarter em between a Japanese character and a Latin letter or digit,
/// either way round, added after the first of the two. Returns what each glyph got after it (taken
/// off again when the line ends there).
fn space_japanese_and_latin(sg: &mut [SGlyph]) -> Vec<f64> {
let mut added = vec![0.0; sg.len()];
for j in 1..sg.len() {
let (before, after) = sg.split_at_mut(j);
let (Some(a), Some(b)) = (before.last_mut(), after.first()) else { continue };
if a.tcy.is_some() || b.tcy.is_some() {
continue;
}
let japanese = if is_japanese_letter(a.ch) && is_latin_letter(b.ch) {
Some(&*a)
} else if is_latin_letter(a.ch) && is_japanese_letter(b.ch) {
Some(b)
} else {
None
};
if let Some(g) = japanese {
let aki = WAKAN_AKI * g.face.units_per_em() * g.sx;
a.adv += aki;
if let Some(x) = added.get_mut(j - 1) {
*x = aki;
}
}
}
added
}
/// The length an upright glyph takes down the column before tracking and justification: its
/// vertical advance (the font's vertical metrics), else its advance, at least one em.
fn upright_cell(g: &SGlyph) -> f64 {
@@ -825,6 +872,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
let mut sg = cx.shape_para(pr.clone());
// Japanese composition: consecutive punctuation shares one half-em space.
let compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] };
// …and a quarter em between Japanese and Latin text.
let wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] };
let pm = {
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
let (cap, xh) = cap_x_heights(cx.db, cx.style_at(pr.start));
@@ -882,10 +931,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
trimmed -= 1;
}
let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1]));
// Mojikumi: a closing bracket, comma or full stop ending the line is set half width.
// Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and
// no Japanese–Latin space is left at the end of a line.
let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) {
Some(k) if !compressed.get(k).copied().unwrap_or(false) => sg.get(k).and_then(|g| punct_half(g, Punct::Closing)).unwrap_or(0.0),
_ => 0.0,
Some(k) => {
let half = if compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) };
half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0)
}
None => 0.0,
};
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
let (align, justify) = match para.justify {
+35
View File
@@ -329,3 +329,38 @@ fn mojikumi_halves_line_end_and_consecutive_punctuation() {
assert_eq!(adv("一二", Mojikumi::LineEndHalf), adv("一二", Mojikumi::None));
}
}
/// Mojikumi (JLREQ 3.2.2): a quarter em between Japanese and Latin letters or digits, either way
/// round, horizontal and vertical; none inside a tate-chu-yoko block, none left at a line's end, and
/// none with Mojikumi None. Needs a font with full-width Japanese (skipped without one).
#[test]
fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() {
use vectorcraft_doc::Mojikumi;
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.mojikumi = m;
layout(FontDb::global(), &t)
};
if (lay("雅", Mojikumi::None, false).glyphs[0].advance - 20.0).abs() > 2.0 {
return; // no font with full-width Japanese here
}
for vertical_type in [false, true] {
let extra = |text: &str| -> Vec<f64> {
let (on, off) = (lay(text, Mojikumi::LineEndHalf, vertical_type), lay(text, Mojikumi::None, vertical_type));
on.glyphs.iter().zip(&off.glyphs).map(|(a, b)| a.advance - b.advance).collect()
};
// 雅楽 2026 年: after 楽 and after 6.
assert_eq!(extra("雅楽2026年").iter().map(|x| (x * 100.0).round() / 100.0).collect::<Vec<_>>(), [0.0, 5.0, 0.0, 0.0, 0.0, 5.0, 0.0]);
// Latin first, and nothing after the last character of the line.
assert_eq!(extra("AB雅").iter().map(|x| x.round()).collect::<Vec<_>>(), [0.0, 5.0, 0.0]);
assert_eq!(extra("雅A").iter().map(|x| x.round()).collect::<Vec<_>>(), [5.0, 0.0]);
// Punctuation takes no Japanese–Latin space (the closing bracket isn't at the line's end).
assert!(extra("「A」です").iter().all(|x| x.abs() < 0.01), "{:?}", extra("「A」です"));
}
// A tate-chu-yoko block is set as a Japanese character, with no space inside or around it.
let on = lay("第10回", Mojikumi::LineEndHalf, true);
let off = lay("第10回", Mojikumi::None, true);
assert!(on.glyphs.iter().zip(&off.glyphs).all(|(a, b)| (a.advance - b.advance).abs() < 0.01));
}