diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b0e29883e..e1ed1cd3b 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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 for ParaStyleFile { @@ -525,6 +531,7 @@ impl From for ParaStyleFile { leading_model, burasagari, kinsoku, + roman_hanging, .. } = p; Self { @@ -545,6 +552,7 @@ impl From for ParaStyleFile { leading_model, burasagari, kinsoku, + roman_hanging, } } } @@ -568,6 +576,7 @@ impl From for ParaStyle { leading_model, burasagari, kinsoku, + roman_hanging, .. } = f; Self { @@ -587,6 +596,7 @@ impl From for ParaStyle { leading_model, burasagari, kinsoku, + roman_hanging, } } } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 34576b927..3b5dc2e77 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "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 { "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 { 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; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index a5f6361db..9ae681349 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -177,6 +177,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("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); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7217f705f..114de14b7 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -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 diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 97c08ed05..b4d5418fe 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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], 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 = 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)); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index aa7d4dbee..5cd6114e3 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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] diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 84c52281f..af33f0d6b 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -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(¶).unwrap().get("hyphenation"), None, "saves no hyphenation options"); + assert!(!para.roman_hanging, "Roman Hanging Punctuation off"); } } } diff --git a/docs/gaps.md b/docs/gaps.md index 6b5507ecb..7471a5ed4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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). diff --git a/docs/mcp.md b/docs/mcp.md index c49b9e6e3..334145772 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. diff --git a/docs/type-parity.md b/docs/type-parity.md index 07bf72659..abab77ad5 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -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 |