From 41d839d931c1944004a81e5bfdaf45a1bad800e9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 05:35:51 -0400 Subject: [PATCH] Text: East Asian vertical text (eaVert) stands ideographs and kana upright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eaVert text was laid out exactly like vert: the whole text turned a quarter turn, so Japanese and Chinese lay on their side. PowerPoint draws ideographs, kana, Hangul, full-width forms, CJK punctuation, circled numbers and emoji upright, each turned back about the centre of its em box, while Latin letters and digits stay on their side ("英字123も": 123 sideways). It keeps the glyphs' horizontal forms (no vert alternates: "、" "「" are simply turned), which DeckCraft now matches. Glyph runs carry an `upright` flag; the renderer turns each such glyph about its em box centre (0.38 em above the baseline). w01-vertical s3 (eaVert) 9.70 -> 8.98, s6/s7 (eaVert rotated, flipped, bulleted) 1.58 -> 1.45, 2.30 -> 1.76. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 14 ++++- crates/render/src/tests.rs | 31 +++++++++++ crates/text/src/lib.rs | 108 ++++++++++++++++++++++++++++++++++--- crates/text/src/tests.rs | 29 +++++++++- 4 files changed, 170 insertions(+), 12 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dc3c18a..e5fff04 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1426,7 +1426,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, // its bottom, from its first to its last character), as in PowerPoint. let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new(); for (gid, x, y) in &run.glyphs { - let at = m * Affine::translate((*x, *y)); + let at = m * glyph_place(run, *gid, *x, *y); let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else { draw_glyph(ctx, run, *gid, at, ps.opacity); continue; @@ -1447,6 +1447,16 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, } } +/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a +/// quarter turn back about its em box's centre when it stands upright in vertical text. +pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine { + let at = Affine::translate((x, y)); + match run.upright_centre(gid, x, y) { + Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at, + None => at, + } +} + /// Fill an underline, strikethrough or highlight with the current paint and transform: its box, /// its dashes, or its zigzag stroked. pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) { @@ -1518,7 +1528,7 @@ fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: T continue; } // The offset is in slide space: a shadow falls the same way whatever the text's rotation. - let at = pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k); + let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k); ctx.set_transform(at); ctx.fill_path(&path); if grow > 0.0 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 951c291..e5b365f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1675,3 +1675,34 @@ fn dotted_and_wavy_underlines_draw_their_pattern() { let (wavy_rows, _) = row_runs("wavy"); assert!(wavy_rows > sng_rows, "a wave spans more rows ({wavy_rows}) than a single line ({sng_rows})"); } + +#[test] +fn east_asian_vertical_text_stands_ideographs_upright() { + // "一" is a horizontal stroke. In eaVert text it stays horizontal (upright) while the line runs + // down the shape; in vert it lies on its side like Latin text. + let ink = |dir: deckcraft_model::text::TextDir| { + let mut sh = Shape { id: ShapeId(770), xfrm: Some(Xfrm::new(100.0, 50.0, 200.0, 400.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("一"); + t.body.vert = Some(dir); + let props = &mut t.paragraphs[0].runs[0].props; + props.size = Some(80.0); + props.fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))); + sh.text = Some(t); + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); + for y in 0..540 { + for x in 0..960 { + if img.pixel(x, y)[0] < 128 { + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + } + assert!(x1 >= x0 && y1 >= y0, "no ink for {dir:?}"); + (x1 - x0, y1 - y0) + }; + let (w, h) = ink(deckcraft_model::text::TextDir::EaVertical); + assert!(w > h * 3, "eaVert: upright stroke {w}x{h}"); + let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); + assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 20ed541..0b02d2c 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -59,6 +59,26 @@ pub struct GlyphRun { pub gradient: Option, /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). pub effects: Option, + /// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn + /// back about the centre of its em box ([`GlyphRun::upright_centre`]) inside text that is + /// laid out turned a quarter turn. + pub upright: bool, +} + +/// Share of the size the ideographic em box's centre sits above the baseline (CJK fonts put the +/// em box from 0.88 em above to 0.12 em below it). +pub const UPRIGHT_CENTRE: f64 = 0.38; + +impl GlyphRun { + /// The point an upright glyph at pen position (`x`, `y`) turns about: the centre of its em box, + /// `advance` wide (`None` when the run is not upright). + pub fn upright_centre(&self, gid: u32, x: f64, y: f64) -> Option<(f64, f64)> { + if !self.upright { + return None; + } + let adv = self.face.advance(gid) * self.size / self.face.upem.max(1.0); + Some((x + adv / 2.0, y - self.size * UPRIGHT_CENTRE)) + } } /// A text run's shadow and glow, colours resolved. @@ -371,6 +391,8 @@ struct Cell { head: usize, /// A grapheme starts here: carets stand and lines break only before such cells. boundary: bool, + /// Drawn upright in East Asian vertical text. + upright: bool, } impl Cell { @@ -672,7 +694,17 @@ pub fn autonum(scheme: &str, n: u32) -> String { } #[allow(clippy::too_many_arguments)] -fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, opts: &Opts, number: Option, no_kern: bool) -> ParaShaped { +fn shape_para( + ctx: &Ctx, + shape: &Shape, + body: &TextBody, + pi: usize, + scale: f64, + opts: &Opts, + number: Option, + no_kern: bool, + vertical: bool, +) -> ParaShaped { let db = FontDb::global(); let Some(para) = body.paragraphs.get(pi) else { let face = db.face("Inter", "Regular"); @@ -710,7 +742,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, char_style.extend(std::iter::repeat_n(si, t.chars().count())); text.push_str(&t); } - let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl); + let cells = shape_cells(ctx, &text, &char_style, &styles, &mut faces, rtl, vertical); let lead = para.runs.first().map(|r| resolve::run(ctx, shape, para, &r.props)).unwrap_or_else(|| resolve::run(ctx, shape, para, ¶.end_props)); let lead_style = make_style(ctx, &lead, scale, opts.prompt_color); let lead_size = lead_style.size; @@ -791,7 +823,16 @@ fn run_effects(ctx: &Ctx, r: &RunProps, scale: f64) -> Option { (shadow.is_some() || glow.is_some()).then_some(RunEffects { shadow, glow }) } -fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], faces: &mut Vec>, rtl: bool) -> Vec { +#[allow(clippy::too_many_arguments)] +fn shape_cells( + ctx: &Ctx, + text: &str, + char_style: &[usize], + styles: &[Style], + faces: &mut Vec>, + rtl: bool, + vertical: bool, +) -> Vec { let db = FontDb::global(); let chars: Vec = text.chars().collect(); let n = chars.len(); @@ -865,22 +906,40 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa adv: 0.0, head: i, boundary: boundary.get(i).copied().unwrap_or(true), + upright: false, }) .collect(); + // In East Asian vertical text, ideographs, kana, Hangul, full-width forms and the like stand + // upright (a whole grapheme at a time); other text lies on its side. + if vertical { + for &(a, b) in &graphemes { + let up = chars.get(a).is_some_and(|c| is_upright(*c)); + for c in cells.get_mut(a..b).into_iter().flatten() { + c.upright = up; + } + } + } // Items: maximal ranges of one level, face, script and shaping style (case mapping, language). let upper = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.caps != Caps::None); let lang = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).and_then(|st| st.lang.as_deref()); let kern = |i: usize| styles.get(char_style.get(i).copied().unwrap_or(0)).is_some_and(|st| st.kern); - let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i)); + let up = |i: usize| cells.get(i).is_some_and(|c| c.upright); + let key = |i: usize| (levels.get(i).copied(), char_face.get(i).copied(), scripts.get(i).map(|s| s.0), upper(i), lang(i), kern(i), up(i)); + let mut items: Vec<(usize, usize)> = Vec::new(); let mut a = 0; while a < n { let mut b = a + 1; while b < n && key(b) == key(a) { b += 1; } - shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a)); + items.push((a, b)); a = b; } + // Upright glyphs keep their horizontal forms: PowerPoint turns "、", "。" and "「" as they are + // (no `vert` alternates). + for (a, b) in items { + shape_item(&mut cells, styles, faces, text, &byte_of, &char_at_byte, a..b, scripts.get(a).and_then(|s| s.0), lang(a), upper(a)); + } cells } @@ -895,6 +954,35 @@ fn caps_char(ch: char) -> char { } } +/// Does `c` stand upright in East Asian vertical text? Ideographs, kana, Hangul, Bopomofo, CJK +/// symbols and punctuation, full-width forms, enclosed and compatibility CJK, circled numbers +/// and emoji (Unicode's Vertical_Orientation U and T, roughly); Latin, digits and the rest of the +/// half-width text lie on their side as in `vert`. +fn is_upright(c: char) -> bool { + matches!(c as u32, + 0x1100..=0x11FF + | 0x2460..=0x24FF + | 0x2E80..=0x2FFF + | 0x3000..=0x303F + | 0x3040..=0x30FF + | 0x3100..=0x312F + | 0x3130..=0x318F + | 0x3190..=0x31FF + | 0x3200..=0x33FF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xA960..=0xA97F + | 0xAC00..=0xD7FF + | 0xF900..=0xFAFF + | 0xFE10..=0xFE1F + | 0xFE30..=0xFE6F + | 0xFF01..=0xFF60 + | 0xFFE0..=0xFFE6 + | 0x1F000..=0x1FAFF + | 0x20000..=0x3FFFF + ) +} + /// Size of superscript and subscript text relative to the run's size. const SUB_SUPER: f64 = 0.66; @@ -1465,6 +1553,8 @@ fn spacing_stops_kerning(ctx: &Ctx, shape: &Shape, body: &TextBody) -> bool { fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64, no_kern: bool) -> TextLayout { let r = opts.rect; let vert = bp.vert.unwrap_or(TextDir::Horizontal); + // East Asian vertical text: CJK stands upright in text turned a quarter turn. + let upright_text = vert == TextDir::EaVertical; let rotation = match vert { TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked => 90.0, TextDir::Vertical270 => 270.0, @@ -1515,7 +1605,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts } None }; - shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, no_kern)); + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, no_kern, upright_text)); } for (pi, p) in shaped_paras.iter().enumerate() { let props = &p.props; @@ -1773,11 +1863,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts alpha: st.alpha, gradient: None, effects: None, + upright: false, }); } // Group visually consecutive cells by (style, face). let mut cur: Option = None; - let mut cur_key = (usize::MAX, usize::MAX, false); + let mut cur_key = (usize::MAX, usize::MAX, false, false); for &k in &place.order { let ci = line.start + k; let Some(cell) = p.cells.get(ci) else { continue }; @@ -1787,7 +1878,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; let size = if st.caps == Caps::Small && cell.ch.is_lowercase() { size * 0.8 } else { size }; // Small capitals are drawn smaller than the capitals beside them: a run of their own. - let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase()); + let key = (cell.style, cell.face, st.caps == Caps::Small && cell.ch.is_lowercase(), cell.upright); if key != cur_key { if let Some(r) = cur.take() { out.runs.push(r); @@ -1808,6 +1899,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts alpha: st.alpha, gradient: st.gradient.clone(), effects: st.effects, + upright: cell.upright, }); cur_key = key; } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 04b2774..4609180 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -144,8 +144,11 @@ fn character_spacing_in_the_body_stops_kerning() { #[test] fn ligatures_stay_for_scripts_that_need_them() { let has = |f: &[deckcraft_fonts::Feature], tag: &str| f.iter().any(|x| Some(*x) == deckcraft_fonts::feature(tag)); - let cells = - |s: &str| s.chars().map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true }).collect::>(); + let cells = |s: &str| { + s.chars() + .map(|ch| Cell { ch, style: 0, face: 0, level: 0, glyphs: vec![], adv: 0.0, head: 0, boundary: true, upright: false }) + .collect::>() + }; let latin = shaping_features(&cells("office"), &[], Some(*b"Latn")); assert!(has(&latin, "-liga") && has(&latin, "-clig") && has(&latin, "-kern")); // Arabic needs its ligatures; emoji sequences are joined by them. @@ -1063,3 +1066,25 @@ fn autonumber_schemes_follow_powerpoint() { assert_eq!(autonum("hebrew2Minus", 23), "אא-"); assert_eq!(autonum("thaiAlphaPeriod", 0), "."); } + +#[test] +fn east_asian_vertical_text_marks_cjk_upright() { + let (p, mut sh) = setup("日本、ABC 12", 300.0, 600.0); + sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::EaVertical); + let l = lay(&p, &sh, 300.0, 600.0); + assert_eq!(l.rotation, 90.0); + let upright: Vec = l + .runs + .iter() + .filter(|r| !r.cells.is_empty()) + .flat_map(|r| r.cells.iter().map(move |c| (c.1, r.upright))) + .filter(|(c, _)| *c != ' ') + .map(|(_, u)| u) + .collect(); + assert_eq!(upright, vec![true, true, true, false, false, false, false, false]); + // In vert nothing stands upright. + sh.text.as_mut().unwrap().body.vert = Some(deckcraft_model::text::TextDir::Vertical); + let l = lay(&p, &sh, 300.0, 600.0); + assert!(l.runs.iter().all(|r| !r.upright)); + assert!(is_upright('あ') && is_upright('、') && is_upright('A') && !is_upright('A') && !is_upright('1')); +}