Text: East Asian vertical text (eaVert) stands ideographs and kana upright

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) <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-13 05:35:51 -04:00
parent bc2fc816d4
commit 41d839d931
4 changed files with 170 additions and 12 deletions

View File

@ -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 {

View File

@ -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}");
}

View File

@ -59,6 +59,26 @@ pub struct GlyphRun {
pub gradient: Option<deckcraft_model::style::Gradient>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
/// 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<u32>, no_kern: bool) -> ParaShaped {
fn shape_para(
ctx: &Ctx,
shape: &Shape,
body: &TextBody,
pi: usize,
scale: f64,
opts: &Opts,
number: Option<u32>,
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, &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<RunEffects> {
(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<Arc<FontFace>>, rtl: bool) -> Vec<Cell> {
#[allow(clippy::too_many_arguments)]
fn shape_cells(
ctx: &Ctx,
text: &str,
char_style: &[usize],
styles: &[Style],
faces: &mut Vec<Arc<FontFace>>,
rtl: bool,
vertical: bool,
) -> Vec<Cell> {
let db = FontDb::global();
let chars: Vec<char> = 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<GlyphRun> = 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;
}

View File

@ -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::<Vec<_>>();
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::<Vec<_>>()
};
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<bool> = 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'));
}