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:
parent
bc2fc816d4
commit
41d839d931
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶.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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue