use std::collections::BTreeMap;
use dxpdf::model::{Block, Inline, RunElement};
use dxpdf::render::layout::draw_command::{DrawCommand, LayoutedPage};
const HEBREW: &str = "test-files/bidi-hebrew.docx";
const ARABIC: &str = "test-files/bidi-arabic.docx";
type Piece = (f32, String);
fn fixture(path: &str) -> (Vec<String>, Vec<Vec<Piece>>) {
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
let doc = dxpdf::docx::parse(&bytes).unwrap_or_else(|e| panic!("{path} parses: {e}"));
let texts: Vec<String> = doc
.body
.iter()
.filter_map(|block| match block {
Block::Paragraph(p) => Some(
p.content
.iter()
.filter_map(|inline| match inline {
Inline::TextRun(run) => {
Some(run.content.iter().filter_map(|el| match el {
RunElement::Text(t) => Some(t.as_str()),
_ => None,
}))
}
_ => None,
})
.flatten()
.collect::<String>(),
),
_ => None,
})
.filter(|t: &String| !t.is_empty())
.collect();
(texts, lines(&dxpdf::render::resolve_and_layout(doc).1))
}
fn lines(pages: &[LayoutedPage]) -> Vec<Vec<Piece>> {
let mut out = Vec::new();
for page in pages {
let mut by_baseline: BTreeMap<i64, Vec<Piece>> = BTreeMap::new();
for command in &page.commands {
if let DrawCommand::Text { position, text, .. } = command {
by_baseline
.entry(position.y.raw().round() as i64)
.or_default()
.push((position.x.raw(), text.to_string()));
}
}
for (_, mut line) in by_baseline {
line.sort_by(|a, b| a.0.total_cmp(&b.0));
if !line.iter().all(|(_, t)| t.trim().is_empty()) {
out.push(line);
}
}
}
out
}
fn right_to_left(line: &[Piece]) -> String {
line.iter().rev().map(|(_, t)| t.as_str()).collect()
}
fn left_to_right(line: &[Piece]) -> String {
line.iter().map(|(_, t)| t.as_str()).collect()
}
#[test]
fn a_right_to_left_paragraph_paints_its_words_right_to_left() {
for (path, expect_lines) in [(HEBREW, 7), (ARABIC, 6)] {
let (texts, laid) = fixture(path);
assert_eq!(
laid.len(),
expect_lines,
"{path}: every fixture paragraph must fit one line",
);
assert_eq!(
right_to_left(&laid[0]),
texts[0],
"{path}: the first paragraph must read back right to left",
);
assert_ne!(
left_to_right(&laid[0]),
texts[0],
"{path}: and must *not* read back left to right — that is the bug",
);
}
}
#[test]
fn run_boundaries_do_not_change_where_words_are_painted() {
for path in [HEBREW, ARABIC] {
let (texts, laid) = fixture(path);
assert_eq!(texts[0], texts[1], "{path}: the two must carry one text");
assert_eq!(
right_to_left(&laid[1]),
texts[1],
"{path}: split across runs, it must still read right to left",
);
let one: Vec<String> = laid[0].iter().map(|(_, t)| t.clone()).collect();
let many: Vec<String> = laid[1].iter().map(|(_, t)| t.clone()).collect();
assert_eq!(one, many, "{path}: same pieces, same order");
}
}
#[test]
fn an_explicit_rtl_run_agrees_with_what_the_characters_say() {
for path in [HEBREW, ARABIC] {
let (texts, laid) = fixture(path);
assert_eq!(texts[0], texts[2]);
assert_eq!(right_to_left(&laid[2]), texts[2], "{path}");
}
}
#[test]
fn an_embedded_latin_phrase_keeps_its_own_order() {
let (_, laid) = fixture(HEBREW);
let painted = left_to_right(&laid[3]);
assert!(
painted.contains("the quick brown fox"),
"the Latin phrase must read forwards: {painted:?}",
);
}
#[test]
fn a_quoted_hebrew_phrase_reverses_inside_a_left_to_right_paragraph() {
let (texts, laid) = fixture(HEBREW);
let line = laid.last().expect("the fixture's last paragraph");
let painted = left_to_right(line);
assert!(
painted.starts_with("Quoted:"),
"the paragraph is left-to-right, so it starts at the left: {painted:?}",
);
assert!(
painted.trim_end().ends_with("end."),
"and ends at the right: {painted:?}",
);
assert_ne!(
painted,
*texts.last().unwrap(),
"but the Hebrew inside it must have moved",
);
}
#[test]
fn western_digits_inside_arabic_keep_their_order() {
let (_, laid) = fixture(ARABIC);
for line in &laid[3..5] {
let painted = left_to_right(line);
assert!(
painted.contains("12") && painted.contains("345"),
"each number must survive as itself: {painted:?}",
);
assert!(
!painted.contains("21") && !painted.contains("543"),
"and must not be reversed digit by digit: {painted:?}",
);
}
}
#[test]
fn brackets_mirror_around_right_to_left_text_only() {
let (texts, laid) = fixture(HEBREW);
let source = &texts[5];
assert!(source.contains("(עולם)") && source.contains("(test)"));
let painted = left_to_right(&laid[5]);
let tight: String = painted.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
tight.contains(")עולם("),
"the Hebrew parenthetical is at an odd level, so its brackets mirror \
— read right to left it opens before עולם: {painted:?}",
);
assert!(
tight.contains("(test)"),
"the Latin one ends up surrounded the same way round it was written, \
which is the point: its brackets are neutrals that took the \
paragraph's level, mirrored, and were then reordered *past* the Latin \
— two wrongs that must make a right: {painted:?}",
);
}
#[test]
fn a_bidi_paragraph_with_no_jc_is_right_aligned() {
let (_, laid) = fixture(HEBREW);
let rtl_left = laid[0].first().expect("pieces").0;
let ltr_left = laid[4].first().expect("pieces").0;
assert!(
rtl_left > ltr_left + 1.0,
"the right-to-left paragraph must be pushed off the left margin \
({rtl_left} vs {ltr_left})",
);
}
#[test]
fn every_right_to_left_run_is_marked_for_shaping() {
use dxpdf::render::shape::RunDirection;
let bytes = std::fs::read(HEBREW).expect("fixture");
let doc = dxpdf::docx::parse(&bytes).expect("parses");
let pages = dxpdf::render::resolve_and_layout(doc).1;
let mut rtl = 0;
let mut ltr = 0;
for page in &pages {
for command in &page.commands {
let DrawCommand::Text { text, shaped, .. } = command else {
continue;
};
let is_hebrew = text.chars().any(|c| ('\u{0590}'..='\u{05FF}').contains(&c));
if is_hebrew {
assert_eq!(
*shaped,
Some(RunDirection::RightToLeft),
"Hebrew run {text:?} must be shaped so its glyphs reverse",
);
rtl += 1;
} else if text.trim().chars().any(|c| c.is_ascii_alphabetic()) {
assert_eq!(
*shaped, None,
"Latin run {text:?} must keep the cmap path unchanged",
);
ltr += 1;
}
}
}
assert!(
rtl > 10,
"the fixture must produce many Hebrew runs, got {rtl}"
);
assert!(ltr > 5, "and some Latin ones, got {ltr}");
}
#[test]
fn reordering_neither_drops_nor_duplicates_text() {
for path in [HEBREW, ARABIC] {
let (texts, laid) = fixture(path);
assert_eq!(texts.len(), laid.len());
for (text, line) in texts.iter().zip(&laid) {
let mut want: Vec<char> = text.chars().filter(|c| !c.is_whitespace()).collect();
let mut got: Vec<char> = left_to_right(line)
.chars()
.map(|c| dxpdf::i18n::bidi::mirror(c).unwrap_or(c))
.filter(|c| !c.is_whitespace())
.collect();
want.sort_unstable();
got.sort_unstable();
let mut want: Vec<char> = want
.into_iter()
.map(|c| dxpdf::i18n::bidi::mirror(c).unwrap_or(c))
.collect();
want.sort_unstable();
assert_eq!(want, got, "{path}: {text:?}");
}
}
}