mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3736 from storytold/land/3362
G4: hanging punctuation keeps its amounts under Glyph Scaling and burasagari
This commit is contained in:
+44
-18
@@ -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, ¶.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(¶.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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+3
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user