Merge pull request #300 from daniel-efrat/codex/automatic-rtl-text

Add automatic Hebrew/Arabic RTL text and paragraph alignment
This commit is contained in:
BFlat 2026-10-08 05:31:25 +07:00 committed by GitHub
commit 80307f11da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 937 additions and 76 deletions

View File

@ -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 | |

3
Cargo.lock generated
View File

@ -4589,6 +4589,9 @@ dependencies = [
"log",
"skrifa 0.47.0",
"subsetter",
"unicode-bidi",
"unicode-general-category",
"unicode-script",
"vectorcraft-doc",
"vectorcraft-geom",
"write-fonts",

View File

@ -181,9 +181,9 @@ The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`]
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
milestones, and honest time-to-parity estimates.
**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
**Where we are (2026-10-07):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and 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.

View File

@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework, the Pen and shape-tool modifier fixes and the Layers, Artboards, Swatches/Brushes and dialog fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages, the font menu, saved selections, MCP and EPS; see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@ -124,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 `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (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).
@ -160,7 +160,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel, Hebrew/Arabic bidirectional text and Paragraph Direction done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
@ -195,7 +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% | 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 |

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M11 3V14" /><path d="M14 3V14" /><path d="M16 3H9.5a3 3 0 0 0 0 6H11" /><path d="M4 19H20" /><path d="M17 16L20 19L17 22" /></svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M13 3V14" /><path d="M10 3V14" /><path d="M8 3H14.5a3 3 0 0 1 0 6H13" /><path d="M20 19H4" /><path d="M7 16L4 19L7 22" /></svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@ -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;

View File

@ -8,6 +8,9 @@ use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, Li
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
/// 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,
Center,
@ -265,33 +268,128 @@ 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<TabStop>,
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style_name: Option<String>,
/// 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).
pub direction: Option<ParaDirection>,
}
/// [`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<TabStop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style_name: Option<String>,
#[serde(default, skip_serializing_if = "Mojikumi::is_none")]
mojikumi: Mojikumi,
#[serde(default, skip_serializing_if = "Option::is_none")]
direction: Option<ParaDirection>,
}
impl From<ParaStyle> 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<ParaStyleFile> 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")]
pub enum ParaDirection {
LeftToRight,
RightToLeft,
}
/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half
@ -618,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::<TextObject>(&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::<ParaStyle>(&json).unwrap(), p);
// Files from before the flag keep their alignment.
assert_eq!(serde_json::from_str::<ParaStyle>(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center);
}
}

View File

@ -402,8 +402,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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 {

View File

@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
"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<Value> {
_ => 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<Value> {
if let Some(v) = mojikumi {
para.mojikumi = v;
}
if let Some(v) = direction {
para.direction = v;
}
super::typecmd::refresh_bounds(t);
}
Ok(())

View File

@ -372,13 +372,12 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
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))?;

View File

@ -115,12 +115,20 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------- 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<T: Serialize>(v: &T) -> Option<Map<String, Value>> {
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<T: Serialize>(v: &T) -> Map<String, Value> {
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<T: Serialize>(v: &T) -> Map<String, Value> {
/// `base` with `attrs` written over it (unknown or ill-typed attributes are an error).
fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Value>, cmd: &str) -> Result<T> {
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<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Val
/// value (or not set by it) take the new value; overrides stay. Unchanged if the style doesn't
/// round-trip through JSON.
fn restyle<T: Serialize + DeserializeOwned + Clone>(cur: &T, old: &Map<String, Value>, new: &Map<String, Value>) -> 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());

View File

@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
has_doc,
set_style
),
@ -190,6 +190,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
fn justify_param(v: &str) -> Option<Justify> {
Some(match v.to_ascii_lowercase().as_str() {
"auto" => Justify::Auto,
"left" => Justify::Left,
"center" => Justify::Center,
"right" => Justify::Right,
@ -216,7 +217,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
};
let tracking = num_param(p, "tracking");
let justify = match str_param(p, "justify") {
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be left|center|right|justifyAll"))?),
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?),
None => None,
};
let fill = match p.get("fill") {

View File

@ -275,6 +275,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
let (sl, sr) = match t.para.justify {
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
Justify::Right | Justify::JustifyRight => (slack, 0.0),
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
_ => (0.0, slack),
};
let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0));
@ -330,6 +331,7 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
let ox = match t.para.justify {
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
Justify::Right | Justify::JustifyRight => x1,
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
_ => x0,
};
t.kind = TextKind::Point;

View File

@ -100,9 +100,10 @@ fn type_picks_up_font_and_size() {
assert!(width(&s, dst) > before, "laid out again at the new size");
// Without character or paragraph attributes only the paints go.
let dst2 = id_of(s.execute("text.create", &json!({"x": 10, "y": 500, "text": "Other"})).unwrap());
let preserved_alignment = style(&s, dst2).1;
s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"character": false, "paragraph": false}})).unwrap();
let (st, justify) = style(&s, dst2);
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, Justify::Left, "#00ff00".into()));
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into()));
}
#[test]

