vectorcraft/crates/text/src/tests_cjk.rs

205 lines
8.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Chinese composition (中文排版): 避头尾 line breaking, 标点挤压 and 两端对齐.
//!
//! Regression coverage for shared CJK punctuation break restrictions, side-bearing-limited
//! punctuation compression, and horizontal inter-character justification. This is an initial
//! composition implementation, not a claim of full typesetting-standard conformance.
use super::*;
use crate::shape::can_break_between;
use kurbo::{Affine, Shape};
use vectorcraft_doc::{CharStyle, Justify, TextKind};
use vectorcraft_geom::PathData;
/// Deterministic on native and web, without installed system fonts.
fn db() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(Vec::new()))
}
fn has_chinese_font() -> bool {
let available = db().face("Noto Sans CJK SC", "Regular").is_some_and(|face| face.glyph_for('中') != 0);
if !available {
eprintln!("skipped: Chinese glyph metrics require the optional craft-fonts input");
}
available
}
fn style(size: f64) -> CharStyle {
CharStyle { size, font_family: "Noto Sans CJK SC".into(), ..CharStyle::default() }
}
fn cjk_area(text: &str, frame: Rect, justify: Justify) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, style(12.0));
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.justify = justify;
t
}
/// The advance the face gives `c` before any 标点挤压.
fn raw_advance(family: &str, c: char, size: f64) -> f64 {
use skrifa::MetadataProvider;
use skrifa::instance::{LocationRef, Size};
let f = db().face(family, "Regular").expect("face");
let gid = f.glyph_for(c);
assert_ne!(gid, 0, "{family} {c}: missing glyph");
let sk = f.skrifa().expect("skrifa");
let m = sk.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(skrifa::GlyphId::new(gid));
f64::from(m.unwrap_or(0.0)) / f.units_per_em() * size
}
/// The advance the layout gives a one-character text object of `c`.
fn laid_advance(family: &str, c: char, size: f64) -> f64 {
let st = CharStyle { font_family: family.into(), size, ..CharStyle::default() };
layout(db(), &TextObject::point(Point::ZERO, c.to_string().as_str(), st)).glyphs.first().expect("glyph").advance
}
#[test]
fn kinsoku_keeps_punctuation_with_its_text() {
// Closing punctuation and closing brackets may not begin a line.
for &c in &[',', '。', '、', ';', ':', '!', '?', ')', '】', '》', '」', '”', '’', '…'] {
assert!(!can_break_between('字', Some(c)), "{c} must not begin a line");
}
// CJK opening brackets may not end a line.
for &c in &['(', '【', '《', '「', '“', '‘', '〔', '〖'] {
assert!(!can_break_between(c, Some('字')), "{c} must not end a line");
}
// Opening brackets may start a line; closing brackets may end one.
for &c in &['(', '【', '《', '「'] {
assert!(can_break_between('字', Some(c)), "{c} may begin a line");
}
for c in [')', '】', '》', '」', '〕', '〗'] {
assert!(can_break_between(c, Some('字')), "{c} may end a line");
}
// Latin brackets keep Western behaviour: a break before them is fine.
for &c in &['(', '[', '{'] {
assert!(can_break_between('a', Some(c)), "a Latin word may break before {c}");
assert!(!can_break_between(c, Some('a')), "{c} must not end a line");
}
// Ordinary ideographs and mixed CJK/Latin boundaries may break.
assert!(can_break_between('中', Some('文')));
assert!(can_break_between('中', Some('a')));
assert!(can_break_between('a', Some('中')));
assert!(can_break_between('中', None), "the paragraph end is always a break");
}
#[test]
fn punctuation_is_squeezed_on_its_outer_side() {
if !has_chinese_font() {
return;
}
for c in ['(', '【', '《', '「'] {
let (r, l) = (raw_advance("Noto Sans CJK SC", c, 20.0), laid_advance("Noto Sans CJK SC", c, 20.0));
assert!(l < r - 1.0, "{c}: laid {l} is not tighter than raw {r}");
assert!(l >= r * 0.5 - 1e-9, "{c}: laid {l} below the half-em floor of {r}");
}
for c in [',', '。', '、'] {
let (r, l) = (raw_advance("Noto Sans CJK SC", c, 20.0), laid_advance("Noto Sans CJK SC", c, 20.0));
assert!(l < r - 1.0 && l >= r * 0.5 - 1e-9, "{c}: laid {l} against raw {r}");
}
}
#[test]
fn no_chinese_punctuation_is_squeezed_out_of_existence() {
if !has_chinese_font() {
return;
}
// The trim must never take more than half a mark's cell, so the advance stays positive and the
// glyph keeps its id even in faces that carry these marks narrower than a full em.
let marks = ",。、;:!?()【】《》「」“”‘’…·";
for family in ["Noto Sans CJK SC", "Source Sans 3"] {
for c in marks.chars() {
let st = CharStyle { font_family: family.into(), size: 20.0, ..CharStyle::default() };
let l = layout(db(), &TextObject::point(Point::ZERO, c.to_string().as_str(), st));
let Some(g) = l.glyphs.first() else { panic!("{family} {c}: no glyph") };
assert!(g.advance > 0.0, "{family} {c}: advance {} squeezed to nothing", g.advance);
assert!(g.gid != 0, "{family} {c}: missing glyph (gid 0)");
}
}
}
#[test]
fn compressed_brackets_do_not_overlap_the_enclosed_text() {
if !has_chinese_font() {
return;
}
let l = layout(db(), &TextObject::point(Point::ZERO, "甲(乙)丙《丁》戊", style(20.0)));
for pair in l.glyphs.windows(2) {
let a = pair[0].outline.bounding_box();
let b = pair[1].outline.bounding_box();
assert!(a.x1 <= b.x0 + 0.01, "overlapping ink: {a:?} and {b:?}");
}
}
#[test]
fn latin_curly_quotes_and_apostrophes_keep_the_fonts_spacing() {
for c in ['‘', '’', '“', '”'] {
let raw = raw_advance("Source Sans 3", c, 40.0);
let laid = laid_advance("Source Sans 3", c, 40.0);
assert!((raw - laid).abs() < 1e-6, "{c}: {raw} vs {laid}");
}
}
#[test]
fn narrow_frames_keep_brackets_with_the_enclosed_character() {
if !has_chinese_font() {
return;
}
let text = "甲甲《乙》丙丙丙";
for width in [12.0, 24.0, 30.0, 40.0, 50.0] {
let l = layout(db(), &cjk_area(text, Rect::new(0.0, 0.0, width, 1000.0), Justify::Left));
for line in &l.lines {
let s = &text[line.start..line.end];
assert!(!s.starts_with('》') && !s.ends_with('《'), "width {width}: {s:?}");
}
}
}
#[test]
fn justified_chinese_reaches_the_frame_edge() {
if !has_chinese_font() {
return;
}
let text: String = "中文排版测试两端对齐标点挤压".repeat(12);
let frame = Rect::new(0.0, 0.0, 200.0, 4000.0);
let l = layout(db(), &cjk_area(&text, frame, Justify::JustifyLeft));
assert!(l.lines.len() > 3, "the text wraps: {} lines", l.lines.len());
for line in &l.lines[..l.lines.len() - 1] {
let avail = line.avail.1 - line.avail.0;
assert!((line.x1 - line.x0 - avail).abs() < 1e-6, "justified line does not reach the edge: {line:?}");
}
// The last line is left ragged rather than stretched.
assert!(l.lines.last().is_some_and(|l| l.x1 - l.x0 < 200.0));
}
#[test]
fn chinese_justification_does_not_track_out_latin_words() {
let frame = Rect::new(0.0, 0.0, 70.0, 4000.0);
let mut text = cjk_area("abcdefghijklmnopqrstuvwx".repeat(4).as_str(), frame, Justify::Left);
text.runs[0].style.font_family = "Source Sans 3".into();
let left = layout(db(), &text);
text.para.justify = Justify::JustifyLeft;
let justified = layout(db(), &text);
assert!(left.lines.len() > 2);
for (a, b) in left.glyphs.iter().zip(&justified.glyphs) {
assert!((a.advance - b.advance).abs() < 1e-9);
}
}
#[test]
fn lines_never_begin_with_closing_punctuation_or_end_with_an_opening_one() {
if !has_chinese_font() {
return;
}
let text: String = "他说:「中文排版,非常讲究。」然后他走了。".repeat(20);
let l = layout(db(), &cjk_area(&text, Rect::new(0.0, 0.0, 120.0, 6000.0), Justify::Left));
assert!(l.lines.len() > 5);
for line in &l.lines {
let s = text.get(line.start..line.end).unwrap_or("");
let first = s.chars().next();
assert!(first.is_none_or(|c| !",。、;:!?)】》」”’…".contains(c)), "line begins with closing punctuation {first:?}: {s:?}");
let last = s.chars().next_back();
assert!(last.is_none_or(|c| !"(【《「“‘[{".contains(c)), "line ends with an opening bracket {last:?}: {s:?}");
}
}