Merge pull request #344 from imachus/pr/ja-justify

Type: justified Japanese lines spread between their characters
This commit is contained in:
Brandon Thomas
2026-10-07 17:03:07 -04:00
4 changed files with 54 additions and 5 deletions
+21 -3
View File
@@ -8,7 +8,7 @@ use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextO
use crate::composer::{Breakpoint, compose};
use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points;
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, shape_range, style_metrics};
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
const EPS: f64 = 1e-6;
@@ -846,10 +846,24 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
Justify::JustifyAll => (0, true),
};
let justify = justify && regions.is_some();
let (mut per_space, mut per_gap) = (0.0, 0.0);
let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0);
let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count();
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block.
let cjk_gap = |j: usize| {
j + 1 < trimmed
&& sg
.get(j)
.zip(sg.get(j + 1))
.is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)))
};
let cjk_gaps = (i..trimmed).filter(|&j| cjk_gap(j)).count();
if justify && (width - w).abs() > EPS {
if spaces > 0 {
if cjk_gaps > 0 && width > w {
// Spread over the CJK gaps and the word spaces alike.
let per = (width - w) / (cjk_gaps + spaces) as f64;
(per_space, per_cjk) = (per, per);
} else if spaces > 0 {
// Composed lines may shrink word spaces (never below zero).
per_space =
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
@@ -888,6 +902,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
} else if j < trimmed {
if g.is_space() {
adv += per_space;
} else if per_cjk != 0.0 {
if cjk_gap(j) {
adv += per_cjk;
}
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
// Between glyphs, never inside a tate-chu-yoko block (one cell).
adv += per_gap;
+1 -1
View File
@@ -137,7 +137,7 @@ pub(crate) fn no_line_end(c: char) -> bool {
)
}
fn is_cjk(c: char) -> bool {
pub(crate) fn is_cjk(c: char) -> bool {
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
}
+31
View File
@@ -491,3 +491,34 @@ fn vertical_area_type_wraps_into_columns_inside_the_frame() {
assert!(l.glyphs.iter().all(|g| g.origin.x >= 0.0 && g.origin.x <= 100.0 && g.origin.y >= 0.0 && g.origin.y <= 65.0));
assert!(l.glyphs[l.lines[1].glyph_start].origin.x < l.glyphs[0].origin.x);
}
/// Justified Japanese lines (no word spaces) spread the leftover room over the gaps between their
/// characters (JLREQ 3.8), not inside a Latin word; the last line of a paragraph stays set solid.
#[test]
fn justified_japanese_lines_spread_between_characters() {
// The ideographs' advance in the fonts at hand, measured; five fit a line, 10 pt left over.
let measure = layout(db(), &point("一", style(20.0)));
let a = measure.glyphs[0].advance;
let width = 5.0 * a + 10.0;
let t = area("一二三四五六七", style(20.0), Rect::new(0.0, 0.0, width, 400.0), Justify::JustifyLeft);
let l = layout(db(), &t);
let first = &l.glyphs[l.lines[0].glyph_start..l.lines[0].glyph_end];
assert_eq!(first.len(), 5);
for w in first.windows(2) {
assert!((w[1].origin.x - w[0].origin.x - (a + 2.5)).abs() < 0.01, "10 pt over 4 gaps");
}
let end = first.last().map(|g| g.origin.x + a).unwrap();
assert!((end - width).abs() < 0.01, "the line reaches the frame's edge: {end} vs {width}");
// The last line keeps its natural spacing.
let last = &l.glyphs[l.lines[1].glyph_start..l.lines[1].glyph_end];
assert!((last[1].origin.x - last[0].origin.x - a).abs() < 0.01);
// A Latin word inside a Japanese line keeps its letters together; the gaps around it share.
let t = area("一二ABC三四五六七八九十一二三", style(20.0), Rect::new(0.0, 0.0, 8.0 * a + 10.0, 400.0), Justify::JustifyLeft);
let l = layout(db(), &t);
let g = &l.glyphs[l.lines[0].glyph_start..l.lines[0].glyph_end];
let at = g.iter().position(|g| g.byte == "一二".len()).unwrap();
let solid =
|k: usize| (g[k + 1].origin.x - g[k].origin.x - layout(db(), &point(&"ABC"[k - at..=k - at], style(20.0))).glyphs[0].advance).abs() < 0.01;
assert!(solid(at) && solid(at + 1), "A–B–C set solid");
assert!(g[at].origin.x - g[at - 1].origin.x > a + 0.1, "二–A takes its share");
}