mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Type: Optical Margin Alignment hangs punctuation and letter edges outside the frame
Type › Optical Margin Alignment was a disabled menu item. The reference app's Optical Margin Alignment hangs Roman punctuation and the edges of letters such as W and A outside the margins of every paragraph of a type object. type.opticalMarginAlignment (Type › Optical Margin Alignment) turns it on or off for the selected type in one undo step, and the menu item is checked while the type has it on. In every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there. Asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half their width, and A, V, W, Y, v, w and y hang by a tenth of theirs. A punctuation mark followed by a quotation mark hangs with it. Left-aligned lines hang at their left margin, right-aligned lines hang at their right margin, and centered and justified lines hang at both. Lines break where they did before, and the space that justification adds between glyphs leaves out the gaps next to a hanging punctuation mark. The setting is off for new type and for documents from before it, and the type object saves it only when it is on. The Type menu's checks for Optical Margin Alignment, Type Orientation and the Type on a Path effects now read the type inside a selected group, as the commands do. Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
This commit is contained in:
@@ -1067,6 +1067,12 @@ pub struct TextObject {
|
||||
/// Wrap objects above this area type, resolved by the engine after each edit (text space).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub wrap: Vec<WrapShape>,
|
||||
/// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted
|
||||
/// sides of letters such as A and W at a line's start or end hang outside the margin there
|
||||
/// (point and area type of left-to-right paragraphs; see the layout's `optical_hangs`). Off
|
||||
/// for new type and for documents from before it, and saved only when on.
|
||||
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub optical_margin: bool,
|
||||
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_bounds: Option<Rect>,
|
||||
@@ -1132,6 +1138,7 @@ impl TextObject {
|
||||
path_align: PathAlign::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -773,6 +773,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
@@ -992,6 +993,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
path_align: Default::default(),
|
||||
path_spacing: 0.0,
|
||||
wrap: Vec::new(),
|
||||
optical_margin: false,
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -34,6 +34,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
create_outlines
|
||||
),
|
||||
cmd!(
|
||||
"type.opticalMarginAlignment",
|
||||
"Optical Margin Alignment",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}",
|
||||
has_selection,
|
||||
optical_margin_alignment
|
||||
),
|
||||
cmd!(
|
||||
"text.setText",
|
||||
"Set Text",
|
||||
@@ -97,6 +106,28 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment: set `on` (without it, the opposite of the first object's
|
||||
/// setting) on every selected text object, in one undo step.
|
||||
fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "Optical Margin Alignment")?;
|
||||
let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => Some(t.optical_margin),
|
||||
_ => None,
|
||||
});
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!first.unwrap_or(false));
|
||||
s.edit("Optical Margin Alignment", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
};
|
||||
t.optical_margin = on;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing
|
||||
/// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type
|
||||
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo
|
||||
@@ -923,6 +954,57 @@ mod area_tests {
|
||||
assert_eq!(old.burasagari, Burasagari::None);
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment: `type.opticalMarginAlignment` sets or toggles the flag on
|
||||
/// the selected type in one undo step, the opening quotation mark then hangs left of the
|
||||
/// frame, and the flag is saved only when on and survives a native save.
|
||||
#[test]
|
||||
fn optical_margin_alignment_is_set_per_object_and_saved_only_when_on() {
|
||||
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, "size": 40, "text": "“Quoted” words", "area": {"width": 300, "height": 100}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
// The left edge of the glyphs in text space, where the frame's left edge is at 0.
|
||||
let left = |s: &Session| text(s).cached_bounds.unwrap().x0;
|
||||
// New type: off, and not saved.
|
||||
assert!(!text(&s).optical_margin);
|
||||
assert!(serde_json::to_value(text(&s)).unwrap().get("opticalMarginAlignment").is_none());
|
||||
let inside = left(&s);
|
||||
assert!(inside >= 0.0, "{inside}");
|
||||
// Nothing but a rectangle selected: an error.
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
|
||||
assert!(s.execute("type.opticalMarginAlignment", &json!({})).is_err());
|
||||
// Toggled on: the quotation mark hangs outside the frame.
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], true);
|
||||
assert!(text(&s).optical_margin);
|
||||
assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s));
|
||||
assert_eq!(serde_json::to_value(text(&s)).unwrap()["opticalMarginAlignment"], true);
|
||||
// `on` sets it; without `on` it toggles.
|
||||
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap()["on"], true);
|
||||
assert!(text(&s).optical_margin);
|
||||
assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false);
|
||||
assert!(!text(&s).optical_margin);
|
||||
assert_eq!(left(&s), inside);
|
||||
// One undo step each.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(text(&s).optical_margin);
|
||||
// Saved and reopened as it was; a document from before it reads as off.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert!(t.optical_margin);
|
||||
let mut old = serde_json::to_value(text(&s)).unwrap();
|
||||
old.as_object_mut().unwrap().remove("opticalMarginAlignment");
|
||||
assert!(!serde_json::from_value::<TextObject>(old).unwrap().optical_margin);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation
|
||||
/// options, rounded and clamped, in one undo step; they are saved only when not the defaults,
|
||||
/// and paragraph styles carry them.
|
||||
|
||||
+109
-18
@@ -1336,6 +1336,65 @@ fn hangs(g: &SGlyph) -> bool {
|
||||
g.inline.is_none() && matches!(g.ch, '、' | '。' | ',' | '.' | '、' | '。') && g.tcy.is_none()
|
||||
}
|
||||
|
||||
/// Optical Margin Alignment: the share of glyph `g`'s advance that hangs outside the margin when
|
||||
/// `g` starts or ends a line. Quotation marks, hyphens, full stops and commas hang whole, and
|
||||
/// asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half (the amounts
|
||||
/// published for Roman Hanging Punctuation). A, V, W, Y, v, w and y hang by a tenth of their
|
||||
/// advance, VectorCraft's own amount for their slanted sides. Other characters, East Asian
|
||||
/// punctuation (which burasagari hangs) and inline graphics don't hang.
|
||||
fn optical_share(g: &SGlyph) -> f64 {
|
||||
if g.inline.is_some() || g.tcy.is_some() {
|
||||
return 0.0;
|
||||
}
|
||||
match g.ch {
|
||||
'-' | '\u{2010}' | '\u{2011}' | '.' | ',' => 1.0,
|
||||
c if is_quote(c) => 1.0,
|
||||
'*' | '~' | '…' | '–' | '—' | ':' | ';' => 0.5,
|
||||
'A' | 'V' | 'W' | 'Y' | 'v' | 'w' | 'y' => 0.1,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A punctuation mark that hangs ([`optical_share`]), not a letter.
|
||||
fn optical_punct(g: &SGlyph) -> bool {
|
||||
!g.ch.is_alphabetic() && optical_share(g) > 0.0
|
||||
}
|
||||
|
||||
/// A single or double quotation mark: straight, curly, low-9 or reversed-9.
|
||||
fn is_quote(c: char) -> bool {
|
||||
matches!(c, '\'' | '"' | '‘' | '’' | '‚' | '‛' | '“' | '”' | '„' | '‟')
|
||||
}
|
||||
|
||||
/// Optical Margin Alignment: how far a line hangs outside its left and right margins, from the
|
||||
/// glyph at each end of `line` (its glyphs in visual order, each with the advance it is set with)
|
||||
/// and its [`optical_share`], and how many glyphs hang at each end → (left, right, left glyphs,
|
||||
/// right glyphs). A punctuation mark followed by a quotation mark hangs together with it: `."`
|
||||
/// ending a line and `'"` starting one hang both their glyphs. A line of one glyph hangs at
|
||||
/// neither end.
|
||||
fn optical_hangs(line: &[(&SGlyph, f64)]) -> (f64, f64, usize, usize) {
|
||||
let n = line.len();
|
||||
if n < 2 {
|
||||
return (0.0, 0.0, 0, 0);
|
||||
}
|
||||
let hang = |k: usize| line.get(k).map_or(0.0, |&(g, adv)| optical_share(g) * adv.max(0.0));
|
||||
let punct = |k: usize| line.get(k).is_some_and(|&(g, _)| optical_punct(g));
|
||||
let quote = |k: usize| line.get(k).is_some_and(|&(g, _)| is_quote(g.ch) && optical_share(g) > 0.0);
|
||||
let mut left = hang(0);
|
||||
// The glyphs that hang at the left; the right end takes none of them.
|
||||
let mut taken = usize::from(left > 0.0);
|
||||
if left > 0.0 && n > 2 && punct(0) && quote(1) {
|
||||
left += hang(1);
|
||||
taken = 2;
|
||||
}
|
||||
let mut right = hang(n - 1);
|
||||
let mut right_glyphs = usize::from(right > 0.0);
|
||||
if right > 0.0 && n > 2 && n - 2 >= taken && punct(n - 2) && quote(n - 1) {
|
||||
right += hang(n - 2);
|
||||
right_glyphs = 2;
|
||||
}
|
||||
(left, right, taken, right_glyphs)
|
||||
}
|
||||
|
||||
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
|
||||
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
|
||||
/// with the one before it).
|
||||
@@ -1776,6 +1835,39 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
});
|
||||
let body = if hang.is_some() { trimmed - 1 } else { trimmed };
|
||||
let w = w - hang.unwrap_or(0.0);
|
||||
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
|
||||
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
|
||||
let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]);
|
||||
// Optical Margin Alignment: the glyphs at the ends of a left-to-right line hang
|
||||
// outside its margins by `opt_l` and `opt_r`. The line is aligned and justified in a
|
||||
// measure that much wider at each end. A left-aligned line then hangs only at its
|
||||
// left, a right-aligned line hangs only at its right, and centered and justified lines
|
||||
// hang at both. A line that ends in a burasagari mark hangs only at its left.
|
||||
// Justification adds no space after a punctuation mark hanging at the left, nor before
|
||||
// one hanging at the right (`held`). A letter that hangs by part of its width keeps its
|
||||
// gaps.
|
||||
let (opt_l, opt_r, held) = if t.optical_margin && !rtl {
|
||||
let at: Vec<usize> = order.iter().map(|&offset| i + offset).filter(|&j| j < body).collect();
|
||||
let mut visible: Vec<(&SGlyph, f64)> =
|
||||
at.iter().filter_map(|&j| sg.get(j).map(|g| (g, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).collect();
|
||||
if let Some(h) = &hyphen {
|
||||
visible.push((h, h.adv));
|
||||
}
|
||||
let (l, r, left_glyphs, right_glyphs) = optical_hangs(&visible);
|
||||
let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) };
|
||||
let n_visible = visible.len();
|
||||
let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g));
|
||||
let held: Vec<usize> = at
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1)))
|
||||
.map(|(_, &j)| j)
|
||||
.collect();
|
||||
(l, r, held)
|
||||
} else {
|
||||
(0.0, 0.0, vec![])
|
||||
};
|
||||
let room = width + opt_l + opt_r;
|
||||
let (align, justify) = match para.justify {
|
||||
Justify::Auto => (if rtl { 2 } else { 0 }, false),
|
||||
Justify::Left => (0, false),
|
||||
@@ -1793,41 +1885,43 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block.
|
||||
let cjk_gap = |j: usize| {
|
||||
j + 1 < body
|
||||
&& !held.contains(&j)
|
||||
&& sg
|
||||
.get(j)
|
||||
.zip(sg.get(j + 1))
|
||||
.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();
|
||||
if justify && (width - w).abs() > EPS {
|
||||
if cjk_gaps > 0 && width > w {
|
||||
if justify && (room - w).abs() > EPS {
|
||||
if cjk_gaps > 0 && room > w {
|
||||
// Spread over the CJK gaps and the word spaces alike.
|
||||
let per = (width - w) / (cjk_gaps + spaces) as f64;
|
||||
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 =
|
||||
((width - 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 && width > w {
|
||||
let gaps = sg.get(i + 1..body).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
|
||||
((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 {
|
||||
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 = (width - w) / gaps as f64;
|
||||
per_gap = (room - w) / gaps as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
let start_x = if justify {
|
||||
ax0
|
||||
ax0 - opt_l
|
||||
} else if regions.is_none() {
|
||||
match align {
|
||||
0 => ind_l,
|
||||
1 => (ind_l - para.right_indent - w) * 0.5,
|
||||
_ => -para.right_indent - w,
|
||||
0 => ind_l - opt_l,
|
||||
1 => (ind_l - opt_l - para.right_indent + opt_r - w) * 0.5,
|
||||
_ => -para.right_indent + opt_r - w,
|
||||
}
|
||||
} else {
|
||||
match align {
|
||||
0 => ax0,
|
||||
1 => ax0 + (width - w) * 0.5,
|
||||
_ => ax1 - w,
|
||||
0 => ax0 - opt_l,
|
||||
1 => ax0 - opt_l + (room - w) * 0.5,
|
||||
_ => ax1 + opt_r - w,
|
||||
}
|
||||
};
|
||||
let li = cx.out.lines.len();
|
||||
@@ -1840,9 +1934,6 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
// A line with tabs is measured as if it started at the left (see `set_width`), then
|
||||
// aligned as a whole: its tabs stop where they were measured, however it is aligned.
|
||||
let tab_shift = start_x - if regions.is_some() { ax0 } else { ind_l };
|
||||
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
|
||||
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
|
||||
let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]);
|
||||
// RTL's logical trailing spaces precede the visible content. Keep them outside
|
||||
// the aligned content extent, just as LTR trailing spaces extend to its right.
|
||||
let leading_space_width: f64 =
|
||||
@@ -1865,7 +1956,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
|
||||
if cjk_gap(j) {
|
||||
adv += per_cjk;
|
||||
}
|
||||
} else if j + 1 < body && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
|
||||
} else if j + 1 < body && !held.contains(&j) && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
|
||||
// Between glyphs, never inside a tate-chu-yoko block (one cell).
|
||||
adv += per_gap;
|
||||
}
|
||||
|
||||
@@ -822,6 +822,91 @@ fn burasagari_leaves_latin_commas_and_full_stops_inside_the_line() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Optical Margin Alignment hangs the glyphs at a line's ends outside its margins: wholly for a
|
||||
/// quotation mark, hyphen, full stop or comma, by half for an em dash, by a tenth of the advance
|
||||
/// for an A, at the left of a left-aligned line, at the right of a right-aligned line, and at
|
||||
/// both ends of centered and justified lines. A full stop and the closing quotation mark after it
|
||||
/// hang together. Off, the lines are placed as before.
|
||||
#[test]
|
||||
fn optical_margin_alignment_hangs_punctuation_and_letter_edges_outside_the_margins() {
|
||||
let frame = Rect::new(0.0, 0.0, 300.0, 400.0);
|
||||
let lay = |text: &str, justify: Justify, on: bool| {
|
||||
let mut t = area(text, style(20.0), frame, justify);
|
||||
t.optical_margin = on;
|
||||
layout(db(), &t).glyphs
|
||||
};
|
||||
let near = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
let end = |g: &PositionedGlyph| g.origin.x + g.advance;
|
||||
// Left aligned: the opening quotation mark hangs wholly, so the H starts at the edge.
|
||||
let (on, off) = (lay("“Hello” there", Justify::Left, true), lay("“Hello” there", Justify::Left, false));
|
||||
assert!(near(off[0].origin.x, 0.0) && near(on[1].origin.x, 0.0), "{} {}", off[0].origin.x, on[1].origin.x);
|
||||
assert!(near(on[0].origin.x, -on[0].advance) && on[0].advance > 1.0);
|
||||
// An em dash hangs by half its advance.
|
||||
let dash = lay("—ab", Justify::Left, true);
|
||||
assert!(near(dash[0].origin.x, -0.5 * dash[0].advance) && dash[0].advance > 1.0, "{}", dash[0].origin.x);
|
||||
// An A hangs by a tenth of its advance; a letter of no class doesn't hang.
|
||||
let a = lay("AVA", Justify::Left, true);
|
||||
assert!(near(a[0].origin.x, -0.1 * a[0].advance), "{}", a[0].origin.x);
|
||||
let v = lay("van", Justify::Left, true);
|
||||
assert!(near(v[0].origin.x, -0.1 * v[0].advance), "{}", v[0].origin.x);
|
||||
assert!(near(lay("Hello", Justify::Left, true)[0].origin.x, 0.0));
|
||||
assert!(near(lay("and", Justify::Left, true)[0].origin.x, 0.0));
|
||||
// A line of one glyph doesn't hang.
|
||||
assert!(near(lay(".", Justify::Left, true)[0].origin.x, 0.0));
|
||||
// Right aligned: the full stop hangs wholly at the right; with a closing quotation mark after
|
||||
// it, both do.
|
||||
let (on, off) = (lay("Hello there.", Justify::Right, true), lay("Hello there.", Justify::Right, false));
|
||||
let n = on.len();
|
||||
assert!(near(end(&off[n - 1]), 300.0) && near(on[n - 1].origin.x, 300.0), "{} {}", end(&off[n - 1]), on[n - 1].origin.x);
|
||||
let on = lay("Say “hi.”", Justify::Right, true);
|
||||
let n = on.len();
|
||||
assert!(near(on[n - 2].origin.x, 300.0) && near(end(&on[n - 3]), 300.0), "{}", on[n - 2].origin.x);
|
||||
// At a line's start, a single quotation mark followed by a double one hangs with it.
|
||||
assert!(near(lay("‘“Hi", Justify::Left, true)[2].origin.x, 0.0));
|
||||
// A right-aligned line doesn't hang at its left, nor a left-aligned one at its right.
|
||||
assert!(near(lay("“Hello", Justify::Right, true).last().map_or(0.0, end), 300.0));
|
||||
assert!(near(lay("Hello.", Justify::Left, true)[0].origin.x, 0.0));
|
||||
// Centered: the letters between the hanging hyphens are centered in the frame.
|
||||
let on = lay("-ab-", Justify::Center, true);
|
||||
assert!(near((on[1].origin.x + end(&on[2])) * 0.5, 150.0), "{on:?}");
|
||||
let off = lay("-ab-", Justify::Center, false);
|
||||
assert!(near((off[0].origin.x + end(&off[3])) * 0.5, 150.0));
|
||||
// Justified: both hyphens hang and the words fill the frame between them.
|
||||
let on = lay("-ab cd-", Justify::JustifyAll, true);
|
||||
let n = on.len();
|
||||
assert!(near(on[1].origin.x, 0.0) && near(on[n - 1].origin.x, 300.0), "{} {}", on[1].origin.x, on[n - 1].origin.x);
|
||||
let off = lay("-ab cd-", Justify::JustifyAll, false);
|
||||
assert!(near(off[0].origin.x, 0.0) && near(end(&off[n - 1]), 300.0));
|
||||
// A line without spaces is justified between its letters, not next to the hanging marks.
|
||||
for text in ["“Hi”", "“Hi.”", "“漢字”"] {
|
||||
let on = lay(text, Justify::JustifyAll, true);
|
||||
let n = on.len();
|
||||
// The glyphs that hang at the right: the closing mark, and the full stop before it.
|
||||
let right = if text.contains('.') { 2 } else { 1 };
|
||||
assert!(near(on[1].origin.x, 0.0) && near(end(&on[n - 1 - right]), 300.0), "{text}: {on:?}");
|
||||
}
|
||||
// Letters that hang by part of their width keep their gaps: the line still fills the frame.
|
||||
let a = lay("AVA", Justify::JustifyAll, true);
|
||||
assert!(near(end(&a[2]), 300.0 + 0.1 * a[2].advance), "{}", end(&a[2]));
|
||||
// Point type hangs at its anchor.
|
||||
let mut t = point("“Hi", style(20.0));
|
||||
t.optical_margin = true;
|
||||
let l = layout(db(), &t);
|
||||
assert!(near(l.glyphs[1].origin.x, 0.0) && l.glyphs[0].origin.x < -1.0, "{:?}", l.glyphs[0].origin);
|
||||
// Vertical type: the letter after the quotation mark takes the mark's place at the column's top.
|
||||
let vertical = |on: bool| {
|
||||
let mut t = area("“Hello", style(20.0), frame, Justify::Left);
|
||||
(t.vertical, t.optical_margin) = (true, on);
|
||||
layout(db(), &t).glyphs
|
||||
};
|
||||
let (on, off) = (vertical(true), vertical(false));
|
||||
assert!((on[1].origin - off[0].origin).hypot() < 1e-6, "{:?} {:?}", on[1].origin, off[0].origin);
|
||||
// Right-to-left paragraphs are placed as before: a full stop ending one, set at the left of a
|
||||
// left-aligned line, doesn't hang.
|
||||
let rtl = |on: bool| lay("שלום, עולם.", Justify::Left, on).iter().map(|g| g.origin).collect::<Vec<_>>();
|
||||
assert_eq!(rtl(true), rtl(false));
|
||||
}
|
||||
|
||||
/// With craft-fonts and no installed fonts (the web), Thai is set in Noto Sans Thai, not as empty
|
||||
/// boxes. Skipped when built without craft-fonts or with a checkout that has no Thai font.
|
||||
#[test]
|
||||
|
||||
@@ -1676,19 +1676,32 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
let v = &app.ui.view;
|
||||
// An item that can be on or off is `Some` whatever the document and selection (off with none):
|
||||
// the macOS menu bar can't change an item's kind without rebuilding.
|
||||
// The first type object (of type on a path, with `onpath`) among the selected objects and the
|
||||
// objects inside them, in the order the type commands act on them.
|
||||
let selected_text = |onpath: bool| {
|
||||
let st = app.session.active()?;
|
||||
st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
|
||||
Some(vectorcraft_engine::doc::NodeKind::Text(t)) if !onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }) => {
|
||||
Some(t)
|
||||
let mut found = None;
|
||||
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
if found.is_none()
|
||||
&& let vectorcraft_engine::doc::NodeKind::Text(t) = &c.kind
|
||||
&& (!onpath || matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }))
|
||||
{
|
||||
found = Some(t);
|
||||
}
|
||||
});
|
||||
if found.is_some() {
|
||||
break;
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
found
|
||||
};
|
||||
Some(match id {
|
||||
"type.orientation.vertical" | "type.orientation.horizontal" => {
|
||||
selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical"))
|
||||
}
|
||||
// Checked when the first type object `selected_text` finds has it on.
|
||||
"type.opticalMarginAlignment" => selected_text(false).is_some_and(|t| t.optical_margin),
|
||||
"view.outline" => v.outline,
|
||||
"view.pixelPreview" => v.pixel_preview,
|
||||
"view.trimView" => v.trim_view,
|
||||
@@ -2646,7 +2659,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})),
|
||||
Sep,
|
||||
c("Create Outlines", "type.createOutlines"),
|
||||
todo("Optical Margin Alignment"),
|
||||
c("Optical Margin Alignment", "type.opticalMarginAlignment"),
|
||||
Sep,
|
||||
c("Fill with Placeholder Text", "type.fillPlaceholder"),
|
||||
c("Insert Inline Symbol", "text.insertInline"),
|
||||
@@ -4114,6 +4127,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type › Optical Margin Alignment runs `type.opticalMarginAlignment`, needs a selection, and
|
||||
/// is checked while the selected type has it on. Its check, and Type Orientation's, read the
|
||||
/// type inside a selected group too.
|
||||
#[test]
|
||||
fn optical_margin_alignment_is_a_checked_type_menu_item() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let ids: Vec<&str> = menu_tree()
|
||||
.into_iter()
|
||||
.filter(|(menu, _)| *menu == "Type")
|
||||
.flat_map(|(_, items)| items)
|
||||
.filter_map(|item| match item {
|
||||
Item::Cmd("Optical Margin Alignment", id, _) => Some(id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids, ["type.opticalMarginAlignment"]);
|
||||
let id = "type.opticalMarginAlignment";
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
assert!(!enabled(&app, id));
|
||||
let text = app.run("text.create", json!({"x": 10, "y": 10, "text": "“Hi”"})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids": [text]})).unwrap();
|
||||
assert!(enabled(&app, id));
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true));
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(false));
|
||||
// With the type in a selected group, the checks show the type the commands change.
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
app.run(id, json!({})).unwrap();
|
||||
assert_eq!(checked(&app, id, &Value::Null), Some(true));
|
||||
app.run("type.orientation.vertical", json!({})).unwrap();
|
||||
assert_eq!(checked(&app, "type.orientation.vertical", &Value::Null), Some(true));
|
||||
assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_ids_are_case_insensitive_and_labels_work() {
|
||||
assert_eq!(normalize_panel("layers"), Some("layers"));
|
||||
|
||||
+20
-5
@@ -3355,8 +3355,27 @@ undo step.
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"burasagari":"forced"}}}
|
||||
```
|
||||
|
||||
## Small Caps, Superscript and Subscript
|
||||
## Optical Margin Alignment
|
||||
|
||||
`type.opticalMarginAlignment {on?: bool}` (Type › Optical Margin Alignment) turns Optical Margin Alignment on or off
|
||||
for the selected type objects (or `ids`); without `on` it sets the opposite of the first object's setting. It replies
|
||||
`{on, ids}`, and the menu item is checked while the first type object it acts on (inside a selected group too) has it
|
||||
on. In every left-to-right paragraph of point and area type, these characters hang outside the margin when they
|
||||
start or end a line: quotation marks, hyphens (a hyphenated line's hyphen too), full stops and commas hang wholly;
|
||||
asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half their width; and A, V, W, Y, v, w
|
||||
and y hang by a tenth of theirs. A punctuation mark followed by a quotation mark hangs with it.
|
||||
Left-aligned lines hang at their left margin, right-aligned lines hang at their right margin, and centered and
|
||||
justified lines hang at both. In vertical type the margins are the top and bottom of a column. The space that
|
||||
justification adds between glyphs leaves out the gaps next to a hanging punctuation mark. Lines break where they did
|
||||
without it, and a line of one glyph does not hang. East Asian commas and full stops hang only through burasagari, and
|
||||
a line that ends in one hangs only at its start. Type on a path and right-to-left paragraphs are laid out as before.
|
||||
New type and documents from before it have it off, and it is saved only when on. One undo step.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"type.opticalMarginAlignment","params":{"on":true}}}
|
||||
```
|
||||
|
||||
## Small Caps, Superscript and Subscript
|
||||
`text.setFormat {smallCaps, position}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, smallCaps,
|
||||
position}` (a range) set what the Character panel's Small Caps, Superscript and Subscript buttons and menu items set.
|
||||
`smallCaps: true` draws lowercase letters as capitals at Document Setup's small caps size (70% of the font size by
|
||||
@@ -3366,11 +3385,7 @@ position (by default 58.3% of the font size, moved by 33.3% of it), and `"normal
|
||||
Setting superscript on subscript characters replaces the subscript, and setting subscript on superscript characters
|
||||
replaces the superscript. When `document.setup {superscript, subscript, smallCapsSize}` changes the proportions, the
|
||||
characters take the new ones. One undo step.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"position":"superscript","smallCaps":false}}}
|
||||
```
|
||||
|
||||
## Character Alignment
|
||||
|
||||
`text.setFormat {charAlign}` (the selected type, or `ids`) and `text.setRangeStyle {id, start, end, charAlign}` (a range)
|
||||
|
||||
@@ -575,6 +575,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged.
|
||||
For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`).
|
||||
An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`).
|
||||
Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object.
|
||||
The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options.
|
||||
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale) Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
|
||||
|
||||
@@ -33,6 +33,8 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Variable fonts | partial | named instances as styles; axis sliders missing |
|
||||
| Colour fonts (OpenType SVG / COLR) | partial | |
|
||||
| Every-line and Single-line composers | done | tuned by eye, never compared side by side |
|
||||
| Hyphenation… and Justification… options | missing | |
|
||||
| Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, in point and area type of left-to-right paragraphs; type on a path and right-to-left paragraphs do not hang |
|
||||
| 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) |
|
||||
| Optical Margin Alignment | missing | menu stub |
|
||||
| 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) |
|
||||
|
||||
Reference in New Issue
Block a user