View File

@ -506,3 +506,51 @@ 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"].as_object_mut().unwrap().remove("justify_auto");
legacy["para"]["justify"] = json!("Left");
assert_eq!(serde_json::from_value::<TextObject>(legacy).unwrap().para.justify, vectorcraft_doc::Justify::Left);
}

View File

@ -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 });
}

View File

@ -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::<Vec<_>>(), [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}");
}
}

View File

@ -545,7 +545,7 @@ impl Rules<'_> {
art.props.push(("line-height", self.len(l)));
}
let align = match t.para.justify {
Justify::Left => None,
Justify::Auto | Justify::Left => None,
Justify::Center => Some("center"),
Justify::Right => Some("right"),
Justify::JustifyLeft | Justify::JustifyCenter | Justify::JustifyRight | Justify::JustifyAll => Some("justify"),

View File

@ -1642,10 +1642,17 @@ 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) {
// 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) || 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);
}
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 +1918,7 @@ struct Segment {
/// caps are upper case.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Vec<Segment>> {
let plain = t.plain_text();
let split_words = !fewer && !matches!(t.para.justify, Justify::Left | Justify::Center | Justify::Right);
let split_words = !fewer && !matches!(t.para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
let mut lines = Vec::with_capacity(lay.lines.len());
for line in &lay.lines {
let mut segs: Vec<Segment> = Vec::new();

View File

@ -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};
@ -612,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));

View File

@ -868,3 +868,16 @@ fn outline_text_writes_glyph_paths() {
let b = art(&back).iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!(b.width() > 80.0 && b.x0 >= 9.0, "{b:?}");
}
#[test]
fn bidirectional_svg_export_preserves_shaped_appearance_as_outlines() {
let t = TextObject::point(Point::new(10.0, 40.0), "שלום Rust 123 مرحبا", CharStyle::default());
let expected =
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).glyphs.iter().filter(|g| !g.outline.elements().is_empty()).count();
assert!(expected > 0);
let d = doc_with(vec![Node::new(NodeId(100), NodeKind::Text(Box::new(t)))]);
let svg = export(&d, &ExportOptions::default());
assert!(!svg.contains("<text"), "visual clusters must not be emitted as reversed live text");
assert!(svg.contains("<path"));
assert!(import(&svg).is_ok());
}

View File

@ -18,6 +18,9 @@ log = { workspace = true }
# Font subsets for embedding (SVG @font-face); MIT OR Apache-2.0, already used by the PDF writer
# (which turns on `variable-fonts` too: named instances of variable fonts are instanced).
subsetter = { version = "0.2", default-features = false, features = ["variable-fonts"] }
unicode-bidi = "0.3.18"
unicode-general-category = "1"
unicode-script = "0.5"
# Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR
# Apache-2.0, already in the build through subsetter.
write-fonts = { version = "0.48", optional = true }

View File

