mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Merge pull request #2771 from VL420/feat/justification
G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
This commit is contained in:
@@ -117,9 +117,9 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, Justify,
|
||||
Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
|
||||
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics,
|
||||
SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
|
||||
@@ -506,6 +506,98 @@ impl Default for HyphenationOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a justified line holding a single word is set ([`JustificationOptions::single_word`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SingleWordJustify {
|
||||
/// Spread across the line, between its letters.
|
||||
#[default]
|
||||
FullJustify,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
}
|
||||
|
||||
/// A paragraph's justification options (Paragraph panel menu › Justification…), in percent.
|
||||
///
|
||||
/// Each spacing has a Minimum, a Desired and a Maximum value (`[min, desired, max]`). Desired
|
||||
/// applies to every line of the paragraph, justified or not: word spaces are that share of the
|
||||
/// font's word space, each letter is followed by that share of a word space, and glyphs are that
|
||||
/// wide. Justified lines may vary them between Minimum and Maximum to fill the measure. Saved only
|
||||
/// when they differ from the defaults: word spacing 80/100/133, letter spacing 0/0/0, glyph
|
||||
/// scaling 100/100/100, Auto Leading 120 and Full Justify.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct JustificationOptions {
|
||||
/// Word Spacing, percent of the font's word space (0..1000).
|
||||
pub word_spacing: [f64; 3],
|
||||
/// Letter Spacing, percent of the font's word space added after each letter (-100..500).
|
||||
pub letter_spacing: [f64; 3],
|
||||
/// Glyph Scaling, percent of the glyphs' width (50..200).
|
||||
pub glyph_scaling: [f64; 3],
|
||||
/// Auto Leading: the leading of characters whose leading is Auto, in percent of their size
|
||||
/// (0..500).
|
||||
pub auto_leading: f64,
|
||||
/// Single Word Justification: how a justified line holding one word is set.
|
||||
pub single_word: SingleWordJustify,
|
||||
}
|
||||
|
||||
impl JustificationOptions {
|
||||
/// Word Spacing's range (percent).
|
||||
pub const WORD_SPACING: (f64, f64) = (0.0, 1000.0);
|
||||
/// Letter Spacing's range (percent).
|
||||
pub const LETTER_SPACING: (f64, f64) = (-100.0, 500.0);
|
||||
/// Glyph Scaling's range (percent).
|
||||
pub const GLYPH_SCALING: (f64, f64) = (50.0, 200.0);
|
||||
/// Auto Leading's range (percent).
|
||||
pub const AUTO_LEADING: (f64, f64) = (0.0, 500.0);
|
||||
|
||||
/// Are Desired word spacing, letter spacing and glyph scaling the defaults (nothing to change
|
||||
/// in a line's natural widths)?
|
||||
pub fn desired_is_default(&self) -> bool {
|
||||
let d = Self::default();
|
||||
self.word_spacing[1] == d.word_spacing[1] && self.letter_spacing[1] == d.letter_spacing[1] && self.glyph_scaling[1] == d.glyph_scaling[1]
|
||||
}
|
||||
|
||||
/// Keeps options read from a file to what `text.setFormat` can set: a value that is not a
|
||||
/// finite number becomes its default, every value is clamped to its range, and a Minimum,
|
||||
/// Desired and Maximum out of order go back to their defaults together.
|
||||
pub fn keep_in_range(&mut self) {
|
||||
let d = Self::default();
|
||||
for (v, dv, (lo, hi)) in [
|
||||
(&mut self.word_spacing, d.word_spacing, Self::WORD_SPACING),
|
||||
(&mut self.letter_spacing, d.letter_spacing, Self::LETTER_SPACING),
|
||||
(&mut self.glyph_scaling, d.glyph_scaling, Self::GLYPH_SCALING),
|
||||
] {
|
||||
for (x, dx) in v.iter_mut().zip(dv) {
|
||||
*x = if x.is_finite() { x.clamp(lo, hi) } else { dx };
|
||||
}
|
||||
if !(v[0] <= v[1] && v[1] <= v[2]) {
|
||||
*v = dv;
|
||||
}
|
||||
}
|
||||
let (lo, hi) = Self::AUTO_LEADING;
|
||||
self.auto_leading = if self.auto_leading.is_finite() { self.auto_leading.clamp(lo, hi) } else { d.auto_leading };
|
||||
}
|
||||
|
||||
/// A paragraph's leading for characters of `size` whose leading is Auto.
|
||||
pub fn auto_leading_for(&self, size: f64) -> f64 {
|
||||
size * self.auto_leading / 100.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JustificationOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
word_spacing: [80.0, 100.0, 133.0],
|
||||
letter_spacing: [0.0, 0.0, 0.0],
|
||||
glyph_scaling: [100.0, 100.0, 100.0],
|
||||
auto_leading: 120.0,
|
||||
single_word: SingleWordJustify::FullJustify,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
@@ -522,6 +614,9 @@ pub struct ParaStyle {
|
||||
pub hyphenate: bool,
|
||||
/// The limits automatic hyphenation keeps to (Paragraph panel menu › Hyphenation…).
|
||||
pub hyphenation: HyphenationOptions,
|
||||
/// Word and letter spacing, glyph scaling, Auto Leading and Single Word Justification
|
||||
/// (Paragraph panel menu › Justification…).
|
||||
pub justification: JustificationOptions,
|
||||
/// Line breaking: Single-line or Every-line Composer.
|
||||
pub composer: Composer,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
@@ -591,6 +686,8 @@ struct ParaStyleFile {
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
hyphenation: HyphenationOptions,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
justification: JustificationOptions,
|
||||
#[serde(default, skip_serializing_if = "Composer::is_default")]
|
||||
composer: Composer,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -627,6 +724,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -648,6 +746,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -664,6 +763,8 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
impl From<ParaStyleFile> for ParaStyle {
|
||||
fn from(f: ParaStyleFile) -> Self {
|
||||
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
|
||||
let mut justification = f.justification;
|
||||
justification.keep_in_range();
|
||||
let ParaStyleFile {
|
||||
left_indent,
|
||||
right_indent,
|
||||
@@ -692,6 +793,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
space_after,
|
||||
hyphenate,
|
||||
hyphenation,
|
||||
justification,
|
||||
composer,
|
||||
tabs,
|
||||
style_name,
|
||||
@@ -1585,6 +1687,37 @@ mod tests {
|
||||
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
|
||||
}
|
||||
|
||||
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
|
||||
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
|
||||
/// Maximum out of order go back to their defaults.
|
||||
#[test]
|
||||
fn justification_options_read_from_a_file_stay_in_range() {
|
||||
let d = JustificationOptions::default();
|
||||
let read: ParaStyle = serde_json::from_value(serde_json::json!({
|
||||
"justification": {
|
||||
"word_spacing": [-5.0, 1e300, 1e308], "letter_spacing": [10.0, 5.0, 20.0],
|
||||
"glyph_scaling": [1.0, 100.0, 9e9], "auto_leading": -1e12
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let j = read.justification;
|
||||
assert_eq!(j.word_spacing, [0.0, 1000.0, 1000.0]);
|
||||
assert_eq!(j.letter_spacing, d.letter_spacing, "out of order");
|
||||
assert_eq!(j.glyph_scaling, [50.0, 100.0, 200.0]);
|
||||
assert_eq!(j.auto_leading, 0.0);
|
||||
let mut j = JustificationOptions {
|
||||
word_spacing: [f64::NAN, 100.0, f64::INFINITY],
|
||||
letter_spacing: [-1.0, 0.0, f64::NEG_INFINITY],
|
||||
auto_leading: f64::NAN,
|
||||
..d.clone()
|
||||
};
|
||||
j.keep_in_range();
|
||||
assert_eq!((j.word_spacing, j.letter_spacing, j.auto_leading), (d.word_spacing, [-1.0, 0.0, 0.0], d.auto_leading));
|
||||
let mut same = d.clone();
|
||||
same.keep_in_range();
|
||||
assert_eq!(same, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_controls_reject_collapsed_and_nonfinite_transforms() {
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! Character/Paragraph attributes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Composer, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify};
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
|
||||
use super::*;
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -236,6 +236,34 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector
|
||||
/// Largest indent or paragraph spacing (points; Illustrator's limit).
|
||||
const MAX_PARA: f64 = 1296.0;
|
||||
|
||||
/// A justification spacing of `text.setFormat` (`wordSpacing`, `letterSpacing`, `glyphScaling`):
|
||||
/// `[min, desired, max]` or `{min?, desired?, max?}`, in percent. The values not given are kept.
|
||||
fn spacing_param(p: &Value, key: &str, c: &str) -> Result<Option<[Option<f64>; 3]>> {
|
||||
let number = |v: &Value| v.as_f64().filter(|v| v.is_finite());
|
||||
let err = || bad(c, format!("`{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent"));
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Array(a)) if a.len() == 3 => {
|
||||
let mut out = [None; 3];
|
||||
for (o, v) in out.iter_mut().zip(a) {
|
||||
*o = Some(number(v).ok_or_else(err)?);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
Some(Value::Object(o)) => {
|
||||
let mut out = [None; 3];
|
||||
for (k, v) in o {
|
||||
let i = ["min", "desired", "max"].iter().position(|n| n == k).ok_or_else(err)?;
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = Some(number(v).ok_or_else(err)?);
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
Some(_) => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setFormat";
|
||||
let ids = super::typecmd::text_targets(s, p, C)?;
|
||||
@@ -269,6 +297,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"hyphenLimit",
|
||||
"hyphenationZone",
|
||||
"hyphenateCapitalized",
|
||||
"wordSpacing",
|
||||
"letterSpacing",
|
||||
"glyphScaling",
|
||||
"autoLeading",
|
||||
"singleWordJustification",
|
||||
"mojikumi",
|
||||
"kinsoku",
|
||||
"direction",
|
||||
@@ -339,6 +372,24 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")),
|
||||
}),
|
||||
};
|
||||
let spacing = |k: &str| spacing_param(p, k, C);
|
||||
let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?);
|
||||
let single_word = match p.get("singleWordJustification") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("fullJustify") => SingleWordJustify::FullJustify,
|
||||
Some("alignLeft") => SingleWordJustify::AlignLeft,
|
||||
Some("alignCenter") => SingleWordJustify::AlignCenter,
|
||||
Some("alignRight") => SingleWordJustify::AlignRight,
|
||||
_ => return Err(bad(C, "`singleWordJustification` must be \"fullJustify\", \"alignLeft\", \"alignCenter\" or \"alignRight\"")),
|
||||
}),
|
||||
};
|
||||
let auto_leading = match p.get("autoLeading") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, "`autoLeading` must be a number"))?),
|
||||
};
|
||||
// A paragraph whose Minimum, Desired and Maximum would no longer be in order.
|
||||
let mut out_of_order: Option<&str> = None;
|
||||
let range = super::typecmd::TextRange::parse(p, C)?;
|
||||
// The character attributes, given the baseline shift and horizontal scale in the type's own units.
|
||||
let chars = |st: &mut vectorcraft_doc::CharStyle, baseline_shift: Option<f64>, h_scale: Option<f64>| {
|
||||
@@ -430,6 +481,28 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("hyphenateCapitalized") {
|
||||
para.hyphenation.capitalized = v;
|
||||
}
|
||||
let j = &mut para.justification;
|
||||
for (name, given, value, limits) in [
|
||||
("wordSpacing", &word_spacing, &mut j.word_spacing, JustificationOptions::WORD_SPACING),
|
||||
("letterSpacing", &letter_spacing, &mut j.letter_spacing, JustificationOptions::LETTER_SPACING),
|
||||
("glyphScaling", &glyph_scaling, &mut j.glyph_scaling, JustificationOptions::GLYPH_SCALING),
|
||||
] {
|
||||
let Some(given) = given else { continue };
|
||||
for (v, g) in value.iter_mut().zip(given) {
|
||||
if let Some(g) = g {
|
||||
*v = g.clamp(limits.0, limits.1);
|
||||
}
|
||||
}
|
||||
if !(value[0] <= value[1] && value[1] <= value[2]) {
|
||||
out_of_order.get_or_insert(name);
|
||||
}
|
||||
}
|
||||
if let Some(v) = auto_leading {
|
||||
j.auto_leading = v.clamp(JustificationOptions::AUTO_LEADING.0, JustificationOptions::AUTO_LEADING.1);
|
||||
}
|
||||
if let Some(v) = single_word {
|
||||
j.single_word = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
@@ -452,6 +525,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para.composer = v;
|
||||
}
|
||||
});
|
||||
if let Some(name) = out_of_order {
|
||||
return Err(bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")));
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
// Graph labels keep a change made to all of their text through the graph being drawn again.
|
||||
|
||||
@@ -164,12 +164,13 @@ pub fn char_attrs(v: &CharStyle) -> Map<String, Value> {
|
||||
}
|
||||
|
||||
/// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's
|
||||
/// JSON leaves out at their defaults (the Hyphenation options, the Every-line Composer, no tab
|
||||
/// JSON leaves out at their defaults (the Hyphenation and Justification options, the Every-line Composer, no tab
|
||||
/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters.
|
||||
pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
let mut m = to_attrs(v);
|
||||
for (k, x) in [
|
||||
("hyphenation", json!(v.hyphenation)),
|
||||
("justification", json!(v.justification)),
|
||||
("composer", json!(v.composer)),
|
||||
("tabs", json!(v.tabs)),
|
||||
("mojikumi", json!(v.mojikumi)),
|
||||
|
||||
@@ -1098,6 +1098,61 @@ mod area_tests {
|
||||
assert!(text(&s).para_at(1).roman_hanging);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and
|
||||
/// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their
|
||||
/// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They
|
||||
/// are saved only when not the defaults, and paragraph styles carry them.
|
||||
#[test]
|
||||
fn justification_options_are_set_per_paragraph_and_saved_only_when_not_default() {
|
||||
use vectorcraft_doc::{JustificationOptions, SingleWordJustify};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let paras = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert_eq!(paras(&s).0.justification, JustificationOptions::default());
|
||||
assert!(serde_json::to_value(paras(&s).0).unwrap().get("justification").is_none());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute(
|
||||
"text.setFormat",
|
||||
&json!({
|
||||
"wordSpacing": [60, 90, 2000], "letterSpacing": {"max": 10}, "glyphScaling": [95, 100, 105],
|
||||
"autoLeading": 150, "singleWordJustification": "alignCenter", "start": 5, "end": 5
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let want = JustificationOptions {
|
||||
word_spacing: [60.0, 90.0, 1000.0],
|
||||
letter_spacing: [0.0, 0.0, 10.0],
|
||||
glyph_scaling: [95.0, 100.0, 105.0],
|
||||
auto_leading: 150.0,
|
||||
single_word: SingleWordJustify::AlignCenter,
|
||||
};
|
||||
let (first, second) = paras(&s);
|
||||
assert_eq!((first.justification, &second.justification), (JustificationOptions::default(), &want), "only the paragraph the caret is in");
|
||||
assert_eq!(serde_json::to_value(&second).unwrap()["justification"]["single_word"], "alignCenter");
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(&second).unwrap()).unwrap();
|
||||
assert_eq!(back.justification, want);
|
||||
// Out of order: nothing changes, and the error names the setting.
|
||||
let e = s.execute("text.setFormat", &json!({"wordSpacing": {"desired": 85}, "letterSpacing": {"min": 5}})).unwrap_err().to_string();
|
||||
assert!(e.contains("letterSpacing"), "{e}");
|
||||
assert_eq!(paras(&s).1.justification, want);
|
||||
assert!(s.execute("text.setFormat", &json!({"glyphScaling": [100, 100]})).is_err());
|
||||
assert!(s.execute("text.setFormat", &json!({"singleWordJustification": "spread"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(paras(&s).1.justification, JustificationOptions::default());
|
||||
// A paragraph style carries them; an older file's paragraph reads the defaults.
|
||||
s.execute("paraStyle.new", &json!({"name": "Airy", "attrs": {"justification": {"auto_leading": 200.0}}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Airy", "id": id})).unwrap();
|
||||
assert_eq!(paras(&s).0.justification, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() });
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left", "hyphenate": true})).unwrap();
|
||||
assert_eq!(old.justification, JustificationOptions::default());
|
||||
}
|
||||
|
||||
/// #432: while the interface is in Japanese, new type (text.create, text.createInPath) starts
|
||||
/// with em box top-to-top leading and em box centre alignment, and new styles made from nothing
|
||||
/// carry them; params override; the values are journaled; imported text keeps the Roman
|
||||
|
||||
@@ -2008,8 +2008,8 @@ impl Writer<'_> {
|
||||
// After a tab, an inline graphic or a justified word space, and before a character that a
|
||||
// reader would draw elsewhere, the next segment is placed.
|
||||
place = seg.brk || (!fewer && !base.anchored && positioned);
|
||||
if (st.h_scale - st.v_scale).abs() > 1e-9 {
|
||||
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
|
||||
if (st.h_scale - st.v_scale).abs() > 1e-9 || seg.scale != 1.0 {
|
||||
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance * seg.scale)));
|
||||
}
|
||||
if st.rotation != 0.0 {
|
||||
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
|
||||
@@ -2241,6 +2241,9 @@ struct Segment {
|
||||
/// The next segment needs its own position (after a tab, a word space of a justified line, or
|
||||
/// the next segment starts with a character the layout draws elsewhere than a reader would).
|
||||
brk: bool,
|
||||
/// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to
|
||||
/// (`textLength`); 1 for other segments.
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
/// How far a reader may draw a character from where the layout draws it before the character is
|
||||
@@ -2263,7 +2266,10 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
let style = t.para_at(para);
|
||||
let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
// Each cluster's advance and drawing offset as a reader gives them, by its byte offset.
|
||||
let reader = if split_words || (!fewer && !style.mojikumi.is_none()) {
|
||||
// Justification…'s Desired word and letter spacing and glyph scaling move characters from
|
||||
// where a reader draws them, on every line.
|
||||
let spaced = !fewer && !style.justification.desired_is_default();
|
||||
let reader = if split_words || spaced || (!fewer && !style.mojikumi.is_none()) {
|
||||
Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true)))
|
||||
} else {
|
||||
None
|
||||
@@ -2316,7 +2322,9 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
|
||||
let piece = if run.style.all_caps { src.to_uppercase() } else { src };
|
||||
let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace));
|
||||
let fresh = std::mem::take(&mut after_inline);
|
||||
// A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width.
|
||||
let scale = if reader.is_some() && (g.scale - 1.0).abs() > 1e-9 && g.scale.is_finite() { g.scale } else { 1.0 };
|
||||
let fresh = std::mem::take(&mut after_inline) || scale != 1.0 || segs.last().is_some_and(|last| last.scale != 1.0);
|
||||
// The layout draws the character elsewhere than a reader continuing the line would.
|
||||
let moved = reader.is_some()
|
||||
&& segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE);
|
||||
@@ -2332,7 +2340,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
last.brk |= moved;
|
||||
end_segment(last, tail, t, lay, &mut unkerned);
|
||||
}
|
||||
segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk });
|
||||
segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale });
|
||||
}
|
||||
}
|
||||
tail = set.map(|_| (g.byte, advance));
|
||||
@@ -2348,7 +2356,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
|
||||
lines
|
||||
}
|
||||
|
||||
/// Ends segment `seg` of horizontally scaled type, which a reader stretches to its width
|
||||
/// Ends segment `seg` of horizontally scaled type (or a glyph Glyph Scaling scales), which a reader stretches to its width
|
||||
/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance
|
||||
/// a reader gives it at the end of a `<tspan>`, without the font's kerning against the character
|
||||
/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them.
|
||||
@@ -2360,7 +2368,7 @@ fn end_segment(
|
||||
unkerned: &mut Option<HashMap<usize, (f64, f64)>>,
|
||||
) {
|
||||
let Some((byte, advance)) = tail else { return };
|
||||
if t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) {
|
||||
if seg.scale == 1.0 && t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) {
|
||||
return;
|
||||
}
|
||||
if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) {
|
||||
@@ -2377,6 +2385,8 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning:
|
||||
let mut set = TextObject { kind: TextKind::Point, ..t.clone() };
|
||||
for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) {
|
||||
p.mojikumi = vectorcraft_doc::Mojikumi::None;
|
||||
// A reader spaces words and letters as the font does.
|
||||
p.justification = vectorcraft_doc::JustificationOptions::default();
|
||||
}
|
||||
if !kerning {
|
||||
for r in &mut set.runs {
|
||||
|
||||
@@ -18,10 +18,16 @@ fn style(size: f64) -> CharStyle {
|
||||
|
||||
/// Area type `w` wide at (20, 20) in a 300 × 200 document.
|
||||
fn area_doc(runs: Vec<TextRun>, w: f64, justify: Justify) -> Document {
|
||||
area_doc_justified(runs, w, justify, vectorcraft_doc::JustificationOptions::default())
|
||||
}
|
||||
|
||||
/// [`area_doc`] with the paragraph's Justification options `j`.
|
||||
fn area_doc_justified(runs: Vec<TextRun>, w: f64, justify: Justify, j: vectorcraft_doc::JustificationOptions) -> Document {
|
||||
let mut t = TextObject::point(Point::new(20.0, 20.0), "", style(12.0));
|
||||
t.runs = runs;
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, 160.0)) };
|
||||
t.para.justify = justify;
|
||||
t.para.justification = j;
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
@@ -252,6 +258,32 @@ fn justify_all_lines_keep_their_letter_gaps() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Justification…: Desired word and letter spacing and glyph scaling, and the ranges a justified
|
||||
/// line uses, are written so a viewer draws each character where and as wide as the canvas does.
|
||||
#[test]
|
||||
fn justification_spacing_and_glyph_scaling_are_drawn_in_place() {
|
||||
use vectorcraft_doc::JustificationOptions;
|
||||
let text = "Typography arranges type to make written language legible and readable";
|
||||
for (justify, j) in [
|
||||
(
|
||||
Justify::Left,
|
||||
JustificationOptions { word_spacing: [0.0, 180.0, 200.0], letter_spacing: [0.0, 10.0, 10.0], ..JustificationOptions::default() },
|
||||
),
|
||||
(Justify::Left, JustificationOptions { glyph_scaling: [50.0, 120.0, 200.0], ..JustificationOptions::default() }),
|
||||
(
|
||||
Justify::JustifyLeft,
|
||||
JustificationOptions {
|
||||
word_spacing: [100.0, 100.0, 100.0],
|
||||
letter_spacing: [0.0, 0.0, 5.0],
|
||||
glyph_scaling: [95.0, 100.0, 110.0],
|
||||
..JustificationOptions::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_drawn_in_place(&area_doc_justified(vec![run(text, style(12.0))], 160.0, justify, j));
|
||||
}
|
||||
}
|
||||
|
||||
/// Justified and Mojikumi lines whose characters a reader draws where the layout does are written
|
||||
/// as before: a segment per style run, and per word on a justified line.
|
||||
#[test]
|
||||
|
||||
+24
-14
@@ -5,18 +5,18 @@
|
||||
//! (badness + penalty) demerits over the whole paragraph, plus a demerit when neighbouring lines
|
||||
//! fall in fitness classes more than one apart (a tight line next to a loose one).
|
||||
//!
|
||||
//! - **Justified** lines: word spacing may stretch to 150% and shrink to 80%; the badness comes
|
||||
//! from the stretch/shrink ratio, so loose lines are traded for evenly spaced ones.
|
||||
//! - **Justified** lines: word spacing, letter spacing and glyph scaling may stretch and shrink
|
||||
//! within the paragraph's Justification options (each glyph's [`Flex`]); the badness comes from
|
||||
//! the stretch/shrink ratio, so loose lines are traded for evenly spaced ones.
|
||||
//! - **Ragged** lines (left/centre/right aligned): spaces keep their width, so a line never
|
||||
//! shrinks (it must fit). The badness comes from the line's leftover space measured against a
|
||||
//! rag zone of a sixth of the line width, so the rag is even rather than full lines followed by
|
||||
//! a short one, and hyphenating costs less. The last line costs nothing unless it is shorter
|
||||
//! than a third of the width (a word or two left dangling).
|
||||
|
||||
use crate::justify::Flex;
|
||||
use crate::shape::SGlyph;
|
||||
|
||||
const STRETCH: f64 = 0.5;
|
||||
const SHRINK: f64 = 0.2;
|
||||
const HYPHEN_PENALTY: f64 = 50.0;
|
||||
const DOUBLE_HYPHEN_DEMERITS: f64 = 3000.0;
|
||||
const LINE_PENALTY: f64 = 10.0;
|
||||
@@ -90,7 +90,8 @@ fn fitness(r: f64, ragged: bool) -> u8 {
|
||||
/// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`;
|
||||
/// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets
|
||||
/// an opening bracket there flush with the line's start); `cands` are the break opportunities
|
||||
/// in increasing order (the paragraph end is added).
|
||||
/// in increasing order (the paragraph end is added); `flex` holds how far each glyph may stretch
|
||||
/// and shrink in a justified line.
|
||||
/// Returns `(end, hyphen)` per line, or `None` if no set of breaks fits within the tolerance
|
||||
/// (the caller then falls back to single-line breaking).
|
||||
pub(crate) fn compose(
|
||||
@@ -98,18 +99,22 @@ pub(crate) fn compose(
|
||||
width: &dyn Fn(usize) -> f64,
|
||||
start_credit: &dyn Fn(usize) -> f64,
|
||||
cands: &[Breakpoint],
|
||||
flex: &[Flex],
|
||||
p: &Params,
|
||||
) -> Option<Vec<(usize, bool)>> {
|
||||
let n = g.len();
|
||||
if n == 0 {
|
||||
return Some(vec![]);
|
||||
}
|
||||
// Prefix sums of advances and of space advances.
|
||||
// Prefix sums of advances and of how far the glyphs may stretch and shrink.
|
||||
let mut w = vec![0.0; n + 1];
|
||||
let mut sp = vec![0.0; n + 1];
|
||||
let mut st = vec![0.0; n + 1];
|
||||
let mut sh = vec![0.0; n + 1];
|
||||
for (i, gl) in g.iter().enumerate() {
|
||||
let f = flex.get(i).copied().unwrap_or_default();
|
||||
w[i + 1] = w[i] + gl.adv;
|
||||
sp[i + 1] = sp[i] + if gl.is_space() { gl.adv } else { 0.0 };
|
||||
st[i + 1] = st[i] + f.stretch();
|
||||
sh[i + 1] = sh[i] + f.shrink();
|
||||
}
|
||||
if !w[n].is_finite() {
|
||||
return None;
|
||||
@@ -152,17 +157,17 @@ pub(crate) fn compose(
|
||||
(lw - natural).max(0.0) / (lw * RAG_ZONE).max(1.0)
|
||||
}
|
||||
} else {
|
||||
let spaces = sp[t] - sp[a.pos];
|
||||
let (stretch, shrink) = (st[t] - st[a.pos], sh[t] - sh[a.pos]);
|
||||
if natural < lw {
|
||||
if last && !p.justify_last {
|
||||
0.0
|
||||
} else if spaces > 0.0 {
|
||||
(lw - natural) / (spaces * STRETCH)
|
||||
} else if stretch > EPS {
|
||||
(lw - natural) / stretch
|
||||
} else {
|
||||
f64::INFINITY
|
||||
}
|
||||
} else if natural > lw {
|
||||
if spaces > 0.0 { (lw - natural) / (spaces * SHRINK) } else { f64::NEG_INFINITY }
|
||||
if shrink > EPS { (lw - natural) / shrink } else { f64::NEG_INFINITY }
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
@@ -176,10 +181,15 @@ pub(crate) fn compose(
|
||||
continue;
|
||||
}
|
||||
let free_last = last && (p.ragged || !p.justify_last);
|
||||
if ratio > p.tolerance && !(last && (p.ragged || (ratio.is_infinite() && p.justify_last))) {
|
||||
// A line that can't stretch at all (no word spaces, nor letter spacing or glyph scaling
|
||||
// to vary) never fills its measure, however loose the tolerance.
|
||||
if (ratio > p.tolerance || ratio == f64::INFINITY) && !(last && (p.ragged || (ratio.is_infinite() && p.justify_last))) {
|
||||
continue;
|
||||
}
|
||||
let badness = if ratio.is_finite() { (100.0 * ratio.abs().powi(3)).min(10_000.0) } else { 10_000.0 };
|
||||
// Justified lines aren't capped: past a ratio of 4.6 (only reached in the unbounded pass)
|
||||
// a looser line must still score worse than a less loose one.
|
||||
let cap = if p.ragged { 10_000.0 } else { f64::INFINITY };
|
||||
let badness = if ratio.is_finite() { (100.0 * ratio.abs().powi(3)).min(cap) } else { 10_000.0 };
|
||||
let penalty = if hyphen { hyphen_penalty } else { 0.0 };
|
||||
let mut d = (LINE_PENALTY + badness).powi(2) + penalty * penalty;
|
||||
if hyphen && a.hyphens > 0 {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+62
-15
@@ -6,13 +6,14 @@ use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2};
|
||||
use unicode_bidi::{BidiInfo, Level};
|
||||
use vectorcraft_doc::{
|
||||
Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect,
|
||||
TextKind, TextObject,
|
||||
SingleWordJustify, TextKind, TextObject,
|
||||
};
|
||||
use vectorcraft_geom::{ArcPath, PathData};
|
||||
|
||||
use crate::composer::{Breakpoint, Params, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
use crate::hyphen::hyphen_points_with;
|
||||
use crate::justify::{Flex, LineFit, apply_desired, fit_line, is_word_space};
|
||||
use crate::shape::{
|
||||
Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start,
|
||||
style_metrics,
|
||||
@@ -136,6 +137,7 @@ impl Ctx<'_> {
|
||||
xf: m,
|
||||
// Vertical type keeps its logical order down the column.
|
||||
rtl: g.level.is_rtl() && !self.vertical,
|
||||
scale: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1643,7 +1645,7 @@ const MAX_COMPOSE_GLYPHS: usize = 200_000;
|
||||
|
||||
/// Every-line composition of paragraph glyphs `sg` if applicable (area text with uniform line
|
||||
/// metrics, justified or ragged); `None` falls back to the greedy single-line composer.
|
||||
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rtl: bool) -> Option<Vec<(usize, bool)>> {
|
||||
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pen: &Pen<'_>, rtl: bool) -> Option<Vec<(usize, bool)>> {
|
||||
let ragged = matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
|
||||
let composer = cx.opts.composer.unwrap_or(para.composer);
|
||||
// A paragraph whose commas or full stops may hang is composed line by line (as the Japanese
|
||||
@@ -1688,16 +1690,19 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
|
||||
let justify_last = para.justify == Justify::JustifyAll;
|
||||
let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX);
|
||||
let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from, hyphen_limit };
|
||||
// Ragged: first look for breaks that leave at most one rag zone (a sixth of the width) on
|
||||
// each line, then accept any lines that fit.
|
||||
let (strict, loose) = if ragged { (1.0, f64::INFINITY) } else { (1.0, 4.0) };
|
||||
// First look for breaks that keep every line within its range (ragged: at most one rag zone,
|
||||
// a sixth of the width, left on each line; justified: the Justification options' Minimum and
|
||||
// Maximum), then accept any lines that fit, the loosest scored worst. Justified text used to
|
||||
// stop at four times its stretch and fall back to the single-line composer; the documented
|
||||
// default range (word spacing up to 133%) is narrow enough that most paragraphs would.
|
||||
let (strict, loose) = (1.0, f64::INFINITY);
|
||||
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
|
||||
// the space before it.
|
||||
let start_credit = |i: usize| {
|
||||
let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf;
|
||||
sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0)
|
||||
};
|
||||
compose(sg, &width, &start_credit, &cands, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, ¶ms(loose)))
|
||||
compose(sg, &width, &start_credit, &cands, flex, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(loose)))
|
||||
}
|
||||
|
||||
/// Flow paragraphs `paras` (byte ranges) of `t`, each with its own paragraph attributes
|
||||
@@ -1730,8 +1735,19 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
let compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] };
|
||||
// …and a quarter em between Japanese and Latin text.
|
||||
let wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] };
|
||||
// Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how
|
||||
// far a justified line may move each one from it.
|
||||
let flex = apply_desired(&mut sg, ¶.justification, !cx.vertical);
|
||||
// …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size.
|
||||
let auto_leading = |st: &CharStyle| st.leading.unwrap_or_else(|| para.justification.auto_leading_for(st.size));
|
||||
if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading {
|
||||
for g in sg.iter_mut().filter(|g| g.inline.is_none()) {
|
||||
g.leading = auto_leading(cx.style_at(g.byte));
|
||||
}
|
||||
}
|
||||
let pm = {
|
||||
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
|
||||
let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start));
|
||||
let lead = auto_leading(cx.style_at(pr.start));
|
||||
let st = cx.style_at(pr.start);
|
||||
let (cap, xh) = cap_x_heights(cx.db, st);
|
||||
let centre = cx.db.style_face(st).map_or(EM_CENTER, |f| f.ideographic_centre());
|
||||
@@ -1741,7 +1757,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
pen.pending += para.space_before;
|
||||
}
|
||||
let n = sg.len();
|
||||
let composed = compose_para(cx, &sg, para, &pen, rtl);
|
||||
let composed = compose_para(cx, &sg, &flex, para, &pen, rtl);
|
||||
// Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the
|
||||
// Hyphen Limit counts the lines in a row that end with a hyphen.
|
||||
let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 };
|
||||
@@ -1887,6 +1903,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
};
|
||||
let justify = justify && regions.is_some();
|
||||
let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0);
|
||||
let mut fit = LineFit::default();
|
||||
let spaces = sg[i..body].iter().filter(|g| g.is_space()).count();
|
||||
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
|
||||
// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block.
|
||||
@@ -1899,20 +1916,37 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
.is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)))
|
||||
};
|
||||
let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count();
|
||||
// Single Word Justification: a justified line holding one word (no word space) is
|
||||
// spread between its letters (Full Justify) or aligned.
|
||||
let single_word = justify && spaces == 0 && cjk_gaps == 0;
|
||||
let (align, justify) = match (single_word, para.justification.single_word) {
|
||||
(false, _) | (true, SingleWordJustify::FullJustify) => (align, justify),
|
||||
(true, SingleWordJustify::AlignLeft) => (0, false),
|
||||
(true, SingleWordJustify::AlignCenter) => (1, false),
|
||||
(true, SingleWordJustify::AlignRight) => (2, false),
|
||||
};
|
||||
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;
|
||||
(per_space, per_cjk) = (per, per);
|
||||
} else if spaces > 0 {
|
||||
// Composed lines may shrink word spaces (never below zero).
|
||||
per_space =
|
||||
((room - w) / spaces as f64).max(-sg[i..body].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
|
||||
} else if para.justify == Justify::JustifyAll && room > w {
|
||||
// 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 line: Vec<Flex> = (i..body).map(line_flex).collect();
|
||||
let floor = sg[i..body].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min);
|
||||
fit = fit_line(&line, room - w, spaces, floor);
|
||||
} else if room > w {
|
||||
// One word: its letter spacing and glyph scaling, then the gaps between its glyphs.
|
||||
let line: Vec<Flex> = (i..body).map(line_flex).collect();
|
||||
fit = fit_line(&line, room - w, 0, 0.0);
|
||||
let used: f64 = line.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum();
|
||||
let rest = room - w - used;
|
||||
let gaps =
|
||||
(i..body).filter(|&j| j + 1 < body && !held.contains(&j) && sg.get(j + 1).is_some_and(|next| !next.continues_tcy())).count();
|
||||
if gaps > 0 {
|
||||
per_gap = (room - w) / gaps as f64;
|
||||
if gaps > 0 && rest > EPS {
|
||||
per_gap = rest / gaps as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1953,10 +1987,19 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
if j + 1 == trimmed {
|
||||
adv -= end_trim;
|
||||
}
|
||||
// Glyph scaling a justified line adds to the glyph (1: none).
|
||||
let mut scale = 1.0;
|
||||
let mut leader = "";
|
||||
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() };
|
||||
if is_word_space(g) {
|
||||
adv += fit.space(&f);
|
||||
} else {
|
||||
adv += fit.letter(&f) + fit.glyph(&f);
|
||||
scale = fit.glyph_scale(&f);
|
||||
}
|
||||
if g.is_space() {
|
||||
adv += per_space;
|
||||
} else if per_cjk != 0.0 {
|
||||
@@ -1969,7 +2012,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
}
|
||||
}
|
||||
let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical);
|
||||
cx.emit(g, Affine::translate((x, y)), Point::new(x, y), 0.0, adv, li);
|
||||
let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) };
|
||||
cx.emit(g, place, Point::new(x, y), 0.0, adv, li);
|
||||
if let Some(out) = cx.out.glyphs.last_mut() {
|
||||
out.scale = flex.get(j).map_or(1.0, |f| f.scale) * scale;
|
||||
}
|
||||
if !leader.is_empty() {
|
||||
// The leader belongs to the tab's glyph: the gap stays one character for the
|
||||
// caret, selection and hit testing.
|
||||
|
||||
@@ -18,6 +18,7 @@ mod features;
|
||||
mod fontconfig;
|
||||
mod fontdb;
|
||||
pub mod hyphen;
|
||||
mod justify;
|
||||
mod layout;
|
||||
mod shape;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -112,6 +113,10 @@ pub struct PositionedGlyph {
|
||||
pub xf: Affine,
|
||||
/// True when the logical leading edge is the glyph’s right edge.
|
||||
pub rtl: bool,
|
||||
/// How much wider than its font draws it the glyph is drawn by its paragraph's Glyph Scaling
|
||||
/// (Justification…), Desired and justification together: 1 at 100%. Horizontal Scale isn't
|
||||
/// included.
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// One line of laid-out text.
|
||||
@@ -552,6 +557,8 @@ mod tests_fit;
|
||||
#[cfg(test)]
|
||||
mod tests_inline;
|
||||
#[cfg(test)]
|
||||
mod tests_justify;
|
||||
#[cfg(test)]
|
||||
mod tests_scripts;
|
||||
#[cfg(test)]
|
||||
mod tests_snap;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Paragraph panel menu › Justification…: word spacing, letter spacing, glyph scaling, Single Word
|
||||
//! Justification and Auto Leading in layout.
|
||||
|
||||
use super::*;
|
||||
use kurbo::{Affine, Shape};
|
||||
use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
fn db() -> &'static FontDb {
|
||||
FontDb::global()
|
||||
}
|
||||
|
||||
fn style(size: f64) -> CharStyle {
|
||||
CharStyle { size, ..CharStyle::default() }
|
||||
}
|
||||
|
||||
fn area(text: &str, width: f64, justify: Justify, j: JustificationOptions) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, style(20.0));
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 1000.0).to_path(0.1)) };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.para.justify = justify;
|
||||
t.para.justification = j;
|
||||
t
|
||||
}
|
||||
|
||||
fn point(text: &str, j: JustificationOptions) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, text, style(20.0));
|
||||
t.xf = Affine::IDENTITY;
|
||||
t.para.justification = j;
|
||||
t
|
||||
}
|
||||
|
||||
/// The glyph of the character at byte `b`.
|
||||
fn at(l: &TextLayout, b: usize) -> &PositionedGlyph {
|
||||
l.glyphs.iter().find(|g| g.byte == b).unwrap()
|
||||
}
|
||||
|
||||
fn width(g: &PositionedGlyph) -> f64 {
|
||||
g.outline.bounding_box().width()
|
||||
}
|
||||
|
||||
/// Desired values apply to unjustified type: word spaces are that share of the font's space,
|
||||
/// letters are followed by that share of a space, and glyphs are drawn that wide.
|
||||
#[test]
|
||||
fn desired_spacing_and_scaling_apply_to_every_line() {
|
||||
let plain = layout(db(), &point("ab cd", JustificationOptions::default()));
|
||||
let space = at(&plain, 2).advance;
|
||||
let a = at(&plain, 0).advance;
|
||||
assert!(space > 0.0 && a > 0.0);
|
||||
|
||||
let words = layout(db(), &point("ab cd", JustificationOptions { word_spacing: [0.0, 200.0, 300.0], ..JustificationOptions::default() }));
|
||||
assert!((at(&words, 2).advance - 2.0 * space).abs() < 1e-6, "a word space at 200%");
|
||||
assert!((at(&words, 0).advance - a).abs() < 1e-6, "letters unchanged");
|
||||
|
||||
let letters = layout(db(), &point("ab cd", JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() }));
|
||||
assert!((at(&letters, 0).advance - (a + 0.5 * space)).abs() < 1e-6, "half a space after a");
|
||||
assert!((at(&letters, 1).advance - at(&plain, 1).advance).abs() < 1e-6, "no letter spacing before a word space");
|
||||
assert!((at(&letters, 4).advance - at(&plain, 4).advance).abs() < 1e-6, "nor after the last letter");
|
||||
|
||||
let scaled = layout(db(), &point("ab cd", JustificationOptions { glyph_scaling: [50.0, 150.0, 200.0], ..JustificationOptions::default() }));
|
||||
assert!((at(&scaled, 0).advance - 1.5 * a).abs() < 1e-6, "advance at 150%");
|
||||
assert!((width(at(&scaled, 0)) - 1.5 * width(at(&plain, 0))).abs() < 1e-6, "outline at 150%");
|
||||
assert!((at(&scaled, 2).advance - space).abs() < 1e-6, "word spaces aren't scaled");
|
||||
}
|
||||
|
||||
/// The line's text from a layout, one string per line.
|
||||
fn lines(l: &TextLayout, text: &str) -> Vec<String> {
|
||||
l.lines.iter().map(|li| text.get(li.start..li.end).unwrap_or_default().trim_end().to_string()).collect()
|
||||
}
|
||||
|
||||
/// A justified line fills its measure with word spacing first, then letter spacing, then glyph
|
||||
/// scaling, each within its range; past them the word spaces take the rest.
|
||||
#[test]
|
||||
fn justified_lines_use_word_then_letter_spacing_then_glyph_scaling() {
|
||||
let text = "ab cd ef gh";
|
||||
let natural = layout(db(), &point("ab cd", JustificationOptions::default()));
|
||||
let space = at(&natural, 2).advance;
|
||||
let full = natural.lines[0].x1 - natural.lines[0].x0;
|
||||
// The four letters' width.
|
||||
let letters_w = full - space;
|
||||
// "ab cd" on the first line, `room` points short of the measure.
|
||||
let line = |room: f64, j: JustificationOptions| {
|
||||
let w = full + room;
|
||||
let l = layout(db(), &area(text, w, Justify::JustifyLeft, j));
|
||||
assert_eq!(lines(&l, text).first().map(String::as_str), Some("ab cd"), "{room}");
|
||||
l
|
||||
};
|
||||
let fixed = JustificationOptions { word_spacing: [100.0, 100.0, 100.0], ..JustificationOptions::default() };
|
||||
|
||||
// Word spacing within its range: only the space grows.
|
||||
let l = line(space * 0.2, JustificationOptions { word_spacing: [80.0, 100.0, 150.0], letter_spacing: [0.0, 0.0, 10.0], ..fixed.clone() });
|
||||
assert!((at(&l, 2).advance - space * 1.2).abs() < 1e-6);
|
||||
assert!((at(&l, 0).advance - at(&natural, 0).advance).abs() < 1e-6);
|
||||
|
||||
// No word spacing range: the letter gaps (a–b and c–d) share the room.
|
||||
let l = line(4.0, JustificationOptions { letter_spacing: [0.0, 0.0, 50.0], ..fixed.clone() });
|
||||
assert!((at(&l, 2).advance - space).abs() < 1e-6, "the space keeps its width");
|
||||
assert!((at(&l, 0).advance - (at(&natural, 0).advance + 2.0)).abs() < 1e-6);
|
||||
assert!((at(&l, 1).advance - at(&natural, 1).advance).abs() < 1e-6, "no gap before the space");
|
||||
|
||||
// Only glyph scaling: the four letters widen, their outlines with them.
|
||||
let l = line(letters_w * 0.05, JustificationOptions { glyph_scaling: [90.0, 100.0, 110.0], ..fixed.clone() });
|
||||
assert!((at(&l, 0).advance - at(&natural, 0).advance * 1.05).abs() < 1e-6, "half of the range");
|
||||
assert!((width(at(&l, 0)) - width(at(&natural, 0)) * 1.05).abs() < 1e-6);
|
||||
let end = at(&l, 4).origin.x + at(&l, 4).advance;
|
||||
assert!((end - (full + letters_w * 0.05)).abs() < 1e-6, "the line reaches the edge: {end}");
|
||||
|
||||
// Every range used up: the word space takes the rest.
|
||||
let l = line(10.0, JustificationOptions { letter_spacing: [0.0, 0.0, 10.0], ..fixed });
|
||||
let letters = 2.0 * 0.1 * space;
|
||||
assert!((at(&l, 2).advance - (space + 10.0 - letters)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// Single Word Justification: a justified line holding one word is spread between its letters
|
||||
/// (Full Justify, the default) or aligned as chosen.
|
||||
#[test]
|
||||
fn single_word_lines_follow_single_word_justification() {
|
||||
let text = "Typography internationalization";
|
||||
let word = layout(db(), &point("Typography", JustificationOptions::default()));
|
||||
// "Typography" alone on the first line, 30 pt short of the measure.
|
||||
let w = word.lines[0].x1 - word.lines[0].x0 + 30.0;
|
||||
let first = |sw: SingleWordJustify| {
|
||||
let l = layout(db(), &area(text, w, Justify::JustifyLeft, JustificationOptions { single_word: sw, ..JustificationOptions::default() }));
|
||||
assert_eq!(lines(&l, text).first().map(String::as_str), Some("Typography"));
|
||||
let li = &l.lines[0];
|
||||
let g: Vec<_> = l.glyphs[li.glyph_start..li.glyph_end].iter().filter(|g| g.byte < "Typography".len()).collect();
|
||||
(g.first().unwrap().origin.x, g.last().map(|g| g.origin.x + g.advance).unwrap())
|
||||
};
|
||||
let (x0, x1) = first(SingleWordJustify::FullJustify);
|
||||
assert!(x0.abs() < 1e-6 && (x1 - w).abs() < 1e-3, "spread across: {x0}..{x1} of {w}");
|
||||
let (x0, x1) = first(SingleWordJustify::AlignLeft);
|
||||
assert!(x0.abs() < 1e-6 && (x1 - (w - 30.0)).abs() < 1e-3, "{x0}..{x1}");
|
||||
let (r0, r1) = first(SingleWordJustify::AlignRight);
|
||||
assert!((r1 - w).abs() < 1e-3 && (r0 - 30.0).abs() < 1e-3, "{r0}..{r1}");
|
||||
let (c0, c1) = first(SingleWordJustify::AlignCenter);
|
||||
assert!((c0 - 15.0).abs() < 1e-3 && (c1 - (w - 15.0)).abs() < 1e-3, "centred: {c0}..{c1}");
|
||||
}
|
||||
|
||||
/// Auto Leading: characters whose leading is Auto are set at the paragraph's share of their size;
|
||||
/// an explicit leading is kept.
|
||||
#[test]
|
||||
fn auto_leading_sets_the_leading_of_auto_characters() {
|
||||
let text = "one\ntwo";
|
||||
let gap = |t: &TextObject| {
|
||||
let l = layout(db(), t);
|
||||
l.lines[1].baseline - l.lines[0].baseline
|
||||
};
|
||||
let t = area(text, 300.0, Justify::Left, JustificationOptions::default());
|
||||
assert!((gap(&t) - 24.0).abs() < 1e-6, "120% of 20 pt");
|
||||
let mut t = area(text, 300.0, Justify::Left, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() });
|
||||
assert!((gap(&t) - 40.0).abs() < 1e-6, "200% of 20 pt");
|
||||
t.runs[0].style.leading = Some(30.0);
|
||||
assert!((gap(&t) - 30.0).abs() < 1e-6, "explicit leading kept");
|
||||
}
|
||||
@@ -672,7 +672,9 @@ fn hyphenation_zone_breaks_before_the_word_with_the_single_line_composer() {
|
||||
|
||||
/// The default Hyphenation options keep the line breaks of hyphenated text: the copy twice over
|
||||
/// at three widths, ragged and justified, with either composer. The constant is an FNV-1a hash of
|
||||
/// each line's end and whether it ends with a hyphen.
|
||||
/// each line's end and whether it ends with a hyphen. It changed once since: justified lines
|
||||
/// follow the default Justification options (word spacing up to 133%, where the composer used
|
||||
/// to stretch spaces to 150%), and the every-line composer no longer gives up on loose lines.
|
||||
#[test]
|
||||
fn default_hyphenation_options_keep_the_line_breaks() {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
@@ -695,7 +697,7 @@ fn default_hyphenation_options_keep_the_line_breaks() {
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(hash, 8_558_443_895_699_068_077);
|
||||
assert_eq!(hash, 4_188_642_633_634_327_633);
|
||||
}
|
||||
|
||||
/// The longest run of consecutive lines of `l` that end with a hyphen.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Paragraph panel menu › Justification… (or `ui.justification`): Word Spacing, Letter Spacing and
|
||||
//! Glyph Scaling (each Minimum, Desired and Maximum, in percent), Auto Leading and Single Word
|
||||
//! Justification of the paragraphs the Paragraph panel acts on. The dialog opens on the values the
|
||||
//! Paragraph panel shows and previews them on the canvas. OK runs `text.setFormat` with the values
|
||||
//! that changed, as one undo step; paragraphs that differ keep the values left alone. OK is
|
||||
//! disabled while a row's Minimum exceeds its Desired value or Desired its Maximum.
|
||||
//!
|
||||
//! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`,
|
||||
//! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading`
|
||||
//! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`,
|
||||
//! with `__initial` (the values it opened with) and `__targets` (the type and range it acts on).
|
||||
//!
|
||||
//! 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 serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{JustificationOptions, SingleWordJustify};
|
||||
|
||||
use super::swatch_options::label;
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Justification.
|
||||
pub const KIND: &str = "justification";
|
||||
|
||||
/// The field holding the values the dialog opened with.
|
||||
const INITIAL: &str = "__initial";
|
||||
/// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on.
|
||||
const TARGETS: &str = "__targets";
|
||||
const CMD: &str = "text.setFormat";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Justification").into(),
|
||||
body,
|
||||
confirm,
|
||||
preview: true,
|
||||
min_width: 460.0,
|
||||
ok_enabled: Some(|_, d| problem(d).is_none()),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// The three spacing rows: (`text.setFormat` key, field prefix, range).
|
||||
const ROWS: [(&str, &str, (f64, f64)); 3] = [
|
||||
("wordSpacing", "word", JustificationOptions::WORD_SPACING),
|
||||
("letterSpacing", "letter", JustificationOptions::LETTER_SPACING),
|
||||
("glyphScaling", "glyph", JustificationOptions::GLYPH_SCALING),
|
||||
];
|
||||
/// The columns of a row: (field suffix, `text.setFormat` key).
|
||||
const COLUMNS: [(&str, &str); 3] = [("Min", "min"), ("Desired", "desired"), ("Max", "max")];
|
||||
|
||||
/// Single Word Justification's choices: (`text.setFormat` value, label).
|
||||
fn single_word_choices() -> [(&'static str, &'static str); 4] {
|
||||
[("fullJustify", tl!("Full Justify")), ("alignLeft", tl!("Align left")), ("alignCenter", tl!("Align center")), ("alignRight", tl!("Align right"))]
|
||||
}
|
||||
|
||||
fn single_word_name(s: SingleWordJustify) -> &'static str {
|
||||
match s {
|
||||
SingleWordJustify::FullJustify => "fullJustify",
|
||||
SingleWordJustify::AlignLeft => "alignLeft",
|
||||
SingleWordJustify::AlignCenter => "alignCenter",
|
||||
SingleWordJustify::AlignRight => "alignRight",
|
||||
}
|
||||
}
|
||||
|
||||
/// Open Justification on the paragraph attributes the Paragraph panel shows.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let (_, para) = crate::panels::character::text_style(app).ok_or("select some type first")?;
|
||||
// While typing, the dialog acts on the paragraphs of the text being edited.
|
||||
let mut targets = json!({});
|
||||
if let Some((id, a, b)) = crate::panels::character::text_editing(app) {
|
||||
crate::panels::character::end_typing(app);
|
||||
targets = json!({"ids": [id.0], "start": a, "end": b});
|
||||
}
|
||||
let j = ¶.justification;
|
||||
let mut values = Map::new();
|
||||
for ((_, prefix, _), v) in ROWS.iter().zip([j.word_spacing, j.letter_spacing, j.glyph_scaling]) {
|
||||
for ((suffix, _), x) in COLUMNS.iter().zip(v) {
|
||||
values.insert(format!("{prefix}{suffix}"), json!(x));
|
||||
}
|
||||
}
|
||||
values.insert("autoLeading".into(), json!(j.auto_leading));
|
||||
values.insert("singleWord".into(), json!(single_word_name(j.single_word)));
|
||||
let mut fields = Value::Object(values.clone());
|
||||
fields[INITIAL] = Value::Object(values);
|
||||
fields[TARGETS] = targets;
|
||||
fields["preview"] = json!(false);
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Why OK can't apply the dialog: a row whose values are out of order.
|
||||
fn problem(d: &Dialog) -> Option<&'static str> {
|
||||
let out_of_order = ROWS.iter().any(|(_, prefix, _)| {
|
||||
let [min, desired, max] = COLUMNS.map(|(suffix, _)| d.f64(&format!("{prefix}{suffix}"), 0.0));
|
||||
!(min <= desired && desired <= max)
|
||||
});
|
||||
out_of_order.then(|| tl!("Minimum must not exceed Desired, nor Desired Maximum."))
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
// Its own grid: a label column and three value columns.
|
||||
egui::Grid::new("justification-spacing").num_columns(4).spacing([10.0, 8.0]).min_col_width(70.0).show(ui, |ui| {
|
||||
ui.label("");
|
||||
for heading in [tl!("Minimum"), tl!("Desired"), tl!("Maximum")] {
|
||||
ui.label(heading);
|
||||
}
|
||||
ui.end_row();
|
||||
for ((_, prefix, (lo, hi)), text) in ROWS.iter().zip([tl!("Word Spacing:"), tl!("Letter Spacing:"), tl!("Glyph Scaling:")]) {
|
||||
label(ui, text);
|
||||
for (suffix, _) in COLUMNS {
|
||||
let key = format!("{prefix}{suffix}");
|
||||
if let Some(v) = widgets::range_field(ui, ("justification", key.as_str()), d.f64(&key, 0.0), *lo..=*hi, "%", 0, 70.0) {
|
||||
d.fields.insert(key, json!(v));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("justification-more").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
label(ui, tl!("Auto Leading:"));
|
||||
let (lo, hi) = JustificationOptions::AUTO_LEADING;
|
||||
if let Some(v) = widgets::range_field(ui, ("justification", "autoLeading"), d.f64("autoLeading", 120.0), lo..=hi, "%", 0, 64.0) {
|
||||
d.fields.insert("autoLeading".into(), json!(v));
|
||||
}
|
||||
ui.end_row();
|
||||
label(ui, tl!("Single Word Justification:"));
|
||||
let choices = single_word_choices();
|
||||
let current = d.str("singleWord");
|
||||
let shown = choices.iter().find(|(v, _)| *v == current).map_or(choices[0].1, |(_, l)| *l);
|
||||
let labels = choices.map(|(_, l)| l);
|
||||
if let Some(i) = widgets::dropdown(ui, ("justification", "singleWord"), shown, &labels, 140.0)
|
||||
&& let Some((v, _)) = choices.get(i)
|
||||
{
|
||||
d.fields.insert("singleWord".into(), json!(v));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
match problem(d) {
|
||||
Some(why) => {
|
||||
ui.add_space(6.0);
|
||||
ui.colored_label(crate::theme::Tokens::get(ui.ctx()).error, why);
|
||||
}
|
||||
None => form::preview(app, ui, d, tl!("Justification"), CMD, Value::Object(params(d))),
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with,
|
||||
/// on the dialog's type and range.
|
||||
fn params(d: &Dialog) -> Map<String, Value> {
|
||||
let initial = d.fields.get(INITIAL).and_then(Value::as_object);
|
||||
let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_f64);
|
||||
let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
for (param, prefix, _) in ROWS {
|
||||
let mut changed = Map::new();
|
||||
for (suffix, name) in COLUMNS {
|
||||
let key = format!("{prefix}{suffix}");
|
||||
let v = d.f64(&key, 0.0);
|
||||
if was(&key) != Some(v) {
|
||||
changed.insert(name.into(), json!(v));
|
||||
}
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
p.insert(param.into(), Value::Object(changed));
|
||||
}
|
||||
}
|
||||
let leading = d.f64("autoLeading", 120.0);
|
||||
if was("autoLeading") != Some(leading) {
|
||||
p.insert("autoLeading".into(), json!(leading));
|
||||
}
|
||||
let single = d.str("singleWord");
|
||||
if initial.and_then(|m| m.get("singleWord")).and_then(Value::as_str) != Some(single.as_str()) {
|
||||
p.insert("singleWordJustification".into(), json!(single));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Does `p` change anything (more than the targets)?
|
||||
fn changes(p: &Map<String, Value>) -> bool {
|
||||
p.keys().any(|k| !matches!(k.as_str(), "ids" | "start" | "end"))
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
if let Some(why) = problem(d) {
|
||||
return Err(why.into());
|
||||
}
|
||||
let p = params(d);
|
||||
if !changes(&p) {
|
||||
if app.session.in_interaction() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
form::commit_preview(app, CMD, Value::Object(p))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::doc::{NodeId, NodeKind, ParaStyle};
|
||||
|
||||
/// A selected area type object with two paragraphs.
|
||||
fn app() -> (VectorcraftApp, u64) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
(app, id)
|
||||
}
|
||||
|
||||
fn paras(app: &VectorcraftApp, id: u64) -> (ParaStyle, ParaStyle) {
|
||||
match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, key: &str, v: Value) {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v);
|
||||
}
|
||||
|
||||
/// `ui.justification` opens the dialog on the selected type's values; OK sets the paragraphs'
|
||||
/// justification options in one undo step.
|
||||
#[test]
|
||||
fn justification_dialog_sets_the_paragraphs_options() {
|
||||
let (mut app, id) = app();
|
||||
app.run("ui.justification", json!({})).unwrap();
|
||||
let d = app.ui.dialog.clone().expect("Justification opened");
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!((d.f64("wordMin", 0.0), d.f64("wordDesired", 0.0), d.f64("wordMax", 0.0)), (80.0, 100.0, 133.0));
|
||||
assert_eq!((d.f64("glyphDesired", 0.0), d.f64("autoLeading", 0.0), d.str("singleWord")), (100.0, 120.0, "fullJustify".to_string()));
|
||||
set(&mut app, "wordMax", json!(150));
|
||||
set(&mut app, "letterMax", json!(5));
|
||||
set(&mut app, "glyphMin", json!(97));
|
||||
set(&mut app, "autoLeading", json!(140));
|
||||
set(&mut app, "singleWord", json!("alignRight"));
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let want = JustificationOptions {
|
||||
word_spacing: [80.0, 100.0, 150.0],
|
||||
letter_spacing: [0.0, 0.0, 5.0],
|
||||
glyph_scaling: [97.0, 100.0, 100.0],
|
||||
auto_leading: 140.0,
|
||||
single_word: SingleWordJustify::AlignRight,
|
||||
};
|
||||
for p in [paras(&app, id).0, paras(&app, id).1] {
|
||||
assert_eq!(p.justification, want);
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
/// OK sends only the values that changed: paragraphs whose other values differ keep them. Values
|
||||
/// out of order keep the dialog open and change nothing.
|
||||
#[test]
|
||||
fn justification_dialog_leaves_unchanged_values_alone_and_refuses_values_out_of_order() {
|
||||
let (mut app, id) = app();
|
||||
app.run("text.setFormat", json!({"ids": [id], "wordSpacing": [70, 100, 200], "start": 5, "end": 5})).unwrap();
|
||||
app.run("ui.justification", json!({})).unwrap();
|
||||
set(&mut app, "letterMax", json!(20));
|
||||
// Desired above Maximum: refused.
|
||||
set(&mut app, "glyphDesired", json!(150));
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert!(problem(&d).is_some());
|
||||
assert!(super::super::confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.is_some(), "the dialog stays open");
|
||||
assert_eq!(paras(&app, id).0.justification, JustificationOptions::default());
|
||||
set(&mut app, "glyphDesired", json!(100.0));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let (first, second) = paras(&app, id);
|
||||
assert_eq!((first.justification.word_spacing, first.justification.letter_spacing), ([80.0, 100.0, 133.0], [0.0, 0.0, 20.0]));
|
||||
assert_eq!((second.justification.word_spacing, second.justification.letter_spacing), ([70.0, 100.0, 200.0], [0.0, 0.0, 20.0]));
|
||||
// Nothing changed: no undo step.
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
app.run("ui.justification", json!({})).unwrap();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||||
// With no type selected the dialog does not open.
|
||||
app.run("select.set", json!({"ids": []})).unwrap();
|
||||
assert!(app.run("ui.justification", json!({})).is_err());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// Preview shows the values on the canvas; Cancel rolls them back.
|
||||
#[test]
|
||||
fn justification_dialog_previews_and_cancel_restores() {
|
||||
let (mut app, id) = app();
|
||||
app.run("ui.justification", json!({})).unwrap();
|
||||
set(&mut app, "autoLeading", json!(200));
|
||||
set(&mut app, "preview", json!(true));
|
||||
frame(&mut app);
|
||||
assert!(app.session.in_interaction(), "the preview runs");
|
||||
assert_eq!(paras(&app, id).0.justification.auto_leading, 200.0);
|
||||
super::super::cancel(&mut app);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(paras(&app, id).0.justification.auto_leading, 120.0);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ pub mod halftone;
|
||||
pub mod hyphenation;
|
||||
pub mod import_pdf;
|
||||
pub mod javascript_files;
|
||||
pub mod justification;
|
||||
pub mod layer_options;
|
||||
pub mod layers_panel_options;
|
||||
pub mod liquify;
|
||||
@@ -269,6 +270,7 @@ registry! {
|
||||
WidthPoint: [width_point::KIND] => width_point::SPEC,
|
||||
Corners: [corners::KIND] => corners::SPEC,
|
||||
Hyphenation: [hyphenation::KIND] => hyphenation::SPEC,
|
||||
Justification: [justification::KIND] => justification::SPEC,
|
||||
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
|
||||
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
|
||||
JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC,
|
||||
|
||||
@@ -5153,3 +5153,13 @@ theme Appearance Външен вид
|
||||
Enable Guides for 9-Slice Scaling Включване на водачи за 9-slice мащабиране
|
||||
Persistent Постоянна
|
||||
Library Panel Options Опции на панела с библиотека
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Желано
|
||||
Word Spacing: Разстояние между думите:
|
||||
Letter Spacing: Разстояние между буквите:
|
||||
Glyph Scaling: Мащабиране на знаците:
|
||||
Auto Leading: Автоматична междуредова разредка:
|
||||
Single Word Justification: Подравняване на единична дума:
|
||||
Full Justify Пълно двустранно
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Минималната стойност не може да надвишава желаната, нито желаната – максималната.
|
||||
Justification Подравняване
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5188,3 +5188,13 @@ mark-style Japanese Japonské
|
||||
@msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Požadované
|
||||
Word Spacing: Mezery mezi slovy:
|
||||
Letter Spacing: Mezery mezi písmeny:
|
||||
Glyph Scaling: Změna velikosti glyfů:
|
||||
Auto Leading: Automatické proklady:
|
||||
Single Word Justification: Zarovnání jednoho slova:
|
||||
Full Justify Zarovnat do bloku
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum nesmí překročit požadovanou hodnotu ani požadovaná hodnota maximum.
|
||||
Justification Zarovnání do bloku
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5172,3 +5172,13 @@ swatch Bright Violet Kraftig violet
|
||||
Typing Indtastning
|
||||
Persistent Vedvarende
|
||||
Library Panel Options Indstillinger for bibliotekspanel
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Ønsket
|
||||
Word Spacing: Ordafstand:
|
||||
Letter Spacing: Bogstavafstand:
|
||||
Glyph Scaling: Skalering af glyffer:
|
||||
Auto Leading: Automatisk linjeafstand:
|
||||
Single Word Justification: Justering af enkeltord:
|
||||
Full Justify Lige margener
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum må ikke overstige Ønsket, og Ønsket ikke Maksimum.
|
||||
Justification Justering
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4763,3 +4763,14 @@ effect Regular Regelmäßig
|
||||
@msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Optimal
|
||||
Word Spacing: Wortabstand:
|
||||
Letter Spacing: Zeichenabstand:
|
||||
Glyph Scaling: Glyphenskalierung:
|
||||
Auto Leading: Automatischer Zeilenabstand:
|
||||
Single Word Justification: Ausrichtung einzelner Wörter:
|
||||
Full Justify Blocksatz
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Das Minimum darf den optimalen Wert nicht übersteigen, der optimale Wert nicht das Maximum.
|
||||
Minimum Minimum
|
||||
Justification Abstände
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5140,3 +5140,13 @@ effect Points Σημεία
|
||||
@msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Επιθυμητό
|
||||
Word Spacing: Απόσταση λέξεων:
|
||||
Letter Spacing: Απόσταση γραμμάτων:
|
||||
Glyph Scaling: Κλιμάκωση γλυφών:
|
||||
Auto Leading: Αυτόματο διάστιχο:
|
||||
Single Word Justification: Στοίχιση μεμονωμένης λέξης:
|
||||
Full Justify Στοίχιση και στα δύο άκρα
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Το ελάχιστο δεν πρέπει να υπερβαίνει το επιθυμητό, ούτε το επιθυμητό το μέγιστο.
|
||||
Justification Πλήρης στοίχιση
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2006 and column 19.
|
@@ -5130,3 +5130,13 @@ mark-style Japanese Japonesas
|
||||
@msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Deseado
|
||||
Word Spacing: Espaciado entre palabras:
|
||||
Letter Spacing: Espaciado entre letras:
|
||||
Glyph Scaling: Escala de glifos:
|
||||
Auto Leading: Interlineado automático:
|
||||
Single Word Justification: Justificación de una sola palabra:
|
||||
Full Justify Justificar completamente
|
||||
Minimum must not exceed Desired, nor Desired Maximum. El mínimo no puede superar el deseado, ni el deseado el máximo.
|
||||
Justification Justificación
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5152,3 +5152,13 @@ mark-style Japanese Japanilainen
|
||||
@msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Haluttu
|
||||
Word Spacing: Sanaväli:
|
||||
Letter Spacing: Kirjainväli:
|
||||
Glyph Scaling: Merkkien skaalaus:
|
||||
Auto Leading: Automaattinen riviväli:
|
||||
Single Word Justification: Yksittäisen sanan tasaus:
|
||||
Full Justify Täysi tasaus
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimi ei saa ylittää haluttua arvoa eikä haluttu arvo maksimia.
|
||||
Justification Tasaus
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5187,3 +5187,13 @@ theme Appearance Apparence
|
||||
@msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Optimal
|
||||
Word Spacing: Espacement des mots :
|
||||
Letter Spacing: Espacement des lettres :
|
||||
Glyph Scaling: Mise à l’échelle des glyphes :
|
||||
Auto Leading: Interlignage automatique :
|
||||
Single Word Justification: Justification d’un mot isolé :
|
||||
Full Justify Justification complète
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Le minimum ne doit pas dépasser la valeur optimale, ni la valeur optimale le maximum.
|
||||
Justification Justification
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5174,3 +5174,13 @@ imageTrace Outline Körvonal
|
||||
@msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Kívánt
|
||||
Word Spacing: Szóköz:
|
||||
Letter Spacing: Betűköz:
|
||||
Glyph Scaling: Karakterméretezés:
|
||||
Auto Leading: Automatikus sorköz:
|
||||
Single Word Justification: Egyetlen szó igazítása:
|
||||
Full Justify Teljes sorkizárás
|
||||
Minimum must not exceed Desired, nor Desired Maximum. A minimum nem haladhatja meg a kívánt értéket, sem a kívánt érték a maximumot.
|
||||
Justification Sorkizárás
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5172,3 +5172,13 @@ swatch Bright Violet Violet cerah
|
||||
Typing Mengetik
|
||||
Persistent Persisten
|
||||
Library Panel Options Opsi panel pustaka
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Diinginkan
|
||||
Word Spacing: Spasi Kata:
|
||||
Letter Spacing: Spasi Huruf:
|
||||
Glyph Scaling: Penskalaan Glif:
|
||||
Auto Leading: Spasi Baris Otomatis:
|
||||
Single Word Justification: Perataan Kata Tunggal:
|
||||
Full Justify Rata Penuh
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum tidak boleh melebihi Diinginkan, dan Diinginkan tidak boleh melebihi Maksimum.
|
||||
Justification Justifikasi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3768 and column 19.
|
@@ -4801,3 +4801,14 @@ lighting Light Luce
|
||||
@msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Desiderato
|
||||
Word Spacing: Spaziatura parole:
|
||||
Letter Spacing: Spaziatura lettere:
|
||||
Glyph Scaling: Ridimensionamento glifi:
|
||||
Auto Leading: Interlinea automatica:
|
||||
Single Word Justification: Giustificazione parola singola:
|
||||
Full Justify Giustificazione completa
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Il minimo non può superare il valore desiderato, né il desiderato il massimo.
|
||||
Minimum Minimo
|
||||
Justification Giustificazione
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4841,3 +4841,14 @@ lighting Light ライト
|
||||
@msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired 最適
|
||||
Word Spacing: 単語間隔:
|
||||
Letter Spacing: 文字間隔:
|
||||
Glyph Scaling: グリフ幅拡大/縮小:
|
||||
Auto Leading: 自動行送り:
|
||||
Single Word Justification: 単語のみの行の揃え:
|
||||
Full Justify 両端揃え
|
||||
Minimum must not exceed Desired, nor Desired Maximum. 最小値は最適値を、最適値は最大値を超えることはできません。
|
||||
Minimum 最小
|
||||
Justification 文字組み
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4764,3 +4764,14 @@ lighting Light 광원
|
||||
@msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired 희망
|
||||
Word Spacing: 단어 간격:
|
||||
Letter Spacing: 문자 간격:
|
||||
Glyph Scaling: 글리프 비율:
|
||||
Auto Leading: 자동 행간:
|
||||
Single Word Justification: 단일 단어 정렬:
|
||||
Full Justify 강제 양쪽 정렬
|
||||
Minimum must not exceed Desired, nor Desired Maximum. 최소값은 희망값을, 희망값은 최대값을 넘을 수 없습니다.
|
||||
Minimum 최소
|
||||
Justification 양끝 맞춤
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5215,3 +5215,14 @@ swatch Bright Violet Felviolet
|
||||
@msg too many library panels were opened in this session er zijn in deze sessie te veel bibliotheekpanelen geopend
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. De afbeelding is {_1} × {_2} pixels, meer dan {LARGE_TRACE_SIDE} aan één zijde: ze wordt getraceerd, maar langzamer. Een voorinstelling met minder detail of een kleinere kopie van de afbeelding traceert sneller.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). De afbeelding is getraceerd op {_1} × {_2} pixels, verkleind van {_3} × {_4} (maximaal {_5} megapixels).
|
||||
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Gewenst
|
||||
Word Spacing: Woordafstand:
|
||||
Letter Spacing: Letterafstand:
|
||||
Glyph Scaling: Glyphschaal:
|
||||
Auto Leading: Automatische regelafstand:
|
||||
Single Word Justification: Uitvullen van één woord:
|
||||
Full Justify Volledig uitvullen
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum mag niet groter zijn dan Gewenst, en Gewenst niet groter dan Maximum.
|
||||
Justification Uitvullen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5040,3 +5040,13 @@ mark-style Japanese Japońskie
|
||||
@msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Optymalne
|
||||
Word Spacing: Odstępy między wyrazami:
|
||||
Letter Spacing: Odstępy między literami:
|
||||
Glyph Scaling: Skalowanie glifów:
|
||||
Auto Leading: Automatyczna interlinia:
|
||||
Single Word Justification: Justowanie pojedynczego wyrazu:
|
||||
Full Justify Pełne justowanie
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum nie może przekraczać wartości optymalnej, a wartość optymalna maksimum.
|
||||
Justification Justowanie
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5105,3 +5105,13 @@ mark-style Japanese Japonesas
|
||||
@msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Desejado
|
||||
Word Spacing: Espaçamento entre Palavras:
|
||||
Letter Spacing: Espaçamento entre Letras:
|
||||
Glyph Scaling: Dimensionamento de Glifos:
|
||||
Auto Leading: Entrelinha Automática:
|
||||
Single Word Justification: Justificação de Palavra Única:
|
||||
Full Justify Justificação Completa
|
||||
Minimum must not exceed Desired, nor Desired Maximum. O mínimo não pode passar do desejado, nem o desejado do máximo.
|
||||
Justification Justificação
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4797,3 +4797,14 @@ lighting Light Свет
|
||||
@msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Изображение трассировано в размере {_1} × {_2} пикселей, уменьшенное с {_3} × {_4} (не более {_5} мегапикселей).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Желательно
|
||||
Word Spacing: Интервал между словами:
|
||||
Letter Spacing: Интервал между буквами:
|
||||
Glyph Scaling: Масштаб глифов:
|
||||
Auto Leading: Автоматический интерлиньяж:
|
||||
Single Word Justification: Выключка одного слова:
|
||||
Full Justify Полная выключка
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Минимум не может превышать желательное значение, а желательное — максимум.
|
||||
Minimum Минимум
|
||||
Justification Выключка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5144,3 +5144,13 @@ theme Appearance Utseende
|
||||
@msg too many library panels were opened in this session för många bibliotekspaneler har öppnats i den här sessionen
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Önskat
|
||||
Word Spacing: Ordavstånd:
|
||||
Letter Spacing: Teckenavstånd:
|
||||
Glyph Scaling: Glyfskalning:
|
||||
Auto Leading: Automatiskt radavstånd:
|
||||
Single Word Justification: Justering av enstaka ord:
|
||||
Full Justify Fullständig marginaljustering
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Minimum får inte överstiga Önskat, och Önskat inte Maximum.
|
||||
Justification Justering
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -4781,3 +4781,14 @@ lighting Light Світло
|
||||
@msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Зображення трасовано в розмірі {_1} × {_2} пікселів, зменшене з {_3} × {_4} (щонайбільше {_5} мегапікселів).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Бажане
|
||||
Word Spacing: Інтервал між словами:
|
||||
Letter Spacing: Інтервал між літерами:
|
||||
Glyph Scaling: Масштаб гліфів:
|
||||
Auto Leading: Автоматичний інтерліньяж:
|
||||
Single Word Justification: Вирівнювання одного слова:
|
||||
Full Justify Повне вирівнювання
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Мінімум не може перевищувати бажане значення, а бажане — максимум.
|
||||
Minimum Мінімум
|
||||
Justification Вирівнювання за шириною
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 725 and column 40.
|
@@ -4781,3 +4781,14 @@ theme Appearance Giao diện
|
||||
@msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này
|
||||
@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn.
|
||||
@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel).
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired Mong muốn
|
||||
Word Spacing: Khoảng cách từ:
|
||||
Letter Spacing: Khoảng cách chữ:
|
||||
Glyph Scaling: Tỷ lệ glyph:
|
||||
Auto Leading: Khoảng cách dòng tự động:
|
||||
Single Word Justification: Căn đều một từ:
|
||||
Full Justify Căn đều hoàn toàn
|
||||
Minimum must not exceed Desired, nor Desired Maximum. Giá trị tối thiểu không được vượt quá giá trị mong muốn, và giá trị mong muốn không được vượt quá tối đa.
|
||||
Minimum Tối thiểu
|
||||
Justification Căn đều
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3948 and column 10.
|
@@ -5023,3 +5023,15 @@ lighting Light 光源
|
||||
@msg only http, https and mailto URLs open in the browser, not “{url}” 浏览器中只会打开 http、https 和 mailto 的 URL,不会打开 “{url}”
|
||||
@msg {id} opens another app: only the person at the keyboard can run it {id} 会打开其他应用程序:只有使用键盘的人才能运行它
|
||||
@msg “{name}” is not a file “{name}” 不是文件
|
||||
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired 所需
|
||||
Word Spacing: 字间距:
|
||||
Letter Spacing: 字母间距:
|
||||
Glyph Scaling: 字形缩放:
|
||||
Auto Leading: 自动行距:
|
||||
Single Word Justification: 单字对齐:
|
||||
Full Justify 全部强制双齐
|
||||
Minimum must not exceed Desired, nor Desired Maximum. 最小值不能超过所需值,所需值不能超过最大值。
|
||||
Minimum 最小值
|
||||
Justification 对齐设置
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4077,3 +4077,14 @@ lighting Light 光源
|
||||
Enable Guides for 9-Slice Scaling 啟用 9-slice 縮放參考線
|
||||
Persistent 持久
|
||||
Library Panel Options 資料庫面板選項
|
||||
# Paragraph panel menu › Justification…
|
||||
Desired 所需
|
||||
Word Spacing: 字間距:
|
||||
Letter Spacing: 字母間距:
|
||||
Glyph Scaling: 字符比例:
|
||||
Auto Leading: 自動行距:
|
||||
Single Word Justification: 單字對齊:
|
||||
Full Justify 全部強迫齊行
|
||||
Minimum must not exceed Desired, nor Desired Maximum. 最小值不可超過所需值,所需值不可超過最大值。
|
||||
Minimum 最小值
|
||||
Justification 對齊設定
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -666,6 +666,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{id?, corners?: [i…]} open Corners for path `id` (default: the selected one) and its corners (anchor indices of the path with its corners uncut, as object.setLiveShape takes them: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; default: the Direct-Selected corners, else every corner) (dialog `corners`: kind (round|invertedRound|chamfer), radius (pt); double-clicking a corner widget opens it too): OK runs object.setLiveShape",
|
||||
),
|
||||
(
|
||||
"ui.justification",
|
||||
"Justification…",
|
||||
"",
|
||||
"{} open Justification (the Paragraph panel menu's Justification…) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, with Preview (dialog `justification`: wordMin, wordDesired, wordMax 0..1000, letterMin, letterDesired, letterMax -100..500, glyphMin, glyphDesired, glyphMax 50..200 (percent), autoLeading 0..500 (percent), singleWord (fullJustify|alignLeft|alignCenter|alignRight), preview): OK runs text.setFormat with the values that changed, as one undo step, and is disabled while a row's Minimum exceeds its Desired or Desired its Maximum; an error with no type selected",
|
||||
),
|
||||
(
|
||||
"ui.hyphenation",
|
||||
"Hyphenation…",
|
||||
@@ -1577,6 +1583,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
|
||||
"ui.corners" => crate::dialogs::corners::open(app, p),
|
||||
"ui.hyphenation" => crate::dialogs::hyphenation::open(app),
|
||||
"ui.justification" => crate::dialogs::justification::open(app),
|
||||
"ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p),
|
||||
"ui.colorPanel" => crate::panels::color::command(app, p),
|
||||
"ui.savePdfDialog" => crate::dialogs::open_save_pdf(app, p),
|
||||
@@ -2251,7 +2258,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
"ui.newArtboard" => app.session.active().is_some(),
|
||||
"ui.sampleScreenColor" => crate::screen_color::available(app),
|
||||
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
|
||||
"ui.hyphenation" => crate::panels::character::text_style(app).is_some(),
|
||||
"ui.hyphenation" | "ui.justification" => crate::panels::character::text_style(app).is_some(),
|
||||
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"ui.saveSwatchLibrary" => app.session.active().is_some(),
|
||||
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), indents,
|
||||
//! space before/after, Hyphenate, Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu
|
||||
//! turns Roman Hanging Punctuation on and off, opens Hyphenation… and picks the Single-line or
|
||||
//! Every-line Composer.
|
||||
//! turns Roman Hanging Punctuation on and off, opens Justification… and Hyphenation… and picks the
|
||||
//! Single-line or Every-line Composer.
|
||||
|
||||
use egui::{Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -288,7 +288,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if menu_item(ui, tl!("Roman Hanging Punctuation"), has, roman) {
|
||||
format(app, json!({"romanHangingPunctuation": !roman}));
|
||||
}
|
||||
menu_item(ui, tl!("Justification…"), false, false);
|
||||
if menu_item(ui, tl!("Justification…"), has, false)
|
||||
&& let Err(e) = app.run("ui.justification", json!({}))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
if menu_item(ui, tl!("Hyphenation…"), has, false)
|
||||
&& let Err(e) = app.run("ui.hyphenation", json!({}))
|
||||
{
|
||||
@@ -339,11 +343,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
/// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no indents or
|
||||
/// paragraph spacing, Hyphenate off with the default Hyphenation options, Auto direction,
|
||||
/// Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the Every-line Composer and
|
||||
/// Roman Hanging Punctuation off.
|
||||
/// paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto
|
||||
/// direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the Every-line
|
||||
/// Composer and Roman Hanging Punctuation off.
|
||||
fn reset_panel(app: &mut VectorcraftApp) {
|
||||
let h = HyphenationOptions::default();
|
||||
let j = vectorcraft_doc::JustificationOptions::default();
|
||||
para_cmd(app, "text.setStyle", json!({"justify": "auto"}));
|
||||
format(
|
||||
app,
|
||||
@@ -351,6 +356,8 @@ fn reset_panel(app: &mut VectorcraftApp) {
|
||||
"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false,
|
||||
"wordsLongerThan": h.words_longer_than, "afterFirst": h.after_first, "beforeLast": h.before_last,
|
||||
"hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized,
|
||||
"wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling,
|
||||
"autoLeading": j.auto_leading, "singleWordJustification": "fullJustify",
|
||||
"direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine",
|
||||
"romanHangingPunctuation": false
|
||||
}),
|
||||
@@ -580,9 +587,9 @@ mod tests {
|
||||
assert!(tips(&mut app) > without, "two more buttons");
|
||||
}
|
||||
|
||||
/// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation
|
||||
/// options back to their defaults in every paragraph it acts on, so the paragraphs save no
|
||||
/// hyphenation options.
|
||||
/// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation and
|
||||
/// Justification options back to their defaults in every paragraph it acts on, so the paragraphs
|
||||
/// save neither.
|
||||
#[test]
|
||||
fn reset_panel_resets_the_hyphenation_options() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
@@ -592,7 +599,7 @@ mod tests {
|
||||
"text.setFormat",
|
||||
json!({
|
||||
"hyphenate": true, "wordsLongerThan": 8, "afterFirst": 2, "beforeLast": 4, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false,
|
||||
"romanHangingPunctuation": true
|
||||
"wordSpacing": [60, 90, 200], "letterSpacing": [-5, 0, 5], "glyphScaling": [95, 100, 105], "autoLeading": 150, "singleWordJustification": "alignLeft", "romanHangingPunctuation": true
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -605,6 +612,7 @@ mod tests {
|
||||
assert!(para.hyphenate);
|
||||
assert_ne!(para.hyphenation, HyphenationOptions::default());
|
||||
assert!(para.roman_hanging);
|
||||
assert_ne!(para.justification, vectorcraft_doc::JustificationOptions::default());
|
||||
}
|
||||
reset_panel(&mut app);
|
||||
for para in paras(&app) {
|
||||
@@ -612,6 +620,7 @@ mod tests {
|
||||
assert_eq!(para.hyphenation, HyphenationOptions::default());
|
||||
assert_eq!(serde_json::to_value(¶).unwrap().get("hyphenation"), None, "saves no hyphenation options");
|
||||
assert!(!para.roman_hanging, "Roman Hanging Punctuation off");
|
||||
assert_eq!(serde_json::to_value(¶).unwrap().get("justification"), None, "saves no justification options");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -190,7 +190,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
- **Improved:** mixed paragraph alignments leave the existing Paragraph/Properties alignment buttons unselected; choosing an alignment still uses the range-aware command. Headless regressions, full integration CI and visual review pass. Other mixed paragraph fields remain; estimate unchanged.
|
||||
- **Improved (#1258):** full font names (name ID 4) resolve the face instead of falling back, and Missing Fonts searches recognize the same aliases. Synthetic regressions cover loaded/cataloged fonts, localized names, real-family precedence and variable default faces. Windows font-service activation still needs reporter QA; estimate unchanged.
|
||||
- **Done:** Paragraph panel menu › Roman Hanging Punctuation (#2756), per paragraph; estimate unchanged.
|
||||
- **Missing:** Justification… options (#2755, type core); CJK vertical
|
||||
- **Done:** Paragraph panel menu › Justification… (#2755): word and letter spacing, glyph scaling (Minimum, Desired,
|
||||
Maximum), Auto Leading and Single Word Justification per paragraph, used by both composers; estimate unchanged.
|
||||
- **Missing:** CJK vertical
|
||||
composition (ruby, mojikumi sets and dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern
|
||||
features beyond bidi and digit types (Character Direction, kashidas, composers), variable font axes, spell check with an open dictionary,
|
||||
Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement).
|
||||
|
||||
+25
@@ -3590,6 +3590,31 @@ changed.
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"hyphenate":true,"wordsLongerThan":8,"hyphenLimit":2}}}
|
||||
```
|
||||
|
||||
|
||||
## Justification options
|
||||
|
||||
`text.setFormat` sets the Paragraph panel menu's Justification… options of the selected type (or `ids`, or the
|
||||
paragraphs a `start` / `end` range touches), in percent:
|
||||
|
||||
- `wordSpacing` (0 to 1000, default 80 / 100 / 133), `letterSpacing` (-100 to 500, a share of the font's word space,
|
||||
default 0 / 0 / 0) and `glyphScaling` (50 to 200, default 100 / 100 / 100), each `[min, desired, max]` or
|
||||
`{min?, desired?, max?}` (the values not given are kept). Desired applies to every line: word spaces are that share
|
||||
of the font's word space, each letter followed by another is followed by that share of a word space, and glyphs are
|
||||
that wide. A justified line may move them between Minimum and Maximum to fill its measure: word spacing first, then
|
||||
letter spacing, then glyph scaling; past all three its word spaces take the rest. Values are clamped to their range,
|
||||
and Minimum ≤ Desired ≤ Maximum must hold in every paragraph afterwards, or nothing changes and the command fails.
|
||||
- `autoLeading` (0 to 500, default 120): the leading of characters whose leading is Auto, as a share of their size.
|
||||
- `singleWordJustification` (`"fullJustify"` (default), `"alignLeft"`, `"alignCenter"` or `"alignRight"`): how a
|
||||
justified line holding a single word is set.
|
||||
|
||||
The options are saved with the paragraph (`justification` in `para`) only when they differ from the defaults, and
|
||||
paragraph styles carry them (`paraStyle.new {attrs: {justification: {auto_leading: 150}}}`). Vertical type isn't glyph
|
||||
scaled, and type on a path doesn't use them. One undo step. `ui.justification` opens the Justification dialog on the
|
||||
values the Paragraph panel shows, with Preview; OK sends the values that changed.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"wordSpacing":[80,100,150],"letterSpacing":{"max":5},"singleWordJustification":"alignLeft"}}}
|
||||
```
|
||||
## Kinsoku Set
|
||||
|
||||
`text.setFormat {kinsoku: "hard"|"soft"|"none"}` sets the Paragraph panel's Kinsoku Set of the selected type (or `ids`):
|
||||
|
||||
+2
-1
@@ -37,7 +37,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
|
||||
| Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right paragraphs; type on a path and right-to-left paragraphs do not hang |
|
||||
| Roman Hanging Punctuation | done | Paragraph panel menu › Roman Hanging Punctuation, `text.setFormat {romanHangingPunctuation}` (#2756): per paragraph, Optical Margin Alignment's punctuation and amounts without the letters; paragraph styles carry it. Evidence: `tests::roman_hanging_punctuation_hangs_punctuation_but_not_letters`, `typecmd::area_tests::roman_hanging_punctuation_is_set_per_paragraph_and_saved_only_when_on` |
|
||||
| Hyphenation… and Justification… options | partial | Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310); Justification… missing (menu stub) |
|
||||
| Hyphenation… and Justification… options | done | Hyphenation… (`ui.hyphenation`, `text.setFormat`) sets each paragraph's Words Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone (Single-line Composer only) and Hyphenate Capitalized Words, and paragraph styles carry them (#2310). Justification… (`ui.justification`, `text.setFormat`, #2755) sets Word Spacing, Letter Spacing and Glyph Scaling (Minimum, Desired, Maximum), Auto Leading and Single Word Justification; Desired applies to every line, justified lines and the Every-line Composer use the ranges. Vertical type isn't glyph scaled; type on a path doesn't use them. Evidence: `crates/text/src/tests_justify.rs`, `dialogs::justification::tests` |
|
||||
| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877) |
|
||||
| Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` |
|
||||
| Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary |
|
||||
@@ -64,6 +64,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Roman Hanging Punctuation (#2756): per-paragraph hanging of Latin punctuation outside the margin; scores unchanged |
|
||||
| 2026-10-17 | minor | Justification… (#2755): word and letter spacing, glyph scaling, Auto Leading and Single Word Justification per paragraph; the default word spacing range is 80/100/133%, so justified lines break differently than with the old fixed 80–150%; scores unchanged |
|
||||
| 2026-10-17 | minor | A click on area type's red out port loads the Selection tool with the overflow text; the next click threads it into a closed path or a new frame of the same shape there, a drag into a new rectangle (`text.thread.add`, #2572 in part). The out port moved up the frame's right side, clear of the corner handle. Scores and hours unchanged. |
|
||||
| 2026-10-16 | minor | Native font-family labels fall back from English-only typographic names to localized legacy names (#2208). English labels and native typographic precedence are preserved; synthetic loaded/catalog tests. Scores and estimates unchanged. |
|
||||
| 2026-10-16 | minor | Align Stroke on live type: the characters' stroke goes inside or outside the glyphs without creating outlines, for every run or a range; kept in the native file and in canvas, PDF, EPS and SVG output (#2294); beyond Illustrator, scores and hours unchanged |
|
||||
|
||||
Reference in New Issue
Block a user