Skip to content

Commit e2cc628

Browse files
artizclaude
andcommitted
feat(pdf): RTL reading order + Arabic shaping + gap-aware cell join
Improves right-to-left (Arabic) extraction and tightens table-PDF assembly: - RTL reading order: cells on a line read right→left, so an Arabic-majority region now sorts each line band by descending left edge (was always LTR). - RTL line wrapping: line splitting is now direction-aware — RTL wraps reset x rightward (`g.l > p.r`), so wrapped Arabic paragraphs no longer merge into one overlapping cell. - RTL word gaps: split at the right-to-left inter-word gap (`p.l - g.r`) rather than trusting pdfium's space glyphs, which it spuriously inserts inside Arabic words. - Arabic lam-alef: pdfium decomposes the إ/أ/آ-lam ligatures in visual order (`alef, lam`); swap mid-word back to logical `lam, alef`. Restricted to the hamza/madda variants — plain `alef+lam` is ambiguous (article vs ligature). - Arabic↔Latin boundary: insert the space pdfium runs together (`وPython` → `و Python`). - Gap-aware cell join (all scripts): same-band cells join without a space when abutting (so a word split across segments isn't broken), with a space across a real gap or line break. right_to_left_01 4→2, right_to_left_03 76→70, and the table papers tighten (2206 210→200, redp5110 300→298, 2203 313→309). The 3 exact PDFs are unchanged. No-op for non-Arabic text. Regenerated affected snapshot fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f3fb4a7 commit e2cc628

19 files changed

Lines changed: 219 additions & 109 deletions

crates/fleischwolf-pdf/examples/dump_regions.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,15 @@ fn main() {
4040
tail
4141
);
4242
}
43-
// cells near the page bottom, to spot orphans below the last region
44-
for c in page.cells.iter().filter(|c| c.t > 595.0 && c.t < 660.0) {
45-
println!(" CELL t={:6.1} b={:6.1} | {}", c.t, c.b, c.text);
43+
// raw line cells in extraction order (to inspect RTL ordering)
44+
if std::env::var("DUMP_CELLS").is_ok() {
45+
for (ci, c) in page.cells.iter().enumerate() {
46+
let snip: String = c.text.chars().take(50).collect();
47+
println!(
48+
" CELL[{ci}] t={:6.1} l={:6.1} r={:6.1} | {}",
49+
c.t, c.l, c.r, snip
50+
);
51+
}
4652
}
4753
}
4854
}