@ -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<usize>) -> Vec<SGlyph> {
/// Shape paragraph `r` (its bidi resolution `bidi`).
fn shape_para(&self, r: Range<usize>, bidi: Option<&BidiInfo<'_>>) -> Vec<SGlyph> {
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,6 +107,8 @@ impl Ctx<'_> {
font_id,
gid: g.gid,
xf: m,
// Vertical type keeps its logical order down the column.
rtl: g.level.is_rtl() && !self.vertical,
});
}
}
@ -848,7 +853,7 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
/// Every-line composition of paragraph glyphs `sg` if applicable (justified area text with uniform
/// line metrics); `None` falls back to the greedy single-line composer.
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> Option<Vec<(usize, bool)>> {
let justified = !matches!(para.justify, Justify::Left | Justify::Center | Justify::Right);
let justified = !matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 {
return None;
}
@ -884,7 +889,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) ->
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], 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.
@ -957,6 +965,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
};
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
let (align, justify) = match para.justify {
Justify::Auto => (if rtl { 2 } else { 0 }, false),
Justify::Left => (0, false),
Justify::Center => (1, false),
Justify::Right => (2, false),
@ -1011,11 +1020,20 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
};
let li = cx.out.lines.len();
let glyph_start = cx.out.glyphs.len();
let mut x = start_x;
let mut x_end = start_x;
// Tab stops are measured from the frame's left edge (point type: the origin).
let tab_origin = if regions.is_some() { x0 } else { 0.0 };
for (j, g) in sg.iter().enumerate().take(end).skip(i) {
let line_start = sg.get(i).map_or(pr.start, |g| g.byte);
let line_end = sg.get(end).map_or(pr.end, |g| g.byte);
let order = visual_order(bidi.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 =
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 +1054,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
}
cx.emit(g, Affine::translate((x, baseline)), Point::new(x, baseline), 0.0, adv, li);
x += adv;
if j + 1 == trimmed {
if j < trimmed {
x_end = x;
}
}
@ -1047,6 +1065,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
x_end = hx + h.adv;
}
cx.out.lines.push(LineInfo {
rtl: rtl && !cx.vertical,
baseline,
x0: start_x,
x1: x_end,
@ -1111,9 +1130,20 @@ impl ArcPath {
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], 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 {
sg.extend(cx.shape_para(pr.clone()));
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));
@ -1123,6 +1153,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
if ap.segs.is_empty() {
cx.out.overflow = !sg.is_empty();
cx.out.lines.push(LineInfo {
rtl,
baseline: 0.0,
x0: 0.0,
x1: 0.0,
@ -1141,6 +1172,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
let avail = if closed { ap.len } else { ap.len - s_start };
let w: f64 = sg.iter().map(|g| g.adv).sum();
let s0 = match para.justify {
Justify::Auto if 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 +1227,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
cx.out.lines.push(LineInfo {
rtl,
baseline: ps.y,
x0: ps.x,
x1: pe.x,
@ -1207,3 +1240,50 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
avail: (0.0, ap.len),
});
}
/// 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<ParaDirection>) -> Option<BidiInfo<'_>> {
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<usize>, glyphs: &[SGlyph]) -> Vec<usize> {
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 levels = bidi.reordered_levels(para, line.start.saturating_sub(paragraph_start)..line.end.saturating_sub(paragraph_start));
let mut clusters: Vec<Range<usize>> = Vec::new();
for (i, g) in glyphs.iter().enumerate() {
if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte) {
if let Some(c) = clusters.last_mut() {
c.end = i + 1;
}
} else {
clusters.push(i..i + 1);
}
}
let cluster_levels: Vec<_> = clusters
.iter()
.filter_map(|c| glyphs.get(c.start))
.map(|g| levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level))
.collect();
unicode_bidi::BidiInfo::reorder_visual(&cluster_levels).into_iter().filter_map(|i| clusters.get(i)).flat_map(|c| c.clone()).collect()
}

View File

@ -100,6 +100,8 @@ pub struct PositionedGlyph {
/// Font units (y down, as [`FontDb::outline`] gives them) → text space: where the glyph is
/// drawn, also for glyphs without an outline (spaces).
pub xf: Affine,
/// True when the logical leading edge is the glyph’s right edge.
pub rtl: bool,
}
/// One line of laid-out text.
@ -108,6 +110,8 @@ pub struct PositionedGlyph {
/// path and `x1` the x of its end point.
#[derive(Clone, Debug)]
pub struct LineInfo {
/// Automatically detected paragraph base direction.
pub rtl: bool,
pub baseline: f64,
/// Left edge of the line content (after alignment).
pub x0: f64,
@ -202,11 +206,11 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) {
return (Point::ZERO, Point::ZERO);
};
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) {
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
(g.origin + dir(g.angle) * (g.advance * frac), g.angle)
} else if let Some(g) = glyphs.last() {
(g.origin + dir(g.angle) * g.advance, g.angle)
(g.origin + dir(g.angle) * (g.advance * if g.rtl { 1.0 - frac } else { frac }), g.angle)
} else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) {
(g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle)
} else {
(layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 })
};
@ -225,7 +229,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
(p - ca).hypot2().total_cmp(&(p - cb).hypot2())
});
return match best {
Some(g) if (p - g.origin).dot(dir(g.angle)) < g.advance * 0.5 => g.byte,
Some(g) if ((p - g.origin).dot(dir(g.angle)) < g.advance * 0.5) != g.rtl => g.byte,
Some(g) => g.byte + g.len,
None => layout.lines.first().map_or(0, |l| l.start),
};
@ -264,10 +268,15 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize {
pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize {
let Some(line) = layout.lines.get(li) else { return 0 };
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) {
return g.byte;
}
line_end(layout, li)
let nearest = glyphs
.iter()
.flat_map(|g| {
let left = layout.logical_point(g.origin).x;
let right = left + g.advance;
if g.rtl { [(right, g.byte), (left, g.byte + g.len)] } else { [(left, g.byte), (right, g.byte + g.len)] }
})
.min_by(|a, b| (a.0 - x).abs().total_cmp(&(b.0 - x).abs()));
nearest.map_or(line.start, |(_, byte)| byte)
}
/// Caret byte at the end of line `li`: a soft-wrapped line ends before its trailing space so the
@ -276,7 +285,7 @@ pub fn line_end(layout: &TextLayout, li: usize) -> usize {
let Some(line) = layout.lines.get(li) else { return 0 };
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
let soft_wrap = layout.lines.get(li + 1).is_some_and(|n| n.start == line.end);
match glyphs.iter().rev().find(|g| g.len > 0) {
match glyphs.iter().filter(|g| g.len > 0).max_by_key(|g| g.byte) {
Some(g) if soft_wrap && g.byte + g.len == line.end => g.byte,
_ => line.end,
}
@ -296,11 +305,11 @@ pub fn line_end_of(layout: &TextLayout, byte: usize) -> usize {
fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 {
let line = &layout.lines[li];
let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end];
if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) {
if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) {
let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 };
return layout.logical_point(g.origin).x + g.advance * frac;
return layout.logical_point(g.origin).x + g.advance * if g.rtl { 1.0 - frac } else { frac };
}
glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance)
glyphs.iter().max_by_key(|g| g.byte + g.len).map_or(line.x0, |g| layout.logical_point(g.origin).x + if g.rtl { 0.0 } else { g.advance })
}
/// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x`
@ -354,6 +363,23 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
}
return out;
}
if layout.glyphs.iter().any(|g| g.rtl) {
for g in layout.glyphs.iter().filter(|g| g.byte < b && g.byte + g.len > a) {
let Some(l) = layout.lines.get(g.line) else { continue };
let p = layout.logical_point(g.origin);
let (x0, x1) = (p.x, p.x + g.advance);
out.push(
[
Point::new(x0, l.baseline - l.ascent),
Point::new(x1, l.baseline - l.ascent),
Point::new(x1, l.baseline + l.descent),
Point::new(x0, l.baseline + l.descent),
]
.map(|p| layout.physical_point(p)),
);
}
return out;
}
for (li, l) in layout.lines.iter().enumerate() {
if l.start > b || l.end < a || (l.end == a && l.start < a && layout.lines.get(li + 1).is_some_and(|n| n.start == l.end)) {
continue;
@ -374,9 +400,59 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
out
}
/// 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,
)
}
/// 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<vectorcraft_doc::ParaDirection>) -> 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<usize>, 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;
#[cfg(test)]
mod tests_embed;
#[cfg(test)]
mod tests_scripts;

