G4: Paragraph panel menu › Roman Hanging Punctuation

The Paragraph panel menu's Roman Hanging Punctuation was a disabled stub.

It is now a per-paragraph attribute: in a left-to-right paragraph of point
or area type with it on, the punctuation Optical Margin Alignment hangs
(quotation marks, hyphens, full stops and commas wholly; asterisks,
tildes, ellipses, en and em dashes, colons and semicolons by half) hangs
outside the margin when it starts or ends a line, on the same sides
(left-aligned lines at their left, right-aligned at their right, centered
and justified at both); letters don't hang. With Optical Margin Alignment
on as well, letters hang too. East Asian punctuation keeps hanging only
through burasagari. The layout shares Optical Margin Alignment's hanging
code, given the share of each glyph that hangs.

text.setFormat takes romanHangingPunctuation; it is off by default, saved
only when on, carried by paragraph styles (New and Redefine capture it)
and turned off by Reset Panel. The panel menu item is a checkbox enabled
with type selected.

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

Closes #2756.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Victor
2026-10-18 01:09:34 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent f7de3d6a01
commit 6190525482
10 changed files with 151 additions and 19 deletions
+10
View File
@@ -458,6 +458,10 @@ pub struct ParaStyle {
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
/// line.
pub kinsoku: Kinsoku,
/// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
pub roman_hanging: bool,
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
@@ -499,6 +503,8 @@ struct ParaStyleFile {
burasagari: Burasagari,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kinsoku: Kinsoku,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
roman_hanging: bool,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -525,6 +531,7 @@ impl From<ParaStyle> for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
roman_hanging,
..
} = p;
Self {
@@ -545,6 +552,7 @@ impl From<ParaStyle> for ParaStyleFile {
leading_model,
burasagari,
kinsoku,
roman_hanging,
}
}
}
@@ -568,6 +576,7 @@ impl From<ParaStyleFile> for ParaStyle {
leading_model,
burasagari,
kinsoku,
roman_hanging,
..
} = f;
Self {
@@ -587,6 +596,7 @@ impl From<ParaStyleFile> for ParaStyle {
leading_model,
burasagari,
kinsoku,
roman_hanging,
}
}
}
+5 -1
View File
@@ -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\"), 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), 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
),
@@ -278,6 +278,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
"digits",
"fontAxes",
"burasagari",
"romanHangingPunctuation",
"composer",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
@@ -444,6 +445,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = burasagari {
para.burasagari = v;
}
if let Some(v) = flag("romanHangingPunctuation") {
para.roman_hanging = v;
}
if let Some(v) = composer {
para.composer = v;
}
+1
View File
@@ -177,6 +177,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
("leading_model", json!(v.leading_model)),
("burasagari", json!(v.burasagari)),
("kinsoku", json!(v.kinsoku)),
("roman_hanging", json!(v.roman_hanging)),
] {
m.insert(k.into(), x);
}
+37
View File
@@ -1061,6 +1061,43 @@ mod area_tests {
assert_eq!(old.hyphenation, HyphenationOptions::default());
}
/// Paragraph panel menu › Roman Hanging Punctuation: `text.setFormat {romanHangingPunctuation}`
/// sets it per paragraph in one undo step, the opening quotation mark then hangs left of the
/// frame, it is saved only when on, and paragraph styles carry it.
#[test]
fn roman_hanging_punctuation_is_set_per_paragraph_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”\n“Two”", "area": {"width": 300, "height": 200}}))
.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"),
};
let left = |s: &Session| text(s).cached_bounds.unwrap().x0;
assert!(!text(&s).para_at(0).roman_hanging);
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("roman_hanging").is_none());
let inside = left(&s);
s.execute("select.set", &json!({"ids": [id]})).unwrap();
// Only the first paragraph (the caret's range).
s.execute("text.setFormat", &json!({"romanHangingPunctuation": true, "start": 0, "end": 0})).unwrap();
assert!(text(&s).para_at(0).roman_hanging && !text(&s).para_at(1).roman_hanging);
assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s));
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["roman_hanging"], true);
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(serde_json::to_value(text(&s).para_at(0)).unwrap()).unwrap();
assert!(back.roman_hanging);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!text(&s).para_at(0).roman_hanging);
assert!((left(&s) - inside).abs() < 1e-9);
// A paragraph style carries it.
s.execute("paraStyle.new", &json!({"name": "Hung", "attrs": {"roman_hanging": true}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Hung", "id": id})).unwrap();
assert!(text(&s).para_at(1).roman_hanging);
}
/// #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
+18 -9
View File
@@ -1354,6 +1354,12 @@ fn optical_share(g: &SGlyph) -> f64 {
}
}
/// Roman Hanging Punctuation: the share of glyph `g`'s advance that hangs outside the margin when
/// `g` starts or ends a line: [`optical_share`]'s punctuation, and no letters.
fn roman_share(g: &SGlyph) -> f64 {
if g.ch.is_alphabetic() { 0.0 } else { optical_share(g) }
}
/// 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
@@ -1364,20 +1370,20 @@ 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,
/// Optical Margin Alignment and Roman Hanging Punctuation: 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 `share` ([`optical_share`] or [`roman_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) {
fn optical_hangs(line: &[(&SGlyph, f64)], share: fn(&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 hang = |k: usize| line.get(k).map_or(0.0, |&(g, adv)| 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 quote = |k: usize| line.get(k).is_some_and(|&(g, _)| is_quote(g.ch) && 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);
@@ -1843,15 +1849,18 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
// 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 {
// gaps. Roman Hanging Punctuation (a paragraph attribute) hangs the same punctuation
// by the same amounts, and no letters; with Optical Margin Alignment on as well, the
// letters hang too.
let (opt_l, opt_r, held) = if (t.optical_margin || para.roman_hanging) && !rtl {
let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share };
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 (l, r, left_glyphs, right_glyphs) = optical_hangs(&visible, share);
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));
+40
View File
@@ -907,6 +907,46 @@ fn optical_margin_alignment_hangs_punctuation_and_letter_edges_outside_the_margi
assert_eq!(rtl(true), rtl(false));
}
/// Roman Hanging Punctuation (a paragraph attribute) hangs the punctuation Optical Margin
/// Alignment hangs, by the same amounts, and no letters; only the paragraphs that have it hang.
#[test]
fn roman_hanging_punctuation_hangs_punctuation_but_not_letters() {
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.para.roman_hanging = 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.
let on = lay("“Hello” there", Justify::Left, true);
assert!(near(on[1].origin.x, 0.0) && near(on[0].origin.x, -on[0].advance) && on[0].advance > 1.0, "{:?}", on[0].origin);
// 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].origin.x);
// Letters don't hang, where Optical Margin Alignment hangs an A by a tenth.
assert!(near(lay("AVA", Justify::Left, true)[0].origin.x, 0.0));
// Right aligned: the full stop and the closing quotation mark after it hang at the right.
let r = lay("“Hi.”", Justify::Right, true);
assert!(near(end(&r[2]), 300.0), "{}", end(&r[2]));
// Justified: a hyphen at each end hangs, the words fill the frame between them.
let j = lay("-ab cd-", Justify::JustifyAll, true);
let n = j.len();
assert!(near(j[1].origin.x, 0.0) && near(j[n - 1].origin.x, 300.0), "{} {}", j[1].origin.x, j[n - 1].origin.x);
// Off: nothing hangs.
assert!(near(lay("“Hello” there", Justify::Left, false)[0].origin.x, 0.0));
// Per paragraph: the second paragraph, without it, doesn't hang.
let mut t = area("“One\n“Two", style(20.0), frame, Justify::Left);
let mut first = t.para.clone();
first.roman_hanging = true;
t.set_paragraph_styles(vec![first, t.para.clone()]);
let l = layout(db(), &t);
let at = |b: usize| l.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x;
assert!(at(0) < -1.0, "first paragraph hangs: {}", at(0));
assert!(near(at("“One\n".len()), 0.0), "second doesn't: {}", at("“One\n".len()));
}
/// 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]
+20 -8
View File
@@ -1,6 +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
//! opens Hyphenation… and picks the Single-line or Every-line Composer.
//! turns Roman Hanging Punctuation on and off, opens Hyphenation… and picks the Single-line or
//! Every-line Composer.
use egui::{Ui, vec2};
use serde_json::{Value, json};
@@ -282,9 +283,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
set_pstate(ui.ctx(), "pa-hide-options", !hidden);
}
ui.separator();
for l in [tl!("Roman Hanging Punctuation"), tl!("Justification…")] {
menu_item(ui, l, false, false);
// Quotation marks, hyphens, full stops and commas at a line's ends hang outside the margin.
let roman = style.as_ref().is_some_and(|(_, p)| p.roman_hanging);
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!("Hyphenation…"), has, false)
&& let Err(e) = app.run("ui.hyphenation", json!({}))
{
@@ -336,7 +340,8 @@ 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 and the Every-line Composer.
/// 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();
para_cmd(app, "text.setStyle", json!({"justify": "auto"}));
@@ -346,7 +351,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,
"direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine"
"direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine",
"romanHangingPunctuation": false
}),
);
}
@@ -574,8 +580,9 @@ mod tests {
assert!(tips(&mut app) > without, "two more buttons");
}
/// Reset Panel turns Hyphenate 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
/// options back to their defaults in every paragraph it acts on, so the paragraphs save no
/// hyphenation options.
#[test]
fn reset_panel_resets_the_hyphenation_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
@@ -583,7 +590,10 @@ mod tests {
let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "First\nSecond"})).unwrap()["id"].clone();
app.run(
"text.setFormat",
json!({"hyphenate": true, "wordsLongerThan": 8, "afterFirst": 2, "beforeLast": 4, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false}),
json!({
"hyphenate": true, "wordsLongerThan": 8, "afterFirst": 2, "beforeLast": 4, "hyphenLimit": 2, "hyphenationZone": 18, "hyphenateCapitalized": false,
"romanHangingPunctuation": true
}),
)
.unwrap();
let paras = |app: &VectorcraftApp| {
@@ -594,12 +604,14 @@ mod tests {
for para in paras(&app) {
assert!(para.hyphenate);
assert_ne!(para.hyphenation, HyphenationOptions::default());
assert!(para.roman_hanging);
}
reset_panel(&mut app);
for para in paras(&app) {
assert!(!para.hyphenate);
assert_eq!(para.hyphenation, HyphenationOptions::default());
assert_eq!(serde_json::to_value(&para).unwrap().get("hyphenation"), None, "saves no hyphenation options");
assert!(!para.roman_hanging, "Roman Hanging Punctuation off");
}
}
}
+2 -1
View File
@@ -187,7 +187,8 @@ 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.
- **Missing:** hyphenation and justification options, Optical Margin Alignment (type core); CJK vertical
- **Done:** Paragraph panel menu › Roman Hanging Punctuation (#2756), per paragraph; estimate unchanged.
- **Missing:** Justification… options (#2755, type core); 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).
+16
View File
@@ -3531,6 +3531,22 @@ New type and documents from before it have it off, and it is saved only when on.
{"name":"run_command","arguments":{"command":"type.opticalMarginAlignment","params":{"on":true}}}
```
## Roman Hanging Punctuation
`text.setFormat {romanHangingPunctuation: bool}` (Paragraph panel menu › Roman Hanging Punctuation) turns it on or off
for the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / `end` range touches). In those
paragraphs the punctuation Optical Margin Alignment hangs hangs outside the margin by the same amounts (quotation marks,
hyphens, full stops and commas wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons by half),
on the same sides (left-aligned lines at their left, right-aligned at their right, centered and justified at both), and
letters don't hang. With Optical Margin Alignment on as well, the letters hang too. East Asian punctuation hangs only
through burasagari; type on a path and right-to-left paragraphs are laid out as before. Off by default, saved with the
paragraph (`roman_hanging` in `para`) only when on, carried by paragraph styles, and turned off by Reset Panel. One undo
step. The menu item is checked while the paragraph the Paragraph panel shows has it on.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"romanHangingPunctuation":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.
+2
View File
@@ -35,6 +35,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Colour fonts (OpenType SVG / COLR) | partial | |
| 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, 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) |
| 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` |
@@ -61,6 +62,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 | 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 |