Files
vectorcraft/crates/text/src/justify.rs
T
VictorandClaude Opus 5.5 a275496ef4 G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
The Paragraph panel menu's Justification… was a disabled stub, and justified
lines stretched word spaces between fixed limits (80% to 150%).

Each paragraph now has Justification options: Word Spacing, Letter Spacing
and Glyph Scaling (Minimum, Desired, Maximum, in percent), Auto Leading and
Single Word Justification. Desired applies to every line; a justified line
fills its measure with word spacing first, then letter spacing, then glyph
scaling, each within its range, and past them its word spaces take the
rest. The Every-line Composer scores lines by that flexibility. Auto
Leading sets the leading of characters whose leading is Auto. A justified
line holding one word is spread between its letters (Full Justify) or
aligned left, centre or right.

The defaults are the documented ones (80/100/133, 0/0/0, 100/100/100, 120,
Full Justify), saved only when they differ. Because the default maximum
word spacing is 133%, justified paragraphs break differently than before;
the Every-line Composer's second pass is now unbounded for justified text
(it used to give up past four times the stretch and fall back to the
Single-line Composer, which the narrower default range would make common),
and lines that can't stretch at all are still refused.

text.setFormat takes wordSpacing, letterSpacing, glyphScaling ([min,
desired, max] or {min?, desired?, max?}), autoLeading and
singleWordJustification; Minimum ≤ Desired ≤ Maximum must hold or nothing
changes. Paragraph styles carry the options (New and Redefine capture
them), and Reset Panel resets them. Justification… opens a dialog
(ui.justification) with Preview; OK is disabled while a row is out of
order. Live SVG text places characters the options move, and a glyph that
Glyph Scaling scales is stretched to its width; PDF uses the glyphs'
transforms. Vertical type isn't glyph scaled; type on a path doesn't use
the options.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2755.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:40:14 +07:00

