Type: kinsoku, tate-chu-yoko and upright placement for vertical type

Builds on the vertical type from #145 (same data, commands and tools).

- Kinsoku (the strict set, horizontal Japanese too): no line starts with a
  closing bracket, 、。, a middle dot, colon, !?, the long vowel mark,
  iteration marks or small kana; no line ends with an opening bracket.
  Hyphenation breaks obey it too.
- Tate-chu-yoko: a run of two or three half-width digits (10月, 第100回)
  stands upright across the column as one block one em long, squeezed to
  the em when wider; longer numbers stay on their side.
- Upright or on its side by Unicode's Vertical_Orientation: CJK and
  symbols upright, marks that need a vertical form on their side when the
  font has none; upright glyphs advance at least one em, centred, and turn
  about the ideographic em box centre so they sit on the column's centre
  line. Point type keeps its anchor on the first column's centre line.
- `vrt2` is no longer requested: its glyphs are already on their side, so
  with the layout turning Latin and digits too they came out upside down
  (and tate-chu-yoko blocks broke) in fonts that have it, e.g. Hiragino.
- The line→text mapping is one affine (`TextLayout::line_xf`, an exact
  quarter turn plus the point-type offset); `logical_point` and
  `physical_point` use it. Pixel snapping skips vertical type.
- Tests: kinsoku in both directions, tate-chu-yoko blocks, upright vs
  sideways, anchor on the centre line, arrows along columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
imachus 2026-10-06 22:01:13 +09:00
parent 7a39cb7b89
commit ab6a668aa9
8 changed files with 403 additions and 60 deletions

View File

@ -35,7 +35,7 @@ since 2026-10-01.
|---|---|---| |---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | | **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) | | **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type has initial support), Variables (data merge), scripting | | **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
@ -58,8 +58,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software 3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors. renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h. The largest single gap. 50–80 h.
4. **Advanced type:** CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical font metrics, 4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, vertical font metrics, manual
mixed-script orientation), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
initial support. 35–48 h across type core and advanced. initial support. 35–48 h across type core and advanced.
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries 5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
@ -112,7 +112,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. - 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), 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). - 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), 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. - **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.
- **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs with `vert`/`vrt2` alternates); 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. - **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system 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.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. - **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, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **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, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):** - **Paint and appearance (M3):**
@ -181,7 +181,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 | | Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 |
| Type advanced | 4 | 45% | CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical metrics, mixed-script orientation), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 | | Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 | | Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 | | Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |

View File

@ -315,6 +315,23 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
mod area_tests { mod area_tests {
use super::*; use super::*;
#[test]
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap();
let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
let wide = bounds(&s);
assert!(wide.width() > wide.height());
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("type.orientation.vertical", &json!({})).unwrap();
let tall = bounds(&s);
assert!(tall.height() > tall.width(), "{tall:?}");
assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(bounds(&s), wide);
}
#[test] #[test]
fn area_options_columns_change_layout() { fn area_options_columns_change_layout() {
let mut s = Session::new(); let mut s = Session::new();

View File

@ -7,7 +7,7 @@ use vectorcraft_doc::CharStyle;
/// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options. /// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OtFeatures { pub struct OtFeatures {
/// Vertical glyph alternates (vert/vrt2), set by the writing direction. /// Vertical glyph alternates (`vert`), set by the writing direction.
pub vertical: bool, pub vertical: bool,
/// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero. /// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero.
pub ligatures: bool, pub ligatures: bool,
@ -142,8 +142,9 @@ impl OtFeatures {
} }
} }
if self.vertical { if self.vertical {
// Not `vrt2`: its glyphs are already turned on their side, and the layout turns Latin
// and digits itself (they would lie upside down, and tate-chu-yoko break).
v.push(f(b"vert", true)); v.push(f(b"vert", true));
v.push(f(b"vrt2", true));
} }
v v
} }

View File

