collective

Browse / github.com/storytold/pdfcraft / patch

M10: Word, HTML and RTF export mark right-to-left text (#4653, direction half)

M10: Word, HTML and RTF export mark right-to-left text (#4653, direction half) ## What this changes Addresses the **direction half** of #4653 (Arabic in PDF conversion). The other half, getting the characters out of the PDF in logical rather than visual order, is the text extraction's and is not changed here (see the limits). The Word, HTML and RTF exporters had no right-to-left handling at all: an Arabic or Hebrew paragraph came out as left to right, so it was aligned and ordered as if it were English. Now a paragraph, heading, table cell or text box that reads right to left is marked so: - **Which text:** its first strong character, by its **Unicode bidi class** (R

Status
open
Score
0.00
Adopters
0 other operators
Author
AdrianTM (imported from pull request #5042, open)
Base
e9fc890a862ea2d8c895ea32ddecda98580483cd
Size
+408 −20 in 10 files
Review
pending
Shared
2026-10-28
Id
4f29483ec459eec11a92bea4

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock ROADMAP.md crates/export/Cargo.toml crates/export/src/as_docx.rs crates/export/src/as_html.rs crates/export/src/as_rtf.rs crates/export/src/layout.rs crates/export/src/lib.rs crates/export/src/tests.rs parity/acrobat-features.toml

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -4266,6 +4266,7 @@ name = "pdfcraft-export" version = "0.14.0" dependencies = [  "flate2",+ "unicode-bidi", ]  [[package]]diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -745,3 +745,4 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.  - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.+- **2026-10-27 (M10, #4653):** Word, HTML and RTF export mark right-to-left paragraphs, headings, table cells and text boxes (first strong character by its Unicode bidi class: Hebrew, Arabic, Syriac, Thaana…): `w:bidi`/`w:rtl` (with the complex-script bold, italic and size) in DOCX, with mixed-script runs cut so that only strong right-to-left text (and the neutrals between it) is marked `w:rtl`, numbers and edge brackets left to Word, `dir="rtl"` in HTML, `\rtlpar` in RTF. The order the characters arrive in is the text extraction's (visual-order Arabic without ActualText is not reordered here), and PPTX and mirrored table columns are not done.diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml--- a/crates/export/Cargo.toml+++ b/crates/export/Cargo.toml@@ -12,3 +12,4 @@ workspace = true  [dependencies] flate2.workspace = true+unicode-bidi = "0.3"diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs--- a/crates/export/src/as_docx.rs+++ b/crates/export/src/as_docx.rs@@ -14,20 +14,34 @@ pub(crate) fn hex(c: [f64; 3]) -> Option<String> {     (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) } -/// A formatted text run (shared by paragraphs and table cells).+/// A formatted text run (shared by paragraphs and table cells), as one Word run per stretch of+/// one direction: text in a right-to-left script is complex-script text to Word (its bold, italic+/// and size are the `Cs` ones, and `w:rtl` says its direction), and only that text is marked, not+/// the Latin letters that share its formatting (#4653). fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String {+    directional_runs(text).into_iter().map(|(part, rtl)| run_part(part, rtl, size, bold, italic, color)).collect()+}++fn run_part(text: &str, rtl: bool, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String {+    // The complex-script counterparts (`bCs`, `iCs`, `szCs`) go on every run, whatever its+    // direction: Word treats Arabic-script digits and punctuation as complex-script text even+    // where they are not marked right to left, and Latin text ignores them. Only the direction,+    // `w:rtl`, is limited to the text that is right to left for certain.     let mut rpr = String::new();     if bold {-        rpr.push_str("<w:b/>");+        rpr.push_str("<w:b/><w:bCs/>");     }     if italic {-        rpr.push_str("<w:i/>");+        rpr.push_str("<w:i/><w:iCs/>");     }     if let Some(h) = hex(color) {         rpr.push_str(&format!("<w:color w:val=\"{h}\"/>"));     }     let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 };-    rpr.push_str(&format!("<w:sz w:val=\"{half_points}\"/>"));+    rpr.push_str(&format!("<w:sz w:val=\"{half_points}\"/><w:szCs w:val=\"{half_points}\"/>"));+    if rtl {+        rpr.push_str("<w:rtl/>");+    }     format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", esc(text)) } @@ -53,7 +67,8 @@ pub(crate) fn docx_table(t: &Table) -> String {             let span = if c.span > 1 { format!("<w:gridSpan w:val=\"{}\"/>", c.span) } else { String::new() };             let w: i64 = widths.iter().skip(column).take(c.span).sum();             s.push_str(&format!(-                "<w:tc><w:tcPr><w:tcW w:w=\"{w}\" w:type=\"dxa\"/>{span}</w:tcPr><w:p>{}</w:p></w:tc>",+                "<w:tc><w:tcPr><w:tcW w:w=\"{w}\" w:type=\"dxa\"/>{span}</w:tcPr><w:p>{}{}</w:p></w:tc>",+                if is_rtl(&c.text) { "<w:pPr><w:bidi/></w:pPr>" } else { "" },                 run_xml(&c.text, c.size, c.bold, c.italic, c.color)             ));             column = column.saturating_add(c.span);@@ -170,8 +185,10 @@ fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, sha             "position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page"         );         s.push_str(&format!(-            "<w:r><w:pict><v:rect id=\"Text{}\" style=\"{style}\" filled=\"f\" stroked=\"f\"><v:textbox inset=\"0,0,0,0\" style=\"mso-fit-shape-to-text:t\"><w:txbxContent><w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\"/></w:pPr>{}</w:p></w:txbxContent></v:textbox><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>",-            shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color)+            "<w:r><w:pict><v:rect id=\"Text{}\" style=\"{style}\" filled=\"f\" stroked=\"f\"><v:textbox inset=\"0,0,0,0\" style=\"mso-fit-shape-to-text:t\"><w:txbxContent><w:p><w:pPr>{}<w:spacing w:before=\"0\" w:after=\"0\"/></w:pPr>{}</w:p></w:txbxContent></v:textbox><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>",+            shape_id,+            if is_rtl(&b.text) { "<w:bidi/>" } else { "" },+            run_xml(&b.text, b.size, b.bold, b.italic, b.color)         ));     }     for im in &page.images {@@ -247,12 +264,16 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {                 match &it {                     Item::Para(runs, lvl) => {                         let style = match lvl {-                            1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",-                            2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",+                            1 => "<w:pStyle w:val=\"Heading1\"/>",+                            2 => "<w:pStyle w:val=\"Heading2\"/>",                             _ => "",                         };+                        // A paragraph that reads right to left says so: Word aligns and orders it so.+                        let bidi = if paragraph_rtl(runs) { "<w:bidi/>" } else { "" };                         body.push_str("<w:p>");-                        body.push_str(style);+                        if !style.is_empty() || !bidi.is_empty() {+                            body.push_str(&format!("<w:pPr>{style}{bidi}</w:pPr>"));+                        }                         for r in runs {                             body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color));                         }diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs--- a/crates/export/src/as_html.rs+++ b/crates/export/src/as_html.rs@@ -1,5 +1,5 @@ use super::as_docx::{hex, rgb8};-use super::layout::{Item, Page, items};+use super::layout::{Item, Page, is_rtl, items, paragraph_rtl};  /// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, /// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them@@ -66,9 +66,11 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi                     t.push_str(&run);                 }                 let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));+                // A paragraph that reads right to left is marked, so it aligns and orders so (#4653).+                let dir = if paragraph_rtl(&runs) { " dir=\"rtl\"" } else { "" };                 match lvl {-                    0 => s.push_str(&format!("<p{style}>{t}</p>\n")),-                    l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),+                    0 => s.push_str(&format!("<p{dir}{style}>{t}</p>\n")),+                    l => s.push_str(&format!("<h{l}{dir}{style}>{t}</h{l}>\n")),                 }             }             Item::Table(t) => {@@ -84,8 +86,9 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi                             body = format!("<strong>{body}</strong>");                         }                         let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };+                        let dir = if is_rtl(&c.text) { " dir=\"rtl\"" } else { "" };                         let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));-                        s.push_str(&format!("<td{span}{style}>{body}</td>"));+                        s.push_str(&format!("<td{span}{dir}{style}>{body}</td>"));                     }                     s.push_str("</tr>\n");                 }diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs--- a/crates/export/src/as_rtf.rs+++ b/crates/export/src/as_rtf.rs@@ -81,9 +81,12 @@ pub fn rtf(pages: &[Page]) -> String {                     fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour });                     fmt                 };-                let mut runs = runs.iter();+                let runs_list = runs;+                let mut runs = runs_list.iter();                 let Some(first) = runs.next() else { continue };-                s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text)));+                // A paragraph that reads right to left is a right-to-left paragraph (#4653).+                let direction = if paragraph_rtl(&runs_list) { "\\rtlpar" } else { "" };+                s.push_str(&format!("{{\\pard{direction}{} {}", fmt(first.block, false), rtf_text(&first.text)));                 for r in runs {                     s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text)));                 }@@ -105,7 +108,7 @@ pub fn rtf(pages: &[Page]) -> String {                         }                     }                     for c in row {-                        let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64);+                        let mut fmt = format!("\\intbl{}\\fs{}", if is_rtl(&c.text) { "\\rtlpar" } else { "" }, (c.size * 2.0).round() as i64);                         if c.bold {                             fmt.push_str("\\b");                         }diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs--- a/crates/export/src/layout.rs+++ b/crates/export/src/layout.rs@@ -1,4 +1,101 @@ use super::*;+use unicode_bidi::{BidiClass, bidi_class};++/// The strong direction of `c` by its Unicode bidi class: `Some(true)` for right to left (R, AL:+/// Hebrew, Arabic, the RLM and ALM marks, Hebrew punctuation such as the maqaf…), `Some(false)`+/// for left to right (L: letters of the left-to-right scripts, the LRM mark…), `None` for the weak+/// and neutral characters (digits, punctuation, spaces).+fn strong_direction(c: char) -> Option<bool> {+    match bidi_class(c) {+        BidiClass::R | BidiClass::AL => Some(true),+        BidiClass::L => Some(false),+        _ => None,+    }+}++/// Whether `text` reads right to left: its first strong character is a right-to-left one, as the+/// HTML `dir="auto"` rule has it (#4653).+pub(crate) fn is_rtl(text: &str) -> bool {+    text.chars().find_map(strong_direction).unwrap_or(false)+}++/// `text` cut into runs, each with whether it is marked right to left. A right-to-left run holds+/// only what is right to left for certain: the strong right-to-left characters (bidi class R and+/// AL), the combining marks and joiners that follow them, and the neutral characters (spaces,+/// punctuation) strictly between two such characters, which the Unicode bidi algorithm resolves+/// to right to left too. Everything else, Latin letters, digits and other weak characters, and+/// the punctuation and brackets at the edges, is left in runs without `w:rtl`: Word marks every+/// neutral and weak character in a `w:rtl` run as right to left, which would reverse numbers and+/// misplace paired brackets, so they are left to be resolved in the paragraph's context (#4653).+/// The runs cover `text` exactly, none empty unless `text` is.+pub(crate) fn directional_runs(text: &str) -> Vec<(&str, bool)> {+    #[derive(Clone, Copy, PartialEq)]+    enum Kind {+        /// Strong right to left.+        R,+        /// A mark or joiner that goes with what is before it.+        Follows,+        /// A space, punctuation or bracket: no direction of its own.+        Neutral,+        /// Anything else: Latin and other left-to-right letters, digits, weak characters.+        Other,+    }+    let kind = |c: char| match bidi_class(c) {+        BidiClass::R | BidiClass::AL => Kind::R,+        BidiClass::NSM | BidiClass::BN => Kind::Follows,+        BidiClass::WS | BidiClass::ON | BidiClass::S | BidiClass::B => Kind::Neutral,+        _ => Kind::Other,+    };+    let chars: Vec<(usize, char)> = text.char_indices().collect();+    // For each character, whether it is in a right-to-left run: from a strong character through+    // what follows it, over neutrals only when another strong character comes after them.+    let mut rtl = vec![false; chars.len()];+    let mut i = 0;+    while i < chars.len() {+        if chars.get(i).map(|(_, c)| kind(*c)) != Some(Kind::R) {+            i += 1;+            continue;+        }+        // A run starting at the strong character `i`; `end` is just past the last character it keeps.+        let mut end = i + 1;+        let mut at = i + 1;+        while let Some((_, c)) = chars.get(at) {+            match kind(*c) {+                // A strong character commits the neutrals skipped since the last one.+                Kind::R => {+                    at += 1;+                    end = at;+                }+                // A mark or joiner joins the run only when it is attached to it directly: after+                // pending neutrals it is no second strong character, so it stays pending with them.+                Kind::Follows => {+                    if at == end {+                        end = at + 1;+                    }+                    at += 1;+                }+                Kind::Neutral => at += 1,+                Kind::Other => break,+            }+        }+        for flag in rtl.iter_mut().take(end).skip(i) {+            *flag = true;+        }+        i = end.max(i + 1);+    }+    // A mark or joiner after a character left out of a run goes with that character, not alone.+    let mut runs: Vec<(&str, bool)> = Vec::new();+    let mut start = 0;+    for (index, (at, _)) in chars.iter().enumerate() {+        let direction = rtl.get(index).copied().unwrap_or(false);+        if index > 0 && rtl.get(index - 1).copied().unwrap_or(false) != direction {+            runs.push((text.get(start..*at).unwrap_or_default(), !direction));+            start = *at;+        }+    }+    runs.push((text.get(start..).unwrap_or_default(), rtl.last().copied().unwrap_or(false)));+    runs+}  /// A paragraph of a page. #[derive(Clone, Debug, PartialEq)]@@ -181,6 +278,11 @@ pub(crate) struct Run<'a> {     pub(crate) text: String, } +/// Whether a paragraph's runs read right to left: its first strong character, across the runs.+pub(crate) fn paragraph_rtl(runs: &[Run]) -> bool {+    runs.iter().flat_map(|r| r.text.chars()).find_map(strong_direction).unwrap_or(false)+}+ /// A page's paragraphs, each a list of runs from left to right along its line. A block that /// continues another on the same line ([`continued_from`]: an italic case name, a link, a /// superscript) is a run in its own style inside that block's paragraph, not a paragraph of itsdiff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs--- a/crates/export/src/lib.rs+++ b/crates/export/src/lib.rs@@ -35,7 +35,7 @@ pub use as_rtf::rtf; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] use layout::{LOOKUP_STEPS, MAX_COLS, body_size};-pub(crate) use layout::{Run, paragraphs, tables};+pub(crate) use layout::{Run, directional_runs, is_rtl, paragraph_rtl, paragraphs, tables};  #[cfg(test)] mod tests;diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs--- a/crates/export/src/tests.rs+++ b/crates/export/src/tests.rs@@ -769,3 +769,259 @@ fn a_paragraph_is_a_heading_only_when_its_text_is() {     assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}");     assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); }++/// An Arabic body paragraph, an English one, a mixed one that starts in English, and an Arabic+/// heading, as separate paragraphs of one page (body 11 pt).+fn bidi_page() -> Page {+    let b = |t: &str, y: f64, size: f64, bold: bool| Block {+        text: t.into(),+        rect: [72.0, y, 500.0, y + size],+        size,+        bold,+        italic: false,+        color: [0.0; 3],+        rotation: 0.0,+        space_around: [false; 2],+    };+    Page {+        width: 612.0,+        height: 792.0,+        blocks: vec![+            b(+                "\u{0645}\u{0646}\u{0630} \u{0627}\u{0644}\u{0642}\u{0631}\u{0646} \u{0627}\u{0644}\u{062a}\u{0627}\u{0633}\u{0639} \u{0639}\u{0634}\u{0631} \u{0643}\u{0627}\u{0646} \u{0647}\u{0630}\u{0627} \u{0627}\u{0644}\u{0646}\u{0635} \u{0645}\u{0643}\u{062a}\u{0648}\u{0628}\u{0627}",+                600.0,+                11.0,+                false,+            ),+            b("This paragraph is written in English and is long enough to be the body text size here.", 560.0, 11.0, false),+            b(+                "Chapter 2 \u{0627}\u{0644}\u{0641}\u{0635}\u{0644} \u{0627}\u{0644}\u{062b}\u{0627}\u{0646}\u{064a} is a mixed paragraph that begins in English and is long enough.",+                520.0,+                11.0,+                false,+            ),+            b("\u{0627}\u{0644}\u{0639}\u{0646}\u{0648}\u{0627}\u{0646}", 700.0, 24.0, true),+        ],+        images: Vec::new(),+        background: None,+    }+}++/// #4653: which text reads right to left: the first strong character decides, as the HTML+/// `dir="auto"` rule has it; digits, punctuation and spaces are not strong.+#[test]+fn text_reads_right_to_left_by_its_first_strong_character() {+    for rtl in [+        "\u{0645}\u{0631}\u{062d}\u{0628}\u{0627}",+        "\u{05e9}\u{05dc}\u{05d5}\u{05dd}",+        "123 \u{0645}\u{0631}\u{062d}\u{0628}\u{0627}",+        "(\u{05e9})",+        "\u{200f}1",+        "\u{0710}\u{0712}",+        "\u{0780}\u{0781}",+    ] {+        assert!(is_rtl(rtl), "{rtl:?}");+    }+    for ltr in [+        "",+        "   ",+        "123 456",+        "Hello \u{0645}\u{0631}\u{062d}\u{0628}\u{0627}",+        "\u{0661}\u{0662}\u{0663}",+        "()",+        "\u{0e01}\u{0e02}",+        "\u{4e2d}\u{6587}",+    ] {+        assert!(!is_rtl(ltr), "{ltr:?}");+    }+}++#[test]+fn word_marks_right_to_left_paragraphs_and_runs() {+    let xml = part(&docx(&[bidi_page()], "Arabic"), "word/document.xml");+    let paragraphs: Vec<&str> = xml.split("<w:p>").skip(1).collect();+    let find = |needle: &str| *paragraphs.iter().find(|p| p.contains(needle)).unwrap_or_else(|| panic!("no paragraph with {needle}"));+    // The Arabic paragraph: right-to-left, its run a complex-script one.+    let arabic = find("\u{0645}\u{0646}\u{0630}");+    assert!(arabic.starts_with("<w:pPr><w:bidi/></w:pPr>"), "{arabic}");+    assert!(arabic.contains("<w:szCs w:val=\"22\"/><w:rtl/>"), "{arabic}");+    // English: neither.+    let english = find("written in English");+    assert!(!english.contains("<w:bidi/>") && !english.contains("<w:rtl/>"), "{english}");+    // Mixed, starting in English: a left-to-right paragraph.+    let mixed = find("mixed paragraph");+    assert!(!mixed.contains("<w:bidi/>"), "{mixed}");+    // A bold Arabic heading: the style first, then the direction (the order Word requires), and+    // the bold is the complex-script bold too.+    let heading = find("\u{0627}\u{0644}\u{0639}\u{0646}\u{0648}\u{0627}\u{0646}");+    assert!(heading.starts_with("<w:pPr><w:pStyle w:val=\"Heading1\"/><w:bidi/></w:pPr>"), "{heading}");+}++#[test]+fn html_and_rtf_mark_right_to_left_paragraphs() {+    let html = html(&[bidi_page()], "Arabic");+    assert!(html.contains("<p dir=\"rtl\">\u{0645}\u{0646}\u{0630}"), "{html}");+    assert!(html.contains("<h1 dir=\"rtl\">\u{0627}\u{0644}\u{0639}"), "{html}");+    assert!(html.contains("<p>This paragraph is written in English"), "no direction on the English one");+    assert!(html.contains("<p>Chapter 2"), "nor on the mixed one that starts in English");+    let rtf = rtf(&[bidi_page()]);+    // RTF writes the Arabic as \u escapes: the right-to-left paragraph is the one with \rtlpar.+    assert_eq!(rtf.matches("\\rtlpar").count(), 2, "the Arabic paragraph and the Arabic heading: {rtf}");+    assert!(rtf.contains("{\\pard\\rtlpar\\fs22 \\u1605?"), "{rtf}");+    assert!(!rtf.contains("\\rtlpar\\fs22 This"), "{rtf}");+}++/// #4653: table cells and positioned text boxes carry their direction too.+#[test]+fn right_to_left_table_cells_and_text_boxes_are_marked() {+    let mut p = table_page();+    for b in &mut p.blocks {+        if b.text == "Unit" {+            b.text = "\u{0627}\u{0644}\u{0648}\u{062d}\u{062f}\u{0629}".into();+        }+    }+    let xml = part(&docx(&[p.clone()], "Table"), "word/document.xml");+    assert_eq!(xml.matches("<w:p><w:pPr><w:bidi/></w:pPr>").count(), 1, "one Arabic cell: {xml}");+    let html = html(&[p.clone()], "Table");+    assert!(html.contains("<td dir=\"rtl\">\u{0627}\u{0644}\u{0648}"), "{html}");+    assert_eq!(html.matches(" dir=\"rtl\"").count(), 1);+    let rtf = rtf(&[p]);+    assert_eq!(rtf.matches("\\rtlpar").count(), 1, "{rtf}");+    // A page that stays in positioned text boxes (rotated text): the box's paragraph.+    let mut q = bidi_page();+    q.blocks[1].rotation = 30.0;+    let xml = part(&docx(&[q], "Boxes"), "word/document.xml");+    assert!(xml.contains("<w:txbxContent><w:p><w:pPr><w:bidi/><w:spacing"), "{xml}");+    assert!(xml.contains("<w:txbxContent><w:p><w:pPr><w:spacing"), "the English box has no direction");+}++/// #4653: the direction is the first strong character's by its Unicode bidi class, not by its+/// script: an explicit left-to-right mark before Arabic makes it left to right, and Hebrew+/// punctuation that is not a letter is still strong right to left.+#[test]+fn direction_follows_the_unicode_bidi_class_of_the_first_strong_character() {+    // LRM (strong L) before Arabic text: left to right.+    assert!(!is_rtl("\u{200e}\u{0645}\u{0631}\u{062d}\u{0628}\u{0627}"));+    assert!(is_rtl("\u{200f}abc"), "RLM is strong R");+    assert!(is_rtl("\u{061c}abc"), "ALM is strong AL");+    // The maqaf is punctuation, and strong R: it starts a right-to-left text.+    assert!(is_rtl("\u{05be}abc"));+    assert!(is_rtl("123 (\u{05be}) abc"));+    // Arabic-Indic digits are weak numbers: they decide nothing.+    assert!(!is_rtl("\u{0661}\u{0662} abc"));+    assert!(is_rtl("\u{0661}\u{0662} \u{0645}"));+    // Across runs: the first strong character of the paragraph, wherever it is.+    let runs = |texts: &[&str]| -> Vec<Run<'static>> {+        let b: &'static Block = Box::leak(Box::new(block("x", 0.0, 0.0, 1.0, 1.0)));+        texts.iter().map(|t| Run { block: b, text: (*t).into() }).collect()+    };+    assert!(!paragraph_rtl(&runs(&["\u{200e}", "\u{0645}\u{0631}"])), "LRM in the first run");+    assert!(paragraph_rtl(&runs(&["12 ", ", ", "\u{05be}x"])), "weak runs, then the maqaf");+    assert!(paragraph_rtl(&runs(&["", " ", "\u{0645}"])));+    assert!(!paragraph_rtl(&runs(&["12", "\u{0661}"])));+    assert!(!paragraph_rtl(&runs(&[])));+    // The same through the exporters: an LRM-first Arabic paragraph is not marked.+    let mut p = bidi_page();+    p.blocks[0].text = format!("\u{200e}{}", p.blocks[0].text);+    let xml = part(&docx(&[p.clone()], "LRM"), "word/document.xml");+    assert!(!xml.contains("<w:p><w:pPr><w:bidi/></w:pPr><w:r><w:rPr><w:sz w:val=\"22\"/><w:szCs w:val=\"22\"/><w:rtl/>"), "{xml}");+    assert!(!html(&[p.clone()], "LRM").contains("<p dir=\"rtl\">\u{200e}"));+    assert!(!rtf(&[p]).contains("\\rtlpar\\fs22 \\u8206"));+}++/// #4653: a run that mixes Arabic and Latin is cut into runs, and only what is right to left for+/// certain is marked `w:rtl`: the strong Arabic, its marks, and the spaces and punctuation between+/// two Arabic characters. Numbers, Latin and the neutral characters at the edges are not, because+/// Word treats every neutral and weak character in a `w:rtl` run as right to left, which reverses+/// numbers and misplaces paired brackets.+#[test]+fn a_mixed_direction_run_is_split_so_only_the_arabic_is_marked_rtl() {+    let arabic = "\u{0645}\u{0631}\u{062d}\u{0628}\u{0627}";+    let more = "\u{0628}\u{0643}";+    let cases: [(String, Vec<(String, bool)>); 17] = [+        // Arabic and then a number: the number is not in the right-to-left run.+        (format!("{arabic} 123"), vec![(arabic.into(), true), (" 123".into(), false)]),+        // A number before the Arabic.+        (format!("123 {arabic}"), vec![("123 ".into(), false), (arabic.into(), true)]),+        // Latin and brackets: both brackets are outside the right-to-left run.+        (format!("abc ({arabic})"), vec![("abc (".into(), false), (arabic.into(), true), (")".into(), false)]),+        // Spaces and punctuation between two Arabic words are inside it.+        (format!("{arabic} {more}"), vec![(format!("{arabic} {more}"), true)]),+        // The Arabic comma is a weak (separator) character, not a neutral one: it is not inside.+        (format!("{arabic}\u{060c} {more}"), vec![(arabic.into(), true), ("\u{060c} ".into(), false), (more.into(), true)]),+        // A number between two Arabic words splits it.+        (format!("{arabic} 12 {more}"), vec![(arabic.into(), true), (" 12 ".into(), false), (more.into(), true)]),+        // Marks and joiners stay with their letter; a mark after Latin stays with the Latin.+        (+            "\u{0645}\u{064e}\u{0631}\u{200c}\u{0628} x".to_string(),+            vec![("\u{0645}\u{064e}\u{0631}\u{200c}\u{0628}".into(), true), (" x".into(), false)],+        ),+        ("e\u{0301} \u{0645}".into(), vec![("e\u{0301} ".into(), false), ("\u{0645}".into(), true)]),+        // Leading and trailing spaces are outside; the first strong character decides nothing here.+        (+            format!("  {arabic} ABC 123 {arabic}  "),+            vec![("  ".into(), false), (arabic.into(), true), (" ABC 123 ".into(), false), (arabic.into(), true), ("  ".into(), false)],+        ),+        // A mark or joiner after pending neutrals is no second strong character: they stay out.+        ("abc \u{0645} \u{200c}x".into(), vec![("abc ".into(), false), ("\u{0645}".into(), true), (" \u{200c}x".into(), false)]),+        ("abc \u{0645} (\u{0301}x".into(), vec![("abc ".into(), false), ("\u{0645}".into(), true), (" (\u{0301}x".into(), false)]),+        ("\u{0645} \u{0301}".into(), vec![("\u{0645}".into(), true), (" \u{0301}".into(), false)]),+        // A real strong character after them commits them, marks and joiners included.+        ("\u{0645} \u{200c} \u{0628}".into(), vec![("\u{0645} \u{200c} \u{0628}".into(), true)]),+        ("\u{0645} \u{0301}\u{0628}".into(), vec![("\u{0645} \u{0301}\u{0628}".into(), true)]),+        // A mark attached to the committed stretch joins it.+        ("\u{0645}\u{0301} x".into(), vec![("\u{0645}\u{0301}".into(), true), (" x".into(), false)]),+        // No strong right-to-left character: one run, not marked.+        ("12, 34 (x)".into(), vec![("12, 34 (x)".into(), false)]),+        ("".into(), vec![("".into(), false)]),+    ];+    for (text, expected) in cases {+        let got: Vec<(String, bool)> = directional_runs(&text).into_iter().map(|(t, d)| (t.to_string(), d)).collect();+        assert_eq!(got, expected, "{text:?}");+        assert_eq!(got.iter().map(|(t, _)| t.as_str()).collect::<String>(), text, "nothing is lost");+        assert!(text.is_empty() || got.iter().all(|(t, _)| !t.is_empty()), "no empty run in {text:?}");+    }+    assert_eq!(directional_runs(arabic), [(arabic, true)]);++    // In Word: one block, Arabic first, with Latin and a number in the middle.+    let p = Page {+        width: 612.0,+        height: 792.0,+        blocks: vec![block(&format!("{arabic} ABC 123 {arabic} and more text so that it is long enough to be the body"), 72.0, 600.0, 500.0, 612.0)],+        images: Vec::new(),+        background: None,+    };+    let xml = part(&docx(&[p], "Mixed"), "word/document.xml");+    let runs: Vec<&str> = xml.split("<w:r>").skip(1).collect();+    let with = |needle: &str| *runs.iter().find(|r| r.contains(needle)).unwrap_or_else(|| panic!("no run with {needle}: {xml}"));+    assert!(with(arabic).contains("<w:rtl/>"));+    let latin = with("ABC 123");+    assert!(!latin.contains("<w:rtl/>"), "{latin}");+    assert!(latin.contains("<w:szCs w:val=\"24\"/>"), "the complex-script size is on every run: {latin}");+    assert!(xml.contains("<w:p><w:pPr><w:bidi/></w:pPr>"), "the paragraph is right to left: {xml}");+    // A number after Arabic, and brackets around Arabic, are in runs without w:rtl.+    for (text, plain) in [(format!("{arabic} 123"), " 123"), (format!("abc ({arabic})"), ")")] {+        let q = Page { width: 612.0, height: 792.0, blocks: vec![block(&text, 72.0, 600.0, 500.0, 612.0)], images: Vec::new(), background: None };+        let xml = part(&docx(&[q], "Edge"), "word/document.xml");+        let run = xml.split("<w:r>").skip(1).find(|r| r.contains(&format!(">{plain}</w:t>"))).unwrap_or_else(|| panic!("no run {plain:?}: {xml}"));+        assert!(!run.contains("<w:rtl/>"), "{plain:?} stays out of the right-to-left run: {run}");+    }+    // Bold Arabic keeps its complex-script bold on the right-to-left run, and none on the Latin.+    let mut bold = block(&format!("{arabic} ABC"), 72.0, 600.0, 500.0, 612.0);+    bold.bold = true;+    let q = Page { width: 612.0, height: 792.0, blocks: vec![bold], images: Vec::new(), background: None };+    let xml = part(&docx(&[q], "Bold"), "word/document.xml");+    assert!(xml.contains("<w:b/><w:bCs/><w:sz w:val=\"24\"/><w:szCs w:val=\"24\"/><w:rtl/>"), "{xml}");+    assert!(xml.contains("<w:b/><w:bCs/><w:sz w:val=\"24\"/><w:szCs w:val=\"24\"/></w:rPr><w:t xml:space=\"preserve\"> ABC</w:t>"), "{xml}");++    // Styled Arabic with Arabic-Indic digits: the digits are not marked right to left, but they+    // keep the block's complex-script bold and size like the letters, so they do not fall back to+    // Word's default for complex script.+    let mut styled = block(&format!("{arabic} \u{0661}\u{0662}"), 72.0, 600.0, 500.0, 612.0);+    (styled.bold, styled.italic, styled.size) = (true, true, 24.0);+    let q = Page { width: 612.0, height: 792.0, blocks: vec![styled], images: Vec::new(), background: None };+    let xml = part(&docx(&[q], "Digits"), "word/document.xml");+    let cs = "<w:b/><w:bCs/><w:i/><w:iCs/><w:sz w:val=\"48\"/><w:szCs w:val=\"48\"/>";+    assert!(xml.contains(&format!("{cs}<w:rtl/></w:rPr><w:t xml:space=\"preserve\">{arabic}</w:t>")), "{xml}");+    assert!(xml.contains(&format!("{cs}</w:rPr><w:t xml:space=\"preserve\"> \u{0661}\u{0662}</w:t>")), "{xml}");+}diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml--- a/parity/acrobat-features.toml+++ b/parity/acrobat-features.toml@@ -7052,8 +7052,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"]-evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"]-notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity."+evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::word_marks_right_to_left_paragraphs_and_runs", "crates/export/src/tests.rs::html_and_rtf_mark_right_to_left_paragraphs", "crates/export/src/tests.rs::right_to_left_table_cells_and_text_boxes_are_marked", "crates/export/src/tests.rs::text_reads_right_to_left_by_its_first_strong_character"]+notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity.. Right-to-left text (#4653): a paragraph, heading, table cell or text box whose first strong character, by its Unicode bidi class (so an LRM mark first makes it left to right and the Hebrew maqaf, though punctuation, starts a right-to-left one), is right to left is marked right to left (w:bidi on the paragraph, w:rtl on the right-to-left text of a run and the complex-script bold, italic and size (bCs, iCs, szCs) on every run; HTML dir=\"rtl\"; RTF \\rtlpar), so Word and browsers align and order it so; a run that mixes scripts is cut so that only the strong right-to-left characters, their marks and joiners, and the spaces and punctuation between two of them are marked w:rtl; digits, Latin and the punctuation and brackets at the edges are left unmarked for Word to resolve in the paragraph, since w:rtl would make Word treat them as right to left (reversing numbers, misplacing brackets). Missing: the characters come out in the order the text extraction gives them (a PDF that draws Arabic in visual order without ActualText is not reordered here), table column order is not mirrored (w:bidiVisual), and the PPTX export does not mark direction"  [[feature]] id = "create.export-rtf"