mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
296 lines
14 KiB
Rust
296 lines
14 KiB
Rust
//! 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);
|
||
}
|