e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
298 lines
11 KiB
Python
298 lines
11 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""PDF text extraction: layout-aware Markdown (pymupdf4llm) with plain-text fallback."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("pymupdf")
|
|
|
|
|
|
def _table_pdf(path):
|
|
import pymupdf
|
|
|
|
doc = pymupdf.open()
|
|
page = doc.new_page()
|
|
page.insert_textbox(pymupdf.Rect(40, 40, 550, 70), "Quarterly Results", fontsize = 16)
|
|
rows = [("Quarter", "Revenue", "Growth"), ("Q1", "$1.2M", "12%"), ("Q2", "$1.5M", "25%")]
|
|
y = 90
|
|
for r in rows:
|
|
page.insert_textbox(pymupdf.Rect(40, y, 250, y + 20), r[0], fontsize = 11)
|
|
page.insert_textbox(pymupdf.Rect(250, y, 400, y + 20), r[1], fontsize = 11)
|
|
page.insert_textbox(pymupdf.Rect(400, y, 540, y + 20), r[2], fontsize = 11)
|
|
y += 24
|
|
doc.save(str(path))
|
|
doc.close()
|
|
|
|
|
|
def test_pdf_extracts_markdown_table(tmp_path, monkeypatch):
|
|
# With Markdown on, the layout is emitted as Markdown markup (heading, and a pipe table
|
|
# where the extractor detects one) that flat get_text never produces.
|
|
pytest.importorskip("pymupdf4llm")
|
|
from core.rag import config, parsers
|
|
|
|
monkeypatch.setattr(config, "PDF_MARKDOWN", True)
|
|
pdf = tmp_path / "table.pdf"
|
|
_table_pdf(pdf)
|
|
text = "\n".join(p.text for p in parsers.parse(str(pdf)))
|
|
assert "Q2" in text and "$1.5M" in text # cell values preserved
|
|
assert "#" in text or "|" in text # Markdown markup (heading or table pipes)
|
|
|
|
|
|
def test_pdf_markdown_off_uses_plain_text(tmp_path, monkeypatch):
|
|
# The toggle (RAG_PDF_MARKDOWN=0) falls back to flat PyMuPDF text: content is still
|
|
# there, but with no Markdown markup.
|
|
from core.rag import config, parsers
|
|
|
|
monkeypatch.setattr(config, "PDF_MARKDOWN", False)
|
|
pdf = tmp_path / "table.pdf"
|
|
_table_pdf(pdf)
|
|
text = "\n".join(p.text for p in parsers.parse(str(pdf)))
|
|
assert "Q2" in text and "$1.5M" in text
|
|
assert "#" not in text and "|" not in text # plain text path emits no Markdown markup
|
|
|
|
|
|
def test_pdf_markdown_passes_only_supported_legacy_kwargs(monkeypatch):
|
|
# The pinned PyMuPDF4LLM legacy path ignores unknown kwargs; do not pass the
|
|
# newer layout-only OCR knobs or Markdown extraction silently loses policy control.
|
|
from core.rag import parsers
|
|
|
|
captured = {}
|
|
|
|
class _FakePymupdf4llm:
|
|
@staticmethod
|
|
def to_markdown(doc, **kwargs):
|
|
captured.update(kwargs)
|
|
return [{"text": "plain markdown"}]
|
|
|
|
class _Doc:
|
|
page_count = 1
|
|
|
|
monkeypatch.setitem(__import__("sys").modules, "pymupdf4llm", _FakePymupdf4llm)
|
|
assert parsers._pdf_markdown(_Doc()) == ["plain markdown"]
|
|
assert captured == {"page_chunks": True, "show_progress": False}
|
|
|
|
|
|
def test_pdf_markdown_falls_back_when_lib_missing(tmp_path, monkeypatch):
|
|
# If pymupdf4llm extraction returns None (missing/failed), parsing still yields the
|
|
# plain-text pages rather than raising.
|
|
from core.rag import config, parsers
|
|
|
|
monkeypatch.setattr(config, "PDF_MARKDOWN", True)
|
|
monkeypatch.setattr(parsers, "_pdf_markdown", lambda doc: None)
|
|
pdf = tmp_path / "table.pdf"
|
|
_table_pdf(pdf)
|
|
pages = parsers.parse(str(pdf))
|
|
assert pages and "Quarter" in pages[0].text
|
|
|
|
|
|
def _long_text_pdf(path):
|
|
import pymupdf
|
|
|
|
doc = pymupdf.open()
|
|
page = doc.new_page()
|
|
body = "The quick brown fox jumps over the lazy dog. " * 12 # >200 letters
|
|
page.insert_textbox(pymupdf.Rect(40, 40, 550, 750), body, fontsize = 11)
|
|
doc.save(str(path))
|
|
doc.close()
|
|
|
|
|
|
def test_pdf_markdown_corruption_falls_back_to_plain(tmp_path, monkeypatch):
|
|
# pymupdf4llm can emit shaped RTL Presentation Forms for Arabic/Hebrew; the parser
|
|
# detects that and uses PyMuPDF's logical-order text instead of the mangled Markdown.
|
|
from core.rag import config, parsers
|
|
|
|
monkeypatch.setattr(config, "PDF_MARKDOWN", True)
|
|
shaped = "".join(chr(c) for c in range(0xFE8D, 0xFEA0)) * 20 # heavy shaped forms
|
|
monkeypatch.setattr(parsers, "_pdf_markdown", lambda doc: [shaped] * doc.page_count)
|
|
pdf = tmp_path / "table.pdf"
|
|
_table_pdf(pdf)
|
|
text = "\n".join(p.text for p in parsers.parse(str(pdf)))
|
|
assert "Quarter" in text # real logical-order text recovered
|
|
assert not parsers._markdown_corrupted(text) # shaped garbage not carried through
|
|
|
|
|
|
def test_pdf_markdown_incomplete_falls_back_to_plain(tmp_path, monkeypatch):
|
|
# If pymupdf4llm silently drops most of a page, the parser prefers the fuller raw layer.
|
|
from core.rag import config, parsers
|
|
|
|
monkeypatch.setattr(config, "PDF_MARKDOWN", True)
|
|
monkeypatch.setattr(parsers, "_pdf_markdown", lambda doc: ["x"] * doc.page_count)
|
|
pdf = tmp_path / "long.pdf"
|
|
_long_text_pdf(pdf)
|
|
text = "\n".join(p.text for p in parsers.parse(str(pdf)))
|
|
assert "quick brown fox" in text # fuller raw layer used, not the near-empty Markdown
|
|
|
|
|
|
def _docx_with_table(path):
|
|
import docx
|
|
|
|
document = docx.Document()
|
|
document.add_paragraph("Intro before table.")
|
|
table = document.add_table(rows = 2, cols = 2)
|
|
table.cell(0, 0).text = "NAME"
|
|
table.cell(0, 1).text = "SCORE"
|
|
table.cell(1, 0).text = "Alice"
|
|
table.cell(1, 1).text = "97pts"
|
|
document.add_paragraph("Outro after table.")
|
|
document.save(str(path))
|
|
|
|
|
|
def test_docx_extracts_table_cells(tmp_path):
|
|
# document.paragraphs alone drops tables; the parser walks body content in order so
|
|
# table cells survive (pipe-joined, which the preview locator anchors on).
|
|
pytest.importorskip("docx")
|
|
from core.rag import parsers
|
|
|
|
docx_path = tmp_path / "t.docx"
|
|
_docx_with_table(docx_path)
|
|
text = "\n".join(p.text for p in parsers.parse(str(docx_path)))
|
|
assert all(v in text for v in ("NAME", "SCORE", "Alice", "97pts")) # cells kept
|
|
assert "Alice | 97pts" in text # row cells joined
|
|
assert text.index("Intro") < text.index("NAME") < text.index("Outro") # order kept
|
|
|
|
|
|
def test_docx_table_keeps_columns_and_collapses_cell_newlines(tmp_path):
|
|
# Empty cells are kept (so columns stay aligned across rows) and a cell's internal
|
|
# newlines are collapsed to spaces (so a multi-paragraph cell can't break the row).
|
|
pytest.importorskip("docx")
|
|
import docx
|
|
|
|
from core.rag import parsers
|
|
|
|
document = docx.Document()
|
|
table = document.add_table(rows = 2, cols = 3)
|
|
table.cell(0, 0).text = "A"
|
|
table.cell(0, 1).text = "" # empty middle cell
|
|
table.cell(0, 2).text = "C"
|
|
multiline = table.cell(1, 0)
|
|
multiline.text = "line1"
|
|
multiline.add_paragraph("line2") # cell now holds an internal newline
|
|
table.cell(1, 1).text = "mid"
|
|
table.cell(1, 2).text = "end"
|
|
path = tmp_path / "aligned.docx"
|
|
document.save(str(path))
|
|
|
|
text = "\n".join(p.text for p in parsers.parse(str(path)))
|
|
assert "A | | C" in text # empty cell preserved -> columns line up
|
|
assert "line1 line2 | mid | end" in text # internal newline collapsed to a space
|
|
|
|
|
|
def test_docx_table_merged_cell_keeps_grid_alignment(tmp_path):
|
|
# A horizontally merged cell repeats across the spanned columns: emit its text once
|
|
# then a placeholder, so the row keeps as many fields as its siblings (columns stay
|
|
# aligned) without duplicating the merged text.
|
|
pytest.importorskip("docx")
|
|
import docx
|
|
|
|
from core.rag import parsers
|
|
|
|
document = docx.Document()
|
|
table = document.add_table(rows = 2, cols = 3)
|
|
table.cell(0, 0).text = "WIDE"
|
|
table.cell(0, 2).text = "END"
|
|
table.cell(0, 0).merge(table.cell(0, 1)) # span the first two columns
|
|
table.cell(1, 0).text = "a"
|
|
table.cell(1, 1).text = "b"
|
|
table.cell(1, 2).text = "c"
|
|
path = tmp_path / "merged.docx"
|
|
document.save(str(path))
|
|
|
|
text = "\n".join(p.text for p in parsers.parse(str(path)))
|
|
assert text.count("WIDE") == 1 # merged cell not duplicated across spanned columns
|
|
assert "WIDE | | END" in text # placeholder keeps 3 fields, aligned with "a | b | c"
|
|
assert "a | b | c" in text
|
|
|
|
|
|
def test_docx_table_pads_omitted_grid_columns(tmp_path):
|
|
# A row that skips leading grid columns exposes the gap via grid_cols_before; pad it
|
|
# with empty fields so the value stays under the right header instead of shifting left.
|
|
pytest.importorskip("docx")
|
|
import docx
|
|
from docx.oxml.ns import qn
|
|
|
|
from core.rag import parsers
|
|
|
|
document = docx.Document()
|
|
table = document.add_table(rows = 2, cols = 3)
|
|
table.cell(0, 0).text = "H1"
|
|
table.cell(0, 1).text = "H2"
|
|
table.cell(0, 2).text = "H3"
|
|
tr = table.rows[1]._tr # drop the first cell and mark it skipped via <w:gridBefore>
|
|
tr.remove(tr.tc_lst[0])
|
|
trPr = tr.get_or_add_trPr()
|
|
trPr.insert(0, trPr.makeelement(qn("w:gridBefore"), {qn("w:val"): "1"}))
|
|
table.rows[1].cells[0].text = "X" # sits in column 2
|
|
path = tmp_path / "gap.docx"
|
|
document.save(str(path))
|
|
|
|
text = "\n".join(p.text for p in parsers.parse(str(path)))
|
|
assert " | X | " in text # leading gap padded so X lines up under H2, not H1
|
|
|
|
|
|
def test_docx_flattens_nested_table(tmp_path):
|
|
# cell.text ignores tables nested inside a cell; walk cell.tables so nested rows are
|
|
# not silently dropped from the indexed text.
|
|
pytest.importorskip("docx")
|
|
import docx
|
|
|
|
from core.rag import parsers
|
|
|
|
document = docx.Document()
|
|
outer = document.add_table(rows = 1, cols = 1).cell(0, 0)
|
|
outer.text = "outer"
|
|
nested = outer.add_table(rows = 1, cols = 2)
|
|
nested.cell(0, 0).text = "NESTED-A"
|
|
nested.cell(0, 1).text = "NESTED-B"
|
|
path = tmp_path / "nested.docx"
|
|
document.save(str(path))
|
|
|
|
text = "\n".join(p.text for p in parsers.parse(str(path)))
|
|
assert "NESTED-A | NESTED-B" in text # nested table flattened, not dropped
|
|
|
|
|
|
def test_docx_nested_table_keeps_in_cell_order(tmp_path):
|
|
# A cell holding paragraph, nested table, paragraph must serialize in that order
|
|
# (cell.text alone would emit both paragraphs before the nested rows).
|
|
pytest.importorskip("docx")
|
|
import docx
|
|
|
|
from core.rag import parsers
|
|
|
|
document = docx.Document()
|
|
cell = document.add_table(rows = 1, cols = 1).cell(0, 0)
|
|
cell.text = "before"
|
|
nested = cell.add_table(rows = 1, cols = 2)
|
|
nested.cell(0, 0).text = "NESTED-A"
|
|
nested.cell(0, 1).text = "NESTED-B"
|
|
cell.add_paragraph("after")
|
|
path = tmp_path / "nested_order.docx"
|
|
document.save(str(path))
|
|
|
|
text = "\n".join(p.text for p in parsers.parse(str(path)))
|
|
assert text.index("before") < text.index("NESTED-A") < text.index("after")
|
|
|
|
|
|
def test_docx_table_vertical_merge_emitted_once(tmp_path):
|
|
# A vertically merged cell maps every continuation row back to the origin <w:tc>;
|
|
# emit it once and leave placeholders below so a row-spanning label isn't repeated.
|
|
pytest.importorskip("docx")
|
|
import docx
|
|
|
|
from core.rag import parsers
|
|
|
|
document = docx.Document()
|
|
table = document.add_table(rows = 3, cols = 2)
|
|
table.cell(0, 0).merge(table.cell(1, 0)).merge(table.cell(2, 0)).text = "SECTION"
|
|
table.cell(0, 1).text = "r0"
|
|
table.cell(1, 1).text = "r1"
|
|
table.cell(2, 1).text = "r2"
|
|
path = tmp_path / "vmerge.docx"
|
|
document.save(str(path))
|
|
|
|
text = "\n".join(p.text for p in parsers.parse(str(path)))
|
|
assert text.count("SECTION") == 1 # not repeated on each spanned row
|
|
assert "SECTION | r0" in text and " | r1" in text and " | r2" in text
|