View File

@ -3,6 +3,9 @@
use std::ops::Range;
use std::sync::Arc;
use unicode_bidi::Level;
use unicode_script::{Script, UnicodeScript};
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
use skrifa::MetadataProvider;
use skrifa::instance::Size;
@ -45,6 +48,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: Level,
}
/// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side
@ -179,15 +184,20 @@ 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<usize>,
runs: &[(Range<usize>, &CharStyle)],
feats: &OtFeatures,
levels: &[Level],
out: &mut Vec<SGlyph>,
) {
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);
let b = rr.end.min(range.end);
@ -199,10 +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 };
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<FontFace>)> = Vec::new();
let mut script = Script::Common;
for (i, c) in text[a..b].char_indices() {
let i = a + i;
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()
@ -214,13 +226,19 @@ pub(crate) fn shape_range(
f
};
let small = small_caps && c.is_lowercase();
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.
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 +246,22 @@ pub(crate) fn shape_range(
shape_segment(text, &seg, feats, out);
}
}
// 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.
@ -238,14 +272,17 @@ struct Segment<'a> {
face: Arc<FontFace>,
/// Lowercase letters drawn as synthesized small capitals.
small: bool,
level: Level,
}
fn is_mark(c: char) -> bool {
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
use unicode_general_category::{GeneralCategory, get_general_category};
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
|| matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
}
fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
let Segment { range, run, st, face, small } = seg;
let Segment { range, run, st, face, small, level } = seg;
let (range, run, small) = (range.clone(), *run, *small);
let text_seg = &text[range.clone()];
let full = st.size.max(0.0);
@ -281,7 +318,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
buf.add(c, cl);
}
}
buf.set_direction(Direction::LeftToRight);
buf.set_direction(if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight });
buf.guess_segment_properties();
let feats: Vec<Feature> = feats.resolve(st);
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
@ -305,11 +342,14 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
}
}
}
let mut cluster_starts: Vec<usize> = raw.iter().map(|r| r.1 as usize).collect();
cluster_starts.sort_unstable();
cluster_starts.dedup();
let n = raw.len();
for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() {
let cl = cl as usize;
// Cluster end: the next larger cluster value in the segment, else the segment end.
let end = raw[gi + 1..].iter().map(|r| r.1 as usize).find(|&c| c > cl).unwrap_or(range.end);
let end = cluster_starts.get(cluster_starts.partition_point(|&c| c <= cl)).copied().unwrap_or(range.end);
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
let ch = first_char(cl);
let mut adv = xa as f64 * k * hs;
@ -339,6 +379,7 @@ fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SG
ch,
tcy: None,
lead: 0.0,
level: *level,
});
}
}

