mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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:
1 parent
839887eb04
commit
6b2fa19f00
14 files changed
+196
-7
No files matched your search
Generated
+2
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ workspace = true
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
log = { workspace = true }
|
||||
+64
-2
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user