crates/fleischwolf-pdf/src/assemble.rs

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ fn order_regions<T>(items: &mut [T], page_w: f32, reg: impl Fn(&T) -> &Region) {
101101
/// `{ ahn }`, `Name 1 .`, `[ 9 ]`), and a geometric gap heuristic diverges from
102102
/// it more than a plain single-space join does.
103103
fn clean_text(text: &str) -> String {
104-
text.replace("\u{2} ", "")
104+
let out = text
105+
.replace("\u{2} ", "")
105106
.replace("\u{ad} ", "")
106107
.replace(['\u{2}', '\u{ad}'], "") // any stray wrap hyphens not at a join
107108
.replace(['\u{2018}', '\u{2019}'], "'") // ‘ ’ → '
@@ -110,7 +111,60 @@ fn clean_text(text: &str) -> String {
110111
.replace('\u{2026}', "...") // … → ...
111112
.split_whitespace()
112113
.collect::<Vec<_>>()
113-
.join(" ")
114+
.join(" ");
115+
fix_arabic_lam_alef(&out)
116+
}
117+
118+
/// pdfium decomposes the Arabic lam-alef ligature (لا / لإ / لأ / لآ) into its
119+
/// glyph constituents in *visual* order — `alef-variant, lam` — but docling keeps
120+
/// logical order, `lam, alef-variant`. Swap a mid-word `alef-variant + lam` back
121+
/// to `lam + alef-variant`. "Mid-word" (the previous char is an Arabic letter)
122+
/// distinguishes the ligature from the definite article `ال` (word-initial
123+
/// `alef + lam`), which must stay. No-op for non-Arabic text.
124+
fn fix_arabic_lam_alef(s: &str) -> String {
125+
let is_arabic_letter = |c: char| ('\u{0620}'..='\u{064A}').contains(&c);
126+
let chars: Vec<char> = s.chars().collect();
127+
if !chars.iter().any(|&c| is_arabic_letter(c)) {
128+
return s.to_string(); // no-op for non-Arabic text
129+
}
130+
// Pass 1: swap mid-word `alef-variant + lam` → `lam + alef-variant`. Only the
131+
// hamza/madda alef variants (إ أ آ) are safe: the definite article is always
132+
// plain `ا + ل`, so plain `alef + lam` is ambiguous (a legitimate `فعالة` vs a
133+
// reversed `لا` ligature look identical) — leaving plain alef alone avoids
134+
// corrupting legitimate words.
135+
let mut a: Vec<char> = Vec::with_capacity(chars.len());
136+
let mut i = 0;
137+
while i < chars.len() {
138+
let c = chars[i];
139+
if matches!(c, '\u{0622}' | '\u{0623}' | '\u{0625}')
140+
&& chars.get(i + 1) == Some(&'\u{0644}')
141+
&& i > 0
142+
&& is_arabic_letter(chars[i - 1])
143+
{
144+
a.push('\u{0644}');
145+
a.push(c);
146+
i += 2;
147+
continue;
148+
}
149+
a.push(c);
150+
i += 1;
151+
}
152+
// Pass 2: insert a space at Arabic↔Latin boundaries (bidi script switch) that
153+
// pdfium runs together — docling separates the embedded Latin run (`وPython`
154+
// → `و Python`).
155+
let mut out: Vec<char> = Vec::with_capacity(a.len());
156+
for (j, &c) in a.iter().enumerate() {
157+
if j > 0 {
158+
let p = a[j - 1];
159+
if (is_arabic_letter(p) && c.is_ascii_alphabetic())
160+
|| (p.is_ascii_alphabetic() && is_arabic_letter(c))
161+
{
162+
out.push(' ');
163+
}
164+
}
165+
out.push(c);
166+
}
167+
out.into_iter().collect()
114168
}
115169

116170
/// Cells assigned to a region (best container), in reading order, joined.
@@ -123,19 +177,49 @@ fn region_text(region: &Region, cells: &[TextCell]) -> String {
123177
})
124178
.collect();
125179
// Quantize the top coordinate into ~line bands so cells on the same line
126-
// sort left-to-right; this is a strict total order (a raw fuzzy comparator
127-
// is not transitive and makes Rust's sort panic).
180+
// sort in reading order; this is a strict total order (a raw fuzzy comparator
181+
// is not transitive and makes Rust's sort panic). For a right-to-left
182+
// (Arabic-majority) region, cells on a line read right→left, so sort the band
183+
// by descending left edge.
128184
let band = inside
129185
.iter()
130186
.map(|c| (c.b - c.t).abs())
131187
.fold(0.0f32, f32::max)
132188
.max(1.0);
133-
inside.sort_by_key(|c| ((c.t / band).round() as i64, (c.l * 10.0) as i64));
134-
let joined = inside
189+
let arabic = inside
135190
.iter()
136-
.map(|c| c.text.trim())
137-
.collect::<Vec<_>>()
138-
.join(" ");
191+
.flat_map(|c| c.text.chars())
192+
.filter(|&c| ('\u{0600}'..='\u{06FF}').contains(&c))
193+
.count();
194+
let latin = inside
195+
.iter()
196+
.flat_map(|c| c.text.chars())
197+
.filter(|c| c.is_ascii_alphabetic())
198+
.count();
199+
let rtl = arabic > latin;
200+
inside.sort_by_key(|c| {
201+
let x = (c.l * 10.0) as i64;
202+
((c.t / band).round() as i64, if rtl { -x } else { x })
203+
});
204+
// Join cells in reading order. Cells on different line-bands join with a
205+
// space (line break). Cells on the same band join with a space only if there
206+
// is a real horizontal gap between them — an RTL line is split into adjacent
207+
// segments mid-word (`الت`|`ي` → `التي`), so abutting same-band cells must not
208+
// get a spurious space.
209+
let mut joined = String::new();
210+
let mut prev: Option<&&TextCell> = None;
211+
for c in &inside {
212+
if let Some(p) = prev {
213+
let same_band = ((p.t / band).round() as i64) == ((c.t / band).round() as i64);
214+
let h = (c.b - c.t).abs().max((p.b - p.t).abs()).max(1.0);
215+
let gap = if rtl { p.l - c.r } else { c.l - p.r };
216+
if !same_band || gap > h * 0.25 {
217+
joined.push(' ');
218+
}
219+
}
220+
joined.push_str(c.text.trim());
221+
prev = Some(c);
222+
}
139223
clean_text(&joined)
140224
}
141225

