T: tate-chu-yoko in vertical frames

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-04 00:46:52 -04:00
1 parent a57a909db1
commit fe40d4b80b
11 files changed
+140 -9

No files matched your search

+1 -1
View File
@@ -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).
+17
View File
@@ -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<designcraft_geom::Affine> {
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,
}
}
+36
View File
@@ -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<Feature> {
let mut v = Vec::new();
if !matches!(p.kerning, Kerning::Metrics | Kerning::Optical) {
@@ -534,6 +568,7 @@ fn control_glyph(face: &Arc<FontFace>, 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,
});
}
+28
View File
@@ -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::<f64>() - 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());
+2
View File
@@ -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<String> = Vec::new(),
+43
View File
@@ -138,6 +138,25 @@ pub fn specs() -> Vec<CommandSpec> {
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));
}
}
+3
View File
@@ -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::<Vec<_>>().join(" "));
}
+1
View File
@@ -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<String> = v.split_whitespace().filter_map(|r| self.condition_names.get(r).cloned()).collect();
if !names.is_empty() {
+5 -4
View File
@@ -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);
+3 -4
View File
@@ -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,
+1
View File
@@ -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",