mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
edc7c931dc
commit
0687d2d127
@@ -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 | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-3
@@ -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 `<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).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
|
||||
+16
-2
@@ -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<ParaDirection>,
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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?: \"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
|
||||
),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Break> = 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<TextRun>) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
t.para.justify = Justify::Left;
|
||||
t.runs = runs;
|
||||
t
|
||||
}
|
||||
|
||||
+52
-21
@@ -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,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<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.
|
||||
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<usize>], para: &ParaStyle, regions: Opt
|
||||
};
|
||||
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
|
||||
let (align, justify) = match para.justify {
|
||||
Justify::Auto => (if bidi.paragraphs.first().is_some_and(|p| p.level.is_rtl()) { 2 } else { 0 }, false),
|
||||
Justify::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<usize>], 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<usize>], para: &ParaStyle, regions: Opt
|
||||
x_end = hx + h.adv;
|
||||
}
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl: bidi.paragraphs.first().is_some_and(|p| p.level.is_rtl()),
|
||||
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<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 {
|
||||
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<usize>], para: &ParaStyle, path: &Be
|
||||
if ap.segs.is_empty() {
|
||||
cx.out.overflow = !sg.is_empty();
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl: unicode_bidi::BidiInfo::new(cx.text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()),
|
||||
rtl,
|
||||
baseline: 0.0,
|
||||
x0: 0.0,
|
||||
x1: 0.0,
|
||||
@@ -1161,9 +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 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<usize>], para: &ParaStyle, path: &Be
|
||||
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
|
||||
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
|
||||
cx.out.lines.push(LineInfo {
|
||||
rtl: unicode_bidi::BidiInfo::new(cx.text, None).paragraphs.first().is_some_and(|p| p.level.is_rtl()),
|
||||
rtl,
|
||||
baseline: ps.y,
|
||||
x0: ps.x,
|
||||
x1: pe.x,
|
||||
@@ -1232,12 +1241,34 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], 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<usize>, glyphs: &[SGlyph], vertical: bool) -> Vec<usize> {
|
||||
if vertical || glyphs.is_empty() || !bidi.has_rtl() {
|
||||
/// 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 Some(para) = bidi.paragraphs.first() else { return (0..glyphs.len()).collect() };
|
||||
let levels = bidi.reordered_levels(para, line.start.saturating_sub(paragraph_start)..line.end.saturating_sub(paragraph_start));
|
||||
let mut clusters: Vec<Range<usize>> = Vec::new();
|
||||
for (i, g) in glyphs.iter().enumerate() {
|
||||
|
||||
+35
-27
@@ -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<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;
|
||||
|
||||
/// 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;
|
||||
|
||||
+28
-17
@@ -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<usize>,
|
||||
runs: &[(Range<usize>, &CharStyle)],
|
||||
feats: &OtFeatures,
|
||||
levels: &[Level],
|
||||
out: &mut Vec<SGlyph>,
|
||||
) {
|
||||
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<FontFace>)> = Vec::new();
|
||||
let mut script = Script::Common;
|
||||
for (i, c) in text[a..b].char_indices() {
|
||||
let i = a + i;
|
||||
let level = bidi.levels.get(i - range.start).copied().unwrap_or(unicode_bidi::Level::ltr());
|
||||
let 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<FontFace>,
|
||||
/// Lowercase letters drawn as synthesized small capitals.
|
||||
small: bool,
|
||||
level: unicode_bidi::Level,
|
||||
level: Level,
|
||||
}
|
||||
|
||||
fn is_mark(c: char) -> bool {
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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í
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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 右から左への段落方向
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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 自動對齊
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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")),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+18
-20
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user