From edc7c931dc721ef5b3f5ca8aaf74904594d38eb6 Mon Sep 17 00:00:00 2001 From: Daniel Efrat Date: Wed, 7 Oct 2026 10:13:19 +0200 Subject: [PATCH 1/3] 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. --- Cargo.lock | 3 + README.md | 4 +- ROADMAP.md | 7 +- crates/doc/src/text.rs | 4 +- crates/engine/src/cmd/typecmd.rs | 5 +- crates/engine/src/cmd/typemenu.rs | 2 + crates/engine/src/tests_eyedropper.rs | 3 +- crates/engine/src/tests_textedit.rs | 47 ++++++++ crates/svg/src/css.rs | 2 +- crates/svg/src/export.rs | 10 +- crates/svg/src/import/text.rs | 4 + crates/svg/tests/roundtrip.rs | 13 ++ crates/text/Cargo.toml | 3 + crates/text/src/layout.rs | 59 +++++++++- crates/text/src/lib.rs | 94 +++++++++++++-- crates/text/src/shape.rs | 44 +++++-- crates/text/src/tests_bidi.rs | 157 +++++++++++++++++++++++++ crates/tools/src/text.rs | 28 ++++- crates/tools/src/text_tests.rs | 16 +++ crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/paragraph.rs | 56 ++++++++- docs/development.md | 24 ++++ 24 files changed, 547 insertions(+), 43 deletions(-) create mode 100644 crates/text/src/tests_bidi.rs diff --git a/Cargo.lock b/Cargo.lock index 5026a8cf..1bd86c24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4589,6 +4589,9 @@ dependencies = [ "log", "skrifa 0.47.0", "subsetter", + "unicode-bidi", + "unicode-general-category", + "unicode-script", "vectorcraft-doc", "vectorcraft-geom", "write-fonts", diff --git a/README.md b/README.md index 4c6bbb1e..8e67deab 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index ec0e3f66..845159da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 ``/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 | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index e06124b1..fecabe42 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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(), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index ed0b7880..b6590e5d 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "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 { fn justify_param(v: &str) -> Option { 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 { }; 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") { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index a114ea7f..425ae9e4 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -275,6 +275,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result { 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 { 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; diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index e704d441..9dd0a9bd 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -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] diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 2285a1b5..18602689 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -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::(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left); +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index 37e2772a..172b8da0 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -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"), diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 253394e0..a2ccf7ce 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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> { 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 = Vec::new(); diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index b6ae4bd2..90c1baae 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -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 = 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) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); + t.para.justify = Justify::Left; t.runs = runs; t } diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 26509408..62e4ebaa 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -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(" { 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 { /// 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> { - 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], 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], para: &ParaStyle, regions: Opt }; let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::() + 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], 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], 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], 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], 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], 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], 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], 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], 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, glyphs: &[SGlyph], vertical: bool) -> Vec { + 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> = 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() +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 5ef9ae28..6fa75b0e 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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 + } +} diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 265c74d8..dbb9e944 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -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, ) { + 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)> = 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, /// 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) { - 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 = 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 = 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 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); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 1336055e..bf140726 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -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) } diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index cbc69110..5ddce3df 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -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(), "שלום"); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e1148c8a..c22efb5e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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í diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f944330a..082fa45a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2942,3 +2942,4 @@ Saved Selection 23 保存した選択範囲 23 Saved Selection 24 保存した選択範囲 24 Saved Selection 25 保存した選択範囲 25 + Automatic alignment 自動揃え diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0c1c57ab..81b2c8a9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2973,3 +2973,5 @@ — {n} characters from another font — {n} 個字符來自其他字體 [Normal Character Style] [正常字元樣式] [Normal Paragraph Style] [正常段落樣式] + + Automatic alignment 自動對齊 diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 507421be..9cf15bd8 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -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); + } +} diff --git a/docs/development.md b/docs/development.md index fdb0489a..761424e1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. From 0687d2d127407f57190af078c799851c8ab8cc57 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 02:49:47 +0700 Subject: [PATCH 2/3] Type: Paragraph Direction instead of an Automatic alignment toggle; bidi only where needed (review of #300) Illustrator sets a paragraph's direction (Left-to-Right / Right-to-Left Paragraph Direction in the Paragraph panel, with the Middle Eastern options) and keeps its alignment buttons physical; it has no automatic alignment toggle. So: - ParaStyle.direction (None: from each paragraph's first strong character, as before; written only when set), the Paragraph panel's two direction buttons with Show Indic Options (original icons), and text.setFormat {direction: "auto"|"leftToRight"|"rightToLeft"}. The layout uses it as the base direction. - The Automatic alignment check box is gone. Justify::Auto stays as the start of the paragraph's direction, set on new type only (text.create, the Type tools, with the mojikumi default in one new_type_para()): TextObject::point is Left again, so importers (PDF, SVG, DXF, EMF) keep their own alignment. The alignment buttons show what Auto resolves to. - Bidi only where there is something right to left: plain paragraphs skip the algorithm (no per-layout allocation), it runs once per paragraph (shaping takes the paragraph's levels instead of recomputing them; path type computed it four times), and the shaper keeps Han, Hiragana, Katakana and Bopomofo in one segment (splitting at every change of script cost Japanese text a shaper call per kana-kanji switch). - Vertical type keeps its logical order, so its glyphs and lines are not marked right to left (the caret no longer runs backwards down a column). - PDF import puts Hebrew and Arabic, which PDFs (and VectorCraft's own export) draw in visual order, back in logical order with the direction that shows it as drawn; with bidi layout it came back mirrored. - SVG import pins the direction SVG implies (left to right unless direction: rtl); SVG export also outlines right-to-left paragraphs. - Docs (development.md), ROADMAP (folded into the Type entry, Type core row) and README updated; catalog rows for the new labels. Co-Authored-By: Claude Opus 5.5 --- ASSETS.md | 2 + README.md | 2 +- ROADMAP.md | 5 +- assets/icons/dc-para-ltr.svg | 1 + assets/icons/dc-para-rtl.svg | 1 + crates/doc/src/lib.rs | 4 +- crates/doc/src/text.rs | 18 ++++- crates/engine/src/cmd/create.rs | 10 ++- crates/engine/src/cmd/panelcmds.rs | 15 +++- crates/engine/src/cmd/textedit.rs | 3 +- crates/engine/src/cmd/typecmd.rs | 2 +- crates/pdf/src/import_text.rs | 22 +++++- crates/pdf/src/tests_live_text.rs | 33 +++++++++ crates/svg/src/export.rs | 7 +- crates/svg/src/import/text.rs | 13 ++-- crates/text/src/layout.rs | 75 ++++++++++++++------ crates/text/src/lib.rs | 62 +++++++++-------- crates/text/src/shape.rs | 45 +++++++----- crates/text/src/tests_bidi.rs | 63 ++++++++++++++++- crates/ui-egui/src/i18n/cs.tsv | 2 - crates/ui-egui/src/i18n/ja.tsv | 3 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- crates/ui-egui/src/icon_data.rs | 2 + crates/ui-egui/src/panels/paragraph.rs | 95 ++++++++++++++++---------- docs/development.md | 38 +++++------ 25 files changed, 376 insertions(+), 151 deletions(-) create mode 100644 assets/icons/dc-para-ltr.svg create mode 100644 assets/icons/dc-para-rtl.svg diff --git a/ASSETS.md b/ASSETS.md index 40760c62..7cf3c28c 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -166,7 +166,9 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-para-justify-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-justify-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-para-ltr.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-para-rtl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-path-eraser.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-pathfinder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-pen-add.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/README.md b/README.md index 8e67deab..a9cd682d 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ milestones, and honest time-to-parity estimates. **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, threading and automatic Hebrew/Arabic bidirectional layout and alignment, and +Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, 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. diff --git a/ROADMAP.md b/ROADMAP.md index 845159da..85ba6089 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,7 +84,6 @@ 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. @@ -125,7 +124,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). -- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. +- **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. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. - **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese and Japanese cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width, and puts a quarter em between Japanese and Latin letters or digits; 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 `` 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 (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -196,7 +195,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% | 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 core | 9 | 79% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); 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 | diff --git a/assets/icons/dc-para-ltr.svg b/assets/icons/dc-para-ltr.svg new file mode 100644 index 00000000..677d1282 --- /dev/null +++ b/assets/icons/dc-para-ltr.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-para-rtl.svg b/assets/icons/dc-para-rtl.svg new file mode 100644 index 00000000..38750e4f --- /dev/null +++ b/assets/icons/dc-para-rtl.svg @@ -0,0 +1 @@ + diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3c5c488d..13054a29 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -77,8 +77,8 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource}; pub use style_libs::StyleLibrary; pub use text::{ - AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, Mojikumi, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, - TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, + AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, Mojikumi, ParaDirection, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, + TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape, }; pub use vectorcraft_color as color; pub use vectorcraft_geom as geom; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fecabe42..cc2f468b 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -8,7 +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. + /// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for + /// left-to-right paragraphs, right for right-to-left ones. New type's alignment. Auto, #[default] Left, @@ -294,6 +295,19 @@ pub struct ParaStyle { /// it and imported text (already set) keep [`Mojikumi::None`]. #[serde(default, skip_serializing_if = "Mojikumi::is_none")] pub mojikumi: Mojikumi, + /// Paragraph direction (Paragraph panel): the base direction of each paragraph for + /// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or + /// Arabic: right to left). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direction: Option, +} + +/// A paragraph's base direction ([`ParaStyle::direction`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ParaDirection { + LeftToRight, + RightToLeft, } /// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half @@ -471,7 +485,7 @@ impl TextObject { kind: TextKind::Point, xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun { text: text.into(), style }], - para: ParaStyle { justify: Justify::Auto, ..ParaStyle::default() }, + para: ParaStyle::default(), area: AreaOptions::default(), path_effect: PathEffect::default(), wrap: Vec::new(), diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 28501c4c..f36238c4 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -402,8 +402,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let text = str_param(p, "text").unwrap_or(""); let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false); - // New type is composed the Japanese way (punctuation spacing), as the Type tools set it. - t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; + t.para = new_type_para(); if let Some(a) = p.get("area") { let w = f64_or(a, "width", 200.0); let h = f64_or(a, "height", 100.0); @@ -414,6 +413,13 @@ fn text_create(s: &mut Session, p: &Value) -> Result { add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None) } +/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of +/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed +/// with Line-end Punctuation Half Width. Imported text keeps its own. +pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle { + vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf, ..Default::default() } +} + /// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the /// defaults, filled with the current fill unless that is None or white (black then). pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 73aacfd3..7fc68deb 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing)}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character)}", has_doc, set_format ), @@ -112,6 +112,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "spaceAfter", "hyphenate", "mojikumi", + "direction", ]; if !keys.iter().any(|k| p.get(*k).is_some()) { return Err(bad(C, "nothing to change")); @@ -125,6 +126,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`mojikumi` must be \"none\" or \"lineEndHalf\"")), }), }; + let direction = match p.get("direction") { + None => None, + Some(v) => Some(match v.as_str() { + Some("auto") => None, + Some("leftToRight") => Some(vectorcraft_doc::ParaDirection::LeftToRight), + Some("rightToLeft") => Some(vectorcraft_doc::ParaDirection::RightToLeft), + _ => return Err(bad(C, "`direction` must be \"auto\", \"leftToRight\" or \"rightToLeft\"")), + }), + }; s.edit("Character", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; @@ -183,6 +193,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = mojikumi { para.mojikumi = v; } + if let Some(v) = direction { + para.direction = v; + } super::typecmd::refresh_bounds(t); } Ok(()) diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index e341e6ee..6d0f6e17 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -372,13 +372,12 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { kind, xf: Affine::IDENTITY, runs: vec![TextRun { text, style }], - para: Default::default(), + para: super::create::new_type_para(), area: Default::default(), path_effect: Default::default(), wrap: Vec::new(), cached_bounds: None, }; - t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf; refresh_bounds(&mut t); let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| { let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?; diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b6590e5d..4818e5a5 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{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}", + "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}", has_doc, set_style ), diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 0d011715..cee13735 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use kurbo::{Affine, BezPath, Point, Vec2}; use vectorcraft_color::Paint; -use vectorcraft_doc::{CharStyle, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextObject, TextRun}; /// One glyph's placement: its baseline origin, advance direction, size and horizontal scale. #[derive(Clone, Copy, Debug)] @@ -262,6 +262,9 @@ impl TextLine { let first = runs.next()?; let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style); t.runs.extend(runs); + if self.upright.is_none() { + to_logical(&mut t); + } let angle = self.at.dir.atan2(); t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle); let db = vectorcraft_text::FontDb::global(); @@ -370,3 +373,20 @@ impl Families { } } } + +/// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text +/// back in logical order, with the paragraph direction that shows it as drawn. +fn to_logical(t: &mut TextObject) { + let Some((order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return }; + let chars: Vec<(char, usize)> = t.runs.iter().enumerate().flat_map(|(i, r)| r.text.chars().map(move |c| (c, i))).collect(); + let mut runs: Vec<(usize, String)> = Vec::with_capacity(t.runs.len()); + for &(c, i) in order.iter().filter_map(|&k| chars.get(k)) { + match runs.last_mut() { + Some((run, text)) if *run == i => text.push(c), + _ => runs.push((i, c.to_string())), + } + } + let runs = runs.into_iter().filter_map(|(i, text)| Some(TextRun { text, style: t.runs.get(i)?.style.clone() })).collect(); + t.runs = runs; + t.para.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }); +} diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index 8270d83a..b86dfd27 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -273,3 +273,36 @@ fn ink_width_of(_font: &Font, size: f32, text: &str) -> f32 { let style = vectorcraft_doc::CharStyle { font_family: "Source Sans 3".into(), size: f64::from(size), ..Default::default() }; ink_width(&TextObject::point(vectorcraft_geom::Point::ZERO, text, style)) as f32 } + +/// Hebrew drawn by a PDF (in visual order, as VectorCraft's own export draws it) comes back as type +/// in logical order that shows as drawn. Needs an installed font with Hebrew (skipped without one). +#[test] +fn hebrew_comes_back_in_logical_order() { + let db = vectorcraft_text::FontDb::global(); + let Some(face) = + ["Arial", "Noto Sans Hebrew", "DejaVu Sans", "Liberation Sans"].into_iter().filter_map(|f| db.face(f, "Regular")).find(|f| f.covers('ש')) + else { + return; + }; + for text in ["שלום עולם", "שלום 123", "Hello שלום"] { + let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), size: 24.0, ..Default::default() }; + let mut d = Document::new(300.0, 200.0); + let l = d.layers[0].id; + let n = vectorcraft_doc::Node::new( + d.alloc_id(), + NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), text, style))), + ); + d.insert(Some(l), 0, n).unwrap(); + let settings: PdfSettings = serde_json::from_value(serde_json::json!({"advanced": {"outlineText": false}})).unwrap(); + let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes; + let r = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + let t = texts(&r.document); + assert_eq!(t.iter().map(TextObject::plain_text).collect::>(), [text], "{:?}", r.warnings); + let shown = |t: &TextObject| -> String { + let plain = t.plain_text(); + vectorcraft_text::layout(db, t).glyphs.iter().filter_map(|g| plain.get(g.byte..)?.chars().next()).collect() + }; + let original = TextObject::point(vectorcraft_geom::Point::ZERO, text, vectorcraft_doc::CharStyle::default()); + assert_eq!(shown(&t[0]), shown(&original), "{text}"); + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index a2ccf7ce..10397ff8 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1642,10 +1642,11 @@ 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. + // Live SVG text is written in logical order from left-aligned pieces: bidirectional text + // (and right-to-left paragraphs) keep their look as outlines until it is written with + // `direction`/`unicode-bidi`. let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - if lay.glyphs.iter().any(|g| g.rtl) { + if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) { self.warn("bidirectional text is outlined to preserve shaping and visual order"); return self.text_outlines(n, t); } diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 90c1baae..4bf5b5fe 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -26,7 +26,7 @@ use std::str::FromStr; use usvg::roxmltree; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, StrokeLayer, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, ParaDirection, StrokeLayer, TextKind, TextObject, TextRun}; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; use vectorcraft_text::{FontDb, TextLayout}; @@ -566,9 +566,6 @@ 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 = vec![]; if let Some((tp, bp)) = path { // Along the path: dx is extra advance, dy shifts off the path. @@ -615,6 +612,13 @@ impl Ctx { } let (runs, servers) = assemble(&cells, &breaks).0; obj.runs = runs; + // SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first + // strong character: pinned when that would read as right to left. + if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { + obj.para.direction = Some(ParaDirection::RightToLeft); + } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { + obj.para.direction = Some(ParaDirection::LeftToRight); + } // The placeholder covers the text (laid out when a paint server needs its bounding box). let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0)); @@ -771,7 +775,6 @@ fn lines( /// A left-aligned point text of `runs` at the origin (for measuring). fn with_runs(runs: Vec) -> TextObject { let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); - t.para.justify = Justify::Left; t.runs = runs; t } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 525e7e89..f3f369b3 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -3,7 +3,8 @@ use std::ops::Range; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2}; -use vectorcraft_doc::{CharStyle, Justify, Mojikumi, ParaStyle, PathEffect, TextKind, TextObject}; +use unicode_bidi::{BidiInfo, Level}; +use vectorcraft_doc::{CharStyle, Justify, Mojikumi, ParaDirection, ParaStyle, PathEffect, TextKind, TextObject}; use crate::composer::{Breakpoint, compose}; use crate::fontdb::FontDb; @@ -33,9 +34,11 @@ impl Ctx<'_> { find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) } - fn shape_para(&self, r: Range) -> Vec { + /// Shape paragraph `r` (its bidi resolution `bidi`). + fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); - shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v); + let levels = bidi.map_or(&[][..], |b| &b.levels); + shape_range(self.db, self.text, r, &self.runs, &self.opts.features, levels, &mut v); if self.vertical { tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size); // An upright glyph advances down the column by its vertical advance (the font's vertical @@ -104,7 +107,8 @@ impl Ctx<'_> { font_id, gid: g.gid, xf: m, - rtl: g.level.is_rtl(), + // Vertical type keeps its logical order down the column. + rtl: g.level.is_rtl() && !self.vertical, }); } } @@ -885,12 +889,14 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Option<&[Region]>) { let mut pen = Pen { regions, ri: 0, prev: None, pending: 0.0, fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min, queued: vec![] }; 'paras: for (pi, pr) in paras.iter().enumerate() { - let mut sg = cx.shape_para(pr.clone()); + let text = cx.text; + let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); + let rtl = is_rtl(bidi.as_ref()); + let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); // Japanese composition: consecutive punctuation shares one half-em space. 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)); @@ -959,7 +965,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt }; let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::() + 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::Auto => (if rtl { 2 } else { 0 }, false), Justify::Left => (0, false), Justify::Center => (1, false), Justify::Right => (2, false), @@ -1019,7 +1025,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, regions: Opt let tab_origin = if regions.is_some() { x0 } else { 0.0 }; 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); + let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); // 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 = @@ -1059,7 +1065,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], 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()), + rtl: rtl && !cx.vertical, baseline, x0: start_x, x1: x_end, @@ -1124,15 +1130,20 @@ impl ArcPath { fn on_path(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, path: &BezPath, start: f64, closed: bool, effect: PathEffect) { cx.out.on_path = true; let mut sg = Vec::new(); + // The first paragraph's direction aligns the line (Auto) and sets the caret's. + let mut rtl = None; for pr in paras { - 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) { + let text = cx.text; + let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); + rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); + let shaped = cx.shape_para(pr.clone(), bidi.as_ref()); + for i in visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, pr.clone(), &shaped) { if let Some(g) = shaped.get(i) { sg.push(g.clone()); } } } + let rtl = rtl.unwrap_or(false); let ap = ArcPath::new(path); let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| { let s = style_metrics(cx.db, cx.style_at(0)); @@ -1142,7 +1153,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], 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()), + rtl, baseline: 0.0, x0: 0.0, x1: 0.0, @@ -1161,9 +1172,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], 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::Auto if 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, @@ -1218,7 +1227,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], 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()), + rtl, baseline: ps.y, x0: ps.x, x1: pe.x, @@ -1232,12 +1241,34 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], para: &ParaStyle, path: &Be }); } -/// Reorder whole shaping clusters after wrapping, including Unicode L1 whitespace reset. -fn visual_order(bidi: &unicode_bidi::BidiInfo<'_>, paragraph_start: usize, line: Range, glyphs: &[SGlyph], vertical: bool) -> Vec { - if vertical || glyphs.is_empty() || !bidi.has_rtl() { - return (0..glyphs.len()).collect(); +/// The bidirectional resolution (UAX #9) of paragraph `text`, its base direction `direction` or, +/// without one, from its first strong character. `None` for the usual paragraph with nothing right +/// to left in it (no right-to-left character or direction), which skips the algorithm. +pub(crate) fn para_bidi(text: &str, direction: Option) -> Option> { + use unicode_bidi::BidiClass::{AL, AN, FSI, R, RLE, RLI, RLO}; + let rtl_set = direction == Some(ParaDirection::RightToLeft); + if !rtl_set && !text.chars().any(|c| matches!(unicode_bidi::bidi_class(c), R | AL | AN | RLE | RLO | RLI | FSI)) { + return None; + } + let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); + Some(BidiInfo::new(text, level)) +} + +/// Does the paragraph run right to left ([`para_bidi`])? +pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool { + bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl()) +} + +/// The visual order of a line's `glyphs` (indices into them), whole shaping clusters reordered +/// (UAX #9 L1–L2) for the line `line` (bytes of the text) of the paragraph starting at byte +/// `paragraph_start`; their own order when nothing is right to left (`bidi` None). +fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range, glyphs: &[SGlyph]) -> Vec { + let Some((bidi, para)) = bidi.filter(|b| b.has_rtl()).and_then(|b| Some((b, b.paragraphs.first()?))) else { + return (0..glyphs.len()).collect(); + }; + if glyphs.is_empty() { + return vec![]; } - 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> = Vec::new(); for (i, g) in glyphs.iter().enumerate() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 6fa75b0e..64f73c9a 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -400,23 +400,6 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 out } -#[cfg(test)] -mod tests; -#[cfg(test)] -mod tests_embed; -#[cfg(test)] -mod tests_scripts; -#[cfg(test)] -mod tests_snap; -#[cfg(test)] -mod tests_sysfonts; -#[cfg(test)] -mod tests_typo; -#[cfg(test)] -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); @@ -446,15 +429,40 @@ pub fn caret_horizontal(layout: &TextLayout, byte: usize, right: bool) -> usize ) } +/// Does paragraph `text` run right to left: its `direction`, else (None) from its first strong +/// character (numbers and punctuation don't count)? +pub fn paragraph_is_rtl(text: &str, direction: Option) -> bool { + layout::is_rtl(layout::para_bidi(text, direction).as_ref()) +} + +/// Text stored in visual order (as PDF content draws it, each glyph where it is drawn), back in +/// logical order: the order of its characters (indices into `visual.chars()`) and whether its +/// paragraph runs right to left; laid out that way, it reads as drawn. `None` when nothing in it is +/// right to left (the order stands). +pub fn logical_order(visual: &str) -> Option<(Vec, bool)> { + let bidi = layout::para_bidi(visual, None)?; + let para = bidi.paragraphs.first()?; + // Reordering is its own inverse for runs of one direction (the usual case): the visual order + // of the visual text is its logical order. + let levels = bidi.reordered_levels_per_char(para, para.range.clone()); + Some((unicode_bidi::BidiInfo::reorder_visual(&levels), para.level.is_rtl())) +} + +#[cfg(test)] +mod tests; #[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 - } -} +#[cfg(test)] +mod tests_embed; +#[cfg(test)] +mod tests_scripts; +#[cfg(test)] +mod tests_snap; +#[cfg(test)] +mod tests_sysfonts; +#[cfg(test)] +mod tests_typo; +#[cfg(test)] +mod tests_variable; +#[cfg(test)] +mod tests_vertical; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index dbb9e944..98dd71f1 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -2,6 +2,8 @@ use std::ops::Range; use std::sync::Arc; + +use unicode_bidi::Level; use unicode_script::{Script, UnicodeScript}; use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer}; @@ -47,7 +49,7 @@ pub(crate) struct SGlyph { /// 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, + pub level: Level, } /// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side @@ -182,17 +184,19 @@ pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) { (face.cap_height * k, face.x_height * k) } -/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style. +/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style, and `levels` +/// each byte's bidi embedding level from `range.start` (empty: all left to right). Glyphs come out +/// in logical order, right-to-left ones shaped right to left. pub(crate) fn shape_range( db: &FontDb, text: &str, range: Range, runs: &[(Range, &CharStyle)], feats: &OtFeatures, + levels: &[Level], out: &mut Vec, ) { - let Some(part) = text.get(range.clone()) else { return }; - let bidi = unicode_bidi::BidiInfo::new(part, None); + let level_at = |i: usize| levels.get(i - range.start).copied().unwrap_or_else(Level::ltr); let output_start = out.len(); for (ri, (rr, st)) in runs.iter().enumerate() { let a = rr.start.max(range.start); @@ -205,19 +209,12 @@ 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, - level: bidi.levels.get(a - range.start).copied().unwrap_or(unicode_bidi::Level::ltr()), - }; + let mut seg = Segment { range: a..a, run: ri, st, face: primary.clone(), small: false, level: level_at(a) }; let mut cache: Vec<(char, Arc)> = 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 level = level_at(i); 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() @@ -229,7 +226,7 @@ pub(crate) fn shape_range( f }; let small = small_caps && c.is_lowercase(); - let next_script = c.script(); + let next_script = shaping_script(c); 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. @@ -249,8 +246,22 @@ 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); + // Right-to-left segments come out of the shaper in visual order: back to logical order for + // line breaking (the lines are put in visual order once broken). + if levels.iter().any(|l| l.is_rtl()) + && let Some(o) = out.get_mut(output_start..) + { + o.sort_by_key(|g| g.byte); + } +} + +/// The script `c` is shaped in: Japanese and Chinese text mixes Han, Hiragana, Katakana and +/// Bopomofo, shaped together (splitting them would cost a shaper call per change of script). +fn shaping_script(c: char) -> Script { + match c.script() { + Script::Hiragana | Script::Katakana | Script::Bopomofo => Script::Han, + s => s, + } } /// A piece of one run shaped in one go: one face and, for Small Caps, one case. @@ -261,7 +272,7 @@ struct Segment<'a> { face: Arc, /// Lowercase letters drawn as synthesized small capitals. small: bool, - level: unicode_bidi::Level, + level: Level, } fn is_mark(c: char) -> bool { diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index ad05712c..b912affe 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -136,7 +136,7 @@ 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); + 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)) }; @@ -148,10 +148,69 @@ fn automatic_alignment_follows_each_paragraph_and_explicit_choices_win() { 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 mut t = TextObject::point(Point::ZERO, "English\nשלום\nمرحبا\nEnglish again", CharStyle::default()); + t.para.justify = vectorcraft_doc::Justify::Auto; 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); } + +/// Paragraph Direction set on the text wins over its first strong character: English set right to +/// left keeps its words but ends at the start of its line (Auto alignment: the right), its full +/// stop on the left; Hebrew set left to right starts on the left. +#[test] +fn paragraph_direction_overrides_the_first_strong_character() { + use vectorcraft_doc::{Justify, ParaDirection}; + let db = FontDb::global(); + let order = |s: &str, direction| { + let mut t = TextObject::point(Point::ZERO, s, CharStyle::default()); + (t.para.justify, t.para.direction) = (Justify::Auto, direction); + let l = layout(db, &t); + let visible: String = l.glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect(); + (visible, l.lines[0].rtl, l.lines[0].x1) + }; + let (visible, rtl, x1) = order("Hello.", Some(ParaDirection::RightToLeft)); + assert_eq!((visible.as_str(), rtl), (".Hello", true)); + assert!(x1.abs() < 1e-6, "aligned to the right of the anchor: {x1}"); + assert_eq!(order("Hello.", None).0, "Hello."); + assert!(!order("Hello.", None).1); + assert_eq!(order("שלום abc", None).0, "abc םולש", "a Hebrew paragraph starts on the right"); + let (visible, rtl, _) = order("שלום abc", Some(ParaDirection::LeftToRight)); + assert_eq!((visible.as_str(), rtl), ("םולש abc", false), "set left to right, the Hebrew word comes first on the left"); + assert!(crate::paragraph_is_rtl("123 שלום", None) && !crate::paragraph_is_rtl("123 שלום", Some(ParaDirection::LeftToRight))); +} + +/// Plain left-to-right text skips the bidi algorithm: nothing is marked right to left. +#[test] +fn left_to_right_text_is_untouched() { + let l = text_layout("Plain text, 123 (and more)."); + assert!(l.glyphs.iter().all(|g| !g.rtl) && l.lines.iter().all(|l| !l.rtl)); + let bytes: Vec<_> = l.glyphs.iter().map(|g| g.byte).collect(); + assert!(bytes.windows(2).all(|w| w[0] < w[1])); +} + +/// Text drawn in visual order (from a PDF) comes back in logical order, with the direction that +/// shows it as it was drawn; left-to-right text is left alone. +#[test] +fn visual_text_comes_back_in_logical_order() { + let logical = |visual: &str| { + let (order, rtl) = logical_order(visual)?; + let chars: Vec = visual.chars().collect(); + Some((order.iter().filter_map(|&i| chars.get(i)).collect::(), rtl)) + }; + assert_eq!(logical("םולש"), Some(("שלום".to_string(), true))); + assert_eq!(logical("123 םולש"), Some(("שלום 123".to_string(), true))); + assert_eq!(logical("abc םולש"), Some(("abc שלום".to_string(), false))); + assert_eq!(logical("plain text"), None); + // Laid out with that direction, the logical text shows as drawn. + for visual in ["123 םולש", "abc םולש"] { + let (text, rtl) = logical(visual).unwrap(); + let mut t = TextObject::point(Point::ZERO, &text, CharStyle::default()); + t.para.direction = Some(if rtl { vectorcraft_doc::ParaDirection::RightToLeft } else { vectorcraft_doc::ParaDirection::LeftToRight }); + let l = layout(FontDb::global(), &t); + let shown: String = l.glyphs.iter().filter_map(|g| text.get(g.byte..)?.chars().next()).collect(); + assert_eq!(shown, visual); + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c22efb5e..e1148c8a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -572,5 +572,3 @@ Collapse Panels to Icons Sbalit panely na ikony Collapse to Icons Sbalit na ikony Expand Panels Rozbalit panely - - Automatic alignment Automatické zarovnání diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 082fa45a..117c1898 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2942,4 +2942,5 @@ Saved Selection 23 保存した選択範囲 23 Saved Selection 24 保存した選択範囲 24 Saved Selection 25 保存した選択範囲 25 - Automatic alignment 自動揃え + Left-to-Right Paragraph Direction 左から右への段落方向 + Right-to-Left Paragraph Direction 右から左への段落方向 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 81b2c8a9..b9c2c666 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1250,6 +1250,7 @@ Left Indent 左縮排 Left Plane 左平面 Left-Justified Tab 靠左定位點 + Left-to-Right Paragraph Direction 從左至右段落方向 Left: 左: Legacy Macintosh RGB (Gamma 1.8) 舊版 Macintosh RGB (Gamma 1.8) Legacy vector clipboard 舊版向量剪貼簿 @@ -2085,6 +2086,7 @@ Right Indent 右縮排 Right Plane 右側平面 Right-Justified Tab 靠右定位點 + Right-to-Left Paragraph Direction 從右至左段落方向 Right: 右: Rise… 上升… Roman Hanging Punctuation 羅馬懸掛標點 @@ -2973,5 +2975,3 @@ — {n} characters from another font — {n} 個字符來自其他字體 [Normal Character Style] [正常字元樣式] [Normal Paragraph Style] [正常段落樣式] - - Automatic alignment 自動對齊 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 1bd75146..68757f02 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -117,7 +117,9 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-para-justify-left", include_bytes!("../../../assets/icons/dc-para-justify-left.svg")), ("dc-para-justify-right", include_bytes!("../../../assets/icons/dc-para-justify-right.svg")), ("dc-para-left", include_bytes!("../../../assets/icons/dc-para-left.svg")), + ("dc-para-ltr", include_bytes!("../../../assets/icons/dc-para-ltr.svg")), ("dc-para-right", include_bytes!("../../../assets/icons/dc-para-right.svg")), + ("dc-para-rtl", include_bytes!("../../../assets/icons/dc-para-rtl.svg")), ("dc-path-eraser", include_bytes!("../../../assets/icons/dc-path-eraser.svg")), ("dc-pathfinder", include_bytes!("../../../assets/icons/dc-pathfinder.svg")), ("dc-pen-add", include_bytes!("../../../assets/icons/dc-pen-add.svg")), diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 9cf15bd8..ebb0d194 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -1,8 +1,9 @@ -//! Paragraph panel: seven alignment buttons, indents, space before/after and Hyphenate. +//! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), indents, +//! space before/after, Hyphenate and Mojikumi Set (with the East Asian options). use egui::{Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::Justify; +use vectorcraft_doc::{Justify, NodeKind, ParaDirection, ParaStyle}; use super::character::text_style; use super::{pstate, set_pstate}; @@ -33,17 +34,26 @@ 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; - } +/// Does the paragraph being edited (at the caret), else the selected text's first paragraph, run +/// right to left: its Paragraph Direction, else from its first strong character? +fn resolved_rtl(app: &VectorcraftApp, para: &ParaStyle) -> bool { 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 Some(NodeKind::Text(t)) = node.map(|n| &n.kind) else { return para.direction == Some(ParaDirection::RightToLeft) }; 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()) + vectorcraft_text::paragraph_is_rtl(plain.get(paragraph).unwrap_or_default(), para.direction) +} + +/// The alignment button that shows `justify` in a paragraph running right to left (`rtl`) or not: +/// Auto aligns to the start of its direction. +fn shown_alignment(justify: Justify, rtl: bool) -> Justify { + match justify { + Justify::Auto if rtl => Justify::Right, + Justify::Auto => Justify::Left, + j => j, + } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -52,23 +62,29 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { 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); + let rtl = resolved_rtl(app, ¶); + let shown = shown_alignment(para.justify, rtl); 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, resolved == j, 28.0).clicked() { + if widgets::icon_button(ui, icon, tip, shown == j, 28.0).clicked() { para_cmd(app, "text.setStyle", json!({"justify": id})); } } + // Paragraph direction, with the Middle Eastern (Indic) options. + if app.session.prefs.show_indic_options { + ui.add_space(6.0); + for (to_rtl, icon, tip, id) in [ + (false, "dc-para-ltr", tl!("Left-to-Right Paragraph Direction"), "leftToRight"), + (true, "dc-para-rtl", tl!("Right-to-Left Paragraph Direction"), "rightToLeft"), + ] { + if widgets::icon_button(ui, icon, tip, rtl == to_rtl, 28.0).clicked() { + format(app, json!({"direction": 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(); @@ -139,7 +155,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); if menu_item(ui, tl!("Reset Panel"), has, false) { para_cmd(app, "text.setStyle", json!({"justify": "auto"})); - format(app, json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false})); + format( + app, + json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, "direction": "auto"}), + ); } } @@ -148,28 +167,34 @@ mod tests { use super::*; use vectorcraft_engine::Session; + /// New Hebrew type aligns to the start of its direction (Align Right shows), an explicit + /// alignment stays as chosen, and Paragraph Direction (with the Indic options) sets the + /// direction: Auto then follows it. #[test] - fn panel_shows_automatic_alignment_and_resolves_the_selected_text() { + fn alignment_buttons_show_the_resolved_alignment_and_direction_sets_it() { 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))); + let shown = |app: &VectorcraftApp| { + let para = text_style(app).unwrap().1; + shown_alignment(para.justify, resolved_rtl(app, ¶)) + }; + assert_eq!(shown(&app), Justify::Right); app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap(); - assert_eq!(effective_alignment(&app, text_style(&app).unwrap().1.justify), Justify::Left); + assert_eq!(shown(&app), Justify::Left); app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap(); - assert_eq!(effective_alignment(&app, text_style(&app).unwrap().1.justify), Justify::Right); + app.session.execute("text.setFormat", &json!({"direction": "leftToRight"})).unwrap(); + assert_eq!(shown(&app), Justify::Left); + // The direction buttons show with the Indic options only. + crate::i18n::set_current(crate::i18n::Lang::EN); + let tips = |app: &mut VectorcraftApp| { + let ctx = egui::Context::default(); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + frame.textures_delta.clear(); + frame.shapes.len() + }; + let without = tips(&mut app); + app.session.prefs.show_indic_options = true; + assert!(tips(&mut app) > without, "two more buttons"); } } diff --git a/docs/development.md b/docs/development.md index 761424e1..54d2898e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -180,26 +180,24 @@ CI runs a few dozen cases each. Before touching an importer, run a deeper search `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) +## Bidirectional type -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. +Point, area and path type lay out Hebrew and Arabic with the Unicode bidirectional algorithm +(`unicode-bidi`, in `crates/text/src/layout.rs`). Each paragraph's base direction is its +`ParaStyle::direction` (Paragraph panel › Left-to-Right / Right-to-Left Paragraph Direction, shown +with Preferences › Type › Show Indic Options; `text.setFormat {direction}`), else its first strong +character. Text is shaped in logical order, right-to-left runs right to left (`shape.rs` splits +segments by bidi level and script), lines are broken in logical order and then each line's whole +shaping clusters are put in visual order. Stored text, undo and style ranges stay logical; the +caret, selection, hit testing and arrow keys follow the visual order. A paragraph with nothing +right to left in it skips the algorithm, so plain text pays nothing for it. -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. +Alignment buttons are physical (Align Left is left in either direction). New type +(`text.create`, the Type tools) is aligned `Justify::Auto`, the start of each paragraph's direction: +Hebrew grows to the left of a point type's anchor. Imported text keeps its own alignment, and SVG +import pins the direction SVG implies (left to right unless `direction: rtl`). -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. +Vertical type keeps its logical order down the column. SVG export outlines text with right-to-left +lines (with a warning) until it is written with `direction`/`unicode-bidi`; PDF and raster exports +use the laid-out glyphs. No font is bundled for these scripts: the installed fonts' fallback +covers them (the web build needs a font with that coverage). From 3121bba275a717d122705b427d0c6696213d5729 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 03:46:19 +0700 Subject: [PATCH 3/3] Type: files with Auto alignment open in older builds (review of #300) New type's Auto alignment is saved as the alignment it has in the paragraph direction (Right for right to left, else Left) plus an optional `justify_auto` flag, so builds without Auto parse the file and lay it out the same; this build reads Auto back. Paragraph styles keep Auto as one `justify` attribute. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/text.rs | 145 +++++++++++++++++++++++++--- crates/engine/src/cmd/textstyles.rs | 20 ++-- crates/engine/src/tests_textedit.rs | 1 + 3 files changed, 149 insertions(+), 17 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index cc2f468b..d1a6a0c1 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -268,40 +268,122 @@ fn default_align_on() -> char { pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; /// Paragraph attributes (the Paragraph panel). +/// +/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before +/// [`Justify::Auto`]. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(from = "ParaStyleFile", into = "ParaStyleFile")] pub struct ParaStyle { - #[serde(default)] pub justify: Justify, - #[serde(default)] pub left_indent: f64, - #[serde(default)] pub right_indent: f64, - #[serde(default)] pub first_line_indent: f64, - #[serde(default)] pub space_before: f64, - #[serde(default)] pub space_after: f64, - #[serde(default)] pub hyphenate: bool, /// Tab stops (Tabs panel), sorted by position. - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tabs: Vec, /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. - #[serde(default, skip_serializing_if = "Option::is_none")] pub style_name: Option, /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made /// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before /// it and imported text (already set) keep [`Mojikumi::None`]. - #[serde(default, skip_serializing_if = "Mojikumi::is_none")] pub mojikumi: Mojikumi, /// Paragraph direction (Paragraph panel): the base direction of each paragraph for /// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or /// Arabic: right to left). - #[serde(default, skip_serializing_if = "Option::is_none")] pub direction: Option, } +/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph +/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto +/// read the alignment and ignore the flag, and builds with it read Auto back. +#[derive(Serialize, Deserialize)] +struct ParaStyleFile { + #[serde(default)] + justify: Justify, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + justify_auto: bool, + #[serde(default)] + left_indent: f64, + #[serde(default)] + right_indent: f64, + #[serde(default)] + first_line_indent: f64, + #[serde(default)] + space_before: f64, + #[serde(default)] + space_after: f64, + #[serde(default)] + hyphenate: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tabs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + style_name: Option, + #[serde(default, skip_serializing_if = "Mojikumi::is_none")] + mojikumi: Mojikumi, + #[serde(default, skip_serializing_if = "Option::is_none")] + direction: Option, +} + +impl From for ParaStyleFile { + fn from(p: ParaStyle) -> Self { + let justify_auto = p.justify == Justify::Auto; + let justify = match p.justify { + Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right, + Justify::Auto => Justify::Left, + j => j, + }; + let ParaStyle { + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + tabs, + style_name, + mojikumi, + direction, + .. + } = p; + Self { + justify, + justify_auto, + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + tabs, + style_name, + mojikumi, + direction, + } + } +} + +impl From for ParaStyle { + fn from(f: ParaStyleFile) -> Self { + let justify = if f.justify_auto { Justify::Auto } else { f.justify }; + let ParaStyleFile { + left_indent, + right_indent, + first_line_indent, + space_before, + space_after, + hyphenate, + tabs, + style_name, + mojikumi, + direction, + .. + } = f; + Self { justify, left_indent, right_indent, first_line_indent, space_before, space_after, hyphenate, tabs, style_name, mojikumi, direction } + } +} + /// A paragraph's base direction ([`ParaStyle::direction`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -634,4 +716,45 @@ mod tests { assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0))); assert_eq!(t, before); } + + /// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore + /// `justify_auto`); this build reads Auto back. + #[test] + fn auto_alignment_saves_readable_by_older_builds() { + #[derive(Deserialize, Debug, PartialEq)] + enum OldJustify { + Left, + Center, + Right, + JustifyLeft, + JustifyCenter, + JustifyRight, + JustifyAll, + } + #[derive(Deserialize)] + struct OldPara { + justify: OldJustify, + } + #[derive(Deserialize)] + struct OldText { + para: OldPara, + } + for (direction, physical) in + [(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)] + { + let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default()); + t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() }; + let json = serde_json::to_string(&t).unwrap(); + let old: OldText = serde_json::from_str(&json).unwrap(); + assert_eq!(old.para.justify, physical, "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), t); + } + // Other alignments save as before, without the flag. + let p = ParaStyle { justify: Justify::Right, ..Default::default() }; + let json = serde_json::to_string(&p).unwrap(); + assert!(!json.contains("justify_auto"), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), p); + // Files from before the flag keep their alignment. + assert_eq!(serde_json::from_str::(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center); + } } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index eef862d9..f11204a8 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -115,12 +115,20 @@ pub fn specs() -> Vec { // ---------- attribute plumbing ---------- +/// `v` as a JSON map with one key per attribute: Auto alignment, which files save as its +/// direction's alignment plus `justify_auto` (see `vectorcraft_doc::ParaStyle`), is `"justify": +/// "Auto"`, so a style's `justify` replaces it. +fn to_map(v: &T) -> Option> { + let Ok(Value::Object(mut m)) = serde_json::to_value(v) else { return None }; + if m.remove("justify_auto") == Some(Value::Bool(true)) { + m.insert("justify".into(), Value::from("Auto")); + } + Some(m) +} + /// A style's attributes as a JSON map, without the style name. fn to_attrs(v: &T) -> Map { - let mut m = match serde_json::to_value(v) { - Ok(Value::Object(m)) => m, - _ => Map::new(), - }; + let mut m = to_map(v).unwrap_or_default(); m.remove("style_name"); m } @@ -128,7 +136,7 @@ fn to_attrs(v: &T) -> Map { /// `base` with `attrs` written over it (unknown or ill-typed attributes are an error). fn with_attrs(base: &T, attrs: &Map, cmd: &str) -> Result { let mut m = to_attrs(base); - if let Ok(Value::Object(full)) = serde_json::to_value(base) { + if let Some(full) = to_map(base) { m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null)); } for (k, v) in attrs { @@ -144,7 +152,7 @@ fn with_attrs(base: &T, attrs: &Map(cur: &T, old: &Map, new: &Map) -> T { - let Ok(Value::Object(mut m)) = serde_json::to_value(cur) else { return cur.clone() }; + let Some(mut m) = to_map(cur) else { return cur.clone() }; for (k, v) in new { if old.get(k).is_none_or(|o| m.get(k) == Some(o)) { m.insert(k.clone(), v.clone()); diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 18602689..2b1a2f4a 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -550,6 +550,7 @@ fn automatic_alignment_updates_during_edits_and_can_be_overridden() { 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"].as_object_mut().unwrap().remove("justify_auto"); legacy["para"]["justify"] = json!("Left"); assert_eq!(serde_json::from_value::(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left); }