View File

@ -0,0 +1,216 @@
//! Direction, source-cluster and editing regressions; no RTL font assets required.
use super::*;
use kurbo::{Point, Rect, Shape};
use vectorcraft_doc::{CharStyle, TextKind};
fn text_layout(s: &str) -> TextLayout {
layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default()))
}
#[test]
fn hebrew_and_arabic_are_visually_rtl_with_exact_source_clusters() {
for s in ["שלום", "مرحبا"] {
let l = text_layout(s);
assert!(!l.glyphs.is_empty());
assert!(l.glyphs.iter().all(|g| g.rtl));
let bytes: Vec<_> = l.glyphs.iter().map(|g| g.byte).collect();
assert!(bytes.windows(2).all(|w| w[0] >= w[1]), "{s}: {bytes:?}");
for g in &l.glyphs {
assert!(s.get(g.byte..g.byte + g.len).is_some(), "{g:?}");
assert!(g.len <= s.len());
}
assert!(caret_position(&l, 0).0.x > caret_position(&l, s.len()).0.x);
}
}
#[test]
fn mixed_text_preserves_latin_and_number_order() {
let s = "שלום Rust 123";
let l = text_layout(s);
let visible: String = l.glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect();
assert_eq!(visible, "Rust 123 םולש");
assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text");
}
#[test]
fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() {
let s = "שלום";
let l = text_layout(s);
for g in &l.glyphs {
let p = Point::new(g.origin.x + g.advance * 0.9, g.origin.y);
assert_eq!(hit_byte(&l, p), g.byte);
let p = Point::new(g.origin.x + g.advance * 0.1, g.origin.y);
assert_eq!(hit_byte(&l, p), g.byte + g.len);
}
assert_eq!(caret_horizontal(&l, 0, false), 2);
assert_eq!(caret_horizontal(&l, 2, true), 0);
assert!(!selection_quads(&l, 0, s.len()).is_empty());
}
#[test]
fn wrapping_retains_logical_line_ranges_and_reorders_each_line() {
let s = "שלום עולם שלום עולם שלום עולם";
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 55.0, 400.0).to_path(0.1)) };
let l = layout(FontDb::global(), &t);
assert!(l.lines.len() > 1);
assert!(!l.overflow);
for line in &l.lines {
assert!(s.get(line.start..line.end).is_some());
for g in &l.glyphs[line.glyph_start..line.glyph_end] {
assert!(g.byte >= line.start && g.byte + g.len <= line.end);
}
}
}
#[test]
fn paragraph_direction_is_independent_and_neutrals_do_not_force_ltr() {
let l = text_layout("123 שלום\nEnglish\nمرحبا");
assert!(l.glyphs.iter().any(|g| g.line == 0 && g.rtl));
assert!(l.glyphs.iter().filter(|g| g.line == 1).all(|g| !g.rtl));
assert!(l.glyphs.iter().filter(|g| g.line == 2).all(|g| g.rtl));
}
#[test]
fn hebrew_marks_stay_with_base_clusters() {
let s = "שָׁלוֹם";
let l = text_layout(s);
for g in &l.glyphs {
assert!(s.get(g.byte..g.byte + g.len).is_some());
}
assert!(l.glyphs.iter().all(|g| g.rtl));
}
#[test]
fn arabic_contextual_forms_and_lam_alef_use_the_shaper() {
let db = FontDb::global();
// Installed fonts are optional; no proprietary or test font is copied into the repo.
let face =
["Geeza Pro", "Arial", "Noto Sans Arabic"].into_iter().filter_map(|name| db.face(name, "Regular")).find(|f| f.covers('ل') && f.covers('ا'));
let Some(face) = face else { return };
let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..CharStyle::default() };
for s in ["سلام", "שלום سلام"] {
let l = layout(db, &TextObject::point(Point::ZERO, s, st.clone()));
let arabic: Vec<_> = l.glyphs.iter().filter(|g| s.get(g.byte..).is_some_and(|t| t.starts_with(['س', 'ل', 'ا', 'م']))).collect();
assert!(
arabic.iter().any(|g| s.get(g.byte..).and_then(|t| t.chars().next()).is_some_and(|c| g.gid != face.glyph_for(c))),
"Arabic must use contextual forms: {s}"
);
assert!(arabic.iter().all(|g| g.rtl));
}
}
#[test]
fn bidi_isolates_numbers_and_brackets_keep_valid_clusters() {
for s in ["שלום (123)", "مرحبا (Rust 42)", "English \u{2067}שלום 123\u{2069} end"] {
let l = text_layout(s);
for g in &l.glyphs {
assert!(s.get(g.byte..g.byte + g.len).is_some());
}
assert!(!selection_quads(&l, 0, s.len()).is_empty());
}
}
#[test]
fn right_aligned_wrapped_rtl_keeps_trailing_spaces_outside_content() {
let mut t = TextObject::point(Point::ZERO, "שלום עולם שלום עולם שלום עולם", CharStyle::default());
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 80.0, 400.0).to_path(0.1)) };
t.para.justify = vectorcraft_doc::Justify::Right;
let l = layout(FontDb::global(), &t);
assert!(l.lines.len() > 1);
for line in &l.lines {
assert!((line.x1 - 80.0).abs() < 1e-6, "{line:?}");
}
}
#[test]
fn visual_arrows_cross_rtl_paragraph_boundaries() {
let l = text_layout("שלום\nעולם");
assert!(l.lines.iter().all(|l| l.rtl));
assert_eq!(caret_horizontal(&l, 8, false), 9);
assert_eq!(caret_horizontal(&l, 9, true), 8);
}
#[test]
fn automatic_alignment_follows_each_paragraph_and_explicit_choices_win() {
let db = FontDb::global();
for s in ["שלום", "مرحبا", "123 שלום"] {
let mut t = TextObject::point(Point::ZERO, s, CharStyle::default());
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 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<char> = visual.chars().collect();
Some((order.iter().filter_map(|&i| chars.get(i)).collect::<String>(), 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);
}
}

View File

@ -486,21 +486,41 @@ impl Tool for TypeTool {
}
ToolKey::Left => {
let to = if a != b && !mods.shift {
a
if lay.glyphs.iter().any(|g| g.rtl)
&& vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x
{
b
} else {
a
}
} else if word {
edit::prev_word(&text, self.caret)
} else {
edit::prev_char(&text, self.caret)
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, false)
} else {
edit::prev_char(&text, self.caret)
}
};
self.move_to(to, mods.shift)
}
ToolKey::Right => {
let to = if a != b && !mods.shift {
b
if lay.glyphs.iter().any(|g| g.rtl)
&& vectorcraft_text::caret_position(&lay, a).0.x > vectorcraft_text::caret_position(&lay, b).0.x
{
a
} else {
b
}
} else if word {
edit::next_word(&text, self.caret)
} else {
edit::next_char(&text, self.caret)
if lay.glyphs.iter().any(|g| g.rtl) {
vectorcraft_text::caret_horizontal(&lay, self.caret, true)
} else {
edit::next_char(&text, self.caret)
}
};
self.move_to(to, mods.shift)
}

