From fe40d4b80b12393ea1f8d992eeb0d3b97acb2aab Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 00:46:52 -0400 Subject: [PATCH] T: tate-chu-yoko in vertical frames Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 17 ++++++++++++++ crates/compose/src/shape.rs | 36 +++++++++++++++++++++++++++++ crates/compose/src/tests.rs | 28 +++++++++++++++++++++++ crates/doc/src/attrs.rs | 2 ++ crates/engine/src/cmd/text.rs | 43 +++++++++++++++++++++++++++++++++++ crates/idml/src/export.rs | 3 +++ crates/idml/src/import.rs | 1 + crates/pdf/src/text.rs | 9 ++++---- crates/render/src/text.rs | 7 +++--- crates/ui-egui/src/menus.rs | 1 + 11 files changed, 140 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aee6c4c..5fa1917 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,7 +105,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. -- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko and vertical metrics (`vmtx`) aren't there yet. +- Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text). Punctuation and brackets take their vertical forms (OpenType `vert`/`vrt2`) in vertical frames. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Vertical metrics (`vmtx`) and warichu aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 90da62a..e605a9d 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -101,6 +101,21 @@ pub struct PlacedGlyph { pub visible: bool, /// Stays upright in vertical frames (CJK ideographs, kana, hangul, full-width forms). pub upright: bool, + /// Tate-chu-yoko placement (see [`shape::Glyph::tcy`]). + pub tcy: Option<[f64; 3]>, +} + +impl PlacedGlyph { + /// In a vertical frame, the turn that sets this glyph upright (about its em box centre, or + /// across the line in a tate-chu-yoko group), applied after drawing it at `x` on `baseline`. + pub fn vertical_xf(&self, baseline: f64) -> Option { + let turn = -std::f64::consts::FRAC_PI_2; + if let Some([along, across, em]) = self.tcy { + let c = Point::new(self.x + along, baseline + self.y - em * 0.38); + return Some(designcraft_geom::Affine::rotate_about(turn, c) * designcraft_geom::Affine::translate((c.x + across - self.x, 0.0))); + } + self.upright.then(|| designcraft_geom::Affine::rotate_about(turn, Point::new(self.x + self.adv / 2.0, baseline + self.y - self.adv * 0.38))) + } } /// Characters set upright (unrotated) in vertical text. @@ -1629,6 +1644,7 @@ fn tab_leader(tab: &Glyph, leader: &str, x: f64, w: f64, origin: f64, out: &mut len: 0, visible: true, upright: false, + tcy: None, }); at += adv; } @@ -1730,6 +1746,7 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { len: g.len, visible, upright: upright_in_vertical(g.ch), + tcy: g.tcy, } } diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index a22b3ba..091aa21 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -38,6 +38,9 @@ pub struct Glyph { pub style: u32, /// Unbreakable (No Break). pub no_break: bool, + /// Tate-chu-yoko in a vertical frame: [centre of the group's em along the line from `x`, + /// this glyph's left edge across the line from that centre, the em] (see [`collapse_tcy`]). + pub tcy: Option<[f64; 3]>, /// Width of a word space (U+0020) in this glyph's font, size and horizontal scale: the unit of /// word and letter spacing in justification. pub space: f64, @@ -255,9 +258,40 @@ pub(crate) fn shape_para( shape_run(db, &story.text, k..b, &props, auto_leading, style, sub, &mut glyphs); } } + collapse_tcy(&mut glyphs, sub.vertical); ShapedPara { glyphs, range } } +/// Tate-chu-yoko: in vertical text each run of marked glyphs takes one em along the line, its +/// glyphs set side by side across it; elsewhere the mark is dropped. +pub(crate) fn collapse_tcy(glyphs: &mut [Glyph], vertical: bool) { + let mut i = 0; + while i < glyphs.len() { + if glyphs[i].tcy.is_none() { + i += 1; + continue; + } + let j = i + glyphs[i..].iter().position(|g| g.tcy.is_none()).unwrap_or(glyphs.len() - i); + if !vertical { + glyphs[i..j].iter_mut().for_each(|g| g.tcy = None); + i = j; + continue; + } + let run = &mut glyphs[i..j]; + let width: f64 = run.iter().map(|g| g.adv).sum(); + let em = run.iter().map(|g| g.size).fold(0.0, f64::max); + let step = em / run.len() as f64; + let mut across = -width / 2.0; + for (k, g) in run.iter_mut().enumerate() { + g.tcy = Some([em / 2.0 - k as f64 * step, across + g.dx, em]); + across += g.adv; + g.adv = step; + g.dx = 0.0; + } + i = j; + } +} + fn features_for(p: &CharProps) -> Vec { let mut v = Vec::new(); if !matches!(p.kerning, Kerning::Metrics | Kerning::Optical) { @@ -534,6 +568,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty size, style, no_break: p.no_break, + tcy: p.tate_chu_yoko.then_some([0.0; 3]), space: face.advance(face.glyph_for(' ')) * k * p.h_scale, } } @@ -603,6 +638,7 @@ fn shape_segment( size, style, no_break: p.no_break, + tcy: p.tate_chu_yoko.then_some([0.0; 3]), space, }); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ff56a7b..436d20c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -841,6 +841,34 @@ fn bidi_matches_the_reference_order() { } } +#[test] +fn tate_chu_yoko_sets_digits_across_one_em() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 400.0), lid, "令和12年", ParaFormat::default()).unwrap(); + let at = "令和".len(); + d.story_mut(sid).unwrap().format_chars(at..at + 2, |f| f.over.tate_chu_yoko = Some(true)); + let glyphs = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone(); + // Horizontal text ignores it. + assert!(glyphs(&d).iter().all(|g| g.tcy.is_none())); + if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { + tf.options.vertical = true; + } + let gs = glyphs(&d); + let digits: Vec<_> = gs.iter().filter(|g| g.tcy.is_some()).collect(); + assert_eq!(digits.len(), 2); + let em = digits[0].tcy.unwrap()[2]; + // The pair takes one em along the line, so the next ideograph follows an em after it. + assert!((digits.iter().map(|g| g.adv).sum::() - em).abs() < 1e-6); + let nen = gs.iter().find(|g| g.byte == at + 2).unwrap(); + assert!((nen.x - (digits[0].x + em)).abs() < 1e-6, "{} {}", nen.x, digits[0].x); + // Both turn about the same centre and sit side by side across it. + let [a0, x0, _] = digits[0].tcy.unwrap(); + let [a1, x1, _] = digits[1].tcy.unwrap(); + assert!(((digits[0].x + a0) - (digits[1].x + a1)).abs() < 1e-6); + assert!(x0 < 0.0 && x1 > x0 && x1 < em, "{x0} {x1}"); +} + #[test] fn vertical_frames_compose_in_the_turned_box() { let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 8ed6174..a44cf68 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -478,6 +478,8 @@ attr_set! { strikethrough_tint: f32 = 1.0, ligatures: bool = true, no_break: bool = false, + /// Tate-chu-yoko: in vertical text, the run is set horizontally within one em of the line. + tate_chu_yoko: bool = false, language: String = "English: USA".into(), /// Additional OpenType features, e.g. `["onum", "ss01"]`. otf_features: Vec = Vec::new(), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3cf827a..3fecc1e 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -138,6 +138,25 @@ pub fn specs() -> Vec { s, &json!({"capitalization": "allCaps"}) )), + cmd!( + "type.tateChuYoko", + "Tate-Chu-Yoko", + [], + None, + "{on?: bool} — set the selected text horizontally within one em of a vertical line (toggles by default)", + has_text_or_frames, + |s, p| { + let on = match p.get("on").and_then(Value::as_bool) { + Some(v) => v, + None => !format_targets(s).first().is_some_and(|t| { + s.doc().ok().and_then(|d| d.doc.text_story(t.story, t.cell)).is_some_and(|st| { + st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.tate_chu_yoko == Some(true) + }) + }), + }; + format_chars(s, &json!({"tate_chu_yoko": on})) + } + ), cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)), cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)), cmd!( @@ -1163,3 +1182,27 @@ mod language_tests { assert!(st.as_array().unwrap().is_empty(), "{st}"); } } + +#[cfg(test)] +mod tcy_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn tate_chu_yoko_toggles_on_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "令和12年", "vertical": true})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let at = "令和".len(); + s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at + 2})).unwrap(); + let get = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(at + 1).over.tate_chu_yoko; + s.execute("type.tateChuYoko", &json!({})).unwrap(); + assert_eq!(get(&s), Some(true)); + s.execute("type.tateChuYoko", &json!({})).unwrap(); + assert_eq!(get(&s), Some(false)); + s.execute("type.tateChuYoko", &json!({"on": true})).unwrap(); + assert_eq!(get(&s), Some(true)); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index b44cce8..87ebd1f 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1037,6 +1037,9 @@ impl<'a> Ex<'a> { if let Some(v) = a.no_break { el.set("NoBreak", bool_s(v)); } + if let Some(v) = a.tate_chu_yoko { + el.set("Tatechuyoko", bool_s(v)); + } if let Some(list) = &a.conditions { el.set("AppliedConditions", list.iter().map(|c| format!("Condition/{}", escape_id(c))).collect::>().join(" ")); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 71df8af..b57f57b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1069,6 +1069,7 @@ impl<'r> Importer<'r> { a.otf_features = Some(otf_list); } a.no_break = e.boolean("NoBreak"); + a.tate_chu_yoko = e.boolean("Tatechuyoko"); if let Some(v) = e.get("AppliedConditions").filter(|v| !v.trim().is_empty()) { let names: Vec = v.split_whitespace().filter_map(|r| self.condition_names.get(r).cloned()).collect(); if !names.is_empty() { diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index d95dce9..cf22083 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -116,16 +116,17 @@ impl Exporter<'_> { continue; } // Vertical type: an upright glyph turns back a quarter about its em box centre. - if ft.vertical && g.upright { - let c = designcraft_geom::Point::new(g.x + g.adv / 2.0, l.baseline + g.y - g.adv * 0.38); - s.push_transform(&crate::export::tf(Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c))); + if ft.vertical + && let Some(turn) = g.vertical_xf(l.baseline) + { + s.push_transform(&crate::export::tf(turn)); self.tagged_run(s, cs, &gs[i..i + 1], l.baseline, story); s.pop(); i += 1; continue; } let mut j = i + 1; - while j < gs.len() && same_run(g, &gs[j]) && !(ft.vertical && gs[j].upright) { + while j < gs.len() && same_run(g, &gs[j]) && !(ft.vertical && (gs[j].upright || gs[j].tcy.is_some())) { j += 1; } self.tagged_run(s, cs, &gs[i..j], l.baseline, story); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 3ab7f7f..51aeb85 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -132,10 +132,9 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line } let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let mut a = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); - if vertical && g.upright { - // Upright in vertical text: turned back a quarter about the em box centre. - let c = kurbo::Point::new(g.x + g.adv / 2.0, l.baseline + g.y - g.adv * 0.38); - a = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c) * a; + if vertical && let Some(turn) = g.vertical_xf(l.baseline) { + // Upright in vertical text. + a = turn * a; } let bp = match runs.iter_mut().find(|r| r.0 == g.style) { Some(r) => &mut r.1, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6d6e0e5..e527fc8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -393,6 +393,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:object.textFrameOptions|Horizontal|{\"vertical\": false}", "cmd:object.textFrameOptions|Vertical|{\"vertical\": true}", "<", + "cmd:type.tateChuYoko", ">Track Changes", "cmd:changes.track", "cmd:changes.acceptAll",