From 46dc8b1bde3182e3e76cfc986171fc0fffb673ab Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:02:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Type:=20Top-to-Top=20Leading=20=E2=80=94=20?= =?UTF-8?q?leading=20measured=20from=20em=20box=20top=20to=20em=20box=20to?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Japanese layout measures leading from the top of one line's ideographic em box to the next (the right side, in vertical type), not from baseline to baseline. The Paragraph panel menu now offers both: - `ParaStyle.leading_model`: RomanBaseline (as before, the default) or EmBoxTop. With EmBoxTop a line's leading is the space below it — the next line's em box top is this line's plus its leading, and the next baseline hangs from there by that line's tallest em box — and area type's first line touches the top of the frame (its em box top on the frame's top, at least Area Type Options' minimum). With baseline leading a line's leading stays the space above it. - Lines of one size are exactly as far apart either way; the models differ for mixed sizes and for the first line of area type (fonts whose ascent isn't the em box top: Hiragino's nearly is). - The em box top comes from the font's vertical metrics (#343), else 0.88 em. Written only when EmBoxTop. - `text.setFormat {leadingModel: "romanBaseline"|"emBoxTop"}`; menu items Top-to-Top Leading / Bottom-to-Bottom Leading (checked as set); catalog rows in ja and zh-hant. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/lib.rs | 4 +- crates/doc/src/text.rs | 37 +++++++++++++++++- crates/engine/src/cmd/panelcmds.rs | 14 ++++++- crates/engine/src/cmd/typecmd.rs | 25 ++++++++++++ crates/text/src/layout.rs | 54 +++++++++++++++++++++----- crates/text/src/tests.rs | 35 +++++++++++++++++ crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/paragraph.rs | 12 +++++- 10 files changed, 172 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8091cd79..50a552a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. -- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) 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 and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. 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 (the strict set) applies to horizontal and vertical Japanese, 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 puts a quarter em between Japanese and Latin letters or digits; 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. +- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) 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 and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. 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 (the strict set) applies to horizontal and vertical Japanese, 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 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; 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. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 7a3bdf11..74ad0b91 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -78,8 +78,8 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ - AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, Mojikumi, ParaDirection, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, - TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, + AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathEffect, ScriptMetrics, + TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index d1a6a0c1..c8c9d75f 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -293,6 +293,9 @@ pub struct ParaStyle { /// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or /// Arabic: right to left). pub direction: Option, + /// How leading is measured (Paragraph panel menu): from baseline to baseline, or from the top + /// of one line's ideographic em box to the next. + pub leading_model: LeadingModel, } /// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph @@ -324,6 +327,8 @@ struct ParaStyleFile { mojikumi: Mojikumi, #[serde(default, skip_serializing_if = "Option::is_none")] direction: Option, + #[serde(default, rename = "leadingModel", skip_serializing_if = "crate::skip::is_default")] + leading_model: LeadingModel, } impl From for ParaStyleFile { @@ -345,6 +350,7 @@ impl From for ParaStyleFile { style_name, mojikumi, direction, + leading_model, .. } = p; Self { @@ -360,6 +366,7 @@ impl From for ParaStyleFile { style_name, mojikumi, direction, + leading_model, } } } @@ -378,9 +385,23 @@ impl From for ParaStyle { style_name, mojikumi, direction, + leading_model, .. } = f; - Self { justify, left_indent, right_indent, first_line_indent, space_before, space_after, hyphenate, tabs, style_name, mojikumi, direction } + Self { + justify, + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + tabs, + style_name, + mojikumi, + direction, + leading_model, + } } } @@ -392,6 +413,20 @@ pub enum ParaDirection { RightToLeft, } +/// How a paragraph's leading is measured. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LeadingModel { + /// From one line's baseline to the next's: a line's leading is the space above it, and area + /// type's first baseline follows Area Type Options › First Baseline. + #[default] + RomanBaseline, + /// From the top of one line's ideographic em box (the right side, in vertical type) to the + /// next's: a line's leading is the space below it, and area type's first line touches the top + /// of the frame. Japanese layout's usual model. + EmBoxTop, +} + /// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half /// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's /// after it. diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 7fc68deb..14ab6669 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -24,7 +24,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, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character)}", + "{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, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), 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)}", has_doc, set_format ), @@ -113,6 +113,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "hyphenate", "mojikumi", "direction", + "leadingModel", ]; if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); @@ -135,6 +136,14 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`direction` must be \"auto\", \"leftToRight\" or \"rightToLeft\"")), }), }; + let leading_model = match p.get("leadingModel") { + None => None, + Some(v) => Some(match v.as_str() { + Some("romanBaseline") => vectorcraft_doc::LeadingModel::RomanBaseline, + Some("emBoxTop") => vectorcraft_doc::LeadingModel::EmBoxTop, + _ => return Err(bad(C, "`leadingModel` must be \"romanBaseline\" or \"emBoxTop\"")), + }), + }; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -196,6 +205,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = direction { para.direction = v; } + if let Some(v) = leading_model { + para.leading_model = v; + } super::typecmd::refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 4818e5a5..f79a53b1 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -408,6 +408,31 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { mod area_tests { use super::*; + #[test] + fn leading_model_is_set_per_paragraph_and_saved_only_when_top_to_top() { + use vectorcraft_doc::LeadingModel; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "一\n二", "area": {"width": 200, "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"), + }; + assert_eq!(text(&s).para.leading_model, LeadingModel::RomanBaseline); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("text.setFormat", &json!({"leadingModel": "middle"})).is_err()); + let before = text(&s).cached_bounds; + s.execute("text.setFormat", &json!({"leadingModel": "emBoxTop"})).unwrap(); + let t = text(&s); + assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop); + assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top"); + assert_eq!(serde_json::to_value(&t.para).unwrap()["leadingModel"], "emBoxTop"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(serde_json::to_value(&text(&s).para).unwrap().get("leadingModel").is_none()); + } + #[test] fn new_type_is_composed_with_line_end_half_width_punctuation() { use vectorcraft_doc::Mojikumi; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f3f369b3..e64100b0 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -4,7 +4,7 @@ use std::ops::Range; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; -use vectorcraft_doc::{CharStyle, Justify, Mojikumi, ParaDirection, ParaStyle, PathEffect, TextKind, TextObject}; +use vectorcraft_doc::{CharStyle, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathEffect, TextKind, TextObject}; use crate::composer::{Breakpoint, compose}; use crate::fontdb::FontDb; @@ -581,11 +581,14 @@ struct Metrics { lead: f64, cap: f64, xh: f64, + /// Height of the top of the ideographic em box above the baseline. + top: f64, } impl Metrics { fn of(g: &SGlyph) -> Self { - Self { asc: g.ascent, desc: g.descent, lead: g.leading, cap: g.cap, xh: g.xh } + let em = g.face.units_per_em() * g.sy; + Self { asc: g.ascent, desc: g.descent, lead: g.leading, cap: g.cap, xh: g.xh, top: (g.face.ideographic_centre() + 0.5) * em } } fn max(g: &[SGlyph]) -> Option { let mut it = g.iter(); @@ -596,6 +599,7 @@ impl Metrics { lead: m.lead.max(g.leading), cap: m.cap.max(g.cap), xh: m.xh.max(g.xh), + top: m.top.max(Self::of(g).top), })) } /// Distance from the frame top to the first baseline. @@ -622,6 +626,9 @@ struct Pen<'r> { fb_min: f64, /// Further spans at the current baseline (text wrapping on both sides of an object). queued: Vec<(f64, f64, f64)>, + /// Leading measured from em box top to em box top: where the next line's em box top goes. + model: LeadingModel, + next_top: Option, } impl Pen<'_> { @@ -630,7 +637,7 @@ impl Pen<'_> { /// The `bool` is true for a further span at the previous line's baseline. fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64) -> Option<(f64, f64, f64, bool)> { let Some(regions) = self.regions else { - let b = self.prev.map_or(0.0, |b| b + est.lead + self.pending); + let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est)); return Some((b, f64::NEG_INFINITY, f64::INFINITY, false)); }; if !self.queued.is_empty() { @@ -640,8 +647,9 @@ impl Pen<'_> { loop { let r = regions.get(self.ri)?; let mut baseline = match self.prev { + None if self.model == LeadingModel::EmBoxTop => r.top() + est.top.max(self.fb_min), None => r.top() + est.first_baseline(self.fb, self.fb_min), - Some(b) => b + est.lead + self.pending, + Some(b) => self.next_baseline(b, est), }; loop { if baseline + est.desc > r.bottom() + 0.01 { @@ -675,6 +683,18 @@ impl Pen<'_> { fn bottom(&self) -> f64 { self.regions.and_then(|r| r.get(self.ri)).map_or(f64::INFINITY, |r| r.bottom()) } + /// The baseline of the line after the one at `prev`, for a line of metrics `est`. + fn next_baseline(&self, prev: f64, est: Metrics) -> f64 { + match (self.model, self.next_top) { + (LeadingModel::EmBoxTop, Some(top)) => top + est.top + self.pending, + _ => prev + est.lead + self.pending, + } + } + /// A line was set at `baseline` with metrics `m`: where the next one goes from. + fn settled(&mut self, baseline: f64, m: Metrics) { + self.prev = Some(baseline); + self.next_top = Some(baseline - m.top + m.lead); + } } /// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then @@ -873,7 +893,7 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> let w = x1 - x0 - ind_l - para.right_indent; widths.push(w); acc += w.max(1.0); - sim.prev = Some(b); + sim.settled(b, m); sim.pending = 0.0; if acc > total * 1.6 + 4.0 * w.max(1.0) { break; @@ -887,7 +907,17 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> } fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Option<&[Region]>) { - let mut pen = Pen { regions, ri: 0, prev: None, pending: 0.0, fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min, queued: vec![] }; + let mut pen = Pen { + regions, + ri: 0, + prev: None, + pending: 0.0, + fb: cx.opts.first_baseline, + fb_min: cx.opts.first_baseline_min, + queued: vec![], + model: para.leading_model, + next_top: None, + }; 'paras: for (pi, pr) in paras.iter().enumerate() { let text = cx.text; let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); @@ -899,8 +929,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt let wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; let pm = { let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start)); - let (cap, xh) = cap_x_heights(cx.db, cx.style_at(pr.start)); - Metrics { asc, desc, lead, cap, xh } + let st = cx.style_at(pr.start); + let (cap, xh) = cap_x_heights(cx.db, st); + let centre = cx.db.face(&st.font_family, &st.font_style).map_or(EM_CENTER, |f| f.ideographic_centre()); + Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * st.size * st.v_scale / 100.0 } }; if pi > 0 { pen.pending += para.space_before; @@ -930,6 +962,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt let m = Metrics::max(&sg[i..end]).unwrap_or(pm); baseline += if same_baseline || (pen.regions.is_none() && first_in_region) { 0.0 + } else if pen.model == LeadingModel::EmBoxTop { + // The em box top is fixed (the frame's top, or the line above's): the baseline + // hangs from it by this line's tallest em box. + if first_in_region { m.top.max(pen.fb_min) - est.top.max(pen.fb_min) } else { m.top - est.top } } else if first_in_region { m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min) } else { @@ -1077,7 +1113,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt glyph_end: cx.out.glyphs.len(), avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) }, }); - pen.prev = Some(baseline); + pen.settled(baseline, m); // Further spans of this line band share the settled baseline. for q in &mut pen.queued { q.0 = baseline; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8837ddab..b361139d 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -522,3 +522,38 @@ fn justified_japanese_lines_spread_between_characters() { assert!(solid(at) && solid(at + 1), "A–B–C set solid"); assert!(g[at].origin.x - g[at - 1].origin.x > a + 0.1, "二–A takes its share"); } + +/// Leading measured from em box top to em box top (Japanese layout's model): area type's first line +/// touches the frame's top; lines of one size are as far apart as with baseline-to-baseline leading; +/// with sizes mixed, a line's leading is the space below it (baseline leading: above it). +#[test] +fn em_box_top_leading_hangs_lines_from_the_line_above() { + use vectorcraft_doc::{LeadingModel, TextRun}; + let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) }; + let lay = |t: &TextObject, m: LeadingModel| { + let mut t = t.clone(); + t.para.leading_model = m; + layout(db(), &t) + }; + // The em box top: 0.88 em above the baseline (fonts without vertical metrics). + let top = |size: f64| 0.88 * size; + let one_size = area("一行目\n二行目\n三行目", st(20.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left); + let (roman, em) = (lay(&one_size, LeadingModel::RomanBaseline), lay(&one_size, LeadingModel::EmBoxTop)); + assert!((em.lines[0].baseline - top(20.0)).abs() < 0.01, "the first line's em box touches the top: {}", em.lines[0].baseline); + assert!((roman.lines[0].baseline - em.lines[0].baseline).abs() > 0.5, "baseline leading keeps Area Type Options' first baseline"); + for l in [&roman, &em] { + assert!((l.lines[1].baseline - l.lines[0].baseline - 30.0).abs() < 0.01 && (l.lines[2].baseline - l.lines[1].baseline - 30.0).abs() < 0.01); + } + // 40 pt over 20 pt (leading 60 and 30). + let mut mixed = point("", st(40.0)); + mixed.runs = vec![TextRun { text: "大\n".into(), style: st(40.0) }, TextRun { text: "小".into(), style: st(20.0) }]; + let roman = lay(&mixed, LeadingModel::RomanBaseline); + assert!((roman.lines[1].baseline - roman.lines[0].baseline - 30.0).abs() < 0.01, "the small line's leading, above it"); + let em = lay(&mixed, LeadingModel::EmBoxTop); + let want = -top(40.0) + 60.0 + top(20.0); + assert!( + (em.lines[1].baseline - em.lines[0].baseline - want).abs() < 0.01, + "the big line's leading, below it: {} vs {want}", + em.lines[1].baseline - em.lines[0].baseline + ); +} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 117c1898..38932d98 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -11,6 +11,7 @@ About VectorCraft VectorCraft について All Scripts すべての文字 Any Kind すべての種類 + Bottom-to-Bottom Leading 欧文ベースライン基準の行送り Decorative 装飾 Favorites お気に入り Japanese 日本語 @@ -25,6 +26,7 @@ Serif / Mincho 明朝・セリフ Settings… 環境設定… Language 表示言語 + Top-to-Top Leading 仮想ボディの上基準の行送り UI Brightness UI の明るさ Dark 暗い Medium Dark やや暗い diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b9c2c666..534a4daa 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -254,6 +254,7 @@ Blur 模糊 Bottom 下 Bottom in Front 下方置前 + Bottom-to-Bottom Leading 基線至基線行距 Bottom: 下: Bounds 邊界 Bounds (Fill Proportionally) 邊界 (按比例填滿) @@ -2647,6 +2648,7 @@ Tools 工具 Top 上 Top in Front 上方在前 + Top-to-Top Leading 上緣至上緣行距 Top: 上: Total Width: 總寬度: Touch Type Tool 觸控文字工具 diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index ebb0d194..75a5b531 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -140,7 +140,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { - let has = text_style(app).is_some(); + let style = text_style(app); + let has = style.is_some(); let hidden: bool = pstate(ui.ctx(), "pa-hide-options"); if menu_item(ui, if hidden { tl!("Show Options") } else { tl!("Hide Options") }, true, false) { set_pstate(ui.ctx(), "pa-hide-options", !hidden); @@ -150,6 +151,15 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { menu_item(ui, l, false, false); } ui.separator(); + // How leading is measured (Japanese layout measures it from em box top to em box top). + let top_to_top = style.as_ref().is_some_and(|(_, p)| p.leading_model == vectorcraft_doc::LeadingModel::EmBoxTop); + if menu_item(ui, tl!("Top-to-Top Leading"), has, top_to_top) { + format(app, json!({"leadingModel": "emBoxTop"})); + } + if menu_item(ui, tl!("Bottom-to-Bottom Leading"), has, has && !top_to_top) { + format(app, json!({"leadingModel": "romanBaseline"})); + } + ui.separator(); menu_item(ui, tl!("Single-line Composer"), false, false); menu_item(ui, tl!("Every-line Composer"), false, true); ui.separator(); From 5865e4cb51bc08573713a41941ad411720544c69 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 05:29:30 +0700 Subject: [PATCH 2/2] Type: Top-to-Top Leading follow-ups (review of #385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The em box top that Top-to-Top Leading asks of every glyph's line metrics parsed the font's cmap and vertical metrics per glyph on every layout: each face now reads it once. - The Paragraph panel menu shows Top-to-Top / Bottom-to-Bottom Leading with the East Asian options (Preferences › Type), as the Mojikumi Set does, and Reset Panel goes back to Bottom-to-Bottom Leading, the default. - Saved as `leading_model`, like the other paragraph attributes in files. - Simplified Chinese (landed since) translates the two menu items. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/text.rs | 2 +- crates/engine/src/cmd/typecmd.rs | 4 ++-- crates/text/src/fontdb.rs | 9 +++++++-- crates/ui-egui/src/i18n/es.tsv | 4 ++++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/panels/paragraph.rs | 21 ++++++++++++--------- 6 files changed, 28 insertions(+), 14 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index c8c9d75f..550e8216 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -327,7 +327,7 @@ struct ParaStyleFile { mojikumi: Mojikumi, #[serde(default, skip_serializing_if = "Option::is_none")] direction: Option, - #[serde(default, rename = "leadingModel", skip_serializing_if = "crate::skip::is_default")] + #[serde(default, skip_serializing_if = "crate::skip::is_default")] leading_model: LeadingModel, } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index f79a53b1..eefb38a1 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -428,9 +428,9 @@ mod area_tests { let t = text(&s); assert_eq!(t.para.leading_model, LeadingModel::EmBoxTop); assert_ne!(t.cached_bounds, before, "the first line moves up to the frame's top"); - assert_eq!(serde_json::to_value(&t.para).unwrap()["leadingModel"], "emBoxTop"); + assert_eq!(serde_json::to_value(&t.para).unwrap()["leading_model"], "emBoxTop"); s.execute("edit.undo", &json!({})).unwrap(); - assert!(serde_json::to_value(&text(&s).para).unwrap().get("leadingModel").is_none()); + assert!(serde_json::to_value(&text(&s).para).unwrap().get("leading_model").is_none()); } #[test] diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 8f426fe8..835f2008 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -77,6 +77,8 @@ pub struct FontFace { location: Location, /// [`Self::variations`] for the shaper (advances, kerning, feature variations). pub(crate) instance: Option, + /// [`Self::ideographic_centre`], read once: layout asks for it per glyph. + ideographic_centre: std::sync::OnceLock, } impl std::fmt::Debug for FontFace { @@ -170,8 +172,10 @@ impl FontFace { /// vertical metrics (an ideograph's cell), else the usual 0.38 (the box running from 0.12 em /// below the baseline to 0.88 em above it). pub fn ideographic_centre(&self) -> f64 { - let gid = ['国', 'あ', '一'].into_iter().map(|c| self.glyph_for(c)).find(|g| *g != 0); - gid.and_then(|g| self.vertical_glyph(g)).map_or(0.38, |(advance, origin)| (origin - advance * 0.5) / self.upem) + *self.ideographic_centre.get_or_init(|| { + let gid = ['国', 'あ', '一'].into_iter().map(|c| self.glyph_for(c)).find(|g| *g != 0); + gid.and_then(|g| self.vertical_glyph(g)).map_or(0.38, |(advance, origin)| (origin - advance * 0.5) / self.upem) + }) } /// Glyph id for `c` (0 = .notdef). pub fn glyph_for(&self, c: char) -> u32 { @@ -813,6 +817,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option