Merge pull request #281 from storytold/fix/issue-260

Fix #260: tate-chu-yoko numbers and upright glyphs keep their own cell in vertical text
This commit is contained in:
BFlat authored and GitHub committed 2026-10-07 12:32:51 +07:00
commit ce3683a508
4 files changed
+163 -16

No files matched your search

+1 -1
View File
@@ -117,7 +117,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko, upright glyphs centred on the column, point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
+22 -12
View File
@@ -40,13 +40,10 @@ impl Ctx<'_> {
tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size);
// An upright glyph advances at least one em down the column (the vertical advance of
// CJK fonts), centred in it: a narrow mark like § must not overlap its neighbours.
for g in v.iter_mut().filter(|g| stands_upright(g)) {
let natural = g.face.advance(g.gid) * g.sx;
let em = g.face.units_per_em() * g.sx;
if natural < em && g.adv > 0.0 {
g.dx += (em - natural) * 0.5;
g.adv += em - natural;
}
for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) {
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
g.dx += extra * 0.5;
g.adv += extra;
}
}
v
@@ -68,9 +65,12 @@ impl Ctx<'_> {
let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m;
} else if self.vertical && stands_upright(g) {
// Turned about the centre of its em box, which the column's centre line runs through.
// Turned about the centre of its em box, which the column's centre line runs through:
// the middle of its own cell, not of its advance (tracking and justification add space
// after the cell, and must not push the glyph off the centre line).
let em = self.style_at(g.byte).size;
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + advance * 0.5, origin.y - EM_CENTER * em)) * m;
let cell = if g.adv > 0.0 { upright_cell(g) } else { advance };
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - EM_CENTER * em)) * m;
}
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
@@ -214,6 +214,12 @@ fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
}
}
/// The length an upright glyph takes down the column before tracking and justification: its
/// advance, at least one em.
fn upright_cell(g: &SGlyph) -> f64 {
(g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx)
}
/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs
/// from 0.12 em below the baseline to 0.88 em above it).
const EM_CENTER: f64 = 0.38;
@@ -829,8 +835,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
// Composed lines may shrink word spaces (never below zero).
per_space =
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
} else if para.justify == Justify::JustifyAll && trimmed - i > 1 && width > w {
per_gap = (width - w) / (trimmed - i - 1) as f64;
} else if para.justify == Justify::JustifyAll && width > w {
let gaps = sg.get(i + 1..trimmed).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
if gaps > 0 {
per_gap = (width - w) / gaps as f64;
}
}
}
let start_x = if justify {
@@ -861,7 +870,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
} else if j < trimmed {
if g.is_space() {
adv += per_space;
} else if j + 1 < trimmed {
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
// Between glyphs, never inside a tate-chu-yoko block (one cell).
adv += per_gap;
}
}
+4
View File
@@ -74,6 +74,10 @@ impl SGlyph {
pub fn is_letter(&self) -> bool {
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
}
/// Vertical type: a tate-chu-yoko glyph after its block's first, sharing that glyph's cell.
pub fn continues_tcy(&self) -> bool {
self.tcy.is_some_and(|t| t.pen > 0.0)
}
}
pub(crate) const SOFT_HYPHEN: char = '\u{00AD}';
+136 -3
View File
@@ -1,11 +1,16 @@
//! Vertical type (bundled fonts only: `§` and `×` stand upright like CJK, letters lie on their side).
//! Vertical type (bundled fonts: `§` and `×` stand upright like CJK, letters lie on their side;
//! Japanese text only with craft-fonts' faces, skipped without them).
use super::*;
use kurbo::Shape;
use vectorcraft_doc::{CharStyle, TextKind};
use vectorcraft_doc::{CharStyle, Justify, TextKind};
use vectorcraft_geom::PathData;
fn vertical(text: &str) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
vertical_styled(text, CharStyle { size: 20.0, ..CharStyle::default() })
}
fn vertical_styled(text: &str, style: CharStyle) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.vertical = true;
t
@@ -128,3 +133,131 @@ fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_brackets_off_line_end
}
}
}
/// Each glyph's cell down the (single) column: from its origin over its advance. A glyph that
/// doesn't advance (the rest of a tate-chu-yoko block) shares the cell of the glyph before it.
fn column_cells(l: &TextLayout) -> Vec<(f64, f64)> {
let mut cells: Vec<(f64, f64)> = Vec::with_capacity(l.glyphs.len());
for g in &l.glyphs {
let cell = match cells.last() {
Some(&prev) if g.advance == 0.0 => prev,
_ => (g.origin.y, g.origin.y + g.advance),
};
cells.push(cell);
}
cells
}
/// One column, cells one after the other, each glyph's ink inside its own cell (within `tol`)
/// and no two glyphs' ink overlapping.
fn assert_own_cells(l: &TextLayout, text: &str, tol: f64) {
let label = |i: usize| &text[l.glyphs[i].byte..l.glyphs[i].byte + l.glyphs[i].len];
let cells = column_cells(l);
for (i, g) in l.glyphs.iter().enumerate() {
assert!((g.origin.x - l.glyphs[0].origin.x).abs() < 1e-9, "{:?} leaves the column: {g:?}", label(i));
if let Some(next) = l.glyphs.get(i + 1) {
assert!((next.origin.y - (g.origin.y + g.advance)).abs() < 1e-9, "{:?} starts where {:?} ends", label(i + 1), label(i));
}
if g.outline.elements().is_empty() {
continue;
}
let (r, (top, bottom)) = (ink(l, i), cells[i]);
assert!(r.y0 >= top - tol && r.y1 <= bottom + tol, "{:?}: ink {r:?} outside its cell {top}..{bottom}", label(i));
for j in (0..i).filter(|&j| !l.glyphs[j].outline.elements().is_empty()) {
let o = ink(l, j);
let (ox, oy) = (r.x1.min(o.x1) - r.x0.max(o.x0), r.y1.min(o.y1) - r.y0.max(o.y0));
assert!(ox <= tol || oy <= tol, "{:?} {r:?} overlaps {:?} {o:?}", label(i), label(j));
}
}
}
/// Index of the glyph of `needle`'s first byte in `text`.
fn glyph_of(l: &TextLayout, text: &str, needle: &str) -> usize {
let b = text.find(needle).unwrap();
l.glyphs.iter().position(|g| g.byte == b).unwrap()
}
/// Issue #260 with bundled glyphs (`§` stands in for the upright CJK characters).
#[test]
fn numbers_keep_their_own_cells_down_the_column() {
let text = "§§ §§§ 10§22§§ 1§ 100§ 2026§";
let l = layout(FontDb::global(), &vertical(text));
assert_own_cells(&l, text, 0.25);
// Tate-chu-yoko: one em of the column, centred on the column's centre line (x = 0), the next
// character right after it.
for block in ["10§", "22§", "100§"] {
let first = glyph_of(&l, text, block);
let digits = block.len() - '§'.len_utf8();
let after = first + digits;
let g = &l.glyphs[first];
assert!((g.advance - 20.0).abs() < 1e-9, "{block}: {g:?}");
assert!(l.glyphs[first + 1..after].iter().all(|d| d.advance == 0.0), "{block}");
assert!((l.glyphs[after].origin.y - (g.origin.y + 20.0)).abs() < 1e-9, "{block}: the next character follows the block's em");
let inks = (first..after).map(|i| ink(&l, i)).reduce(|a, b| a.union(b)).unwrap();
assert!(inks.center().x.abs() < 1.0, "{block} centred on the column: {inks:?}");
assert!((inks.center().y - (g.origin.y + 10.0)).abs() < 1.5, "{block} centred in its em: {inks:?} {g:?}");
}
// A single digit and longer numbers lie on their side, advancing by their own length.
let one = glyph_of(&l, text, "1§");
let r = ink(&l, one);
assert!(r.width() > r.height() && l.glyphs[one].advance < 20.0, "1 on its side: {r:?}");
let year = glyph_of(&l, text, "2026");
let len: f64 = l.glyphs[year..year + 4].iter().map(|g| g.advance).sum();
assert!(len > 30.0 && (l.glyphs[year + 4].origin.y - l.glyphs[year].origin.y - len).abs() < 1e-9, "2026 takes its length: {len}");
// Half-width spaces take their own (narrow) width.
for (i, g) in l.glyphs.iter().enumerate().filter(|(_, g)| text[g.byte..].starts_with(' ')) {
assert!(g.advance > 2.0 && g.advance < 10.0, "space {i}: {g:?}");
}
}
#[test]
fn justified_columns_keep_tate_chu_yoko_blocks_whole_and_marks_centred() {
let text = "§10§§";
let mut t = vertical(text);
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 40.0, 200.0).to_path(0.1)) };
t.para.justify = Justify::JustifyAll;
let l = layout(FontDb::global(), &t);
assert_eq!(l.lines.len(), 1);
// 200 pt of column, four 20 pt cells: three gaps of 40 pt between them, none inside "10".
let (one, zero) = (ink(&l, 1), ink(&l, 2));
assert!((one.center().y - zero.center().y).abs() < 0.5 && zero.x0 > one.x1, "10 side by side: {one:?} {zero:?}");
let marks: Vec<Rect> = [0, 3, 4].iter().map(|&i| ink(&l, i)).collect();
assert!((marks[1].center().y - marks[0].center().y - 120.0).abs() < 1e-6, "{marks:?}");
assert!((marks[2].center().y - marks[1].center().y - 60.0).abs() < 1e-6, "{marks:?}");
assert!(marks.iter().all(|m| (m.center().x - marks[0].center().x).abs() < 1e-6), "upright marks stay on the centre line: {marks:?}");
assert!(marks[2].y1 <= 200.0 + 1e-6, "inside the frame: {marks:?}");
}
#[test]
fn tracking_spaces_upright_marks_without_moving_them_off_the_centre_line() {
let plain = layout(FontDb::global(), &vertical("§§"));
let tracked = layout(FontDb::global(), &vertical_styled("§§", CharStyle { size: 20.0, tracking: 200.0, ..CharStyle::default() }));
for i in 0..2 {
assert!((ink(&tracked, i).center().x - ink(&plain, i).center().x).abs() < 1e-6, "{:?} {:?}", ink(&tracked, i), ink(&plain, i));
}
let step = ink(&tracked, 1).center().y - ink(&tracked, 0).center().y;
assert!((step - 24.0).abs() < 1e-6, "one em plus 200/1000 em of tracking: {step}");
}
/// Issue #260 as reported, with a Japanese face of craft-fonts (whose own digits are proportional).
#[test]
fn japanese_numbers_keep_their_own_cells_down_the_column() {
let Some(face) = crate::craft_fonts::japanese_ui_fonts(false).into_iter().next() else {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)");
return;
};
let db = FontDb::with_font_dirs(vec![]);
let text = "はただ商店 秋のセール 10月22日まで";
let t = vertical_styled(text, CharStyle { size: 28.0, font_family: face.family.into(), font_style: face.style.into(), ..CharStyle::default() });
let l = layout(&db, &t);
assert!(l.glyphs.iter().filter(|g| !text[g.byte..].starts_with(' ')).all(|g| g.gid != 0), "real glyphs, no tofu");
assert_own_cells(&l, text, 0.5);
for block in ["10月", "22日"] {
let first = glyph_of(&l, text, block);
let g = &l.glyphs[first];
assert!((g.advance - 28.0).abs() < 1e-9 && l.glyphs[first + 1].advance == 0.0, "{block}: one em");
assert!((l.glyphs[first + 2].origin.y - (g.origin.y + 28.0)).abs() < 1e-9, "{block}: the next character follows the em");
let inks = ink(&l, first).union(ink(&l, first + 1));
assert!(inks.center().x.abs() < 2.8, "{block} centred on the column: {inks:?}");
}
}