View File

@ -319,3 +319,19 @@ fn arrows_follow_the_columns_of_vertical_type() {
key(&mut tool, &d, ToolKey::Up, Mods::default());
assert_eq!(tool.caret, 0);
}
#[test]
fn hebrew_typing_and_visual_arrows_keep_logical_text() {
let (mut d, id, mut tool) = editing("");
let snap = d.clone();
let (sel, p) = (Selection::default(), paint());
let actions = tool.text_input(&cx(&d, &sel, &p), "שלום");
apply(&mut d, &snap, &actions);
assert_eq!(tool.caret, "שלום".len());
key(&mut tool, &d, ToolKey::Right, Mods::default());
assert_eq!(tool.caret, 6, "Right moves toward the logical start in Hebrew");
key(&mut tool, &d, ToolKey::Left, Mods::default());
assert_eq!(tool.caret, 8);
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
assert_eq!(t.plain_text(), "שלום");
}

View File

@ -2942,3 +2942,5 @@
Saved Selection 23 保存した選択範囲 23
Saved Selection 24 保存した選択範囲 24
Saved Selection 25 保存した選択範囲 25
Left-to-Right Paragraph Direction 左から右への段落方向
Right-to-Left Paragraph Direction 右から左への段落方向

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -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 羅馬懸掛標點

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -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")),

View File

@ -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,19 +34,55 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
app.run(cmd, p).ok();
}
/// 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(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::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) {
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 rtl = resolved_rtl(app, &para);
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, para.justify == 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);
let fw = ((ui.available_width() - 66.0) / 2.0).clamp(60.0, 100.0);
@ -117,7 +154,47 @@ 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"}));
format(app, json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false}));
para_cmd(app, "text.setStyle", json!({"justify": "auto"}));
format(
app,
json!({"leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, "direction": "auto"}),
);
}
}
#[cfg(test)]
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 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();
let shown = |app: &VectorcraftApp| {
let para = text_style(app).unwrap().1;
shown_alignment(para.justify, resolved_rtl(app, &para))
};
assert_eq!(shown(&app), Justify::Right);
app.session.execute("text.setStyle", &json!({"justify": "left"})).unwrap();
assert_eq!(shown(&app), Justify::Left);
app.session.execute("text.setStyle", &json!({"justify": "auto"})).unwrap();
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");
}
}

View File

@ -179,3 +179,25 @@ 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
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.
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`).
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).