@ -8,19 +8,22 @@ use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextO
use crate::composer::{Breakpoint, compose}; use crate::composer::{Breakpoint, compose};
use crate::fontdb::FontDb; use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points; use crate::hyphen::hyphen_points;
use crate::shape::{SGlyph, cap_x_heights, hyphen_glyph, shape_range, style_metrics}; use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, TextLayout}; use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
const EPS: f64 = 1e-6; const EPS: f64 = 1e-6;
struct Ctx<'a> { struct Ctx<'a> {
vertical: bool,
on_path: bool, on_path: bool,
db: &'a FontDb, db: &'a FontDb,
text: &'a str, text: &'a str,
runs: Vec<(Range<usize>, &'a CharStyle)>, runs: Vec<(Range<usize>, &'a CharStyle)>,
default: CharStyle, default: CharStyle,
opts: &'a LayoutOptions, opts: &'a LayoutOptions,
/// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned
/// a quarter turn back (see [`stands_upright`]); [`TextLayout::line_xf`] then stands the lines
/// up as columns.
vertical: bool,
out: TextLayout, out: TextLayout,
} }
@ -33,6 +36,19 @@ impl Ctx<'_> {
fn shape_para(&self, r: Range<usize>) -> Vec<SGlyph> { fn shape_para(&self, r: Range<usize>) -> Vec<SGlyph> {
let mut v = Vec::with_capacity(r.len()); let mut v = Vec::with_capacity(r.len());
shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v); shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v);
if self.vertical {
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;
}
}
}
v v
} }
@ -40,20 +56,21 @@ impl Ctx<'_> {
let src = self.db.outline(&g.face, g.gid); let src = self.db.outline(&g.face, g.gid);
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
let mut m = pre * local; let mut m = pre * local;
let mut origin = origin; if self.vertical && self.on_path {
let mut angle = angle; // Vertical path type keeps the baseline path and turns each glyph across it.
if self.vertical { m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m;
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); } else if let (true, Some(t)) = (self.vertical, g.tcy) {
if self.on_path { // Tate-chu-yoko: the block set across the column, centred on its em of it.
// Vertical path type keeps the baseline path and turns each glyph across it. let em = self.style_at(g.byte).size;
m = Affine::translate(origin.to_vec2()) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate(-origin.to_vec2()) * m; let start = origin.x - t.pen;
} else { let centre = Point::new(start + em * 0.5, origin.y - EM_CENTER * em);
let physical = writing * origin; let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0));
let upright = matches!(g.ch as u32, 0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff | 0xfe10..=0xfe4f | 0xff01..=0xff60 | 0x20000..=0x3134f); let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
m = if upright { Affine::translate((physical - origin) + Vec2::new(-g.descent, g.ascent)) * m } else { writing * m }; m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m;
origin = physical; } else if self.vertical && stands_upright(g) {
angle += std::f64::consts::FRAC_PI_2; // Turned about the centre of its em box, which the column's centre line runs through.
} 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;
} }
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). // 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() { let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
@ -116,48 +133,122 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
} }
} }
paras.push(s..text.len()); paras.push(s..text.len());
let mut vertical_opts = opts.clone(); let vertical = t.vertical;
vertical_opts.features.vertical = t.vertical;
let opts = &vertical_opts;
let is_on_path = matches!(&t.kind, TextKind::OnPath { .. }); let is_on_path = matches!(&t.kind, TextKind::OnPath { .. });
let mut cx = Ctx { let vopts;
vertical: t.vertical, let opts = if vertical {
on_path: is_on_path, vopts = LayoutOptions { features: OtFeatures { vertical: true, ..opts.features }, ..opts.clone() };
db, &vopts
text: &text, } else {
runs, opts
default: CharStyle::default(), };
opts, let mut cx = Ctx { on_path: is_on_path, db, text: &text, runs, default: CharStyle::default(), opts, vertical, out: TextLayout::default() };
out: TextLayout { vertical: t.vertical && !is_on_path, ..Default::default() }, // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next
// one to the left). Point type's anchor is on the first column's centre line. Vertical path
// type stays on its path (each glyph turned across it in `emit`).
let line_xf = match &t.kind {
_ if !vertical => Affine::IDENTITY,
TextKind::OnPath { .. } => Affine::IDENTITY,
TextKind::Point => Affine::translate((-EM_CENTER * cx.style_at(0).size, 0.0)) * QUARTER_TURN,
_ => QUARTER_TURN,
}; };
match &t.kind { match &t.kind {
TextKind::Point => flow(&mut cx, &paras, &t.para, None), TextKind::Point => flow(&mut cx, &paras, &t.para, None),
TextKind::Area { frame } => { TextKind::Area { frame } => {
let inverse = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); let to_lines = line_xf.inverse();
let frame = if t.vertical { frame.transformed(inverse) } else { frame.clone() }; let wrap: Vec<vectorcraft_doc::WrapShape> = if vertical {
let mut wrap = t.wrap.clone(); t.wrap.iter().map(|w| vectorcraft_doc::WrapShape { path: w.path.transformed(to_lines), ..w.clone() }).collect()
if t.vertical { } else {
for w in &mut wrap { t.wrap.clone()
w.path.transform(inverse); };
} let regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
} cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
let regions = Region::cells(&frame.to_bezpath(), opts, &wrap);
cx.out.frames = regions.iter().map(|r| r.cell).collect();
flow(&mut cx, &paras, &t.para, Some(&regions)); flow(&mut cx, &paras, &t.para, Some(&regions));
} }
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect), TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
} }
if cx.out.vertical { if vertical && !is_on_path {
let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); // Glyph origins, outlines and transforms to text space (lines stay in line space).
for frame in &mut cx.out.frames { for g in &mut cx.out.glyphs {
*frame = writing.transform_rect_bbox(*frame); g.outline.apply_affine(line_xf);
g.xf = line_xf * g.xf;
g.origin = line_xf * g.origin;
g.angle += std::f64::consts::FRAC_PI_2;
} }
cx.out.vertical = true;
cx.out.line_xf = line_xf;
} }
finish_bounds(&mut cx.out); finish_bounds(&mut cx.out);
cx.out cx.out
} }
/// Tate-chu-yoko: a run of two or three half-width digits (`10`月, `100`年) stands upright across
/// the column as one block one em long, squeezed to the em when wider; longer numbers (`2026`)
/// stay on their side.
fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
let digits: Vec<bool> = g.iter().map(|g| g.ch.is_ascii_digit()).collect();
let digit = |i: usize| digits.get(i).copied().unwrap_or(false);
let mut i = 0;
while i < g.len() {
if !digit(i) {
i += 1;
continue;
}
let mut end = i;
while digit(end) {
end += 1;
}
if (2..=3).contains(&(end - i)) {
let em = size(&g[i]);
let width: f64 = g[i..end].iter().map(|g| g.adv).sum();
let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 };
let mut ink = 0.0;
for (k, gl) in g[i..end].iter_mut().enumerate() {
let adv = gl.adv;
gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze });
gl.adv = if k == 0 { em } else { 0.0 };
ink += adv;
}
}
i = end;
}
}
/// 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;
/// A quarter turn clockwise (y down), exact: line space → text space for vertical type.
const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
/// Does the glyph stand upright in vertical type? CJK characters and symbols do (their vertical
/// forms come from the font's `vert` feature); Latin letters, digits and other characters lie on
/// their side. Brackets, the long vowel mark and similar marks that need a vertical form lie on
/// their side when the font has none (Unicode's Vertical_Orientation Tr).
fn stands_upright(g: &SGlyph) -> bool {
let c = g.ch as u32;
let upright = matches!(c,
0x00A7 | 0x00A9 | 0x00AE | 0x00B1 | 0x00BC..=0x00BE | 0x00D7 | 0x00F7
| 0x2016 | 0x2020 | 0x2021 | 0x2030 | 0x2031 | 0x203B | 0x203C | 0x2042 | 0x2047..=0x2049 | 0x2051
| 0x20DD..=0x20E0 | 0x20E2..=0x20E4 | 0x2100..=0x2101 | 0x2103..=0x2109 | 0x210F | 0x2113 | 0x2116 | 0x2117
| 0x211E..=0x2123 | 0x2125 | 0x2127 | 0x2129 | 0x212E | 0x2135..=0x213F | 0x2145..=0x214A | 0x214C | 0x214D | 0x214F..=0x2189
| 0x2460..=0x24FF | 0x25A0..=0x27BF | 0x2B12..=0x2B2F | 0x2B50..=0x2B59
| 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0xA4CF | 0xA960..=0xA97F | 0xAC00..=0xD7FF
| 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7
| 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF);
if !upright {
return false;
}
let needs_vertical_form = matches!(c,
0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC
| 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF01 | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B | 0xFF1F
| 0xFF3B | 0xFF3D | 0xFF3F | 0xFF5B..=0xFF60 | 0xFFE3);
// Without a vertical alternate the shaped glyph is the nominal one.
!needs_vertical_form || g.gid != g.face.glyph_for(g.ch)
}
fn finish_bounds(out: &mut TextLayout) { fn finish_bounds(out: &mut TextLayout) {
let xf = out.line_xf;
let mut b: Option<Rect> = None; let mut b: Option<Rect> = None;
let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r))); let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r)));
for g in &out.glyphs { for g in &out.glyphs {
@ -167,8 +258,7 @@ fn finish_bounds(out: &mut TextLayout) {
} }
if !out.on_path { if !out.on_path {
for l in &out.lines { for l in &out.lines {
let r = Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent); add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent)));
add(if out.vertical { Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]).transform_rect_bbox(r) } else { r });
} }
} }
out.bounds = b.unwrap_or_default(); out.bounds = b.unwrap_or_default();
@ -502,7 +592,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
break; break;
} }
x += gl.adv; x += gl.adv;
if gl.break_after() { if gl.break_after() && kinsoku_allows(g, j) {
if gl.is_soft_hyphen() { if gl.is_soft_hyphen() {
if x + hyphen_glyph(gl).adv <= width + EPS { if x + hyphen_glyph(gl).adv <= width + EPS {
last_break = Some((j + 1, true)); last_break = Some((j + 1, true));
@ -524,7 +614,7 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
we += 1; we += 1;
} }
let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum(); let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum();
let pts = hyphen_breaks(text, g, word_start, we); let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)).collect();
for &k in pts.iter().rev() { for &k in pts.iter().rev() {
if k <= j && k > i { if k <= j && k > i {
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv; let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
@ -545,6 +635,30 @@ fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -
(end, hy) (end, hy)
} }
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
/// with the one before it).
fn kinsoku_allows(g: &[SGlyph], j: usize) -> bool {
g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch)))
}
/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph).
fn kinsoku_between(before: char, after: Option<char>) -> bool {
!no_line_end(before) && after.is_none_or(|c| !no_line_start(c))
}
#[cfg(test)]
#[test]
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() {
for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] {
assert!(!kinsoku_between(a, Some(b)), "{a}{b}");
}
for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] {
assert!(kinsoku_between(a, Some(b)), "{a}{b}");
}
assert!(kinsoku_between('。', None));
}
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go. /// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go.
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> { fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
// Strip leading/trailing punctuation (quotes, commas). // Strip leading/trailing punctuation (quotes, commas).
@ -579,19 +693,19 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
let end_of_word = gl.is_space() || gl.break_after(); let end_of_word = gl.is_space() || gl.break_after();
if end_of_word { if end_of_word {
if hyphenate && j > ws { if hyphenate && j > ws {
for k in hyphen_breaks(text, g, ws, j) { for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
} }
} }
let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 }; let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 };
if j + 1 < g.len() && g[j + 1].byte != gl.byte { if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j) {
v.push(Breakpoint { end: j + 1, hyphen: hy }); v.push(Breakpoint { end: j + 1, hyphen: hy });
} }
ws = j + 1; ws = j + 1;
} }
} }
if hyphenate && g.len() > ws { if hyphenate && g.len() > ws {
for k in hyphen_breaks(text, g, ws, g.len()) { for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
} }
} }