205 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paragraph panel menu › Justification…: word spacing, letter spacing and glyph scaling.
//!
//! Each has a Minimum, a Desired and a Maximum value in percent
//! ([`vectorcraft_doc::JustificationOptions`]). [`apply_desired`] sets every glyph of a paragraph
//! at the Desired values, so they apply to ragged lines too, and returns how far each glyph may
//! stretch or shrink towards Maximum and Minimum. The every-line composer scores lines by that
//! flexibility, and [`fit_line`] spreads a justified line's leftover space over it: the word spaces
//! first, then the gaps between letters, then the glyphs' widths. A line that still has room left
//! (or too little) takes the rest on its word spaces, as before these options existed.
//!
//! Behaviour reference (public documentation):
//! <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>.
use vectorcraft_doc::JustificationOptions;
use crate::shape::SGlyph;
/// How far one glyph may stretch (`.0`) or shrink (`.1`), in points, in each kind of spacing.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Flex {
/// A word space's width, between Minimum and Maximum Word Spacing.
pub word: (f64, f64),
/// The gap after a letter, between Minimum and Maximum Letter Spacing.
pub letter: (f64, f64),
/// The glyph's own width, between Minimum and Maximum Glyph Scaling.
pub glyph: (f64, f64),
/// The glyph's width at Desired Glyph Scaling, without its letter spacing: what a glyph
/// scaling adjustment is measured against.
pub width: f64,
/// The Desired Glyph Scaling the glyph is drawn at (1: 100%).
pub scale: f64,
}
impl Default for Flex {
fn default() -> Self {
Self { word: (0.0, 0.0), letter: (0.0, 0.0), glyph: (0.0, 0.0), width: 0.0, scale: 1.0 }
}
}
impl Flex {
/// All the glyph may stretch.
pub fn stretch(&self) -> f64 {
self.word.0 + self.letter.0 + self.glyph.0
}
/// All the glyph may shrink.
pub fn shrink(&self) -> f64 {
self.word.1 + self.letter.1 + self.glyph.1
}
}
/// A glyph justification spreads space over as a word space (tabs keep their stops).
pub(crate) fn is_word_space(g: &SGlyph) -> bool {
g.is_space() && g.ch != '\t'
}
/// A glyph letter spacing follows: a visible glyph followed by another one in the same word.
fn letter_gap(g: &SGlyph, next: Option<&SGlyph>) -> bool {
!g.is_space() && g.inline.is_none() && next.is_some_and(|n| !n.is_space())
}
/// The width of a word space in `g`'s font and size: what letter spacing is a share of.
fn space_width(g: &SGlyph) -> f64 {
let gid = g.face.glyph_for(' ');
let w = if gid != 0 { g.face.advance(gid) * g.sx } else { g.face.upem * 0.25 * g.sx };
if w.is_finite() { w.abs() } else { 0.0 }
}
/// Set the glyphs `sg` of a paragraph at `j`'s Desired values and return each glyph's [`Flex`].
/// `scale_glyphs` is false in vertical type, whose glyphs are not scaled across the column.
pub(crate) fn apply_desired(sg: &mut [SGlyph], j: &JustificationOptions, scale_glyphs: bool) -> Vec<Flex> {
let pct = |v: f64| if v.is_finite() { v / 100.0 } else { 0.0 };
let [w_min, w_des, w_max] = j.word_spacing.map(pct);
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()];
for k in 0..sg.len() {
let gap = letter_gap(&sg[k], sg.get(k + 1));
let (Some(g), Some(f)) = (sg.get_mut(k), flex.get_mut(k)) else { continue };
if is_word_space(g) {
// The font's word space (with its tracking), set at Desired.
let base = g.adv;
g.adv = base * w_des;
f.word = (base * (w_max - w_des).max(0.0), base * (w_des - w_min).max(0.0));
continue;
}
if g.is_space() || g.inline.is_some() {
continue;
}
let base = g.adv;
if g_des != 1.0 {
g.adv *= g_des;
g.sx *= g_des;
g.dx *= g_des;
}
f.width = g.adv;
f.scale = g_des;
f.glyph = (base * (g_max - g_des).max(0.0), base * (g_des - g_min).max(0.0));
if gap {
let space = space_width(g);
g.adv += space * l_des;
f.letter = (space * (l_max - l_des).max(0.0), space * (l_des - l_min).max(0.0));
}
}
flex
}
/// How a justified line takes up its leftover space: the share of each kind of flexibility used
/// (negative when the line shrinks), and what each word space takes beyond its own range.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct LineFit {
pub word: f64,
pub letter: f64,
pub glyph: f64,
/// Added to every word space once the ranges are used up (negative: taken off).
pub per_space: f64,
}
impl LineFit {
/// The extra advance of glyph `f` that is a word space.
pub fn space(&self, f: &Flex) -> f64 {
pick(f.word, self.word) + self.per_space
}
/// The extra advance after a letter: its letter spacing.
pub fn letter(&self, f: &Flex) -> f64 {
pick(f.letter, self.letter)
}
/// How much wider glyph `f` is drawn (1: as it is).
pub fn glyph_scale(&self, f: &Flex) -> f64 {
let extra = pick(f.glyph, self.glyph);
if f.width > 0.0 && extra != 0.0 { (f.width + extra) / f.width } else { 1.0 }
}
/// The extra advance of glyph `f` from its scaling.
pub fn glyph(&self, f: &Flex) -> f64 {
pick(f.glyph, self.glyph)
}
}
/// The stretch (share > 0) or shrink (share < 0) of a `(stretch, shrink)` pair.
fn pick(range: (f64, f64), share: f64) -> f64 {
if share >= 0.0 { range.0 * share } else { range.1 * share }
}
/// Spread `room` (the line's width less its natural width; negative when it is too long) over
/// the glyphs `flex` of a justified line: word spaces first, then letter gaps, then glyph widths,
/// each up to its range. `spaces` counts the line's word spaces, and `shrink_floor` is how much a
/// word space may lose beyond its range (its whole width: never below zero). The rest goes on the
/// word spaces when the line has any.
pub(crate) fn fit_line(flex: &[Flex], room: f64, spaces: usize, shrink_floor: f64) -> LineFit {
let mut fit = LineFit::default();
if !room.is_finite() || room == 0.0 {
return fit;
}
let grow = room > 0.0;
let total = |part: fn(&Flex) -> (f64, f64)| flex.iter().map(|f| if grow { part(f).0 } else { part(f).1 }).sum::<f64>();
let mut left = room.abs();
for (part, share) in [
((|f: &Flex| f.word) as fn(&Flex) -> (f64, f64), &mut fit.word),
(|f: &Flex| f.letter, &mut fit.letter),
(|f: &Flex| f.glyph, &mut fit.glyph),
] {
let cap = total(part);
if left <= 0.0 || cap <= 0.0 {
continue;
}
let used = left.min(cap);
*share = if grow { used / cap } else { -used / cap };
left -= used;
}
if left > 1e-9 && spaces > 0 {
let per = left / spaces as f64;
fit.per_space = if grow { per } else { -per.min(shrink_floor.max(0.0)) };
}
fit
}
#[cfg(test)]
mod tests {
use super::*;
fn flex(word: (f64, f64), letter: (f64, f64), glyph: (f64, f64), width: f64) -> Flex {
Flex { word, letter, glyph, width, scale: 1.0 }
}
#[test]
fn word_spaces_take_the_room_first_then_letters_then_glyphs() {
let line = [flex((2.0, 1.0), (0.0, 0.0), (0.0, 0.0), 0.0), flex((0.0, 0.0), (1.0, 1.0), (4.0, 4.0), 8.0)];
// Within the word spaces' range.
assert_eq!(fit_line(&line, 1.0, 1, 3.0), LineFit { word: 0.5, ..LineFit::default() });
// Spaces full, half of the letter gaps.
let fit = fit_line(&line, 2.5, 1, 3.0);
assert_eq!((fit.word, fit.letter, fit.glyph), (1.0, 0.5, 0.0));
// Everything used, the rest on the word space.
let fit = fit_line(&line, 10.0, 1, 3.0);
assert_eq!((fit.word, fit.letter, fit.glyph, fit.per_space), (1.0, 1.0, 1.0, 3.0));
assert_eq!(fit.glyph_scale(&line[1]), 1.5);
// Shrinking: the space can't lose more than its width.
let fit = fit_line(&line, -10.0, 1, 3.0);
assert_eq!((fit.word, fit.letter, fit.glyph, fit.per_space), (-1.0, -1.0, -1.0, -3.0));
assert_eq!(fit.space(&line[0]), -4.0);
// No room to share, or nowhere to put it.
assert_eq!(fit_line(&line, 0.0, 1, 3.0), LineFit::default());
assert_eq!(fit_line(&[], 5.0, 0, 0.0), LineFit::default());
}
}