T: right-to-left text — bidi shaping and line reordering, paragraph direction

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 05:29:56 -04:00
1 parent 839887eb04
commit 6b2fa19f00
14 files changed
+196 -7

No files matched your search

Generated
+2
View File
@@ -908,6 +908,7 @@ dependencies = [
"miniz_oxide 0.8.9",
"proptest",
"regex",
"unicode-bidi",
]
[[package]]
@@ -968,6 +969,7 @@ dependencies = [
"kurbo",
"log",
"skrifa 0.47.0",
"unicode-bidi",
]
[[package]]
+1
View File
@@ -38,6 +38,7 @@ serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
proptest = "1"
rayon = "1"
unicode-bidi = "0.3"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
# text / rendering / formats
+1
View File
@@ -93,6 +93,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window opens that second view rather than a separate OS window.
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): spreads with transparency are rasterised per page in the exported PDF. It doesn't yet keep text and vectors outside transparent areas.
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. No in-app playback or PDF rich media.
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Kashida justification, caret movement in visual order and digit substitution aren't there yet.
- Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`).
- Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export.
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
+1
View File
@@ -9,6 +9,7 @@ description = "DesignCraft text composition: paragraph composer (Knuth–Plass),
workspace = true
[dependencies]
unicode-bidi = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
designcraft-doc = { workspace = true }
+42
View File
@@ -1513,7 +1513,13 @@ fn layout_line(
let ratio = if ratio_n > 0 { ratio_sum / ratio_n as f64 } else { 1.0 };
let mut out = Vec::with_capacity(line.len());
let mut x = x0 + offset;
let rtl_para = pp.direction == designcraft_doc::TextDirection::RightToLeft;
let bidi = (rtl_para || line.iter().any(|g| designcraft_fonts::is_rtl(g.ch))) && leaders.is_empty();
let mut pens = Vec::with_capacity(if bidi { line.len() } else { 0 });
for (i, g) in line.iter().enumerate() {
if bidi {
pens.push(x);
}
let mut p = place(g, x);
if scale[i] != 1.0 {
p.sx *= scale[i];
@@ -1529,9 +1535,45 @@ fn layout_line(
tab_leader(g, l, tab_x, tab_w, tab_origin, &mut out);
}
}
if bidi {
pens.push(x);
reorder_visual(&line, &mut out, &pens, x0 + offset, rtl_para);
}
(out, x, ratio)
}
/// Bidi: put a laid-out line (glyphs in text order, `pens` their pen positions and then the end
/// position) into visual order from `start`, cluster by cluster (Unicode Bidirectional
/// Algorithm, rule L2).
fn reorder_visual(line: &[Glyph], out: &mut [PlacedGlyph], pens: &[f64], start: f64, rtl: bool) {
use unicode_bidi::{BidiInfo, Level};
// Clusters: runs of glyphs from the same source character.
let mut units: Vec<std::ops::Range<usize>> = Vec::new();
for i in 0..line.len() {
match units.last_mut() {
Some(u) if line[u.start].byte == line[i].byte && line[i].len > 0 => u.end = i + 1,
_ => units.push(i..i + 1),
}
}
let text: String = units.iter().map(|u| line[u.start].ch).collect();
let info = BidiInfo::new(&text, Some(if rtl { Level::rtl() } else { Level::ltr() }));
let levels: Vec<Level> = text.char_indices().map(|(b, _)| info.levels[b]).collect();
let order = BidiInfo::reorder_visual(&levels);
let width = |u: &std::ops::Range<usize>| pens[u.end] - pens[u.start];
let mut cur = start;
let mut shifts = vec![0.0; line.len()];
for &k in &order {
let u = &units[k];
for i in u.clone() {
shifts[i] = cur - pens[u.start];
}
cur += width(u);
}
for (p, d) in out.iter_mut().zip(shifts) {
p.x += d;
}
}
/// Fill a tab's gap with repeats of its leader, on a grid shared by all lines (so dots align).
fn tab_leader(tab: &Glyph, leader: &str, x: f64, w: f64, origin: f64, out: &mut Vec<PlacedGlyph>) {
let glyphs: Vec<(u32, f64)> = leader
+43
View File
@@ -797,3 +797,46 @@ fn hj_severity_shades() {
assert_eq!(hj_severity(2.5, 0.8, 1.33), 3);
assert_eq!(hj_severity(0.6, 0.8, 1.33), 3);
}
#[test]
fn right_to_left_runs_are_ordered_visually() {
// Hebrew between Latin words: the Hebrew letters read right to left.
let text = "abc \u{5D0}\u{5D1}\u{5D2} def";
let (d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 500.0, 200.0), ParaAttrs::default());
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = all_lines(&cs)[0];
let x_of = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.x).unwrap();
let (alef, bet, gimel) = (4, 6, 8);
assert!(x_of(alef) > x_of(bet) && x_of(bet) > x_of(gimel), "Hebrew reversed");
assert!(x_of(0) < x_of(gimel) && x_of(alef) < x_of(11), "Latin around it stays in place");
// A right-to-left paragraph puts its first word on the right.
let rtl = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), ..Default::default() };
let (d, sid, _) = doc_with("\u{5D0}\u{5D1} abc", Rect::new(36.0, 36.0, 500.0, 200.0), rtl);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = all_lines(&cs)[0];
let x_of = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.x).unwrap();
assert!(x_of(0) > x_of(5), "the Hebrew word (first in the text) is right of the Latin one");
// Shaping keeps clusters in text order.
let face = designcraft_fonts::FontDb::global().face(designcraft_fonts::DEFAULT_FAMILY, "Regular");
let g = designcraft_fonts::shape(&face, "ab \u{5D0}\u{5D1}", &[], |c| c);
assert!(g.windows(2).all(|w| w[0].cluster <= w[1].cluster), "{:?}", g.iter().map(|g| g.cluster).collect::<Vec<_>>());
}
#[test]
fn bidi_matches_the_reference_order() {
use unicode_bidi::{BidiInfo, Level};
let text = "English then \u{645}\u{631}\u{62D}\u{628}\u{627} \u{628}\u{627}\u{644}\u{639}\u{627}\u{644}\u{645} 123 and \u{5E9}\u{5DC}\u{5D5}\u{5DD} \u{5E2}\u{5D5}\u{5DC}\u{5DD} end.";
for rtl in [false, true] {
let dir = if rtl { designcraft_doc::TextDirection::RightToLeft } else { designcraft_doc::TextDirection::LeftToRight };
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 2000.0, 200.0), ParaAttrs { direction: Some(dir), ..Default::default() });
let cs = compose_story(&d, sid, &ComposeOptions::default());
let l = all_lines(&cs)[0];
// Characters by glyph x (one glyph per character in this text).
let mut gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0).collect();
gs.sort_by(|a, b| a.x.total_cmp(&b.x));
let ours: String = gs.iter().map(|g| text[g.byte..].chars().next().unwrap()).collect();
let info = BidiInfo::new(text, Some(if rtl { Level::rtl() } else { Level::ltr() }));
let reference = info.reorder_line(&info.paragraphs[0], 0..text.len()).to_string();
assert_eq!(ours, reference, "rtl={rtl}");
}
}
+11
View File
@@ -115,6 +115,15 @@ pub enum Leading {
Points(f64),
}
/// Paragraph direction.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextDirection {
#[default]
LeftToRight,
RightToLeft,
}
/// A tracked change on text.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -483,6 +492,8 @@ attr_set! {
/// Paragraph attributes (Paragraph panel / paragraph styles).
ParaAttrs / ParaProps {
align: Align = Align::Left,
/// Paragraph Direction (World-Ready): the base direction lines are ordered in.
direction: TextDirection = TextDirection::LeftToRight,
left_indent: f64 = 0.0,
right_indent: f64 = 0.0,
first_line_indent: f64 = 0.0,
+2 -1
View File
@@ -211,7 +211,8 @@ pub fn story_html(doc: &Document, sid: StoryId) -> String {
let pf = &st.paras[pi];
let class = slug(&pf.style);
let text_empty = st.text[r.clone()].trim().is_empty();
let _ = write!(out, "<p class=\"{class}\">");
let rtl = doc.styles.resolve_para(pf).0.direction == designcraft_doc::TextDirection::RightToLeft;
let _ = write!(out, "<p class=\"{class}\"{}>", if rtl { " dir=\"rtl\"" } else { "" });
if text_empty {
out.push_str("&#160;");
}
+1
View File
@@ -12,4 +12,5 @@ workspace = true
kurbo = { workspace = true }
skrifa = { workspace = true }
harfrust = { workspace = true }
unicode-bidi = { workspace = true }
log = { workspace = true }
+64 -2
View File
@@ -42,9 +42,71 @@ pub fn feature(tag: &str) -> Option<Feature> {
Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..))
}
/// A strong right-to-left character (Hebrew, Arabic, …)?
pub fn is_rtl(c: char) -> bool {
use unicode_bidi::BidiClass::{AL, R};
matches!(unicode_bidi::bidi_class(c), R | AL)
}
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
let mut cur: Option<bool> = None;
let mut start = 0;
for (i, c) in text.char_indices() {
let strong = if is_rtl(c) {
Some(true)
} else if unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::L {
Some(false)
} else {
None
};
match (cur, strong) {
(None, Some(d)) => cur = Some(d),
(Some(a), Some(d)) if a != d => {
runs.push((start..i, a));
start = i;
cur = Some(d);
}
_ => {}
}
}
runs.push((start..text.len(), cur.unwrap_or(false)));
runs
}
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
/// Caps) while keeping clusters pointing into the original string.
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
if !text.chars().any(is_rtl) {
return shape_dir(face, text, features, &map, false);
}
let mut out = Vec::with_capacity(text.len());
for (r, rtl) in direction_runs(text) {
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl);
for x in &mut g {
x.cluster += r.start;
}
if rtl {
// Visual (clusters descending) → logical, cluster by cluster.
let mut groups: Vec<Vec<ShapedGlyph>> = Vec::new();
for x in g {
match groups.last_mut() {
Some(last) if last[0].cluster == x.cluster => last.push(x),
_ => groups.push(vec![x]),
}
}
groups.sort_by_key(|grp| grp[0].cluster);
g = groups.into_iter().flatten().collect();
}
out.extend(g);
}
out
}
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
let mut out = Vec::with_capacity(text.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
@@ -52,8 +114,8 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha
for (i, c) in text.char_indices() {
buf.add(map(c), i as u32);
}
buf.set_direction(Direction::LeftToRight);
buf.guess_segment_properties();
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
out.push(ShapedGlyph {
+6
View File
@@ -1123,6 +1123,12 @@ impl<'a> Ex<'a> {
if let Some(v) = a.align {
el.set("Justification", names::align_out(v));
}
if let Some(v) = a.direction {
el.set(
"ParagraphDirection",
if v == designcraft_doc::TextDirection::RightToLeft { "RightToLeftDirection" } else { "LeftToRightDirection" },
);
}
n!(left_indent, "LeftIndent");
n!(right_indent, "RightIndent");
n!(first_line_indent, "FirstLineIndent");
+3
View File
@@ -1078,6 +1078,9 @@ impl<'r> Importer<'r> {
let u = |k: &str| e.num(k).map(|v| v.max(0.0) as u32);
let frac = |k: &str| e.num(k).map(|v| v / 100.0);
a.align = e.prop("Justification").and_then(|v| names::align_in(v.trim()));
a.direction = e.prop("ParagraphDirection").map(|v| {
if v.trim() == "RightToLeftDirection" { designcraft_doc::TextDirection::RightToLeft } else { designcraft_doc::TextDirection::LeftToRight }
});
a.left_indent = e.num("LeftIndent");
a.right_indent = e.num("RightIndent");
a.first_line_indent = e.num("FirstLineIndent");
+15
View File
@@ -1288,6 +1288,21 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
}
}
});
// Paragraph Direction (World-Ready): switching also mirrors left/right alignment.
let rtl = p["direction"] == "rightToLeft";
ui.horizontal(|ui| {
caption(ui, "Direction");
for (on, label, dir) in [(!rtl, "Left to Right", "leftToRight"), (rtl, "Right to Left", "rightToLeft")] {
if ui.selectable_label(on, label).clicked() && !on {
let align = match cur {
Align::Left => Align::Right,
Align::Right => Align::Left,
a => a,
};
let _ = app.run("type.para", json!({"attrs": {"direction": dir, "align": align}}));
}
}
});
egui::Grid::new("paragrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| {
for (row, ((la, ka), (lb, kb))) in [
(("Left", "leftIndent"), ("Right", "rightIndent")),
+4 -4
View File
@@ -5,13 +5,13 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of
Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded.
<!-- SUMMARY -->
**Weighted breadth parity: 94%** over 275 features.
**Weighted breadth parity: 95%** over 275 features.
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 92 | 5 | 0 | 97% |
| P2 | 76 | 40 | 27 | 9 | 70% |
| P2 | 76 | 40 | 28 | 8 | 71% |
| Area | Parity |
|---|---|
@@ -23,7 +23,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Fill, stroke, colour | 96% |
| Effects & transparency | 75% |
| Placing & links | 91% |
| Type & text frames | 94% |
| Type & text frames | 95% |
| Typography | 98% |
| Styles | 97% |
| Tables | 98% |
@@ -214,7 +214,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Type & text frames | Drag & drop text editing | P1 | D |
| Type & text frames | Paste text with/without formatting | P0 | D |
| Type & text frames | Math expressions | P2 | M |
| Type & text frames | Right-to-left / World-Ready composer | P2 | M |
| Type & text frames | Right-to-left / World-Ready composer | P2 | P |
| Typography | Character attributes | P0 | D |
| Typography | Underline/Strikethrough options | P1 | D |
| Typography | OpenType features | P0 | D |