mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 23:05:05 +00:00
Type: letter spacing and Full Justify keep shaped clusters together
Single Word Justification's Full Justify counted and placed a gap after every drawn glyph, so a combining mark or the vowel sign of a Devanagari syllable moved away from its letter. Desired Letter Spacing and the letter spacing range of a justified line did the same between two glyphs of one cluster. - The Full Justify gap count and placement share one check that skips glyphs with the same cluster byte as the glyph drawn after them. - Letter spacing goes after the last glyph of a cluster only. - Letter spacing, Full Justify and CJK inter-character gaps pass over every default-ignorable character drawn with no width (soft hyphens, bidi marks, embeddings, overrides and isolates, joiners, word joiners, variation selectors, tags, the byte order mark) and put the space between the visible characters on either side. The Hangul fillers stay letters. - A zero-width space takes no word-space room on a justified line and stays a line break opportunity. Letter spacing before it is set after it, so a line broken there ends at its last letter. - A single word that Full Justify spreads and that breaks at a hyphen has the same gap before the drawn hyphen as between its letters. - Kinsoku push-in counts the gaps it can close past format controls, as the layout closes them. Tests cover horizontal, right-to-left and vertical type, Desired and fitted letter spacing, format controls, zero-width spaces, the drawn hyphen, push-in, and a Devanagari syllable when a Devanagari face is installed.
This commit is contained in:
@@ -48,14 +48,61 @@ impl Flex {
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph justification spreads space over as a word space (tabs keep their stops).
|
||||
/// A glyph justification spreads space over as a word space (tabs keep their stops). The
|
||||
/// zero-width space is a line break opportunity but not a word separator, so it takes none.
|
||||
pub(crate) fn is_word_space(g: &SGlyph) -> bool {
|
||||
g.is_space() && g.ch != '\t'
|
||||
g.is_space() && g.ch != '\t' && !is_format_control(g.ch)
|
||||
}
|
||||
|
||||
/// A glyph letter spacing follows: a visible glyph followed by another one in the same word.
|
||||
/// A zero-width format control: an assigned default-ignorable character (Unicode's
|
||||
/// Default_Ignorable_Code_Point) that is drawn with no width. That is a soft hyphen, a bidi mark,
|
||||
/// embedding, override or isolate (the Arabic letter mark included), a zero-width space,
|
||||
/// non-joiner or joiner, a word joiner or invisible operator, a combining grapheme joiner, a
|
||||
/// variation selector, a Mongolian free variation selector or vowel separator, a shorthand or
|
||||
/// musical format control, a tag, or the byte order mark. Letter spacing and justification add no
|
||||
/// space next to one; the space goes between the visible characters on either side. The
|
||||
/// zero-width space stays a line break opportunity ([`SGlyph::is_space`]), and it takes the letter
|
||||
/// spacing of the letter before it, which a line that breaks there trims. The Hangul fillers,
|
||||
/// which fonts draw with a width, are letters.
|
||||
pub(crate) fn is_format_control(ch: char) -> bool {
|
||||
matches!(
|
||||
ch,
|
||||
'\u{00AD}'
|
||||
| '\u{034F}'
|
||||
| '\u{061C}'
|
||||
| '\u{17B4}'..='\u{17B5}'
|
||||
| '\u{180B}'..='\u{180F}'
|
||||
| '\u{200B}'..='\u{200F}'
|
||||
| '\u{202A}'..='\u{202E}'
|
||||
| '\u{2060}'..='\u{206F}'
|
||||
| '\u{FE00}'..='\u{FE0F}'
|
||||
| '\u{FEFF}'
|
||||
| '\u{1BCA0}'..='\u{1BCA3}'
|
||||
| '\u{1D173}'..='\u{1D17A}'
|
||||
| '\u{E0000}'..='\u{E0FFF}'
|
||||
)
|
||||
}
|
||||
|
||||
/// The zero-width space: a format control that is a line break opportunity.
|
||||
const ZERO_WIDTH_SPACE: char = '\u{200B}';
|
||||
|
||||
/// A glyph that may take the letter spacing before the next character: a visible glyph that is
|
||||
/// not a space, a line break, an inline graphic or a format control.
|
||||
fn gap_owner(g: &SGlyph) -> bool {
|
||||
!g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && !is_format_control(g.ch)
|
||||
}
|
||||
|
||||
/// Whether `next`, the glyph after `g` (passing over format controls other than the zero-width
|
||||
/// space), continues the word: a visible character of another shaped cluster.
|
||||
fn continues_word(g: &SGlyph, next: Option<&SGlyph>) -> bool {
|
||||
next.is_some_and(|n| n.byte != g.byte && !n.is_space() && n.ch != '\u{2028}')
|
||||
}
|
||||
|
||||
/// A glyph letter spacing follows: a visible glyph followed by another character in the same word
|
||||
/// (`next` is the next glyph that is not a format control, or a zero-width space). The glyphs of
|
||||
/// one shaped cluster (a letter and its combining marks) take none between them.
|
||||
fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool {
|
||||
!g.is_space() && g.ch != '\u{2028}' && g.inline.is_none() && next.is_some_and(|n| !n.is_space() && n.ch != '\u{2028}')
|
||||
gap_owner(g) && continues_word(g, next)
|
||||
}
|
||||
|
||||
/// The width of a word space in `g`'s font and size: what letter spacing is a share of.
|
||||
@@ -73,9 +120,33 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g
|
||||
let [l_min, l_des, l_max] = j.letter_spacing.map(pct);
|
||||
let [g_min, g_des, g_max] = if scale_glyphs { j.glyph_scaling.map(pct) } else { [1.0; 3] };
|
||||
let mut flex = vec![Flex::default(); sg.len()];
|
||||
// The last glyph before `k` that is not a format control, when that glyph may take a letter gap.
|
||||
let mut owner: Option<usize> = None;
|
||||
for k in 0..sg.len() {
|
||||
let gap = letter_gap(&sg[k], sg.get(k + 1));
|
||||
let ch = sg[k].ch;
|
||||
let zero_width_space = ch == ZERO_WIDTH_SPACE;
|
||||
// Only a glyph that is not a format control, or a zero-width space, looks past the
|
||||
// controls after it, up to the next zero-width space, so each run of controls is passed
|
||||
// over once.
|
||||
let scans = zero_width_space || !is_format_control(ch);
|
||||
let next =
|
||||
if scans { sg.get(k + 1..).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch) || n.ch == ZERO_WIDTH_SPACE)) } else { None };
|
||||
// A letter followed by a zero-width space takes no gap. The zero-width space takes the
|
||||
// letter's gap instead (the letter's space width, after its glyph scaling), so a line
|
||||
// that breaks there ends without letter spacing.
|
||||
let space_gap = if zero_width_space && continues_word(&sg[k], next) { owner.and_then(|o| sg.get(o)).map(space_width) } else { None };
|
||||
let gap = !zero_width_space && scans && letter_gap(&sg[k], next);
|
||||
if !is_format_control(ch) {
|
||||
owner = None;
|
||||
}
|
||||
let (Some(g), Some(f)) = (sg.get_mut(k), flex.get_mut(k)) else { continue };
|
||||
if zero_width_space {
|
||||
if let Some(space) = space_gap {
|
||||
g.adv += space * l_des;
|
||||
f.letter = (space * (l_max - l_des).max(0.0), space * (l_des - l_min).max(0.0));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_word_space(g) {
|
||||
// The font's word space (with its tracking), set at Desired.
|
||||
let base = g.adv;
|
||||
@@ -102,6 +173,9 @@ pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_g
|
||||
g.adv += space * l_des;
|
||||
f.letter = (space * (l_max - l_des).max(0.0), space * (l_des - l_min).max(0.0));
|
||||
}
|
||||
if gap_owner(g) {
|
||||
owner = Some(k);
|
||||
}
|
||||
}
|
||||
flex
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::{
|
||||
Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph,
|
||||
hyphen_glyph, is_cjk, no_line_end, no_line_start, points_for, soft_line_start,
|
||||
};
|
||||
use crate::justify::is_format_control;
|
||||
|
||||
/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -659,7 +660,7 @@ fn push_in(
|
||||
|| (a + 2 == last && punct(last - 1))
|
||||
|| (a + 3 == last && punct(last - 2) && quote(last - 1)))
|
||||
};
|
||||
let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum();
|
||||
let room: f64 = (i..end - 1).filter(|&a| closes(g, a, end) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum();
|
||||
if x - line_end_credit(g, line_end, last) - width > room + EPS {
|
||||
return None;
|
||||
}
|
||||
@@ -685,9 +686,11 @@ fn push_in(
|
||||
|
||||
/// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them
|
||||
/// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space,
|
||||
/// a tab or an inline graphic before it.
|
||||
fn closes(g: &[SGlyph], a: usize) -> bool {
|
||||
let (Some(p), Some(n)) = (g.get(a), g.get(a + 1)) else { return false };
|
||||
/// a tab or an inline graphic before it. Zero-width format controls take no gap: the gap is the
|
||||
/// one between the visible characters on either side, before `end`, as the layout justifies it.
|
||||
fn closes(g: &[SGlyph], a: usize, end: usize) -> bool {
|
||||
let Some(p) = g.get(a).filter(|p| !is_format_control(p.ch)) else { return false };
|
||||
let Some(n) = g.get(a + 1..end).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch))) else { return false };
|
||||
p.len > 0
|
||||
&& p.byte != n.byte
|
||||
&& p.inline.is_none()
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::{
|
||||
ListMarker, Mojikumi, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose,
|
||||
fit_line, hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics,
|
||||
};
|
||||
use crate::justify::is_format_control;
|
||||
use crate::shape::keep_korean_words;
|
||||
|
||||
/// Does paragraph `para` push characters in (Kinsoku Shori Type Push In First or Push Out First,
|
||||
@@ -707,14 +708,56 @@ fn drawn_after(index: usize, glyph: usize, after: &[Option<usize>]) -> Option<us
|
||||
glyph.checked_sub(index).and_then(|offset| after.get(offset).copied().flatten())
|
||||
}
|
||||
|
||||
// The next drawn glyph after `glyph` that is not a zero-width format control.
|
||||
fn visible_after(index: usize, glyph: usize, after: &[Option<usize>], sg: &[SGlyph]) -> Option<usize> {
|
||||
let mut next = drawn_after(index, glyph, after);
|
||||
for _ in 0..after.len() {
|
||||
match next {
|
||||
Some(n) if sg.get(n).is_some_and(|g| is_format_control(g.ch)) => next = drawn_after(index, n, after),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
// The glyph after which a gap between drawn glyphs opens, and the visible glyph that follows it:
|
||||
// not a held glyph, and never a zero-width format control on either side.
|
||||
fn gap_pair<'a>(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &'a [SGlyph]) -> Option<(&'a SGlyph, &'a SGlyph)> {
|
||||
if held.contains(&glyph) {
|
||||
return None;
|
||||
}
|
||||
let a = sg.get(glyph).filter(|a| !is_format_control(a.ch))?;
|
||||
visible_after(index, glyph, after, sg).and_then(|next| sg.get(next)).map(|b| (a, b))
|
||||
}
|
||||
|
||||
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
|
||||
// gaps next to a CJK character, not inside a Latin word, a tate-chu-yoko block or a shaped
|
||||
// cluster.
|
||||
fn is_cjk_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph]) -> bool {
|
||||
!held.contains(&glyph)
|
||||
&& drawn_after(index, glyph, after).and_then(|next| sg.get(glyph).zip(sg.get(next))).is_some_and(|(a, b)| {
|
||||
a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))
|
||||
})
|
||||
gap_pair(glyph, held, after, index, sg).is_some_and(|(a, b)| {
|
||||
a.byte != b.byte && !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))
|
||||
})
|
||||
}
|
||||
|
||||
// Single Word Justification's Full Justify spreads a line's leftover room over the gaps between
|
||||
// its drawn glyphs: not after a held glyph, nor inside a tate-chu-yoko block (one cell) or a
|
||||
// shaped cluster, whose glyphs (a letter and its combining marks, a conjunct and its matra) stay
|
||||
// together. On a line that ends in a drawn hyphen, the gap before the hyphen counts too: it follows
|
||||
// the line's last visible glyph.
|
||||
fn is_letter_gap(glyph: usize, held: &[usize], after: &[Option<usize>], index: usize, sg: &[SGlyph], hyphen: bool) -> bool {
|
||||
if held.contains(&glyph) || sg.get(glyph).is_none_or(|a| is_format_control(a.ch)) {
|
||||
return false;
|
||||
}
|
||||
match gap_pair(glyph, held, after, index, sg) {
|
||||
Some((a, b)) => a.byte != b.byte && !b.continues_tcy(),
|
||||
None => hyphen,
|
||||
}
|
||||
}
|
||||
|
||||
// A space that takes a share of a justified line's room: not a zero-width space, which is a line
|
||||
// break opportunity but not a word separator.
|
||||
fn is_justified_space(g: &SGlyph) -> bool {
|
||||
g.is_space() && !is_format_control(g.ch)
|
||||
}
|
||||
|
||||
// May a pushed-in line close a CJK gap after `g`? Not after a list marker or an inline graphic,
|
||||
@@ -760,7 +803,7 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
|
||||
Justify::JustifyAll => (0, true),
|
||||
};
|
||||
justify &= paragraph.regions.is_some();
|
||||
let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).count();
|
||||
let spaces = (line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).count();
|
||||
// A gap that justification adds between two glyphs goes into the advance of the one
|
||||
// drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a
|
||||
// right-to-left line comes before it in the text.
|
||||
@@ -812,7 +855,8 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
|
||||
// Word spacing, letter spacing and glyph scaling within their ranges, then the
|
||||
// word spaces alone; composed lines may shrink word spaces (never below zero).
|
||||
let flex: Vec<Flex> = (line.index..line.body).map(|j| flow_line_flex(j, ¶graph.flex, held, hung)).collect();
|
||||
let floor = (line.index..line.body).filter(|&j| j >= paragraph.marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min);
|
||||
let floor =
|
||||
(line.index..line.body).filter(|&j| j >= paragraph.marker && is_justified_space(&sg[j])).map(|j| sg[j].adv).fold(f64::MAX, f64::min);
|
||||
fit = fit_line(&flex, room - width, spaces, floor);
|
||||
} else if room > width {
|
||||
// One word: its letter spacing and glyph scaling, then the gaps between its glyphs.
|
||||
@@ -820,11 +864,8 @@ fn justify_flow_line(cx: &Ctx<'_>, paragraph: &ParagraphData<'_, '_>, line: &Lin
|
||||
fit = fit_line(&flex, room - width, 0, 0.0);
|
||||
let used: f64 = flex.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum();
|
||||
let rest = room - width - used;
|
||||
let gaps = (line.index..line.body)
|
||||
.filter(|&j| {
|
||||
!held.contains(&j) && drawn_after(line.index, j, &after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy())
|
||||
})
|
||||
.count();
|
||||
let hyphen = line.hyphen.is_some();
|
||||
let gaps = (line.index..line.body).filter(|&j| is_letter_gap(j, held, &after, line.index, sg, hyphen)).count();
|
||||
if gaps > 0 && rest > EPS {
|
||||
per_gap = rest / gaps as f64;
|
||||
}
|
||||
@@ -942,16 +983,13 @@ fn emit_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, paragraph: &ParagraphData
|
||||
advance += justification.fit.letter(&flex) + justification.fit.glyph(&flex);
|
||||
scale = justification.fit.glyph_scale(&flex);
|
||||
}
|
||||
if glyph.is_space() {
|
||||
if is_justified_space(glyph) {
|
||||
advance += justification.per_space;
|
||||
} else if justification.per_cjk != 0.0 || justification.squeeze != 0.0 {
|
||||
if is_cjk_gap(glyph_index, &line.held, &justification.after, index, sg) {
|
||||
advance += justification.per_cjk + squeeze_after(glyph, justification.squeeze, cx.vertical);
|
||||
}
|
||||
} else if !line.held.contains(&glyph_index)
|
||||
&& drawn_after(index, glyph_index, &justification.after).and_then(|next| sg.get(next)).is_some_and(|next| !next.continues_tcy())
|
||||
{
|
||||
// Between glyphs, never inside a tate-chu-yoko block (one cell).
|
||||
} else if is_letter_gap(glyph_index, &line.held, &justification.after, index, sg, line.hyphen.is_some()) {
|
||||
advance += justification.per_gap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,6 +426,21 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
|
||||
assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in");
|
||||
}
|
||||
|
||||
/// A zero-width joiner between two CJK characters takes no gap of its own: the layout closes the
|
||||
/// one gap between the characters on either side, so the push-in counts five gaps (5 pt), short of
|
||||
/// the 6 pt the line overflows by.
|
||||
#[test]
|
||||
fn kinsoku_shori_type_counts_no_gap_at_a_format_control() {
|
||||
let text = "一二\u{200D}三四五」六";
|
||||
let mut glyphs = full_width(text);
|
||||
glyphs[2].adv = 0.0;
|
||||
let end = |width: f64| {
|
||||
break_line(text, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[]).0
|
||||
};
|
||||
assert_eq!(end(54.0), 5, "五 is pushed out with 」");
|
||||
assert_eq!(end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in");
|
||||
}
|
||||
|
||||
/// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed
|
||||
/// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last
|
||||
/// glyph of the last cluster counts, and only where the line may end.
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
use vectorcraft_doc::{CharStyle, TextKind};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
fn devanagari_test_face(test: &str) -> Option<Arc<FontFace>> {
|
||||
pub(super) fn devanagari_test_face(test: &str) -> Option<Arc<FontFace>> {
|
||||
let db = FontDb::global();
|
||||
// The face must have OpenType (GSUB) Devanagari lookups. An AAT face such as macOS's Devanagari MT
|
||||
// shapes with `morx`, and हिन्दी comes out as three clusters because the conjunct न्दी is split in two.
|
||||
|
||||
@@ -130,6 +130,263 @@ fn a_justified_right_to_left_word_is_spread_between_its_drawn_letters() {
|
||||
assert!(gaps.iter().all(|&g| g > 1.0 && (g - gaps[0]).abs() < 1e-6), "{gaps:?}");
|
||||
}
|
||||
|
||||
/// A word with a combining mark that has no precomposed form, `x` + U+0301: the base and the mark
|
||||
/// are two glyphs of one shaped cluster at byte 1, between `a` (byte 0) and `z` (byte 4).
|
||||
const MARKED: &str = "ax\u{301}z";
|
||||
|
||||
/// Where each glyph of the cluster at byte `b` sits from the cluster's first glyph.
|
||||
fn cluster_offsets(l: &TextLayout, b: usize) -> Vec<Vec2> {
|
||||
let glyphs: Vec<&PositionedGlyph> = l.glyphs.iter().filter(|g| g.byte == b).collect();
|
||||
glyphs.iter().map(|g| g.origin - glyphs[0].origin).collect()
|
||||
}
|
||||
|
||||
fn assert_same_offsets(a: &[Vec2], b: &[Vec2], what: &str) {
|
||||
assert!(a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (*p - *q).hypot() < 1e-6), "{what}: {a:?} vs {b:?}");
|
||||
}
|
||||
|
||||
/// The precondition of the cluster tests: `MARKED` shapes the base and its mark as two glyphs
|
||||
/// with one byte.
|
||||
fn marked_cluster(l: &TextLayout) -> Vec<Vec2> {
|
||||
let offsets = cluster_offsets(l, 1);
|
||||
assert_eq!(offsets.len(), 2, "x and its mark are one cluster: {:?}", l.glyphs);
|
||||
offsets
|
||||
}
|
||||
|
||||
/// The space Full Justify put between two clusters, from `a`'s first glyph to `b`'s, beyond the
|
||||
/// distance the unspaced `natural` layout has between them.
|
||||
fn added_gap(l: &TextLayout, natural: &TextLayout, a: usize, b: usize) -> f64 {
|
||||
let d = |l: &TextLayout| (at(l, b).origin - at(l, a).origin).hypot();
|
||||
d(l) - d(natural)
|
||||
}
|
||||
|
||||
/// Full Justify spreads a single word between its clusters, never between a letter and its
|
||||
/// combining mark: the mark keeps its place on its letter, the two gaps are equal, and the word
|
||||
/// still reaches the measure.
|
||||
#[test]
|
||||
fn single_word_full_justify_keeps_combining_mark_with_base() {
|
||||
let natural = layout(db(), &point(MARKED, JustificationOptions::default()));
|
||||
let unspaced = marked_cluster(&natural);
|
||||
let l = layout(db(), &area(MARKED, 300.0, Justify::JustifyAll, JustificationOptions::default()));
|
||||
assert_same_offsets(&cluster_offsets(&l, 1), &unspaced, "the mark stays on its letter");
|
||||
let (first, second) = (added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 4));
|
||||
assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}");
|
||||
let z = at(&l, 4);
|
||||
assert!((z.origin.x + at(&natural, 4).advance - 300.0).abs() < 1e-6, "{:?}", z.origin);
|
||||
}
|
||||
|
||||
/// Desired Letter Spacing on a ragged line, and the letter spacing range of a justified one, go
|
||||
/// after each cluster, not between a letter and its mark.
|
||||
#[test]
|
||||
fn desired_and_fitted_letter_spacing_skip_shaped_cluster() {
|
||||
let natural = layout(db(), &point(MARKED, JustificationOptions::default()));
|
||||
let unspaced = marked_cluster(&natural);
|
||||
|
||||
let desired = layout(db(), &point(MARKED, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() }));
|
||||
assert_same_offsets(&cluster_offsets(&desired, 1), &unspaced, "Desired Letter Spacing");
|
||||
let gap = at(&desired, 0).advance - at(&natural, 0).advance;
|
||||
assert!(gap > 1.0, "{gap}");
|
||||
assert!((added_gap(&desired, &natural, 1, 4) - gap).abs() < 1e-6, "one gap after the cluster");
|
||||
|
||||
// 4 pt of room, less than the two gaps' Maximum (half a space each): letter spacing alone.
|
||||
let w = natural.lines[0].x1 - natural.lines[0].x0 + 4.0;
|
||||
let range = JustificationOptions { letter_spacing: [0.0, 0.0, 50.0], ..JustificationOptions::default() };
|
||||
let fitted = layout(db(), &area(MARKED, w, Justify::JustifyAll, range));
|
||||
assert_same_offsets(&cluster_offsets(&fitted, 1), &unspaced, "fitted letter spacing");
|
||||
let (first, second) = (added_gap(&fitted, &natural, 0, 1), added_gap(&fitted, &natural, 1, 4));
|
||||
assert!((first - 2.0).abs() < 1e-6 && (second - 2.0).abs() < 1e-6, "{first} {second}");
|
||||
}
|
||||
|
||||
/// Right to left (a bidi override), Full Justify puts its gaps between the clusters as they are
|
||||
/// drawn and leaves the mark on its letter.
|
||||
#[test]
|
||||
fn rtl_single_word_cluster_gap_follows_drawn_order() {
|
||||
// RLO, then MARKED, then PDF: a at byte 3, the cluster at byte 4, z at byte 7.
|
||||
let text = "\u{202E}ax\u{301}z\u{202C}";
|
||||
let natural = layout(db(), &point(text, JustificationOptions::default()));
|
||||
let unspaced = cluster_offsets(&natural, 4);
|
||||
assert_eq!(unspaced.len(), 2, "x and its mark are one cluster: {:?}", natural.glyphs);
|
||||
assert!(at(&natural, 4).rtl, "the override sets the word right to left");
|
||||
let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default()));
|
||||
assert_same_offsets(&cluster_offsets(&l, 4), &unspaced, "the mark stays on its letter");
|
||||
// Drawn from left to right: z, the cluster, a.
|
||||
assert!(at(&l, 7).origin.x < at(&l, 4).origin.x && at(&l, 4).origin.x < at(&l, 3).origin.x);
|
||||
let (first, second) = (added_gap(&l, &natural, 7, 4), added_gap(&l, &natural, 4, 3));
|
||||
assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}");
|
||||
// The zero-width override marks take no gap: the word runs from edge to edge.
|
||||
assert!(at(&l, 7).origin.x.abs() < 1e-6, "{:?}", at(&l, 7).origin);
|
||||
assert!((at(&l, 3).origin.x + at(&natural, 3).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 3).origin);
|
||||
}
|
||||
|
||||
/// Desired Letter Spacing goes between the letters of a word wrapped in bidi override marks,
|
||||
/// not after the zero-width marks or before the closing one.
|
||||
#[test]
|
||||
fn desired_letter_spacing_skips_bidi_format_controls() {
|
||||
let text = "\u{202E}ax\u{301}z\u{202C}";
|
||||
let natural = layout(db(), &point(text, JustificationOptions::default()));
|
||||
let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() }));
|
||||
let gap = at(&spaced, 3).advance - at(&natural, 3).advance;
|
||||
assert!(gap > 1.0, "{gap}");
|
||||
assert!((at(&spaced, 7).origin.x - at(&natural, 7).origin.x).abs() < 1e-6, "{:?}", at(&spaced, 7).origin);
|
||||
let extent = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0;
|
||||
assert!((extent(&spaced) - extent(&natural) - 2.0 * gap).abs() < 1e-6, "{} {} {gap}", extent(&spaced), extent(&natural));
|
||||
}
|
||||
|
||||
/// A zero-width joiner at the start and end of a word, and a word joiner and a soft hyphen inside
|
||||
/// it, take no letter spacing and no Full Justify gap: the space goes between a, b and c only.
|
||||
#[test]
|
||||
fn letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens() {
|
||||
// ZWJ (bytes 0..3), a (3), word joiner (4..7), b (7), soft hyphen (8..10), c (10), ZWJ (11..14).
|
||||
let text = "\u{200D}a\u{2060}b\u{AD}c\u{200D}";
|
||||
let natural = layout(db(), &point(text, JustificationOptions::default()));
|
||||
|
||||
let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() }));
|
||||
let gap = at(&spaced, 3).advance - at(&natural, 3).advance;
|
||||
assert!(gap > 1.0, "{gap}");
|
||||
assert!((at(&spaced, 3).origin.x - at(&natural, 3).origin.x).abs() < 1e-6, "{:?}", at(&spaced, 3).origin);
|
||||
assert!((added_gap(&spaced, &natural, 7, 10) - gap).abs() < 1e-6, "one gap between b and c");
|
||||
let extent = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0;
|
||||
assert!((extent(&spaced) - extent(&natural) - 2.0 * gap).abs() < 1e-6, "{} {} {gap}", extent(&spaced), extent(&natural));
|
||||
|
||||
let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default()));
|
||||
let (first, second) = (added_gap(&l, &natural, 3, 7), added_gap(&l, &natural, 7, 10));
|
||||
assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}");
|
||||
assert!(at(&l, 3).origin.x.abs() < 1e-6, "{:?}", at(&l, 3).origin);
|
||||
assert!((at(&l, 10).origin.x + at(&natural, 10).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 10).origin);
|
||||
}
|
||||
|
||||
/// Every assigned default-ignorable character drawn with no width is a format control, the
|
||||
/// zero-width space included; word spaces, the Hangul fillers and ordinary letters are not.
|
||||
#[test]
|
||||
fn format_controls_are_the_zero_width_default_ignorables() {
|
||||
use crate::justify::is_format_control;
|
||||
let controls = "\u{AD}\u{34F}\u{61C}\u{180E}\u{200B}\u{200C}\u{200D}\u{200E}\u{202E}\u{2060}\u{2065}\u{2066}\u{206F}";
|
||||
let more = "\u{FE0F}\u{FEFF}\u{1BCA0}\u{1D173}\u{E0001}\u{E007F}\u{E0100}";
|
||||
for ch in controls.chars().chain(more.chars()) {
|
||||
assert!(is_format_control(ch), "U+{:04X}", ch as u32);
|
||||
}
|
||||
for ch in "a \u{2009}\u{3000}\u{2028}\u{3164}\u{FFA0}\u{301}".chars() {
|
||||
assert!(!is_format_control(ch), "U+{:04X}", ch as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// A zero-width space is a line break opportunity but not a word separator: a justified line
|
||||
/// gives it no share of its room, Full Justify spreads a word with one between its visible letters,
|
||||
/// and Desired Letter Spacing goes between the letters on either side of it.
|
||||
#[test]
|
||||
fn zero_width_space_takes_no_word_space_room() {
|
||||
// a (0), b (1), zero-width space (2..5), c (5), d (6), space (7), e (8), f (9).
|
||||
let natural = layout(db(), &point("ab\u{200B}cd ef", JustificationOptions::default()));
|
||||
let l = layout(db(), &area("ab\u{200B}cd ef", 300.0, Justify::JustifyAll, JustificationOptions::default()));
|
||||
assert!(added_gap(&l, &natural, 1, 5).abs() < 1e-6, "b and c stay together: {}", added_gap(&l, &natural, 1, 5));
|
||||
assert!((at(&l, 9).origin.x + at(&natural, 9).advance - 300.0).abs() < 1e-6, "the word space takes the room: {:?}", at(&l, 9).origin);
|
||||
|
||||
// No word space: the zero-width space does not stand in for one.
|
||||
let text = "ab\u{200B}cd";
|
||||
let natural = layout(db(), &point(text, JustificationOptions::default()));
|
||||
let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default()));
|
||||
let gaps = [added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 5), added_gap(&l, &natural, 5, 6)];
|
||||
assert!(gaps[0] > 1.0 && gaps.iter().all(|g| (g - gaps[0]).abs() < 1e-6), "{gaps:?}");
|
||||
assert!((at(&l, 6).origin.x + at(&natural, 6).advance - 300.0).abs() < 1e-6, "{:?}", at(&l, 6).origin);
|
||||
|
||||
let spaced = layout(db(), &point(text, JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() }));
|
||||
let gap = at(&spaced, 0).advance - at(&natural, 0).advance;
|
||||
assert!(gap > 1.0 && (added_gap(&spaced, &natural, 1, 5) - gap).abs() < 1e-6, "{gap} {}", added_gap(&spaced, &natural, 1, 5));
|
||||
|
||||
// It is still a place to break the line.
|
||||
let w = natural.lines[0].x1 - natural.lines[0].x0 - 5.0;
|
||||
let wrapped = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default()));
|
||||
assert_eq!(wrapped.lines.len(), 2);
|
||||
assert_eq!(wrapped.lines[0].end, 5, "breaks after the zero-width space");
|
||||
}
|
||||
|
||||
/// Desired Letter Spacing puts the gap between b and c on the zero-width space, so a line that
|
||||
/// breaks there ends at b's own advance and wraps where the unspaced text does.
|
||||
#[test]
|
||||
fn desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap() {
|
||||
// a (0), b (1), zero-width space (2..5), c (5), d (6).
|
||||
let text = "ab\u{200B}cd";
|
||||
let desired = JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() };
|
||||
let natural = layout(db(), &point(text, JustificationOptions::default()));
|
||||
let spaced = layout(db(), &point(text, desired.clone()));
|
||||
let gap = at(&spaced, 0).advance - at(&natural, 0).advance;
|
||||
assert!(gap > 1.0, "{gap}");
|
||||
// Room for a, its gap and b, and half a gap more: not for a second gap after b.
|
||||
let w = at(&natural, 0).advance + gap + at(&natural, 1).advance + 0.5 * gap;
|
||||
let unspaced = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default()));
|
||||
assert_eq!(unspaced.lines.first().map(|l| l.end), Some(5), "the unspaced text breaks after the zero-width space");
|
||||
let wrapped = layout(db(), &area(text, w, Justify::Left, desired));
|
||||
assert_eq!(wrapped.lines.len(), 2, "{:?}", wrapped.lines);
|
||||
assert_eq!(wrapped.lines[0].end, 5, "breaks after the zero-width space");
|
||||
assert!((at(&wrapped, 1).advance - at(&natural, 1).advance).abs() < 1e-6, "b keeps its own advance: {}", at(&wrapped, 1).advance);
|
||||
let b = at(&wrapped, 1);
|
||||
let end = b.origin.x + b.advance;
|
||||
assert!((end - (at(&natural, 0).advance + gap + at(&natural, 1).advance)).abs() < 1e-6, "the line ends at b: {end}");
|
||||
}
|
||||
|
||||
/// A single word under Full Justify that breaks at a soft hyphen spreads its first line between its
|
||||
/// letters and the hyphen drawn after the last one, all with the same gap.
|
||||
#[test]
|
||||
fn single_word_full_justify_spreads_up_to_the_drawn_hyphen() {
|
||||
// a (0), b (1), c (2), soft hyphen (3..5), then d at byte 5.
|
||||
let text = "abc\u{AD}defghij";
|
||||
let hyphenated = layout(db(), &point("abc-", JustificationOptions::default()));
|
||||
let w = hyphenated.lines[0].x1 - hyphenated.lines[0].x0 + 30.0;
|
||||
// The same first line, set ragged: the distances justification adds to.
|
||||
let natural = layout(db(), &area(text, w, Justify::Left, JustificationOptions::default()));
|
||||
let l = layout(db(), &area(text, w, Justify::JustifyLeft, JustificationOptions::default()));
|
||||
for layout in [&natural, &l] {
|
||||
assert!(layout.lines.len() >= 2 && layout.lines[0].end == 5, "the first line ends at the soft hyphen: {:?}", layout.lines);
|
||||
}
|
||||
let hyphen = |l: &TextLayout| l.glyphs[l.lines[0].glyph_end - 1].clone();
|
||||
let ab = added_gap(&l, &natural, 0, 1);
|
||||
let bc = added_gap(&l, &natural, 1, 2);
|
||||
let c_hyphen = (hyphen(&l).origin.x - at(&l, 2).origin.x) - (hyphen(&natural).origin.x - at(&natural, 2).origin.x);
|
||||
assert!(ab > 1.0 && (ab - bc).abs() < 1e-6 && (ab - c_hyphen).abs() < 1e-6, "{ab} {bc} {c_hyphen}");
|
||||
let end = hyphen(&l).origin.x + hyphen(&l).advance;
|
||||
assert!((end - w).abs() < 1e-6, "the hyphen reaches the edge: {end} {w}");
|
||||
}
|
||||
|
||||
/// In vertical type, Full Justify spreads a word down the column between its clusters and leaves
|
||||
/// the mark on its letter.
|
||||
#[test]
|
||||
fn vertical_single_word_cluster_gap_follows_drawn_order() {
|
||||
let vertical = |t: &mut TextObject| t.vertical = true;
|
||||
let mut plain = point(MARKED, JustificationOptions::default());
|
||||
vertical(&mut plain);
|
||||
let natural = layout(db(), &plain);
|
||||
let unspaced = marked_cluster(&natural);
|
||||
let mut t = area(MARKED, 100.0, Justify::JustifyAll, JustificationOptions::default());
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 300.0).to_path(0.1)) };
|
||||
vertical(&mut t);
|
||||
let l = layout(db(), &t);
|
||||
assert_same_offsets(&cluster_offsets(&l, 1), &unspaced, "the mark stays on its letter");
|
||||
assert!(at(&l, 0).origin.y < at(&l, 1).origin.y && at(&l, 1).origin.y < at(&l, 4).origin.y, "down the column");
|
||||
let (first, second) = (added_gap(&l, &natural, 0, 1), added_gap(&l, &natural, 1, 4));
|
||||
assert!(first > 1.0 && (first - second).abs() < 1e-6, "{first} {second}");
|
||||
}
|
||||
|
||||
/// A Devanagari syllable whose vowel sign is drawn before its consonant (कि) is one cluster of two
|
||||
/// glyphs: Full Justify keeps them together. Skipped without an installed Devanagari face.
|
||||
#[test]
|
||||
fn single_word_full_justify_keeps_conjunct_cluster() {
|
||||
let Some(face) = super::devanagari::devanagari_test_face("single_word_full_justify_keeps_conjunct_cluster") else { return };
|
||||
// कि (bytes 0..6) and क (byte 6).
|
||||
let text = "\u{915}\u{93F}\u{915}";
|
||||
let styled = |mut t: TextObject| {
|
||||
for run in &mut t.runs {
|
||||
run.style.font_family = face.family.clone();
|
||||
run.style.font_style = face.style.clone();
|
||||
}
|
||||
t
|
||||
};
|
||||
let natural = layout(db(), &styled(point(text, JustificationOptions::default())));
|
||||
let unspaced = cluster_offsets(&natural, 0);
|
||||
assert!(unspaced.len() >= 2, "कि is one cluster of several glyphs: {:?}", natural.glyphs);
|
||||
let l = layout(db(), &styled(area(text, 300.0, Justify::JustifyAll, JustificationOptions::default())));
|
||||
assert_same_offsets(&cluster_offsets(&l, 0), &unspaced, "the syllable stays together");
|
||||
assert!(added_gap(&l, &natural, 0, 6) > 1.0);
|
||||
}
|
||||
|
||||
/// Single Word Justification: a justified line holding one word is spread between its letters
|
||||
/// (Full Justify, the default) or aligned as chosen.
|
||||
#[test]
|
||||
|
||||
@@ -649,6 +649,7 @@ since 2026-10-01.
|
||||
- **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
- **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy `<name> copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
- **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>) also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`.
|
||||
- **Letter spacing and Full Justify between shaped clusters:** Desired Letter Spacing on any line, the letter spacing range of a justified line, and Single Word Justification's Full Justify add space only between shaped clusters, in horizontal, right-to-left and vertical type, so a combining mark stays on its letter and a Devanagari syllable's glyphs stay together. They add no space next to a zero-width format control (a soft hyphen, a bidi mark, embedding, override or isolate, a zero-width space, a joiner or non-joiner, a word joiner, a variation selector, a tag, the byte order mark), so a word wrapped in an override still reaches both edges of a justified line, and a Kinsoku push-in counts no gap at one either. A zero-width space is a line break opportunity but not a word separator ([CSS Text Level 3](https://www.w3.org/TR/css-text-3/#word-separator) does not list it), so a justified line gives it no share of its room. Desired Letter Spacing between the letters on either side of a zero-width space goes after the zero-width space, so a line that breaks there ends at its last letter, and the line breaker does not measure a gap after it. A line that Full Justify spreads and that ends in a drawn hyphen puts the same gap between its last letter and the hyphen. The cluster boundary is VectorCraft's own rule, based on the typographic character unit of [CSS Text Level 3](https://www.w3.org/TR/css-text-3/#typographic-character-unit). Tests `tests::justify::single_word_full_justify_keeps_combining_mark_with_base`, `desired_and_fitted_letter_spacing_skip_shaped_cluster`, `rtl_single_word_cluster_gap_follows_drawn_order`, `vertical_single_word_cluster_gap_follows_drawn_order`, `single_word_full_justify_keeps_conjunct_cluster`, `desired_letter_spacing_skips_bidi_format_controls`, `letter_spacing_and_full_justify_skip_joiners_and_soft_hyphens`, `format_controls_are_the_zero_width_default_ignorables`, `zero_width_space_takes_no_word_space_room`, `desired_letter_spacing_ends_a_line_broken_at_a_zero_width_space_without_a_gap`, `single_word_full_justify_spreads_up_to_the_drawn_hyphen` and `layout::wrapping_tests::kinsoku_shori_type_counts_no_gap_at_a_format_control`. Public reference: the help page (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>).
|
||||
- **The text style panel menus' Duplicate and Delete:** in the Character Styles and Paragraph Styles panel menus, Duplicate highlights the copy, as Create New Style highlights the new style, and Delete leaves no style highlighted, as the Delete button does, so Duplicate, Clear Overrides, Redefine and Style Options are disabled until another style is clicked. A highlighted style that is renamed in Style Options or deleted by a command is no longer highlighted either. The tests `panels::text_styles::tests::panel_menu_duplicate_and_delete_update_the_highlighted_style` and `a_highlighted_style_that_is_no_longer_listed_is_not_highlighted` cover this. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
- **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
- **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions.
|
||||
|
||||
Reference in New Issue
Block a user