Skip to content

Commit 62e9644

Browse files
danielhanchenshimmyshimmerImagineer99
authored
Studio RAG: fix RTL/Indic PDF corruption and dropped DOCX tables (#6780)
* Studio RAG: fix RTL/Indic PDF corruption and dropped DOCX tables The RAG parser prefers pymupdf4llm.to_markdown for PDFs, but that rebuilds text from positioned glyphs and mangles complex-shaping scripts (RTL Arabic/Hebrew come back as shaped Presentation Forms, Indic matras drop to U+FFFD) and can silently drop most of a heavy-RTL page. _pdf now compares the Markdown against PyMuPDF's logical-order get_text() per page and falls back to it when the Markdown looks corrupted (shaped Presentation Forms or U+FFFD above a small floor/ratio) or holds far fewer letters than the raw layer. Latin PDFs are unaffected and keep their Markdown tables/headings. _docx walked document.paragraphs, which excludes table cells, so DOCX tables were dropped entirely. It now walks body content in document order via iter_inner_content, emitting each table row as pipe-joined cells (deduped across merged cells); the preview locator already anchors on pipes. Adds parser tests for the corruption and incompleteness fallbacks and for DOCX table extraction. These mirror the chat document-extractor guard raised in the unslothai/ unsloth#5351 review; the RAG parser is a separate module and needed its own fix. * RAG DOCX: keep empty table cells and collapse in-cell newlines Skipping empty cells shifted later cells left and broke column alignment across rows; a cell with internal paragraphs (newlines) also broke the pipe-joined row. Keep every cell (dropping the row only when all are empty) and normalize each cell with " ".join(split()) so multi-paragraph cells stay on one row. Adds a test for both. * RAG DOCX: dedup merged table cells on the <w:tc> element directly Store the shared <w:tc> lxml element in the seen set instead of its id(); it is hashable and compares by the underlying node, so it dedups spanned/merged cells the same way without relying on id(). Adds a merged-cell test. * RAG DOCX: align merged cells, pad skipped grid columns, flatten nested tables * RAG DOCX: walk cells in document order so nested tables keep in-cell position * RAG DOCX: dedup vertically merged cells so a spanning label is indexed once --------- Co-authored-by: danielhanchen <michaelhan2050@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
1 parent ac6ba96 commit 62e9644

2 files changed

Lines changed: 307 additions & 5 deletions

File tree

studio/backend/core/rag/parsers.py

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import logging
1414
import os
15+
import re
1516
from dataclasses import dataclass
1617
from html.parser import HTMLParser
1718

@@ -69,6 +70,39 @@ def _html(raw: str) -> list[Page]:
6970
return [_page("\n".join(parser.out), 1)]
7071

7172

73+
# pymupdf4llm rebuilds text from positioned glyphs, which mangles complex-shaping
74+
# scripts (RTL Arabic/Hebrew emerge as shaped Presentation Forms, Indic matras drop to
75+
# U+FFFD) and can silently drop most of a heavy-RTL page. When Markdown trips these
76+
# signals we fall back to PyMuPDF's logical-order get_text(). Thresholds mirror the chat
77+
# extractor guard (unslothai/unsloth#5351 review).
78+
_SHAPED_PRESENTATION_FORMS = re.compile("[\ufb1d-\ufdff\ufe70-\ufefc]")
79+
_PDF_FALLBACK_MIN_BAD_GLYPHS = 5
80+
_PDF_FALLBACK_BAD_GLYPH_RATIO = 0.0005
81+
_PDF_INCOMPLETE_RATIO = 0.75
82+
_PDF_INCOMPLETE_MIN_LETTERS = 200
83+
84+
85+
def _markdown_corrupted(text: str) -> bool:
86+
"""True when pymupdf4llm's glyph reconstruction mangled the text: shaped RTL
87+
Presentation Forms or U+FFFD replacements above a small floor/ratio (so a lone
88+
legitimate shaped glyph does not force the fallback)."""
89+
if not text:
90+
return False
91+
threshold = max(_PDF_FALLBACK_MIN_BAD_GLYPHS, _PDF_FALLBACK_BAD_GLYPH_RATIO * len(text))
92+
shaped = len(_SHAPED_PRESENTATION_FORMS.findall(text))
93+
return shaped > threshold or text.count("\ufffd") > threshold
94+
95+
96+
def _markdown_incomplete(markdown: str, plain: str) -> bool:
97+
"""True when ``markdown`` holds far fewer letters than the raw ``get_text`` layer -- a
98+
coarse guard for heavy-RTL pages pymupdf4llm silently drops without shaped glyphs."""
99+
plain_letters = sum(1 for c in plain if c.isalnum())
100+
if plain_letters < _PDF_INCOMPLETE_MIN_LETTERS:
101+
return False
102+
markdown_letters = sum(1 for c in markdown if c.isalnum())
103+
return markdown_letters < _PDF_INCOMPLETE_RATIO * plain_letters
104+
105+
72106
def _pdf_markdown(doc) -> list[str] | None:
73107
"""Per-page layout-aware Markdown (tables, headings, lists) via pymupdf4llm; index
74108
i maps to page i+1. Returns None when the lib is missing, extraction fails, or the
@@ -100,9 +134,19 @@ def _pdf(path: str, want_images: bool) -> tuple[list[Page], list[ParsedImage]]:
100134
try:
101135
md = _pdf_markdown(doc) if config.PDF_MARKDOWN else None
102136
for i, page in enumerate(doc):
103-
# Prefer layout-aware Markdown (keeps tables/headings legible for retrieval);
104-
# fall back to plain text when Markdown is off, unavailable, or empty here.
105-
text = (md[i] if md else "") or page.get_text("text") or ""
137+
plain = page.get_text("text") or ""
138+
candidate = md[i] if md else ""
139+
# Prefer layout-aware Markdown (keeps tables/headings legible for retrieval),
140+
# but drop to PyMuPDF's logical-order text when Markdown is off/empty or when
141+
# pymupdf4llm mangled it (RTL/Indic) or dropped most of the page.
142+
if (
143+
candidate
144+
and not _markdown_corrupted(candidate)
145+
and not _markdown_incomplete(candidate, plain)
146+
):
147+
text = candidate
148+
else:
149+
text = plain
106150
pages.append(_page(text, i + 1))
107151
if want_images:
108152
for img in page.get_images(full = True):
@@ -308,12 +352,61 @@ def render_pdf_pages(
308352
doc.close()
309353

310354

355+
def _docx_table_rows(table) -> list[str]:
356+
"""Each row as pipe-joined cell text (the locator splits anchors on pipes).
357+
Columns stay aligned to the layout grid (merged cells fill their spanned slots,
358+
skipped leading/trailing grid columns become empty fields). Cells are walked in
359+
document order so a nested table, and any text after it, flattens in place."""
360+
from docx.table import Table
361+
from docx.text.paragraph import Paragraph
362+
363+
rows: list[str] = []
364+
seen: set = set() # <w:tc> already emitted; dedups merges spanning columns or rows
365+
for row in table.rows:
366+
cells: list[str] = [""] * getattr(row, "grid_cols_before", 0)
367+
trailing: list[str] = [] # nested rows + any post-nested text, kept in order
368+
for cell in row.cells:
369+
# A merged cell shares one <w:tc> across the columns and rows it spans:
370+
# emit its text once, then placeholders, so columns and rows stay aligned.
371+
if cell._tc in seen:
372+
cells.append("")
373+
continue
374+
seen.add(cell._tc)
375+
# Paragraph text before the first nested table is the aligned field; the
376+
# nested table and anything after it flatten below the row, in order.
377+
field: list[str] = []
378+
after_table = False
379+
for item in cell.iter_inner_content():
380+
if isinstance(item, Table):
381+
after_table = True
382+
trailing.extend(_docx_table_rows(item))
383+
elif isinstance(item, Paragraph):
384+
text = " ".join(item.text.split()) # collapse in-cell newlines
385+
if text:
386+
(trailing if after_table else field).append(text)
387+
cells.append(" ".join(field)) # empty cells kept so columns line up
388+
cells.extend([""] * getattr(row, "grid_cols_after", 0))
389+
if any(c.strip() for c in cells):
390+
rows.append(" | ".join(cells))
391+
rows.extend(trailing)
392+
return rows
393+
394+
311395
def _docx(path: str) -> list[Page]:
312396
import docx
397+
from docx.table import Table
398+
from docx.text.paragraph import Paragraph
313399

314400
document = docx.Document(path)
315-
text = "\n".join(p.text for p in document.paragraphs)
316-
return [_page(text, None)]
401+
lines: list[str] = []
402+
# Walk body content in document order: paragraphs alone drop tables entirely.
403+
for block in document.iter_inner_content():
404+
if isinstance(block, Paragraph):
405+
if block.text.strip():
406+
lines.append(block.text)
407+
elif isinstance(block, Table):
408+
lines.extend(_docx_table_rows(block))
409+
return [_page("\n".join(lines), None)]
317410

318411

319412
def parse(path: str, *, want_images: bool = False):

studio/backend/tests/test_rag_parsing.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,212 @@ def test_pdf_markdown_falls_back_when_lib_missing(tmp_path, monkeypatch):
8686
_table_pdf(pdf)
8787
pages = parsers.parse(str(pdf))
8888
assert pages and "Quarter" in pages[0].text
89+
90+
91+
def _long_text_pdf(path):
92+
import pymupdf
93+
94+
doc = pymupdf.open()
95+
page = doc.new_page()
96+
body = "The quick brown fox jumps over the lazy dog. " * 12 # >200 letters
97+
page.insert_textbox(pymupdf.Rect(40, 40, 550, 750), body, fontsize = 11)
98+
doc.save(str(path))
99+
doc.close()
100+
101+
102+
def test_pdf_markdown_corruption_falls_back_to_plain(tmp_path, monkeypatch):
103+
# pymupdf4llm can emit shaped RTL Presentation Forms for Arabic/Hebrew; the parser
104+
# detects that and uses PyMuPDF's logical-order text instead of the mangled Markdown.
105+
from core.rag import config, parsers
106+
107+
monkeypatch.setattr(config, "PDF_MARKDOWN", True)
108+
shaped = "".join(chr(c) for c in range(0xFE8D, 0xFEA0)) * 20 # heavy shaped forms
109+
monkeypatch.setattr(parsers, "_pdf_markdown", lambda doc: [shaped] * doc.page_count)
110+
pdf = tmp_path / "table.pdf"
111+
_table_pdf(pdf)
112+
text = "\n".join(p.text for p in parsers.parse(str(pdf)))
113+
assert "Quarter" in text # real logical-order text recovered
114+
assert not parsers._markdown_corrupted(text) # shaped garbage not carried through
115+
116+
117+
def test_pdf_markdown_incomplete_falls_back_to_plain(tmp_path, monkeypatch):
118+
# If pymupdf4llm silently drops most of a page, the parser prefers the fuller raw layer.
119+
from core.rag import config, parsers
120+
121+
monkeypatch.setattr(config, "PDF_MARKDOWN", True)
122+
monkeypatch.setattr(parsers, "_pdf_markdown", lambda doc: ["x"] * doc.page_count)
123+
pdf = tmp_path / "long.pdf"
124+
_long_text_pdf(pdf)
125+
text = "\n".join(p.text for p in parsers.parse(str(pdf)))
126+
assert "quick brown fox" in text # fuller raw layer used, not the near-empty Markdown
127+
128+
129+
def _docx_with_table(path):
130+
import docx
131+
132+
document = docx.Document()
133+
document.add_paragraph("Intro before table.")
134+
table = document.add_table(rows = 2, cols = 2)
135+
table.cell(0, 0).text = "NAME"
136+
table.cell(0, 1).text = "SCORE"
137+
table.cell(1, 0).text = "Alice"
138+
table.cell(1, 1).text = "97pts"
139+
document.add_paragraph("Outro after table.")
140+
document.save(str(path))
141+
142+
143+
def test_docx_extracts_table_cells(tmp_path):
144+
# document.paragraphs alone drops tables; the parser walks body content in order so
145+
# table cells survive (pipe-joined, which the preview locator anchors on).
146+
pytest.importorskip("docx")
147+
from core.rag import parsers
148+
149+
docx_path = tmp_path / "t.docx"
150+
_docx_with_table(docx_path)
151+
text = "\n".join(p.text for p in parsers.parse(str(docx_path)))
152+
assert all(v in text for v in ("NAME", "SCORE", "Alice", "97pts")) # cells kept
153+
assert "Alice | 97pts" in text # row cells joined
154+
assert text.index("Intro") < text.index("NAME") < text.index("Outro") # order kept
155+
156+
157+
def test_docx_table_keeps_columns_and_collapses_cell_newlines(tmp_path):
158+
# Empty cells are kept (so columns stay aligned across rows) and a cell's internal
159+
# newlines are collapsed to spaces (so a multi-paragraph cell can't break the row).
160+
pytest.importorskip("docx")
161+
import docx
162+
163+
from core.rag import parsers
164+
165+
document = docx.Document()
166+
table = document.add_table(rows = 2, cols = 3)
167+
table.cell(0, 0).text = "A"
168+
table.cell(0, 1).text = "" # empty middle cell
169+
table.cell(0, 2).text = "C"
170+
multiline = table.cell(1, 0)
171+
multiline.text = "line1"
172+
multiline.add_paragraph("line2") # cell now holds an internal newline
173+
table.cell(1, 1).text = "mid"
174+
table.cell(1, 2).text = "end"
175+
path = tmp_path / "aligned.docx"
176+
document.save(str(path))
177+
178+
text = "\n".join(p.text for p in parsers.parse(str(path)))
179+
assert "A | | C" in text # empty cell preserved -> columns line up
180+
assert "line1 line2 | mid | end" in text # internal newline collapsed to a space
181+
182+
183+
def test_docx_table_merged_cell_keeps_grid_alignment(tmp_path):
184+
# A horizontally merged cell repeats across the spanned columns: emit its text once
185+
# then a placeholder, so the row keeps as many fields as its siblings (columns stay
186+
# aligned) without duplicating the merged text.
187+
pytest.importorskip("docx")
188+
import docx
189+
190+
from core.rag import parsers
191+
192+
document = docx.Document()
193+
table = document.add_table(rows = 2, cols = 3)
194+
table.cell(0, 0).text = "WIDE"
195+
table.cell(0, 2).text = "END"
196+
table.cell(0, 0).merge(table.cell(0, 1)) # span the first two columns
197+
table.cell(1, 0).text = "a"
198+
table.cell(1, 1).text = "b"
199+
table.cell(1, 2).text = "c"
200+
path = tmp_path / "merged.docx"
201+
document.save(str(path))
202+
203+
text = "\n".join(p.text for p in parsers.parse(str(path)))
204+
assert text.count("WIDE") == 1 # merged cell not duplicated across spanned columns
205+
assert "WIDE | | END" in text # placeholder keeps 3 fields, aligned with "a | b | c"
206+
assert "a | b | c" in text
207+
208+
209+
def test_docx_table_pads_omitted_grid_columns(tmp_path):
210+
# A row that skips leading grid columns exposes the gap via grid_cols_before; pad it
211+
# with empty fields so the value stays under the right header instead of shifting left.
212+
pytest.importorskip("docx")
213+
import docx
214+
from docx.oxml.ns import qn
215+
216+
from core.rag import parsers
217+
218+
document = docx.Document()
219+
table = document.add_table(rows = 2, cols = 3)
220+
table.cell(0, 0).text = "H1"
221+
table.cell(0, 1).text = "H2"
222+
table.cell(0, 2).text = "H3"
223+
tr = table.rows[1]._tr # drop the first cell and mark it skipped via <w:gridBefore>
224+
tr.remove(tr.tc_lst[0])
225+
trPr = tr.get_or_add_trPr()
226+
trPr.insert(0, trPr.makeelement(qn("w:gridBefore"), {qn("w:val"): "1"}))
227+
table.rows[1].cells[0].text = "X" # sits in column 2
228+
path = tmp_path / "gap.docx"
229+
document.save(str(path))
230+
231+
text = "\n".join(p.text for p in parsers.parse(str(path)))
232+
assert " | X | " in text # leading gap padded so X lines up under H2, not H1
233+
234+
235+
def test_docx_flattens_nested_table(tmp_path):
236+
# cell.text ignores tables nested inside a cell; walk cell.tables so nested rows are
237+
# not silently dropped from the indexed text.
238+
pytest.importorskip("docx")
239+
import docx
240+
241+
from core.rag import parsers
242+
243+
document = docx.Document()
244+
outer = document.add_table(rows = 1, cols = 1).cell(0, 0)
245+
outer.text = "outer"
246+
nested = outer.add_table(rows = 1, cols = 2)
247+
nested.cell(0, 0).text = "NESTED-A"
248+
nested.cell(0, 1).text = "NESTED-B"
249+
path = tmp_path / "nested.docx"
250+
document.save(str(path))
251+
252+
text = "\n".join(p.text for p in parsers.parse(str(path)))
253+
assert "NESTED-A | NESTED-B" in text # nested table flattened, not dropped
254+
255+
256+
def test_docx_nested_table_keeps_in_cell_order(tmp_path):
257+
# A cell holding paragraph, nested table, paragraph must serialize in that order
258+
# (cell.text alone would emit both paragraphs before the nested rows).
259+
pytest.importorskip("docx")
260+
import docx
261+
262+
from core.rag import parsers
263+
264+
document = docx.Document()
265+
cell = document.add_table(rows = 1, cols = 1).cell(0, 0)
266+
cell.text = "before"
267+
nested = cell.add_table(rows = 1, cols = 2)
268+
nested.cell(0, 0).text = "NESTED-A"
269+
nested.cell(0, 1).text = "NESTED-B"
270+
cell.add_paragraph("after")
271+
path = tmp_path / "nested_order.docx"
272+
document.save(str(path))
273+
274+
text = "\n".join(p.text for p in parsers.parse(str(path)))
275+
assert text.index("before") < text.index("NESTED-A") < text.index("after")
276+
277+
278+
def test_docx_table_vertical_merge_emitted_once(tmp_path):
279+
# A vertically merged cell maps every continuation row back to the origin <w:tc>;
280+
# emit it once and leave placeholders below so a row-spanning label isn't repeated.
281+
pytest.importorskip("docx")
282+
import docx
283+
284+
from core.rag import parsers
285+
286+
document = docx.Document()
287+
table = document.add_table(rows = 3, cols = 2)
288+
table.cell(0, 0).merge(table.cell(1, 0)).merge(table.cell(2, 0)).text = "SECTION"
289+
table.cell(0, 1).text = "r0"
290+
table.cell(1, 1).text = "r1"
291+
table.cell(2, 1).text = "r2"
292+
path = tmp_path / "vmerge.docx"
293+
document.save(str(path))
294+
295+
text = "\n".join(p.text for p in parsers.parse(str(path)))
296+
assert text.count("SECTION") == 1 # not repeated on each spanned row
297+
assert "SECTION | r0" in text and " | r1" in text and " | r2" in text

0 commit comments

Comments
 (0)