Merge pull request #3736 from storytold/land/3362

G4: hanging punctuation keeps its amounts under Glyph Scaling and burasagari
This commit is contained in:
BFlat
2026-10-23 07:52:28 +07:00
committed by GitHub
7 changed files with 355 additions and 24 deletions
+44 -18
View File
@@ -2065,9 +2065,23 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
None => 0.0,
};
let w: f64 = set_width(&sg, i, trimmed, &para.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]);
// Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs.
let share: Option<fn(&SGlyph) -> f64> =
(t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share });
// The glyphs before `stop` in visual order, each with the advance it is set with.
let visible = |stop: usize| -> (Vec<usize>, Vec<(&SGlyph, f64)>) {
let at: Vec<usize> = order.iter().map(|&offset| i + offset).filter(|&j| j < stop).collect();
let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).collect();
(at, glyphs)
};
// Burasagari: a comma or full stop ending an area type line hangs outside it (Standard:
// when it doesn't fit; Forced: always). The rest of the line is aligned and justified
// without it, and it follows the line's last character.
// when it doesn't fit, counting what hangs at the line's start as room; Forced:
// always). The rest of the line is aligned and justified without it, and it follows
// the line's last character.
let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0);
let hang = (regions.is_some() && !rtl && hyphen.is_none() && trimmed > i + 1)
.then(|| sg.get(trimmed - 1))
.flatten()
@@ -2075,17 +2089,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
.map(|g| g.adv - end_trim)
.filter(|_| match para.burasagari {
Burasagari::None => false,
Burasagari::Standard => w > width + EPS,
Burasagari::Standard => w > width + room_at_start() + EPS,
Burasagari::Forced => true,
});
let body = if hang.is_some() { trimmed - 1 } else { trimmed };
let w = w - hang.unwrap_or(0.0);
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]);
// The glyphs before the line's trailing spaces (and its burasagari mark), in the order
// they are drawn.
let drawn: Vec<usize> = order.iter().map(|&offset| i + offset).filter(|&j| j < body).collect();
let (drawn, mut visible) = visible(body);
// Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins
// by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much
// wider at each end. A left-aligned line then hangs only at its left, a right-aligned
@@ -2094,13 +2105,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
// left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its
// left. Justification adds no space after a punctuation mark hanging at the left, nor
// before one hanging at the right (`held`). A letter that hangs by part of its width
// keeps its gaps. Roman Hanging Punctuation (a paragraph attribute) hangs the same
// punctuation by the same amounts, and no letters; with Optical Margin Alignment on as
// well, the letters hang too.
let (opt_l, opt_r, held) = if t.optical_margin || para.roman_hanging {
let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share };
let mut visible: Vec<(&SGlyph, f64)> =
drawn.iter().filter_map(|&j| sg.get(j).map(|g| (g, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).collect();
// keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so
// they hang exactly by their published share. Roman Hanging Punctuation (a paragraph
// attribute) hangs the same punctuation by the same amounts, and no letters; with
// Optical Margin Alignment on as well, the letters hang too.
let (opt_l, opt_r, held, hung) = if let Some(share) = share {
if let Some(h) = &hyphen {
visible.push((h, h.adv));
}
@@ -2115,9 +2124,27 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
.filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1)))
.map(|(_, &j)| j)
.collect();
(l, r, held)
let hung: Vec<usize> = drawn
.iter()
.enumerate()
.filter(|&(k, _)| k < left_glyphs || (right_glyphs > 0 && k + right_glyphs >= n_visible))
.map(|(_, &j)| j)
.collect();
(l, r, held, hung)
} else {
(0.0, 0.0, vec![])
(0.0, 0.0, vec![], vec![])
};
// What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph
// scaling of a `hung` one.
let line_flex = |j: usize| {
if held.contains(&j) {
return Flex::default();
}
let mut f = flex.get(j).copied().unwrap_or_default();
if hung.contains(&j) {
f.glyph = (0.0, 0.0);
}
f
};
let room = width + opt_l + opt_r;
let (align, justify) = match para.justify {
@@ -2168,7 +2195,6 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
if justify && !cx.vertical && room > w + EPS { kashida::plan(cx.text, &sg, i..body, para.kashida, room - w) } else { vec![] };
let w = w + kashidas.iter().map(|k| k.1).sum::<f64>();
if justify && (room - w).abs() > EPS {
let line_flex = |j: usize| if held.contains(&j) { Flex::default() } else { flex.get(j).copied().unwrap_or_default() };
if cjk_gaps > 0 && room > w {
// Spread over the CJK gaps and the word spaces alike.
let per = (room - w) / (cjk_gaps + spaces) as f64;
@@ -2236,7 +2262,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
if g.ch == '\t' {
(adv, leader) = tab_advance(&para.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]);
} else if j < body {
let f = if held.contains(&j) { Flex::default() } else { flex.get(j).copied().unwrap_or_default() };
let f = line_flex(j);
if is_word_space(g) {
adv += fit.space(&f);
} else {
+2
View File
@@ -633,6 +633,8 @@ mod tests_embed;
#[cfg(test)]
mod tests_fit;
#[cfg(test)]
mod tests_hang;
#[cfg(test)]
mod tests_inline;
#[cfg(test)]
mod tests_joiners;
+295
View File
@@ -0,0 +1,295 @@
//! Roman Hanging Punctuation and Optical Margin Alignment over whole paragraphs: both composers,
//! justification options, mixed directions, mojikumi and burasagari (helpx "Formatting
//! paragraphs" › Hanging punctuation,
//! <https://helpx.adobe.com/illustrator/using/formatting-paragraphs.html>).
use super::*;
use kurbo::{Affine, Shape};
use vectorcraft_doc::{CharStyle, Composer, Justify, TextKind};
use vectorcraft_geom::PathData;
/// The bundled fonts only: what these tests measure doesn't change with the fonts installed.
fn db() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(vec![]))
}
fn area(text: &str, size: f64, frame: Rect, justify: Justify) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size, ..CharStyle::default() });
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.justify = justify;
t
}
/// The character a glyph sets (an automatic hyphen has no source characters: `-`).
fn ch(t: &TextObject, g: &PositionedGlyph) -> char {
if g.len == 0 { '-' } else { t.plain_text().get(g.byte..).and_then(|s| s.chars().next()).unwrap_or('\0') }
}
fn is_space(c: char) -> bool {
c == ' ' || c == '\n'
}
fn is_quote(c: char) -> bool {
matches!(c, '\'' | '"' | '‘' | '’' | '‚' | '‛' | '“' | '”' | '„' | '‟')
}
/// The published share of a character's advance that hangs (Roman Hanging Punctuation), plus the
/// slanted letters Optical Margin Alignment hangs by a tenth when `optical`.
fn ratio(c: char, optical: bool) -> f64 {
match c {
'-' | '\u{2010}' | '\u{2011}' | '.' | ',' => 1.0,
c if is_quote(c) => 1.0,
'*' | '~' | '…' | '–' | '—' | ':' | ';' => 0.5,
'A' | 'V' | 'W' | 'Y' | 'v' | 'w' | 'y' if optical => 0.1,
_ => 0.0,
}
}
/// The glyphs of each line, left to right, without the spaces that end it (a left-to-right
/// paragraph's trailing spaces are set at its right, outside the measure).
fn inked<'a>(t: &TextObject, l: &'a TextLayout) -> Vec<(&'a LineInfo, Vec<&'a PositionedGlyph>)> {
l.lines
.iter()
.map(|li| {
let mut gs: Vec<&PositionedGlyph> = l.glyphs.get(li.glyph_start..li.glyph_end).unwrap_or_default().iter().collect();
gs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
while gs.last().is_some_and(|g| is_space(ch(t, g))) {
gs.pop();
}
(li, gs)
})
.collect()
}
/// How far the glyphs at the ends of `gs` hang: the end glyph's share, and a punctuation mark
/// next to a quotation mark at that end hangs with it.
fn hung(t: &TextObject, gs: &[&PositionedGlyph], optical: bool) -> (f64, f64) {
let r = |g: &PositionedGlyph| ratio(ch(t, g), optical) * g.advance;
let punct = |g: &PositionedGlyph| ratio(ch(t, g), false) > 0.0;
let quote = |g: &PositionedGlyph| is_quote(ch(t, g));
if gs.len() < 2 {
return (0.0, 0.0);
}
let (mut left, mut right) = (0.0, 0.0);
if let [a, b, rest @ ..] = gs {
left = r(a);
if left > 0.0 && !rest.is_empty() && punct(a) && quote(b) {
left += r(b);
}
}
if let [.., a, b] = gs {
right = r(b);
if right > 0.0 && gs.len() > 2 && punct(a) && quote(b) {
right += r(a);
}
}
(left, right)
}
/// Per line: (first character, how far the line starts before its start edge less what hangs
/// there, last character, how far it ends after its end edge less what hangs there): 0 and 0 for a
/// line that hangs exactly as published.
fn edges(t: &TextObject, l: &TextLayout, optical: bool) -> Vec<(char, f64, char, f64)> {
inked(t, l)
.into_iter()
.filter_map(|(li, gs)| {
let (first, last) = (gs.first()?, gs.last()?);
let (left, right) = hung(t, &gs, optical);
Some((ch(t, first), li.avail.0 - first.origin.x - left, ch(t, last), last.origin.x + last.advance - li.avail.1 - right))
})
.collect()
}
const TEXT: &str = "\u{201C}Hanging\u{201D} quotes, commas. And periods. \"Straight\" quotes and well-known \
hyphens, dashes \u{2014} colons: semicolons; and \u{2018}single\u{2019} ones. Extraordinarily comprehensive \
typographical considerations notwithstanding. \u{201C}Again\u{201D}, then more words follow here.";
/// A whole paragraph, ragged and justified, with both composers: every line starts exactly as
/// far outside its start edge as its first mark hangs; right-aligned and justified lines end
/// exactly as far outside the end edge as their last mark hangs, and ragged lines never pass it.
#[test]
fn every_line_of_a_paragraph_hangs_as_published_with_both_composers() {
let frame = Rect::new(0.0, 0.0, 150.0, 4000.0);
let (mut quote, mut hyphen, mut period) = (false, false, false);
for justify in [Justify::Left, Justify::Right, Justify::JustifyLeft] {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = area(TEXT, 12.0, frame, justify);
t.para.hyphenate = true;
t.para.composer = composer;
t.para.roman_hanging = true;
let on = layout(db(), &t);
assert!(!on.overflow);
let n = on.lines.len();
for (k, (c0, before, c1, after)) in edges(&t, &on, false).into_iter().enumerate() {
if justify != Justify::Right {
assert!(before.abs() < 1e-6, "{justify:?} {composer:?} line {k} starts {c0:?}: {before}");
quote |= matches!(c0, '\u{201C}' | '"' | '\u{2018}');
}
let full = justify == Justify::Right || (justify == Justify::JustifyLeft && k + 1 < n);
if full {
assert!(after.abs() < 1e-6, "{justify:?} {composer:?} line {k} ends {c1:?}: {after}");
hyphen |= c1 == '-';
period |= matches!(c1, '.' | ',');
} else {
assert!(after <= 1e-6, "{justify:?} {composer:?} line {k} passes the hung edge: {after}");
}
}
}
}
assert!(quote && hyphen && period, "quote {quote}, hyphen {hyphen}, period {period}");
}
/// Justify All Lines with Optical Margin Alignment: every line fills to the hung edges, letters
/// included, with both composers.
#[test]
fn justified_lines_fill_to_the_hung_edges_with_both_composers() {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = area(TEXT, 11.0, Rect::new(0.0, 0.0, 170.0, 4000.0), Justify::JustifyAll);
t.para.composer = composer;
t.para.hyphenate = true;
t.optical_margin = true;
let l = layout(db(), &t);
let mut count = 0;
for (c0, before, c1, after) in edges(&t, &l, true) {
assert!(before.abs() < 1e-6 && after.abs() < 1e-6, "{composer:?}: {c0:?} {before} / {c1:?} {after}");
count += usize::from(ratio(c0, true) > 0.0) + usize::from(ratio(c1, true) > 0.0);
}
assert!(count >= 3, "{composer:?}: {count}");
}
}
/// Centred lines centre what doesn't hang: the hung characters stand outside it at either end.
#[test]
fn centred_lines_centre_the_text_between_the_hung_characters() {
let mut t = area(TEXT, 12.0, Rect::new(0.0, 0.0, 150.0, 4000.0), Justify::Center);
t.para.roman_hanging = true;
let l = layout(db(), &t);
let mut count = 0;
for (k, (li, gs)) in inked(&t, &l).into_iter().enumerate() {
let (Some(first), Some(last)) = (gs.first(), gs.last()) else { continue };
let (left, right) = hung(&t, &gs, false);
count += usize::from(left > 0.0) + usize::from(right > 0.0);
let (body_start, body_end) = (first.origin.x + left, last.origin.x + last.advance - right);
assert!(((body_start - li.avail.0) - (li.avail.1 - body_end)).abs() < 1e-6, "line {k}: {body_start} … {body_end} in {:?}", li.avail);
}
assert!(count >= 2, "{count}");
}
/// A right-to-left run inside a left-to-right paragraph: the line hangs by its visual ends, so
/// nothing passes the frame but the mark at the line's visual start or end, by what it hangs.
#[test]
fn right_to_left_runs_in_a_left_to_right_paragraph_stay_inside() {
let shalom = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}";
let text = format!("Go {shalom} {shalom} {shalom}. {shalom} {shalom}. abc def");
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = area(&text, 12.0, Rect::new(0.0, 0.0, 56.0, 400.0), Justify::JustifyLeft);
t.para.composer = composer;
t.para.roman_hanging = true;
let l = layout(db(), &t);
assert!(!l.overflow);
assert!(l.glyphs.iter().any(|g| g.rtl));
for (li, gs) in inked(&t, &l) {
let n = gs.len();
for (k, g) in gs.iter().enumerate() {
let c = ch(&t, g);
let lo = if k == 0 && n > 1 { ratio(c, false) * g.advance } else { 0.0 };
let hi = if k + 1 == n && n > 1 { ratio(c, false) * g.advance } else { 0.0 };
assert!(g.origin.x >= li.avail.0 - lo - 1e-6, "{composer:?}: {c:?} starts at {} before {}", g.origin.x, li.avail.0);
assert!(
g.origin.x + g.advance <= li.avail.1 + hi + 1e-6,
"{composer:?}: {c:?} ends at {} past {}",
g.origin.x + g.advance,
li.avail.1
);
}
}
}
}
/// Justification's Glyph Scaling and Letter Spacing leave the hung first and last marks as they
/// are: they hang exactly as published, and the character after a mark hung wholly outside the
/// start stands on the edge.
#[test]
fn glyph_scaling_and_letter_spacing_keep_the_hung_edges() {
use vectorcraft_doc::JustificationOptions;
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = area(TEXT, 12.0, Rect::new(0.0, 0.0, 160.0, 4000.0), Justify::JustifyAll);
t.para.composer = composer;
t.para.roman_hanging = true;
t.para.justification =
JustificationOptions { letter_spacing: [-5.0, 0.0, 20.0], glyph_scaling: [95.0, 100.0, 105.0], ..JustificationOptions::default() };
let l = layout(db(), &t);
let mut count = 0;
for (c0, before, c1, after) in edges(&t, &l, false) {
assert!(before.abs() < 1e-6 && after.abs() < 1e-6, "{composer:?}: {c0:?} {before} / {c1:?} {after}");
count += usize::from(ratio(c0, false) > 0.0) + usize::from(ratio(c1, false) > 0.0);
}
assert!(count >= 3, "{composer:?}: {count}");
for (li, gs) in inked(&t, &l) {
if let [first, second, ..] = gs[..]
&& ratio(ch(&t, first), false) == 1.0
&& !is_quote(ch(&t, second))
{
assert!((second.origin.x - li.avail.0).abs() < 1e-6, "{composer:?}: {:?} at {}", ch(&t, second), second.origin.x);
}
}
}
}
/// Line-end Punctuation Half Width with Roman Hanging Punctuation: both composers set every
/// line on its hung edges (a Japanese bracket isn't Roman punctuation and doesn't hang).
#[test]
fn mojikumi_and_roman_hanging_agree() {
use vectorcraft_doc::Mojikumi;
let text = format!("{TEXT} \u{300C}\u{4E00}\u{4E8C}\u{300D} \u{201C}words\u{201D} {TEXT}");
for composer in [Composer::SingleLine, Composer::EveryLine] {
let mut t = area(&text, 12.0, Rect::new(0.0, 0.0, 150.0, 4000.0), Justify::JustifyLeft);
t.para.composer = composer;
t.para.mojikumi = Mojikumi::LineEndHalf;
t.para.hyphenate = true;
t.para.roman_hanging = true;
let l = layout(db(), &t);
assert!(!l.overflow);
let n = l.lines.len();
for (k, (c0, before, c1, after)) in edges(&t, &l, false).into_iter().enumerate() {
if c0 != '\u{300C}' {
assert!(before.abs() < 1e-6, "{composer:?} line {k} starts {c0:?}: {before}");
}
if k + 1 < n && !matches!(c1, '\u{300D}' | '\u{3001}' | '\u{3002}') {
assert!(after.abs() < 1e-6, "{composer:?} line {k} ends {c1:?}: {after}");
}
}
}
}
/// Standard burasagari with a hung opening quote: the quote's hang is room on the line, so a
/// comma that fits once the quote hangs stays inside (ends on the edge) instead of hanging.
/// Needs a font with full-width Japanese punctuation (skipped without one).
#[test]
fn burasagari_counts_the_hung_start() {
use vectorcraft_doc::Burasagari;
let lay = |text: &str, roman: bool| {
let mut t = area(text, 20.0, Rect::new(0.0, 0.0, 100.0, 200.0), Justify::JustifyLeft);
t.para.burasagari = Burasagari::Standard;
t.para.roman_hanging = roman;
// Installed fonts too: the bundled ones have no Japanese.
layout(FontDb::global(), &t)
};
if lay("\u{4E00}\u{3001}\u{4E8C}", false).glyphs.get(1).is_none_or(|g| (g.advance - 20.0).abs() > 2.0) {
eprintln!("skipping: no font with full-width punctuation");
return;
}
const EPS: f64 = 1e-6;
let l = lay("\u{201C}\u{4E00}\u{4E8C}\u{4E09}\u{56DB}\u{3001}\u{4E94}\u{516D}\u{4E03}", true);
// The installed font may not set the nine characters as nine glyphs (a fallback that lacks
// some, or ligates them): the positions below would then name other characters.
let (9, Some(quote), Some(comma)) = (l.glyphs.len(), l.glyphs.first(), l.glyphs.get(5)) else {
eprintln!("skipping: the installed fonts don't set the nine characters as nine glyphs ({})", l.glyphs.len());
return;
};
assert_eq!(comma.line, quote.line, "、 ends the first line");
assert!((quote.origin.x + quote.advance).abs() < EPS, "the quote hangs: {:?}", quote.origin);
assert!((comma.origin.x + comma.advance - 100.0).abs() < EPS, "、 inside, on the edge: {:?} {}", comma.origin, comma.advance);
}
+3 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -1
View File
File diff suppressed because one or more lines are too long