View File

@ -126,6 +126,9 @@ pub struct LineInfo {
pub struct TextLayout { pub struct TextLayout {
/// Lines retain inline/block coordinates; glyph geometry is in physical text space. /// Lines retain inline/block coordinates; glyph geometry is in physical text space.
pub vertical: bool, pub vertical: bool,
/// Inline/block (line) space → physical text space: identity for horizontal type, a quarter
/// turn clockwise for vertical type (point type also centres its first column on the anchor).
pub line_xf: Affine,
pub glyphs: Vec<PositionedGlyph>, pub glyphs: Vec<PositionedGlyph>,
pub lines: Vec<LineInfo>, pub lines: Vec<LineInfo>,
/// Ink/advance bounds in text space. /// Ink/advance bounds in text space.
@ -142,10 +145,10 @@ pub struct TextLayout {
impl TextLayout { impl TextLayout {
/// Convert physical text coordinates to inline/block coordinates. /// Convert physical text coordinates to inline/block coordinates.
pub fn logical_point(&self, p: Point) -> Point { pub fn logical_point(&self, p: Point) -> Point {
if self.vertical { Point::new(p.y, -p.x) } else { p } if self.vertical { self.line_xf.inverse() * p } else { p }
} }
pub fn physical_point(&self, p: Point) -> Point { pub fn physical_point(&self, p: Point) -> Point {
if self.vertical { Point::new(-p.y, p.x) } else { p } if self.vertical { self.line_xf * p } else { p }
} }
/// All glyph outlines combined (e.g. for Create Outlines). /// All glyph outlines combined (e.g. for Create Outlines).
pub fn to_bezpath(&self) -> BezPath { pub fn to_bezpath(&self) -> BezPath {
@ -166,7 +169,7 @@ impl TextLayout {
/// the layout changed. /// the layout changed.
pub fn snap_to_pixels(&mut self, to_device: Affine) -> bool { pub fn snap_to_pixels(&mut self, to_device: Affine) -> bool {
let [a, b, c, d, _, _] = to_device.as_coeffs(); let [a, b, c, d, _, _] = to_device.as_coeffs();
if self.on_path || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 { if self.on_path || self.vertical || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 {
return false; return false;
} }
let mut moved = false; let mut moved = false;
@ -379,3 +382,5 @@ mod tests_snap;
mod tests_sysfonts; mod tests_sysfonts;
#[cfg(test)] #[cfg(test)]
mod tests_typo; mod tests_typo;
#[cfg(test)]
mod tests_vertical;

View File

@ -40,6 +40,22 @@ pub(crate) struct SGlyph {
pub xh: f64, pub xh: f64,
/// First source character of the cluster. /// First source character of the cluster.
pub ch: char, pub ch: char,
/// Vertical type: part of a tate-chu-yoko block (set across the column, upright).
pub tcy: Option<Tcy>,
}
/// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side
/// by side across it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Tcy {
/// Laid-out distance from the block's start to this glyph's pen position.
pub pen: f64,
/// Distance from the block's start to the glyph in the block's own (horizontal) setting.
pub ink: f64,
/// Width of the block's own setting.
pub width: f64,
/// Horizontal scale that fits the block into one em (1 when it fits as it is).
pub squeeze: f64,
} }
impl SGlyph { impl SGlyph {
@ -77,6 +93,46 @@ pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
h h
} }
/// Kinsoku (Japanese line breaking, the strict set): a character that can't start a line —
/// closing brackets, the Japanese comma and full stop, middle dots, colons, ! and ?, the long
/// vowel mark, iteration marks and small kana.
pub(crate) fn no_line_start(c: char) -> bool {
matches!(c,
')' | ']' | '}' | ',' | '.' | ':' | ';' | '!' | '?' | '»' | '’' | '”' | '‐' | '–' | '‼' | '⁇' | '⁈' | '⁉'
| '、' | '。' | '〉' | '》' | '」' | '』' | '】' | '〕' | '〗' | '〙' | '〛' | '〟' | '〜' | '゠' | '・' | '⦆'
| 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '々' | '〻'
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' | 'ㇰ'..='ㇿ'
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ')
}
/// Kinsoku: a character that can't end a line (opening brackets).
pub(crate) fn no_line_end(c: char) -> bool {
matches!(
c,
'(' | '['
| '{'
| '«'
| '‘'
| '“'
| '〈'
| '《'
| '「'
| '『'
| '【'
| '〔'
| '〖'
| '〘'
| '〚'
| '〝'
| '('
| '['
| '{'
| '⦅'
| '「'
)
}
fn is_cjk(c: char) -> bool { fn is_cjk(c: char) -> bool {
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
} }
@ -255,6 +311,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
cap, cap,
xh, xh,
ch, ch,
tcy: None,
}); });
} }
} }

View File

@ -0,0 +1,130 @@
//! Vertical type (bundled fonts only: `§` and `×` stand upright like CJK, letters lie on their side).
use super::*;
use kurbo::Shape;
use vectorcraft_doc::{CharStyle, TextKind};
use vectorcraft_geom::PathData;
fn vertical(text: &str) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.vertical = true;
t
}
fn ink(l: &TextLayout, i: usize) -> Rect {
l.glyphs[i].outline.bounding_box()
}
#[test]
fn columns_run_down_and_follow_each_other_to_the_left() {
let l = layout(FontDb::global(), &vertical("§§\n§"));
assert!(l.vertical);
let (a, b, c) = (ink(&l, 0), ink(&l, 1), ink(&l, 2));
assert!(b.center().y > a.center().y + 15.0, "down the column: {a:?} {b:?}");
assert!((a.center().x - b.center().x).abs() < 0.5, "one column");
assert!(c.center().x < a.center().x - 15.0, "the next column is to the left: {c:?}");
// Point type: the anchor is on the first column's centre line.
assert!(a.center().x.abs() < 4.0, "{a:?}");
assert!(l.bounds.contains(a.center()) && l.bounds.contains(c.center()));
}
#[test]
fn upright_marks_stand_and_letters_lie_on_their_side() {
let h = layout(FontDb::global(), &TextObject::point(Point::ZERO, "§l", CharStyle { size: 20.0, ..CharStyle::default() }));
let v = layout(FontDb::global(), &vertical("§l"));
let (hs, hl, vs, vl) = (ink(&h, 0), ink(&h, 1), ink(&v, 0), ink(&v, 1));
assert!((vs.width() - hs.width()).abs() < 0.01 && (vs.height() - hs.height()).abs() < 0.01, "§ upright: {hs:?} {vs:?}");
assert!((vl.width() - hl.height()).abs() < 0.01 && (vl.height() - hl.width()).abs() < 0.01, "l turned: {hl:?} {vl:?}");
assert!(vl.width() > vl.height(), "a tall l lies across the column");
}
#[test]
fn caret_and_selection_turn_with_the_columns() {
let l = layout(FontDb::global(), &vertical("§§§"));
let (top, bottom) = caret_position(&l, "§".len());
assert!((top.y - bottom.y).abs() < 1e-6 && (top.x - bottom.x).abs() > 10.0, "a horizontal caret across the column: {top:?} {bottom:?}");
let between = (ink(&l, 0).center().y + ink(&l, 1).center().y) / 2.0;
assert!((top.y - between).abs() < 3.0, "between the first two marks: {top:?} {between} {:?} {:?}", ink(&l, 0), ink(&l, 1));
let q = selection_quads(&l, 0, "§§".len());
let r = Rect::from_points(q[0][0], q[0][2]);
assert!(r.height() > r.width() && r.contains(ink(&l, 0).center()) && r.contains(ink(&l, 1).center()) && !r.contains(ink(&l, 2).center()));
// Clicking a mark finds it.
let c = ink(&l, 2).center();
assert_eq!(hit_byte(&l, c + Vec2::new(0.0, 6.0)), "§§§".len());
assert_eq!(hit_byte(&l, c - Vec2::new(0.0, 6.0)), "§§".len());
}
#[test]
fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
let mut t = vertical(&"§".repeat(12));
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 100.0, 105.0).to_path(0.1)) };
let l = layout(FontDb::global(), &t);
let first = ink(&l, 0);
assert!(first.x1 <= 100.0 + 1e-6 && first.x1 > 80.0 && first.y0 >= 0.0, "top right: {first:?}");
let cols: Vec<f64> = l.glyphs.iter().map(|g| (g.outline.bounding_box().center().x * 10.0).round()).collect();
let mut distinct = cols.clone();
distinct.dedup();
assert!(distinct.len() >= 2 && distinct.windows(2).all(|w| w[1] < w[0]), "columns right to left: {distinct:?}");
assert!(l.glyphs.iter().all(|g| g.outline.bounding_box().y1 <= 105.0 + 1e-6), "inside the frame");
}
#[test]
fn type_on_a_path_stays_horizontal() {
let mut t = vertical("ab");
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0 };
assert!(!layout(FontDb::global(), &t).vertical);
}
#[test]
fn three_digits_stand_across_the_column_squeezed_into_one_em() {
let l = layout(FontDb::global(), &vertical("§100§"));
let block: Vec<Rect> = (1..4).map(|i| ink(&l, i)).collect();
assert!(block.windows(2).all(|w| w[1].center().x > w[0].center().x + 2.0 && (w[1].center().y - w[0].center().y).abs() < 1.0), "{block:?}");
let span = block[0].union(block[2]);
assert!(span.width() <= 20.0 + 1e-6, "squeezed into the 20 pt em: {span:?}");
assert!(ink(&l, 4).center().y - ink(&l, 0).center().y < 45.0, "one em of the column");
}
#[test]
fn two_digits_stand_across_the_column_and_longer_numbers_lie_on_their_side() {
let l = layout(FontDb::global(), &vertical("§12§2026"));
let (one, two) = (ink(&l, 1), ink(&l, 2));
assert!((one.center().y - two.center().y).abs() < 1.0 && two.center().x > one.center().x + 3.0, "12 side by side: {one:?} {two:?}");
assert!(one.height() > one.width(), "upright digits are taller than wide: {one:?}");
let (before, after) = (ink(&l, 0), ink(&l, 3));
assert!(after.center().y - before.center().y < 45.0, "the block takes one em (20 pt) of the column");
let year: Vec<Rect> = (4..8).map(|i| ink(&l, i)).collect();
assert!(year.windows(2).all(|w| w[1].center().y > w[0].center().y + 5.0), "2026 runs down the column on its side");
}
/// Each line's text, from the layout's line ranges.
fn line_texts(t: &TextObject, l: &TextLayout) -> Vec<String> {
let text = t.plain_text();
l.lines.iter().map(|li| text.get(li.start..li.end).unwrap_or("").trim_end().to_string()).collect()
}
#[test]
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_brackets_off_line_ends() {
for vertical_type in [false, true] {
// Five characters fit a line: without kinsoku "。" would start the second line.
let text = "あいうえお。かきくけ「こさしすせそ」";
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
t.xf = Affine::IDENTITY;
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 101.0, 400.0).to_path(0.1)) };
if vertical_type {
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 400.0, 101.0).to_path(0.1)) };
t.vertical = true;
}
let l = layout(FontDb::global(), &t);
let lines = line_texts(&t, &l);
assert!(lines.len() > 2, "{lines:?}");
for (i, line) in lines.iter().enumerate() {
if i > 0 {
let first = line.chars().next().unwrap_or(' ');
assert!(!crate::shape::no_line_start(first), "line {i} starts with {first}: {lines:?}");
}
let last = line.chars().last().unwrap_or(' ');
assert!(!crate::shape::no_line_end(last), "line {i} ends with {last}: {lines:?}");
}
}
}

View File

@ -227,3 +227,22 @@ fn vertical_tools_preserve_their_ids_and_create_vertical_text() {
assert!(tool.vertical); assert!(tool.vertical);
} }
} }
#[test]
fn arrows_follow_the_columns_of_vertical_type() {
let (mut d, id) = doc_with_text("§§§\n§§§");
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.vertical = true;
}
let mut tool = TypeTool::new("type");
tool.start_editing(id, 0);
let s = "§".len();
key(&mut tool, &d, ToolKey::Down, Mods::default());
assert_eq!(tool.caret, s, "↓ goes down the column to the next character");
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!(tool.caret, "§§§\n§".len(), "← goes on to the next column, keeping the place in it");
key(&mut tool, &d, ToolKey::Right, Mods::default());
assert_eq!(tool.caret, s, "→ comes back");
key(&mut tool, &d, ToolKey::Up, Mods::default());
assert_eq!(tool.caret, 0);
}