Type: Hebrew and Arabic text laid out right to left (bidi), aligned by its direction

- Unicode bidi (unicode-bidi, unicode-script, unicode-general-category):
  each paragraph's direction from its first strong character, Hebrew and
  Arabic shaped right to left, embedded Latin and numbers keep theirs,
  lines wrapped in logical order and reordered per line by whole
  clusters; source text, undo and style ranges stay logical.
- Caret, hit testing, selection and the arrow keys follow the visual
  order.
- New text aligns automatically: right-to-left paragraphs right, others
  left (Justify::Auto, text.setStyle {justify: "auto"}); the Paragraph
  panel highlights the resolved alignment and has an Automatic alignment
  toggle. Explicit alignments, and older documents', are kept.
- SVG export outlines bidirectional text (with a warning) until it can
  be written as directional live text.
This commit is contained in:
Daniel Efrat authored and bflatastic committed 2026-10-08 05:24:44 +07:00
1 parent 7c3030ff52
commit edc7c931dc
24 files changed
+547 -43

No files matched your search

Generated
+3
View File
@@ -4589,6 +4589,9 @@ dependencies = [
"log",
"skrifa 0.47.0",
"subsetter",
"unicode-bidi",
"unicode-general-category",
"unicode-script",
"vectorcraft-doc",
"vectorcraft-geom",
"write-fonts",
+2 -2
View File
@@ -181,9 +181,9 @@ The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`]
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
milestones, and honest time-to-parity estimates.
**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and automatic Hebrew/Arabic bidirectional layout and alignment, and
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
+4 -3
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework, the Pen and shape-tool modifier fixes and the Layers, Artboards, Swatches/Brushes and dialog fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages, the font menu, saved selections, MCP and EPS; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -84,6 +84,7 @@ When a task lands, update this section, the parity table and "Shipped so far" in
grade by behaviour against `plan/illustrator/`, not by whether a menu item exists.
## Shipped so far
- **M7.RTL:** automatic Unicode bidirectional text for horizontal point, area and path type; Hebrew/Arabic directional shaping, mixed Latin/numbers, per-line visual ordering, logical source clusters, caret hit testing and selection. New text automatically aligns RTL paragraphs right and LTR paragraphs left; explicit alignment overrides remain available. RTL fonts use the existing installed-font fallback; browser builds require an available embedded font with script coverage.
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
- **Automation:**
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
@@ -160,7 +161,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
@@ -195,7 +196,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| 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 |
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type core | 9 | 78% | RTL directional overrides and dual caret affinity at mixed-direction boundaries; composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — hanging punctuation, line-start brackets, kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), 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 |
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
+3 -1
View File
@@ -8,6 +8,8 @@ use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, Li
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
/// Align each paragraph to its automatically detected reading direction.
Auto,
#[default]
Left,
Center,
@@ -469,7 +471,7 @@ impl TextObject {
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
para: ParaStyle::default(),
para: ParaStyle { justify: Justify::Auto, ..ParaStyle::default() },
area: AreaOptions::default(),
path_effect: PathEffect::default(),
wrap: Vec::new(),
+3 -2
View File
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
has_doc,
set_style
),
@@ -190,6 +190,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
fn justify_param(v: &str) -> Option<Justify> {
Some(match v.to_ascii_lowercase().as_str() {
"auto" => Justify::Auto,
"left" => Justify::Left,
"center" => Justify::Center,
"right" => Justify::Right,
@@ -216,7 +217,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
};
let tracking = num_param(p, "tracking");
let justify = match str_param(p, "justify") {
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be left|center|right|justifyAll"))?),
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?),
None => None,
};
let fill = match p.get("fill") {
+2
View File
@@ -275,6 +275,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
let (sl, sr) = match t.para.justify {
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
Justify::Right | Justify::JustifyRight => (slack, 0.0),
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
_ => (0.0, slack),
};
let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0));
@@ -330,6 +331,7 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
let ox = match t.para.justify {
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
Justify::Right | Justify::JustifyRight => x1,
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
_ => x0,
};
t.kind = TextKind::Point;
+2 -1
View File
@@ -100,9 +100,10 @@ fn type_picks_up_font_and_size() {
assert!(width(&s, dst) > before, "laid out again at the new size");
// Without character or paragraph attributes only the paints go.
let dst2 = id_of(s.execute("text.create", &json!({"x": 10, "y": 500, "text": "Other"})).unwrap());
let preserved_alignment = style(&s, dst2).1;
s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"character": false, "paragraph": false}})).unwrap();
let (st, justify) = style(&s, dst2);
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, Justify::Left, "#00ff00".into()));
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into()));
}
#[test]
+47
View File
@@ -506,3 +506,50 @@ fn vertical_text_creation_orientation_and_persistence() {
let old: TextObject = serde_json::from_value(old).unwrap();
assert!(!old.vertical);
}
#[test]
fn rtl_edit_commands_and_undo_preserve_logical_source_and_style_ranges() {
let mut s = session();
let id = text(&mut s, "שלום Rust 123");
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 8, "insert": "مرحبا"})).unwrap();
let t = obj(&s, id);
assert_eq!(t.plain_text(), "مرحبا Rust 123");
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(lay.glyphs.iter().any(|g| g.rtl));
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 10, "size": 30})).unwrap();
assert_eq!(obj(&s, id).runs[0].text, "مرحبا");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "שלום Rust 123");
}
#[test]
fn automatic_alignment_updates_during_edits_and_can_be_overridden() {
let mut s = session();
let id = text(&mut s, "English");
let db = vectorcraft_text::FontDb::global();
assert_eq!(obj(&s, id).para.justify, vectorcraft_doc::Justify::Auto);
for content in ["שלום", "مرحبا", "English"] {
let len = obj(&s, id).plain_text().len();
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": len, "insert": content})).unwrap();
let t = obj(&s, id);
let l = vectorcraft_text::layout(db, &t);
if content == "English" {
assert_eq!(l.lines[0].x0, 0.0);
} else {
assert!(l.lines[0].x1.abs() < 1e-6);
}
}
s.execute("text.setStyle", &json!({"id": id.0, "justify": "center"})).unwrap();
s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 7, "insert": "שלום"})).unwrap();
assert_eq!(obj(&s, id).para.justify, vectorcraft_doc::Justify::Center);
s.execute("text.setStyle", &json!({"id": id.0, "justify": "auto"})).unwrap();
let t = obj(&s, id);
assert!(vectorcraft_text::layout(db, &t).lines[0].x1.abs() < 1e-6);
let restored: TextObject = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap();
assert_eq!(restored.para.justify, vectorcraft_doc::Justify::Auto);
// Legacy documents with explicit alignment retain that choice.
let mut legacy = serde_json::to_value(t).unwrap();
legacy["para"]["justify"] = json!("Left");
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
}
+1 -1
View File
@@ -545,7 +545,7 @@ impl Rules<'_> {
art.props.push(("line-height", self.len(l)));
}
let align = match t.para.justify {
Justify::Left => None,
Justify::Auto | Justify::Left => None,
Justify::Center => Some("center"),
Justify::Right => Some("right"),
Justify::JustifyLeft | Justify::JustifyCenter | Justify::JustifyRight | Justify::JustifyAll => Some("justify"),
+8 -2
View File
@@ -1642,10 +1642,16 @@ impl Writer<'_> {
/// (`text-anchor`) at their centre or right end, so they stay aligned in a viewer whose font
/// differs; other lines are placed where each style run (and, justified, each word) starts.
fn text(&mut self, n: &Node, t: &TextObject) {
// SVG's live-text reconstruction assumes ascending source clusters. Preserve bidi
// shaping and placement exactly until directional live-text export is implemented.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
if lay.glyphs.iter().any(|g| g.rtl) {
self.warn("bidirectional text is outlined to preserve shaping and visual order");
return self.text_outlines(n, t);
}
if let TextKind::OnPath { path, start } = &t.kind {
return self.text_on_path(n, t, path, *start);
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
self.note_fonts(t, &lay);
let lines = text_lines(t, &lay, self.opts.fewer_tspans);
// A tab starts a new chunk at its stop: only lines without tabs can be anchored.
@@ -1911,7 +1917,7 @@ struct Segment {
/// caps are upper case.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Vec<Segment>> {
let plain = t.plain_text();
let split_words = !fewer && !matches!(t.para.justify, Justify::Left | Justify::Center | Justify::Right);
let split_words = !fewer && !matches!(t.para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
let mut lines = Vec::with_capacity(lay.lines.len());
for line in &lay.lines {
let mut segs: Vec<Segment> = Vec::new();
+4
View File
@@ -566,6 +566,9 @@ impl Ctx {
});
let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
// Imported SVG anchors are resolved into positions below, including textPath offsets.
// Keep their explicit placement instead of applying the new-text automatic alignment.
obj.para.justify = Justify::Left;
let mut breaks: Vec<Break> = vec![];
if let Some((tp, bp)) = path {
// Along the path: dx is extra advance, dy shifts off the path.
@@ -768,6 +771,7 @@ fn lines(
/// A left-aligned point text of `runs` at the origin (for measuring).
fn with_runs(runs: Vec<TextRun>) -> TextObject {
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
t.para.justify = Justify::Left;
t.runs = runs;
t
}
+13
View File
@@ -868,3 +868,16 @@ fn outline_text_writes_glyph_paths() {
let b = art(&back).iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!(b.width() > 80.0 && b.x0 >= 9.0, "{b:?}");
}
#[test]
fn bidirectional_svg_export_preserves_shaped_appearance_as_outlines() {
let t = TextObject::point(Point::new(10.0, 40.0), "שלום Rust 123 مرحبا", CharStyle::default());
let expected =
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.iter().filter(|g| !g.outline.elements().is_empty()).count();
assert!(expected > 0);
let d = doc_with(vec![Node::new(NodeId(100), NodeKind::Text(Box::new(t)))]);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("<text"), "visual clusters must not be emitted as reversed live text");
assert!(svg.contains("<path"));
assert!(import(&svg).is_ok());
}
+3
View File
@@ -18,6 +18,9 @@ log = { workspace = true }
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer
# (which turns on `variable-fonts` too: named instances of variable fonts are instanced).
subsetter = { version = "0.2", default-features = false, features = ["variable-fonts"] }
unicode-bidi = "0.3.18"
unicode-general-category = "1"
unicode-script = "0.5"
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
# Apache-2.0, already in the build through subsetter.
write-fonts = { version = "0.48", optional = true }
+54 -5
View File
@@ -104,6 +104,7 @@ impl Ctx<'_> {
font_id,
gid: g.gid,
xf: m,
rtl: g.level.is_rtl(),
});
}
}
@@ -848,7 +849,7 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
/// Every-line composition of paragraph glyphs `sg` if applicable (justified area text with uniform
/// line metrics); `None` falls back to the greedy single-line composer.
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> Option<Vec<(usize, bool)>> {
let justified = !matches!(para.justify, Justify::Left | Justify::Center | Justify::Right);
let justified = !matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 {
return None;
}
@@ -889,6 +890,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
let compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] };
// …and a quarter em between Japanese and Latin text.
let wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] };
let bidi = unicode_bidi::BidiInfo::new(cx.text.get(pr.clone()).unwrap_or_default(), None);
let pm = {
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
let (cap, xh) = cap_x_heights(cx.db, cx.style_at(pr.start));
@@ -957,6 +959,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
};
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
let (align, justify) = match para.justify {
Justify::Auto => (if bidi.paragraphs.first().is_some_and(|p| p.level.is_rtl()) { 2 } else { 0 }, false),
Justify::Left => (0, false),
Justify::Center => (1, false),
Justify::Right => (2, false),
@@ -1011,11 +1014,20 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
};
let li = cx.out.lines.len();
let glyph_start = cx.out.glyphs.len();
let mut x = start_x;
let mut x_end = start_x;
// Tab stops are measured from the frame's left edge (point type: the origin).
let tab_origin = if regions.is_some() { x0 } else { 0.0 };
for (j, g) in sg.iter().enumerate().take(end).skip(i) {
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
let order = visual_order(&bidi, pr.start, line_start..line_end, &sg[i..end], cx.vertical);
// RTL's logical trailing spaces precede the visible content. Keep them outside
// the aligned content extent, just as LTR trailing spaces extend to its right.
let leading_space_width: f64 =
order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum();
let mut x = start_x - leading_space_width;
for offset in order {
let j = i + offset;
let Some(g) = sg.get(j) else { continue };
let mut adv = g.adv;
if j + 1 == trimmed {
adv -= end_trim;
@@ -1036,7 +1048,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
}
cx.emit(g, Affine::translate((x, baseline)), Point::new(x, baseline), 0.0, adv, li);
x += adv;
if j + 1 == trimmed {
if j < trimmed {
x_end = x;
}
}
@@ -1047,6 +1059,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
x_end = hx + h.adv;
}
cx.out.lines.push(LineInfo {
rtl: bidi.paragraphs.first().is_some_and(|p| p.level.is_rtl()),
baseline,
x0: start_x,
x1: x_end,
@@ -1112,7 +1125,13 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
cx.out.on_path = true;
let mut sg = Vec::new();
for pr in paras {
sg.extend(cx.shape_para(pr.clone()));
let shaped = cx.shape_para(pr.clone());
let bidi = unicode_bidi::BidiInfo::new(cx.text.get(pr.clone()).unwrap_or_default(), None);
for i in visual_order(&bidi, pr.start, pr.clone(), &shaped, cx.vertical) {
if let Some(g) = shaped.get(i) {
sg.push(g.clone());
}
}
}
let ap = ArcPath::new(path);
let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| {
@@ -1123,6 +1142,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
if ap.segs.is_empty() {
cx.out.overflow = !sg.is_empty();
cx.out.lines.push(LineInfo {
rtl: unicode_bidi::BidiInfo::new(cx.text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()),
baseline: 0.0,
x0: 0.0,
x1: 0.0,
@@ -1141,6 +1161,9 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
let avail = if closed { ap.len } else { ap.len - s_start };
let w: f64 = sg.iter().map(|g| g.adv).sum();
let s0 = match para.justify {
Justify::Auto if unicode_bidi::BidiInfo::new(cx.text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()) => {
s_start + (avail - w).max(0.0)
}
Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0),
Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0),
_ => s_start,
@@ -1195,6 +1218,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
cx.out.lines.push(LineInfo {
rtl: unicode_bidi::BidiInfo::new(cx.text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()),
baseline: ps.y,
x0: ps.x,
x1: pe.x,
@@ -1207,3 +1231,28 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
avail: (0.0, ap.len),
});
}
/// Reorder whole shaping clusters after wrapping, including Unicode L1 whitespace reset.
fn visual_order(bidi: &unicode_bidi::BidiInfo<'_>, paragraph_start: usize, line: Range<usize>, glyphs: &[SGlyph], vertical: bool) -> Vec<usize> {
if vertical || glyphs.is_empty() || !bidi.has_rtl() {
return (0..glyphs.len()).collect();
}
let Some(para) = bidi.paragraphs.first() else { return (0..glyphs.len()).collect() };
let levels = bidi.reordered_levels(para, line.start.saturating_sub(paragraph_start)..line.end.saturating_sub(paragraph_start));
let mut clusters: Vec<Range<usize>> = Vec::new();
for (i, g) in glyphs.iter().enumerate() {
if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte) {
if let Some(c) = clusters.last_mut() {
c.end = i + 1;
}
} else {
clusters.push(i..i + 1);
}
}
let cluster_levels: Vec<_> = clusters
.iter()
.filter_map(|c| glyphs.get(c.start))
.map(|g| levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level))
.collect();
unicode_bidi::BidiInfo::reorder_visual(&cluster_levels).into_iter().filter_map(|i| clusters.get(i)).flat_map(|c| c.clone()).collect()
}
+81 -13
View File
@@ -100,6 +100,8 @@ pub struct PositionedGlyph {
/// Font units (y down, as [`FontDb::outline`] gives them) → text space: where the glyph is
/// drawn, also for glyphs without an outline (spaces).
pub xf: Affine,
/// True when the logical leading edge is the glyph’s right edge.
pub rtl: bool,
}
/// One line of laid-out text.
@@ -108,6 +110,8 @@ pub struct PositionedGlyph {
/// path and `x1` the x of its end point.
#[derive(Clone, Debug)]
pub struct LineInfo {
/// Automatically detected paragraph base direction.
pub rtl: bool,
pub baseline: f64,
/// Left edge of the line content (after alignment).
pub x0: f64,
@@ -202,11 +206,11 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
return (Point::ZERO, Point::ZERO);
};
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) {
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
(g.origin + dir(g.angle) * (g.advance * frac), g.angle)
} else if let Some(g) = glyphs.last() {
(g.origin + dir(g.angle) * g.advance, g.angle)
(g.origin + dir(g.angle) * (g.advance * if g.rtl { 1.0 - frac } else { frac }), g.angle)
} else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) {
(g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle)
} else {
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 })
};
@@ -225,7 +229,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
(p - ca).hypot2().total_cmp(&(p - cb).hypot2())
});
return match best {
Some(g) if (p - g.origin).dot(dir(g.angle)) < g.advance * 0.5 => g.byte,
Some(g) if ((p - g.origin).dot(dir(g.angle)) < g.advance * 0.5) != g.rtl => g.byte,
Some(g) => g.byte + g.len,
None => layout.lines.first().map_or(0, |l| l.start),
};
@@ -264,10 +268,15 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize {
let Some(line) = layout.lines.get(li) else { return 0 };
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) {
return g.byte;
}
line_end(layout, li)
let nearest = glyphs
.iter()
.flat_map(|g| {
let left = layout.logical_point(g.origin).x;
let right = left + g.advance;
if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] }
})
.min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs()));
nearest.map_or(line.start, |(_, byte)| byte)
}
/// Caret byte at the end of line `li`: a soft-wrapped line ends before its trailing space so the
@@ -276,7 +285,7 @@ pub fn line_end(layout: &TextLayout, li: usize) -> usize {
let Some(line) = layout.lines.get(li) else { return 0 };
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
let soft_wrap = layout.lines.get(li + 1).is_some_and(|n| n.start == line.end);
match glyphs.iter().rev().find(|g| g.len > 0) {
match glyphs.iter().filter(|g| g.len > 0).max_by_key(|g| g.byte) {
Some(g) if soft_wrap && g.byte + g.len == line.end => g.byte,
_ => line.end,
}
@@ -296,11 +305,11 @@ pub fn line_end_of(layout: &TextLayout, byte: usize) -> usize {
fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 {
let line = &layout.lines[li];
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) {
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
return layout.logical_point(g.origin).x + g.advance * frac;
return layout.logical_point(g.origin).x + g.advance * if g.rtl { 1.0 - frac } else { frac };
}
glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance)
glyphs.iter().max_by_key(|g| g.byte + g.len).map_or(line.x0, |g| layout.logical_point(g.origin).x + if g.rtl { 0.0 } else { g.advance })
}
/// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x`
@@ -354,6 +363,23 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
}
return out;
}
if layout.glyphs.iter().any(|g| g.rtl) {
for g in layout.glyphs.iter().filter(|g| g.byte < b && g.byte + g.len > a) {
let Some(l) = layout.lines.get(g.line) else { continue };
let p = layout.logical_point(g.origin);
let (x0, x1) = (p.x, p.x + g.advance);
out.push(
[
Point::new(x0, l.baseline - l.ascent),
Point::new(x1, l.baseline - l.ascent),
Point::new(x1, l.baseline + l.descent),
Point::new(x0, l.baseline + l.descent),
]
.map(|p| layout.physical_point(p)),
);
}
return out;
}
for (li, l) in layout.lines.iter().enumerate() {
if l.start > b || l.end < a || (l.end == a && l.start < a && layout.lines.get(li + 1).is_some_and(|n| n.start == l.end)) {
continue;
@@ -390,3 +416,45 @@ mod tests_typo;
mod tests_variable;
#[cfg(test)]
mod tests_vertical;
/// Move horizontally through visual caret stops; source offsets stay in logical order.
pub fn caret_horizontal(layout: &TextLayout, byte: usize, right: bool) -> usize {
let li = layout.line_of(byte);
let Some(line) = layout.lines.get(li) else { return byte };
let x = layout.logical_point(caret_position(layout, byte).0).x;
let mut stops: Vec<_> =
layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default().iter().flat_map(|g| [g.byte, g.byte + g.len]).collect();
stops.sort_unstable();
stops.dedup();
stops
.into_iter()
.filter_map(|b| {
let bx = layout.logical_point(caret_position(layout, b).0).x;
let d = if right { bx - x } else { x - bx };
(d > 1e-6).then_some((d, b))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map_or_else(
|| {
if right != line.rtl {
layout.lines.get(li + 1).map_or(byte, |next| next.start)
} else {
li.checked_sub(1).and_then(|previous| layout.lines.get(previous)).map_or(byte, |previous| previous.end)
}
},
|(_, b)| b,
)
}
#[cfg(test)]
mod tests_bidi;
/// Resolve automatic alignment from a paragraph's first strong Unicode character.
/// Numbers and punctuation do not force a Hebrew/Arabic paragraph to align left.
pub fn automatic_alignment(text: &str) -> vectorcraft_doc::Justify {
if unicode_bidi::BidiInfo::new(text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()) {
vectorcraft_doc::Justify::Right
} else {
vectorcraft_doc::Justify::Left
}
}
+37 -7
View File
@@ -2,6 +2,7 @@
use std::ops::Range;
use std::sync::Arc;
use unicode_script::{Script, UnicodeScript};
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
use skrifa::MetadataProvider;
@@ -45,6 +46,8 @@ pub(crate) struct SGlyph {
/// Japanese composition: the space taken off before the glyph (an opening bracket after
/// another, see [`crate::layout`]); the glyph is drawn that much earlier on the line.
pub lead: f64,
/// Resolved Unicode bidi embedding level (logical source order).
pub level: unicode_bidi::Level,
}
/// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side
@@ -188,6 +191,9 @@ pub(crate) fn shape_range(
feats: &OtFeatures,
out: &mut Vec<SGlyph>,
) {
let Some(part) = text.get(range.clone()) else { return };
let bidi = unicode_bidi::BidiInfo::new(part, None);
let output_start = out.len();
for (ri, (rr, st)) in runs.iter().enumerate() {
let a = rr.start.max(range.start);
let b = rr.end.min(range.end);
@@ -199,10 +205,19 @@ pub(crate) fn shape_range(
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
let small_caps = st.small_caps.is_some() && !st.all_caps;
// Split into segments by font coverage (and case, for Small Caps).
let mut seg = Segment { range: a..a, run: ri, st, face: primary.clone(), small: false };
let mut seg = Segment {
range: a..a,
run: ri,
st,
face: primary.clone(),
small: false,
level: bidi.levels.get(a - range.start).copied().unwrap_or(unicode_bidi::Level::ltr()),
};
let mut cache: Vec<(char, Arc<FontFace>)> = Vec::new();
let mut script = Script::Common;
for (i, c) in text[a..b].char_indices() {
let i = a + i;
let level = bidi.levels.get(i - range.start).copied().unwrap_or(unicode_bidi::Level::ltr());
let covered = c.is_whitespace() || c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some());
let face = if covered {
primary.clone()
@@ -214,13 +229,19 @@ pub(crate) fn shape_range(
f
};
let small = small_caps && c.is_lowercase();
let next_script = c.script();
let strong_script = !matches!(next_script, Script::Common | Script::Inherited);
let script_change = strong_script && script != Script::Common && script != next_script;
// Combining marks stay with their base.
if (face.id() != seg.face.id() || small != seg.small) && !is_mark(c) {
if (face.id() != seg.face.id() || small != seg.small || level != seg.level || script_change) && !is_mark(c) {
if i > seg.range.start {
seg.range.end = i;
shape_segment(text, &seg, feats, out);
}
seg = Segment { range: i..i, face, small, ..seg };
seg = Segment { range: i..i, face, small, level, ..seg };
}
if strong_script {
script = next_script;
}
}
if b > seg.range.start {
@@ -228,6 +249,8 @@ pub(crate) fn shape_range(
shape_segment(text, &seg, feats, out);
}
}
// Line breaking works in logical order; visual ordering happens after wrapping.
out[output_start..].sort_by_key(|g| g.byte);
}
/// A piece of one run shaped in one go: one face and, for Small Caps, one case.
@@ -238,14 +261,17 @@ struct Segment<'a> {
face: Arc<FontFace>,
/// Lowercase letters drawn as synthesized small capitals.
small: bool,
level: unicode_bidi::Level,
}
fn is_mark(c: char) -> bool {
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
use unicode_general_category::{GeneralCategory, get_general_category};
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
|| matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
}
fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
let Segment { range, run, st, face, small } = seg;
let Segment { range, run, st, face, small, level } = seg;
let (range, run, small) = (range.clone(), *run, *small);
let text_seg = &text[range.clone()];
let full = st.size.max(0.0);
@@ -281,7 +307,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
buf.add(c, cl);
}
}
buf.set_direction(Direction::LeftToRight);
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
let feats: Vec<Feature> = feats.resolve(st);
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
@@ -305,11 +331,14 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
}
}
}
let mut cluster_starts: Vec<usize> = raw.iter().map(|r| r.1 as usize).collect();
cluster_starts.sort_unstable();
cluster_starts.dedup();
let n = raw.len();
for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() {
let cl = cl as usize;
// Cluster end: the next larger cluster value in the segment, else the segment end.
let end = raw[gi + 1..].iter().map(|r| r.1 as usize).find(|&c| c > cl).unwrap_or(range.end);
let end = cluster_starts.get(cluster_starts.partition_point(|&c| c <= cl)).copied().unwrap_or(range.end);
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
let ch = first_char(cl);
let mut adv = xa as f64 * k * hs;
@@ -339,6 +368,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
ch,
tcy: None,
lead: 0.0,
level: *level,
});
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Direction, source-cluster and editing regressions; no RTL font assets required.
use super::*;
use kurbo::{Point, Rect, Shape};
use vectorcraft_doc::{CharStyle, TextKind};
fn text_layout(s: &str) -> TextLayout {
layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default()))
}
#[test]
fn hebrew_and_arabic_are_visually_rtl_with_exact_source_clusters() {
for s in ["שלום", "مرحبا"] {
let l = text_layout(s);
assert!(!l.glyphs.is_empty());
assert!(l.glyphs.iter().all(|g| g.rtl));
let bytes: Vec<_> = l.glyphs.iter().map(|g| g.byte).collect();
assert!(bytes.windows(2).all(|w| w[0] >= w[1]), "{s}: {bytes:?}");
for g in &l.glyphs {
assert!(s.get(g.byte..g.byte + g.len).is_some(), "{g:?}");
assert!(g.len <= s.len());
}
assert!(caret_position(&l, 0).0.x > caret_position(&l, s.len()).0.x);
}
}
#[test]
fn mixed_text_preserves_latin_and_number_order() {
let s = "שלום Rust 123";
let l = text_layout(s);
let visible: String = l.glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect();
assert_eq!(visible, "Rust 123 םולש");
assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text");
}
#[test]
fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() {
let s = "שלום";
let l = text_layout(s);
for g in &l.glyphs {
let p = Point::new(g.origin.x + g.advance * 0.9, g.origin.y);
assert_eq!(hit_byte(&l, p), g.byte);
let p = Point::new(g.origin.x + g.advance * 0.1, g.origin.y);
assert_eq!(hit_byte(&l, p), g.byte + g.len);
}
assert_eq!(caret_horizontal(&l, 0, false), 2);
assert_eq!(caret_horizontal(&l, 2, true), 0);
assert!(!selection_quads(&l, 0, s.len()).is_empty());
}
#[test]
fn wrapping_retains_logical_line_ranges_and_reorders_each_line() {
let s = "שלום עולם שלום עולם שלום עולם";
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 55.0, 400.0).to_path(0.1)) };
let l = layout(FontDb::global(), &t);
assert!(l.lines.len() > 1);
assert!(!l.overflow);
for line in &l.lines {
assert!(s.get(line.start..line.end).is_some());
for g in &l.glyphs[line.glyph_start..line.glyph_end] {
assert!(g.byte >= line.start && g.byte + g.len <= line.end);
}
}
}
#[test]
fn paragraph_direction_is_independent_and_neutrals_do_not_force_ltr() {
let l = text_layout("123 שלום\nEnglish\nمرحبا");
assert!(l.glyphs.iter().any(|g| g.line == 0 && g.rtl));
assert!(l.glyphs.iter().filter(|g| g.line == 1).all(|g| !g.rtl));
assert!(l.glyphs.iter().filter(|g| g.line == 2).all(|g| g.rtl));
}
#[test]
fn hebrew_marks_stay_with_base_clusters() {
let s = "שָׁלוֹם";
let l = text_layout(s);
for g in &l.glyphs {
assert!(s.get(g.byte..g.byte + g.len).is_some());
}
assert!(l.glyphs.iter().all(|g| g.rtl));
}
#[test]
fn arabic_contextual_forms_and_lam_alef_use_the_shaper() {
let db = FontDb::global();
// Installed fonts are optional; no proprietary or test font is copied into the repo.
let face =
["Geeza Pro", "Arial", "Noto Sans Arabic"].into_iter().filter_map(|name| db.face(name, "Regular")).find(|f| f.covers('ل') && f.covers('ا'));
let Some(face) = face else { return };
let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..CharStyle::default() };
for s in ["سلام", "שלום سلام"] {
let l = layout(db, &TextObject::point(Point::ZERO, s, st.clone()));
let arabic: Vec<_> = l.glyphs.iter().filter(|g| s.get(g.byte..).is_some_and(|t| t.starts_with(['س', 'ل', 'ا', 'م']))).collect();
assert!(
arabic.iter().any(|g| s.get(g.byte..).and_then(|t| t.chars().next()).is_some_and(|c| g.gid != face.glyph_for(c))),
"Arabic must use contextual forms: {s}"
);
assert!(arabic.iter().all(|g| g.rtl));
}
}
#[test]
fn bidi_isolates_numbers_and_brackets_keep_valid_clusters() {
for s in ["שלום (123)", "مرحبا (Rust 42)", "English \u{2067}שלום 123\u{2069} end"] {
let l = text_layout(s);
for g in &l.glyphs {
assert!(s.get(g.byte..g.byte + g.len).is_some());
}
assert!(!selection_quads(&l, 0, s.len()).is_empty());
}
}
#[test]
fn right_aligned_wrapped_rtl_keeps_trailing_spaces_outside_content() {
let mut t = TextObject::point(Point::ZERO, "שלום עולם שלום עולם שלום עולם", CharStyle::default());
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 80.0, 400.0).to_path(0.1)) };
t.para.justify = vectorcraft_doc::Justify::Right;
let l = layout(FontDb::global(), &t);
assert!(l.lines.len() > 1);
for line in &l.lines {
assert!((line.x1 - 80.0).abs() < 1e-6, "{line:?}");
}
}
#[test]
fn visual_arrows_cross_rtl_paragraph_boundaries() {
let l = text_layout("שלום\nעולם");
assert!(l.lines.iter().all(|l| l.rtl));
assert_eq!(caret_horizontal(&l, 8, false), 9);
assert_eq!(caret_horizontal(&l, 9, true), 8);
}
#[test]
fn automatic_alignment_follows_each_paragraph_and_explicit_choices_win() {
let db = FontDb::global();
for s in ["שלום", "مرحبا", "123 שלום"] {
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
assert_eq!(t.para.justify, vectorcraft_doc::Justify::Auto);
let l = layout(db, &t);
assert!((l.lines[0].x1).abs() < 1e-6, "RTL point type grows left from its anchor: {s}");
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1)) };
let l = layout(db, &t);
assert!((l.lines[0].x1 - 200.0).abs() < 1e-6);
t.para.justify = vectorcraft_doc::Justify::Left;
assert_eq!(layout(db, &t).lines[0].x0, 0.0, "explicit left alignment is preserved");
t.para.justify = vectorcraft_doc::Justify::Center;
let l = layout(db, &t);
assert!((l.lines[0].x0 + l.lines[0].x1 - 200.0).abs() < 1e-6);
}
let t = TextObject::point(Point::ZERO, "English\nשלום\nمرحبا\nEnglish again", CharStyle::default());
let l = layout(db, &t);
assert_eq!(l.lines[0].x0, 0.0);
assert!(l.lines[1].x0 < 0.0 && l.lines[1].x1.abs() < 1e-6);
assert!(l.lines[2].x0 < 0.0 && l.lines[2].x1.abs() < 1e-6);
assert_eq!(l.lines[3].x0, 0.0);
}
+24 -4
View File
@@ -486,21 +486,41 @@ impl Tool for TypeTool {
}
ToolKey::Left => {
let to = if a != b && !mods.shift {
a
if lay.glyphs.iter().any(|g| g.rtl)
&& vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x
{
b
} else {
a
}
} else if word {
edit::prev_word(&text, self.caret)
} else {
edit::prev_char(&text, self.caret)
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, false)
} else {
edit::prev_char(&text, self.caret)
}
};
self.move_to(to, mods.shift)
}
ToolKey::Right => {
let to = if a != b && !mods.shift {
b
if lay.glyphs.iter().any(|g| g.rtl)
&& vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x
{
a
} else {
b
}
} else if word {
edit::next_word(&text, self.caret)
} else {
edit::next_char(&text, self.caret)
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, true)
} else {
edit::next_char(&text, self.caret)
}
};
self.move_to(to, mods.shift)
}
+16
View File
@@ -319,3 +319,19 @@ fn arrows_follow_the_columns_of_vertical_type() {
key(&mut tool, &d, ToolKey::Up, Mods::default());
assert_eq!(tool.caret, 0);
}
#[test]
fn hebrew_typing_and_visual_arrows_keep_logical_text() {
let (mut d, id, mut tool) = editing("");
let snap = d.clone();
let (sel, p) = (Selection::default(), paint());
let actions = tool.text_input(&cx(&d, &sel, &p), "שלום");
apply(&mut d, &snap, &actions);
assert_eq!(tool.caret, "שלום".len());
key(&mut tool, &d, ToolKey::Right, Mods::default());
assert_eq!(tool.caret, 6, "Right moves toward the logical start in Hebrew");
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!(tool.caret, 8);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "שלום");
}
+2
View File
@@ -572,3 +572,5 @@
Collapse Panels to Icons Sbalit panely na ikony
Collapse to Icons Sbalit na ikony
Expand Panels Rozbalit panely
Automatic alignment Automatické zarovnání
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2942,3 +2942,4 @@
Saved Selection 23 保存した選択範囲 23
Saved Selection 24 保存した選択範囲 24
Saved Selection 25 保存した選択範囲 25
Automatic alignment 自動揃え
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2973,3 +2973,5 @@
— {n} characters from another font — {n} 個字符來自其他字體
[Normal Character Style] [正常字元樣式]
[Normal Paragraph Style] [正常段落樣式]
Automatic alignment 自動對齊
Can't render this file because it contains an unexpected character in line 6 and column 47.
+54 -2
View File
@@ -33,21 +33,42 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
app.run(cmd, p).ok();
}
fn effective_alignment(app: &VectorcraftApp, alignment: Justify) -> Justify {
if alignment != Justify::Auto {
return alignment;
}
let editing = super::character::text_editing(app);
let selected = super::first_selected(app);
let node = editing.and_then(|(id, _, _)| app.session.active().and_then(|d| d.doc.node(id))).or(selected.as_ref());
let Some(vectorcraft_doc::NodeKind::Text(t)) = node.map(|n| &n.kind) else { return Justify::Left };
let plain = t.plain_text();
let paragraph = vectorcraft_text::edit::paragraph_at(&plain, editing.map_or(0, |(_, a, _)| a));
vectorcraft_text::automatic_alignment(plain.get(paragraph).unwrap_or_default())
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some((_, para)) = text_style(app) else {
super::empty_state(ui, "pilcrow", tl!("No text selected"), tl!("Select a text object to edit its paragraph attributes."));
return;
};
let resolved = effective_alignment(app, para.justify);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for (j, icon, tip, id) in ALIGNMENTS {
if widgets::icon_button(ui, icon, tip, para.justify == j, 28.0).clicked() {
if widgets::icon_button(ui, icon, tip, resolved == j, 28.0).clicked() {
para_cmd(app, "text.setStyle", json!({"justify": id}));
}
}
});
ui.add_space(4.0);
if widgets::check(ui, tl!("Automatic alignment"), para.justify == Justify::Auto, true) {
para_cmd(
app,
"text.setStyle",
json!({"justify": if para.justify != Justify::Auto { "auto" } else if resolved == Justify::Right { "right" } else { "left" }}),
);
}
let fw = ((ui.available_width() - 66.0) / 2.0).clamp(60.0, 100.0);
// Indents and paragraph spacing are distances (General); type sizes follow Units ▸ Type.
let unit = app.session.general_unit();
@@ -117,7 +138,38 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
menu_item(ui, tl!("Every-line Composer"), false, true);
ui.separator();
if menu_item(ui, tl!("Reset Panel"), has, false) {
para_cmd(app, "text.setStyle", json!({"justify": "left"}));
para_cmd(app, "text.setStyle", json!({"justify": "auto"}));
format(app, json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false}));
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn panel_shows_automatic_alignment_and_resolves_the_selected_text() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
app.session.execute("text.create", &json!({"x": 200, "y": 50, "text": "שלום"})).unwrap();
assert_eq!(effective_alignment(&app, Justify::Auto), Justify::Right);
crate::i18n::set_current(crate::i18n::Lang::EN);
let ctx = egui::Context::default();
let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui));
// This headless frame inspects shapes without uploading textures to a renderer.
frame.textures_delta.clear();
fn has_label(shape: &egui::Shape) -> bool {
match shape {
egui::Shape::Text(t) => t.galley.text() == "Automatic alignment",
egui::Shape::Vec(shapes) => shapes.iter().any(has_label),
_ => false,
}
}
assert!(frame.shapes.iter().any(|shape| has_label(&shape.shape)));
app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
assert_eq!(effective_alignment(&app, text_style(&app).unwrap().1.justify), Justify::Left);
app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap();
assert_eq!(effective_alignment(&app, text_style(&app).unwrap().1.justify), Justify::Right);
}
}
+24
View File
@@ -179,3 +179,27 @@ Untrusted input has property tests that must never panic:
CI runs a few dozen cases each. Before touching an importer, run a deeper search, e.g.
`PROPTEST_CASES=20000 cargo test --release -p vectorcraft-engine --test import_fuzz`. When it finds a
panic, fix the code and add the input as a regular test.
## Bidirectional type (M7.RTL)
Horizontal point, area and path text automatically resolve paragraph direction using Unicode
first-strong detection. Hebrew and Arabic runs shape right to left; embedded Latin and numbers
retain their direction. Wrapping happens in logical order, followed by visual ordering of whole
shaping clusters. Text commands, pasted text and the Type tool use the same layout; stored text,
undo and style byte ranges remain in logical order. New text defaults to automatic alignment:
Hebrew/Arabic paragraphs align right and grow left from a point anchor, while LTR paragraphs
align left. Alignment is resolved again during edits and independently for each paragraph.
The Paragraph panel highlights the resolved alignment and provides an Automatic alignment toggle.
Explicit Left/Center/Right choices override detection; `text.setStyle {justify: "auto"}` restores
automatic mode. Saved documents retain the mode; existing explicit alignments are preserved.
Combining marks stay with their bases and Arabic uses the font's contextual forms and ligatures.
Use an installed font covering the script; no new fonts are bundled. Web builds need a font with
Hebrew/Arabic coverage supplied through the existing font setup. SVG export outlines bidirectional
text to preserve its appearance; native files retain editable text. PDF and raster exports use the
same positioned glyphs as the canvas.
Remaining refinements: explicit paragraph direction overrides, dual caret affinity at mixed-direction
boundaries, RTL tab-stop conventions, and editable bidirectional SVG export. Vertical type retains
its existing logical column order. Unicode direction detection is script based, not a language or
keyboard-layout classifier.