crates/fleischwolf-pdf/src/pdfium_backend.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,15 @@ fn lines_from_glyphs(gs: &[Glyph], page_h: f32, code: bool) -> Vec<TextCell> {
318318
// *large* drop (≥1.5× the line height — a skipped line, e.g. a centered
319319
// page-number footer below a short last word) is always a new line,
320320
// even without the x-reset.
321-
new_line = (p.b - g.b > h * 0.5 && g.l < p.r) || (p.b - g.b > line_h.max(h) * 1.5);
321+
// LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
322+
// rightward (the new line begins at the far right). A large drop
323+
// (≥1.5× line height) is a new line regardless of x.
324+
let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
325+
g.l > p.r
326+
} else {
327+
g.l < p.r
328+
};
329+
new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
322330
// Don't split before closing punctuation, after opening punctuation, or
323331
// after a period that runs into a digit/lowercase letter — docling
324332
// keeps `engines,` / `[37` / `i.e.` / `98.5` together even across a
@@ -332,6 +340,12 @@ fn lines_from_glyphs(gs: &[Glyph], page_h: f32, code: bool) -> Vec<TextCell> {
332340
let word_gap = line_h.max(h) * 0.25;
333341
new_word = if code {
334342
new_line || pending_space
343+
} else if is_arabic(g.ch) || is_arabic(p.ch) {
344+
// RTL runs right-to-left, so the inter-word gap is `p.l - g.r`. A
345+
// real word space has a gap; pdfium also emits spurious zero-gap
346+
// space glyphs inside words (`التي`), so require the gap rather
347+
// than trusting a bare space glyph.
348+
new_line || (p.l - g.r > word_gap && !glued)
335349
} else {
336350
new_line || ((pending_space || g.l - p.r > word_gap) && !glued)
337351
};
@@ -389,7 +403,15 @@ fn words_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
389403
let mut new_line = false;
390404
let mut new_word = false;
391405
if let Some(p) = prev {
392-
new_line = (p.b - g.b > h * 0.5 && g.l < p.r) || (p.b - g.b > line_h.max(h) * 1.5);
406+
// LTR wraps reset x leftward (`g.l < p.r`); RTL (Arabic) wraps reset
407+
// rightward (the new line begins at the far right). A large drop
408+
// (≥1.5× line height) is a new line regardless of x.
409+
let x_reset = if is_arabic(g.ch) || is_arabic(p.ch) {
410+
g.l > p.r
411+
} else {
412+
g.l < p.r
413+
};
414+
new_line = (p.b - g.b > h * 0.5 && x_reset) || (p.b - g.b > line_h.max(h) * 1.5);
393415
// No digit-digit glue here (unlike the prose grouping): table cells in
394416
// adjacent columns are numeric and a column gap must still split them
395417
// (`0.965` `0.934`, not `0.9650.934`). Intra-number digits have no gap
@@ -436,6 +458,10 @@ fn words_from_glyphs(gs: &[Glyph], page_h: f32) -> Vec<TextCell> {
436458
cells
437459
}
438460

461+
fn is_arabic(c: char) -> bool {
462+
('\u{0600}'..='\u{06FF}').contains(&c)
463+
}
464+
439465
fn is_close_punct(c: char) -> bool {
440466
matches!(
441467
c,

tests/pdf_snapshots/latex/sources/2305.03393/llncsdoc.pdf.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ From a technical point of view, the llncs document class does not require any sp
159159
The llncs document class supports some additional special characters:
160160

161161
```
162-
\grole yields > < \getsto yields ← → \lid yields < = \gid yields > =
162+
\grole yields >< \getsto yields ← → \lid yields <= \gid yields >=
163163
```
164164

165165
If you need blackboard bold characters, i.e. for sets of numbers, please load the related AMS-TEXfonts. If for some reason this is not possible you can also use the following commands from the llncs class:
@@ -192,7 +192,7 @@ cas e (env.) Other theorem-like environments render the text in roman, while the
192192
example (env.)
193193

194194
```
195-
property(env.) question(env.) exercise(env.) solution(env.)heading is bold as well: problem(env.) note(env.) \begin{case} <text> \end{case} \begin{conjecture} <text> \end{conjecture} \begin{example} <text> \end{example} \begin{exercise} <text> \end{exercise} \begin{note} <text> \end{note} \begin{problem} <text> \end{problem} remark(env.) \begin{property} <text> \end{property} \begin{question} <text> \end{question} \begin{remark} <text> \end{remark} \begin{solution} <text> \end{solution}
195+
property(env.)question(env.)exercise(env.)solution(env.)heading is bold as well:problem(env.)note(env.) \begin{case} <text> \end{case}\begin{conjecture} <text> \end{conjecture}\begin{example} <text> \end{example}\begin{exercise} <text> \end{exercise}\begin{note} <text> \end{note}\begin{problem} <text> \end{problem} remark(env.) \begin{property} <text> \end{property}\begin{question} <text> \end{question}\begin{remark} <text> \end{remark}\begin{solution} <text> \end{solution}
196196
```
197197

198198
claim (env.) Finally, there are also two unnumbered environments that have the run-in headproof (env.) ing in italics and the text in upright roman.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
<!-- image -->
22

3-
DM Mathematics Aux-Loss-Based Layer 12 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364Wikipedia (en) Github
3+
DM Mathematics Aux-Loss-Based Layer 1212345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364Wikipedia (en)Github

tests/pdf_snapshots/mets_gbs/sources/32044009881525_select.tar.gz.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
recently become prevalent that he who speaks of military power is a " militarist . " This , how- reverse assertion ever , is as great a fallacy as the that he who talks of nothing but peace is a 66 pacifist . "
1+
recently become prevalent that he who speaks of military power is a "militarist." This, how- reverse assertion ever, is as great a fallacy as the that he who talks of nothing but peace is a 66 pacifist."
22

3-
Truth , even bitter truth , is better than the most high - minded fallacy .
3+
Truth, even bitter truth, is better than the most high-minded fallacy.
44

5-
The author has visited Japan , Siberia , China , the Philippines , the Malay States , and Hawaii in 1919 and 1920 , and his personal impressions and investigations form the basis of the present book . The list of works which he has perused in the course of his investigation of the problem of the Pacific is hereto appended .
5+
The author has visited Japan, Siberia, China, the Philippines, the Malay States, and Hawaii in 1919 and 1920, and his personal impressions and investigations form the basis of the present book. The list of works which he has perused in the course of his investigation of the problem of the Pacific is hereto appended.
66

7-
The author wishes to acknowledge his debt to Admiral A. D. Bubnov , who has contributed Chapters VII - X . Admiral Bubnov took part in the Russo - Japanese War , was Professor of the Naval Staff College at Petrograd , and Chief of the Naval Section of the Staff of the Supreme Commander - in - Chief in the Great War . The Admiral is an authoritative student of the questions of naval strategy discussed in the chapters that belong to his pen .
7+
The author wishes to acknowledge his debt to Admiral A. D. Bubnov, who has contributed Chapters VII- X. Admiral Bubnov took part in the Russo-Japanese War, was Professor ofthe Naval Staff College at Petrograd, and Chief of the Naval Section of the Staff of the Supreme Commander-in-Chief in the Great War. The Admiral is an authoritative student of the questions of naval strategy discussed in the chapters that belong to his pen.
88

9-
The author also has to thank Mr. C. Nabokoff , the late Russian Chargé d'Affaires in London , book . for undertaking the translation of his
9+
The author also has to thank Mr. C. Nabokoff, the late Russian Chargé d'Affaires in London, book. for undertaking the translation of his
1010

1111
## 62 THE PROBLEM OF THE PACIFIC
1212

13-
copper mines with up - to - date technical equip- ment in various provinces of Central China ¹ : -
13+
copper mines with up-to-date technical equip- ment in various provinces of Central China ¹:-
1414

1515
| | | | |
1616
| - | - | - | - |
@@ -26,15 +26,15 @@ copper mines with up - to - date technical equip- ment in various provinces of C
2626
| | | | |
2727
| | | | |
2828

29-
It has been mentioned in one of the preceding chapters that Japanese industries needed iron . The desire to obtain the necessary supplies of iron is thus perfectly legitimate . Japan's endea- vour to acquire concessions for the production of iron and coal is not , therefore , a proof of the Imperialism of her policy . But , as the French saying goes , Le ton fait la chanson . Japan is trying to get hold of the entire iron industry of China . She is doing so by the veiled seizure of political and administrative control of the respective provinces of China . To Europe and America this is presented under the guise of the nebulous formula of 66 special interests in the regions of China adjacent to Japan . " Man- churia and Shantung are first in that list . When Japan succeeds , she will have deprived China
29+
It has been mentioned in one of the preceding chapters that Japanese industries needed iron. The desire to obtain the necessary supplies of iron is thus perfectly legitimate. Japan's endea- vour to acquire concessions for the production of iron and coal is not, therefore, a proof of the Imperialism of her policy. But, as the French saying goes, Le ton fait la chanson. Japan is trying to get hold of the entire iron industry of China. She is doing so by the veiled seizure of political and administrative control of the respective provinces of China. To Europe and America this is presented under the guise of the nebulous formula of 66special interests in the regions of China adjacent to Japan." Man- churia and Shantung are first in that list. When Japan succeeds, she will have deprived China
3030

31-
1 These data relate to 1915 .
31+
1 These data relate to 1915.
3232

3333
## 252 THE PROBLEM OF THE PACIFIC
3434

35-
France may lay down new tonnage in the years 1927 , 1929 , and 1931 , as provided in Part 3 , Section II .
35+
France may lay down new tonnage in the years 1927, 1929, and 1931, as provided in Part 3, Section II.
3636

37-
Ships which may be retained by Italy .
37+
Ships which may be retained by Italy.
3838

3939
| | | | |
4040
| - | - | - | - |
@@ -50,7 +50,7 @@ Ships which may be retained by Italy .
5050
| | | | |
5151
| | | | |
5252

53-
Italy may lay down new tonnage in the years 1927 , 1929 and 1931 , as provided in Part 3 , Section II .
53+
Italy may lay down new tonnage in the years 1927, 1929 and 1931, as provided in Part 3, Section II.
5454

5555
| | | | | | | |
5656
| - | - | - | - | - | - | - |
@@ -66,8 +66,8 @@ Italy may lay down new tonnage in the years 1927 , 1929 and 1931 , as provided i
6666
| | | | | | | |
6767
| | | | | | | |
6868

69-
- Part 2. - RULES FOR SCRAPPING VESSELs of War . The following rules shall be observed for the scrapping of vessels of war which are to be disposed of in accordance with Articles II and III .
70-
- I. A vessel to be scrapped must be placed in such condition that it cannot be put to combative use .
71-
- II . This result must be finally effected in any one of the following ways :
72-
- ( a ) Permanent sinking of the vessel ;
73-
- ( b ) Breaking the vessel up . This shall always involve the destruction or removal of all machinery , boilers and armour , and all deck , side and bottom plating ;
69+
- Part 2.-RULES FOR SCRAPPING VESSELs of War. The following rules shall be observed for the scrapping of vessels of war which are to be disposed of in accordance with Articles II and III.
70+
- I. A vessel to be scrapped must be placed in such condition that it cannot be put to combative use.
71+
- II. This result must be finally effected in any one of the following ways:
72+
- (a) Permanent sinking of the vessel ;
73+
- (b) Breaking the vessel up. This shall always involve the destruction or removal of all machinery, boilers and armour, and all deck, side and bottom plating;
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
## e e a e g r s s s ● e 1
1+
## e e a e gr ss s ● e 1
22

33
```
4-
eW e● a s e F l ● e T e● ● u e T r F e● ● ● s e F C● ● t C A 2● ● ● C ● a b e T l a s F l ● u r a s F l ● a s l a s e u e l T r ● ● B ● ● a s s l S d l i
4+
eWe●aseFl● eTe●● ueTrFe●●● seFC●● tCA2●●● C● abeTl asFl● ur asFl● asl aseuelTr●● B●● assl Sdli
55
```
66

77
<!-- image -->
88

99
- S
1010

11-
A• :• S e t I m B e t I m o e o m i n f
11+
A•:•S etImB etIm oeominf
1212

13-
- o t I t I m
13+
- o tI tI m
1414
- e e e m m
1515
- n f o

tests/pdf_snapshots/odf/sources/odf_presentation_02.odp.pdf.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<!-- image -->
22

3-
m d e b r a m s m i a u t a e i q l i u s , a p m m v c s v u t e a c e e u s a a f t v i v a l l u a , r t n r i u u e m u s c c n u d a u s c p l a r r . . o t c u e m p s b i u M M r m c o b i e u a t s e a a c s u l c r u m s e t m u r e o n a c t i . s . o . p s i c e d n u M s N d i e i n i m n s a o d u o . r d a e u n u a a l l V o l e s n s i r l e
3+
m d e br a m s m i a ut a e i qli us ,a p m m v c s v u te a c e e us a af tv iv a ll u a ,r tn ri u u e m us c c n u da us c pl arr . . ot cu e m p s bi u M M r m c o bi e u at se a a c s ul cr u m s et m ur e on a ct i. s . o . p si ce d n u M s N di e i n im n s a od u o. r d a e u n u a all V ol e s n si r l e
44

5-
m n m e a e t b a r . h n a i x p u e n t . o l M c i d m , o u l c N e r o u t i f v i t r m m u m r a l . I e a t t t i n b i v t n s l i e o e n o l c q i t i e , r q
5+
m n m e a e t b ar . h n ai x p u en t. ol M ci d m , o ul c N er o u ti f vi tr m m u m r al .I e at tti n bi v tn sli e o en ol c q i ti e , r q
66

77
<!-- image -->
88

0 commit comments

Comments
 (0)