mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 08:20:13 +00:00
Type on a path could only be repositioned by Type on a Path Options, whose menu item sent
`{start: 0}`, so OK threw the text to the start of its path; nothing showed or dragged its
brackets. Now, as in Illustrator:
- Type on a path flows between a start and an end bracket (`TextKind::OnPath { end }`, None:
the end of the path or once round a closed one; `TextKind::path_span`). Flipping
(`TextObject::flip_on_path`) reverses the path keeping a closed path's first anchor and swaps
the brackets, so the type keeps its stretch of the path.
- Type on a Path Options gains Align to Path (ascender, descender, center, baseline) and
Spacing (glyphs spaced as if set that far above the path: closed up round the outside of
curves, opened up round the inside), laid out by the text engine.
- `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them in
one undo step; with none of them it queries. The menu item opens the dialog with the selected
type's Effect, Flip, Align to Path and Spacing (dropdowns for the choices) and leaves the
brackets alone, so OK as opened changes nothing.
- The Selection and Direct Selection tools show selected type on a path's path and its start,
centre and end brackets (`tools::pathtype`, with a bracket cursor): dragging the start or end
bracket sets where the type runs, dragging the centre bracket slides it along the path and,
across the path, flips it (Cmd/Ctrl held: it only slides). Each drag is one undo step.
- Arc-length measuring moved to `vectorcraft_geom::ArcPath` (layout, the tools and
`text.createInPath` share it). SVG export outlines path type whose end bracket, alignment or
spacing `<textPath>` can't express.
- docs/mcp.md documents the command and the drags; ROADMAP updated; dialog strings translated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
409 lines
23 KiB
Rust
409 lines
23 KiB
Rust
//! 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, Justify, TextKind};
|
||
use vectorcraft_geom::PathData;
|
||
|
||
fn vertical(text: &str) -> TextObject {
|
||
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
|
||
}
|
||
|
||
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, end: None };
|
||
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:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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:?}");
|
||
}
|
||
}
|
||
|
||
/// Upright glyphs take their cell from the font's vertical metrics: the advance down the column,
|
||
/// and where the glyph hangs from its vertical origin (VORG, or the glyph's top and top side
|
||
/// bearing). Fonts without them keep one em and the em box centre.
|
||
#[test]
|
||
fn upright_glyphs_follow_the_fonts_vertical_metrics() {
|
||
use crate::test_fonts::{VERTICAL_FAMILY, VERTICAL_TALL, vertical_font};
|
||
for vorg in [false, true] {
|
||
let db = FontDb::with_font_dirs(vec![]);
|
||
db.add_font(vertical_font(vorg).unwrap());
|
||
let text = format!("{VERTICAL_TALL}{VERTICAL_TALL}");
|
||
let mut t = vertical(&text);
|
||
t.runs[0].style.font_family = VERTICAL_FAMILY.into();
|
||
let l = layout(&db, &t);
|
||
let step = l.glyphs[1].origin.y - l.glyphs[0].origin.y;
|
||
assert!((step - 28.0).abs() < 0.01, "1.4 em down the column at 20 pt (vorg {vorg}): {step}");
|
||
// The glyph hangs from its vertical origin, 1.1 em above the baseline: its top is
|
||
// (1.1 em − its own top) below the top of its cell.
|
||
let face = db.face(VERTICAL_FAMILY, "Regular").unwrap();
|
||
let (_, origin) = face.vertical_glyph(face.glyph_for(VERTICAL_TALL)).unwrap();
|
||
assert!((origin - 1100.0).abs() < 1.0, "vorg {vorg}: {origin}");
|
||
let top = db.outline(&face, face.glyph_for(VERTICAL_TALL)).bounding_box().y0; // y-down: −yMax
|
||
let cell_top = l.glyphs[0].origin.y;
|
||
let expected = cell_top + (1.1 + top / 1000.0) * 20.0;
|
||
assert!((ink(&l, 0).y0 - expected).abs() < 0.05, "vorg {vorg}: ink top {} vs {expected}", ink(&l, 0).y0);
|
||
}
|
||
// The same glyph in the font without vertical metrics: one em, as before.
|
||
let l = layout(FontDb::global(), &vertical("§§"));
|
||
assert!((l.glyphs[1].origin.y - l.glyphs[0].origin.y - 20.0).abs() < 0.01);
|
||
}
|
||
|
||
/// Mojikumi (JLREQ 3.1): with Line-end Punctuation Half Width, a closing mark ending a line is set
|
||
/// half width, a closing mark followed by punctuation loses the space after it, and an opening
|
||
/// bracket after another loses the space before it; vertical type the same way down the column.
|
||
/// Needs a font with full-width Japanese punctuation (skipped without one).
|
||
#[test]
|
||
fn mojikumi_halves_line_end_and_consecutive_punctuation() {
|
||
use vectorcraft_doc::Mojikumi;
|
||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.mojikumi = m;
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
let solid = lay("一。", Mojikumi::None, false);
|
||
if (solid.glyphs[1].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width punctuation here
|
||
}
|
||
for vertical_type in [false, true] {
|
||
let adv = |text: &str, m| lay(text, m, vertical_type).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>();
|
||
// Line end: 。 half width.
|
||
assert!((adv("一。", Mojikumi::LineEndHalf)[1] - 10.0).abs() < 0.01, "vertical {vertical_type}");
|
||
assert!((adv("一。", Mojikumi::None)[1] - 20.0).abs() < 0.01);
|
||
// 」 before 「: the closing mark's space goes; 「 keeps its own.
|
||
let a = adv("一」「二", Mojikumi::LineEndHalf);
|
||
assert!((a[1] - 10.0).abs() < 0.01 && (a[2] - 20.0).abs() < 0.01, "{a:?}");
|
||
// 「「: the second bracket loses the space before it and is drawn half an em earlier.
|
||
let a = adv("「「一", Mojikumi::LineEndHalf);
|
||
assert!((a[0] - 20.0).abs() < 0.01 && (a[1] - 10.0).abs() < 0.01, "{a:?}");
|
||
let (on, off) = (lay("「「一", Mojikumi::LineEndHalf, vertical_type), lay("「「一", Mojikumi::None, vertical_type));
|
||
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
|
||
let moved = along(off.glyphs[1].outline.bounding_box()) - along(on.glyphs[1].outline.bounding_box());
|
||
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: the second 「 is drawn half an em earlier ({moved})");
|
||
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
|
||
let drift = across(off.glyphs[1].outline.bounding_box()) - across(on.glyphs[1].outline.bounding_box());
|
||
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
|
||
// Text without punctuation is untouched.
|
||
assert_eq!(adv("一二", Mojikumi::LineEndHalf), adv("一二", Mojikumi::None));
|
||
}
|
||
}
|
||
|
||
/// Mojikumi (JLREQ 3.2.2): a quarter em between Japanese and Latin letters or digits, either way
|
||
/// round, horizontal and vertical; none inside a tate-chu-yoko block, none left at a line's end, and
|
||
/// none with Mojikumi None. Needs a font with full-width Japanese (skipped without one).
|
||
#[test]
|
||
fn mojikumi_spaces_japanese_from_latin_by_a_quarter_em() {
|
||
use vectorcraft_doc::Mojikumi;
|
||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.mojikumi = m;
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
if (lay("雅", Mojikumi::None, false).glyphs[0].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width Japanese here
|
||
}
|
||
for vertical_type in [false, true] {
|
||
let extra = |text: &str| -> Vec<f64> {
|
||
let (on, off) = (lay(text, Mojikumi::LineEndHalf, vertical_type), lay(text, Mojikumi::None, vertical_type));
|
||
on.glyphs.iter().zip(&off.glyphs).map(|(a, b)| a.advance - b.advance).collect()
|
||
};
|
||
// 雅楽 2026 年: after 楽 and after 6.
|
||
assert_eq!(extra("雅楽2026年").iter().map(|x| (x * 100.0).round() / 100.0).collect::<Vec<_>>(), [0.0, 5.0, 0.0, 0.0, 0.0, 5.0, 0.0]);
|
||
// Latin first, and nothing after the last character of the line.
|
||
assert_eq!(extra("AB雅").iter().map(|x| x.round()).collect::<Vec<_>>(), [0.0, 5.0, 0.0]);
|
||
assert_eq!(extra("雅A").iter().map(|x| x.round()).collect::<Vec<_>>(), [5.0, 0.0]);
|
||
// Punctuation takes no Japanese–Latin space (the closing bracket isn't at the line's end).
|
||
assert!(extra("「A」です").iter().all(|x| x.abs() < 0.01), "{:?}", extra("「A」です"));
|
||
}
|
||
// A tate-chu-yoko block is set as a Japanese character, with no space inside or around it.
|
||
let on = lay("第10回", Mojikumi::LineEndHalf, true);
|
||
let off = lay("第10回", Mojikumi::None, true);
|
||
assert!(on.glyphs.iter().zip(&off.glyphs).all(|(a, b)| (a.advance - b.advance).abs() < 0.01));
|
||
}
|
||
|
||
/// Mojikumi (JLREQ 3.1.5): with Line-end Punctuation Half Width, an opening bracket starting a
|
||
/// wrapped line is set flush with the line's start (the space before it goes), giving the line half
|
||
/// an em more room; at the start of a paragraph it keeps its full width. Horizontal and vertical.
|
||
/// Needs a font with full-width Japanese punctuation (skipped without one).
|
||
#[test]
|
||
fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
|
||
use vectorcraft_doc::Mojikumi;
|
||
// 20 pt type in a frame three and a half ems across the lines.
|
||
let lay = |text: &str, m: Mojikumi, vertical_type: bool| {
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size: 20.0, ..CharStyle::default() });
|
||
t.xf = Affine::IDENTITY;
|
||
t.vertical = vertical_type;
|
||
t.para.mojikumi = m;
|
||
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, 70.0) } else { Rect::new(0.0, 0.0, 70.0, 200.0) };
|
||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
||
layout(FontDb::global(), &t)
|
||
};
|
||
if (lay("一「", Mojikumi::None, false).glyphs[1].advance - 20.0).abs() > 2.0 {
|
||
return; // no font with full-width punctuation here
|
||
}
|
||
for vertical_type in [false, true] {
|
||
let along = |r: Rect| if vertical_type { r.y0 } else { r.x0 };
|
||
// 一二三 / 「四五六: the bracket can't end the first line, so it starts the second.
|
||
let (on, off) = (lay("一二三「四五六", Mojikumi::LineEndHalf, vertical_type), lay("一二三「四五六", Mojikumi::None, vertical_type));
|
||
let moved = along(off.glyphs[3].outline.bounding_box()) - along(on.glyphs[3].outline.bounding_box());
|
||
assert!((moved - 10.0).abs() < 0.01, "vertical {vertical_type}: 「 is drawn half an em earlier ({moved})");
|
||
let across = |r: Rect| if vertical_type { r.x0 } else { r.y0 };
|
||
let drift = across(off.glyphs[3].outline.bounding_box()) - across(on.glyphs[3].outline.bounding_box());
|
||
assert!(drift.abs() < 0.01, "vertical {vertical_type}: and not moved across the line ({drift})");
|
||
assert!((on.glyphs[3].advance - 10.0).abs() < 0.01, "vertical {vertical_type}: {}", on.glyphs[3].advance);
|
||
// The half em it gave up lets 六 stay on the line.
|
||
assert_eq!(on.glyphs[6].line, on.glyphs[3].line, "vertical {vertical_type}");
|
||
assert_ne!(off.glyphs[6].line, off.glyphs[3].line, "vertical {vertical_type}");
|
||
// At the start of a paragraph the bracket keeps its full width.
|
||
let first = lay("「一」", Mojikumi::LineEndHalf, vertical_type);
|
||
assert!((first.glyphs[0].advance - 20.0).abs() < 0.01, "vertical {vertical_type}: {}", first.glyphs[0].advance);
|
||
}
|
||
}
|