"""docparse: exact structure parsers, layout analysis, and (when weights exist) the OCR model."""
import datetime as dt
import random
import tempfile
import unittest
from pathlib import Path
import numpy as np
from docparse.extract import DEFAULT_MODEL, extract, to_markdown, to_text
from docparse.ocr.charset import CHARS, decode_greedy, encode, normalize
from docparse.ocr.metrics import cer, summary
from docparse.ocr.page import analyze, group
from docparse.synth_pages import make_page
class StubReader:
"""Stands in for the network: returns the box geometry as text, so layout can be tested alone."""
def read(self, images, batch=32):
return [(f"w{a.shape[1]}", 1.0) for a in images]
class CharsetTests(unittest.TestCase):
def test_normalize_folds_typography(self):
self.assertEqual(normalize("“Café” – naïve…"), '"Cafe" - naive...')
def test_encode_decode_roundtrip_with_ctc_blanks(self):
h, e, l, o = encode("helo")
self.assertEqual(decode_greedy([0, h, h, 0, e, l, 0, l, o, o]), "hello")
self.assertEqual(decode_greedy([l, l]), "l") # repeats need a blank between them
self.assertEqual(len(CHARS), len(set(CHARS)))
def test_metrics(self):
self.assertEqual(cer("abc", "abc"), 0)
self.assertAlmostEqual(cer("abd", "abc"), 1 / 3)
s = summary(["a b", "c"], ["a b", "d"])
self.assertEqual(s["line_exact"], 0.5)
class StructureTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = Path(self.tmp.name)
def tearDown(self):
self.tmp.cleanup()
def test_docx_headings_lists_tables_merged_cells(self):
import docx
d = docx.Document()
d.sections[0].header.paragraphs[0].text = "Header text"
d.add_heading("Main title", 1)
d.add_paragraph("Body paragraph.")
d.add_paragraph("Bullet", style="List Bullet")
d.add_paragraph("Nested", style="List Bullet 2")
t = d.add_table(rows=2, cols=3)
t.cell(0, 0).merge(t.cell(0, 1)).text = "Wide"
t.cell(0, 2).text = "C"
for c, v in enumerate("xyz"):
t.cell(1, c).text = v
p = d.add_paragraph()
p.add_run("Total").bold = True
d.save(self.dir / "a.docx")
doc = extract(self.dir / "a.docx")
types = [(b["type"], b.get("text")) for b in doc["blocks"] if b["type"] != "table"]
self.assertIn(("header", "Header text"), types)
self.assertIn(("heading", "Main title"), types)
self.assertIn(("paragraph", "Body paragraph."), types)
items = [b for b in doc["blocks"] if b["type"] == "list_item"]
self.assertEqual([(b["text"], b.get("level", 0)) for b in items], [("Bullet", 0), ("Nested", 1)])
table = next(b for b in doc["blocks"] if b["type"] == "table")
self.assertEqual(table["rows"], [["Wide", "", "C"], ["x", "y", "z"]])
self.assertIn("| Wide | | C |", to_markdown(doc))
def test_xlsx_values_formulas_regions_dates(self):
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
ws.append(["Date", "Qty", "Price", "Total"])
ws.append([dt.date(2024, 2, 1), 2, 1.5, "=B2*C2"])
ws["G1"], ws["G2"] = "Note", "apart"
ws.merge_cells("A5:B5")
ws["A5"] = "Footer"
wb.save(self.dir / "b.xlsx")
doc = extract(self.dir / "b.xlsx")
tables = [b for b in doc["blocks"] if b["type"] == "table"]
self.assertEqual(tables[0]["range"], "A1:D2")
self.assertEqual(tables[0]["rows"][1], ["2024-02-01", 2, 1.5, "=B2*C2"]) # no cached value -> formula
self.assertEqual([t["range"] for t in tables[1:]], ["G1:G2", "A5:A5"])
sheet = doc["sheets"][0]
self.assertEqual(sheet["formulas"], {"D2": "=B2*C2"})
self.assertEqual(sheet["merged"], ["A5:B5"])
def test_csv_sniffs_delimiter(self):
(self.dir / "c.csv").write_text("a;b\n1;2,5\n", encoding="utf-8")
doc = extract(self.dir / "c.csv")
self.assertEqual(doc["blocks"][0]["rows"], [["a", "b"], ["1", "2,5"]])
def test_pdf_text_layer_headings_paragraphs_tables(self):
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
except ImportError:
self.skipTest("reportlab not installed")
st = getSampleStyleSheet()
body = ("The quarterly figures were reviewed by the committee and found to be in line with "
"the forecast published at the start of the year, with small differences in two regions.")
t = Table([["Region", "Units", "Amount"], ["North", "120", "1,250.00"], ["South", "98", "980.50"]])
t.setStyle(TableStyle([("GRID", (0, 0), (-1, -1), 1, colors.black)]))
SimpleDocTemplate(str(self.dir / "d.pdf"), pagesize=A4).build(
[Paragraph("Annual Review", st["Title"]), Paragraph(body, st["BodyText"]), Spacer(1, 12),
Paragraph(body.replace("quarterly", "monthly"), st["BodyText"]), Spacer(1, 12), t])
doc = extract(self.dir / "d.pdf", ocr="never")
kinds = [b["type"] for b in doc["blocks"]]
self.assertEqual(kinds, ["heading", "paragraph", "paragraph", "table"])
self.assertEqual(doc["blocks"][0]["text"], "Annual Review")
self.assertEqual(doc["blocks"][1]["text"], body)
self.assertEqual(doc["blocks"][3]["rows"][1], ["North", "120", "1,250.00"])
self.assertTrue(all(b["source"] == "text-layer" for b in doc["blocks"]))
class PdfDetailTests(unittest.TestCase):
def test_inline_bold_italic_and_superscript(self):
from docparse.extract import _styled_text, plain
def w(text, x0, x1, font="Times-Roman", size=10, bottom=100):
return {"text": text, "x0": x0, "x1": x1, "top": bottom - size, "bottom": bottom, "size": size,
"fontname": font}
ws = [w("Water", 0, 30), w("is", 33, 40, "Times-Bold"), w("H", 43, 50), w("2", 50.2, 54, size=6, bottom=102),
w("O", 54.3, 61), w("ref", 64, 80, "Times-Italic"), w("1", 80.2, 83, size=6, bottom=96)]
out = _styled_text(ws, 10)
self.assertEqual(out, "Water **is** H2O *ref*1")
self.assertEqual(plain(out), "Water is H2O ref1")
def test_page_furniture(self):
from docparse.extract import mark_page_furniture
blocks = [{"type": "paragraph", "text": "Annual Report 2024", "bbox": [50, 20, 300, 35]},
{"type": "heading", "text": "Title", "bbox": [50, 40, 300, 60], "level": 1},
{"type": "paragraph", "text": "Body", "bbox": [50, 300, 300, 320]},
{"type": "paragraph", "text": "Page 3 of 10", "bbox": [250, 770, 330, 785]}]
mark_page_furniture(blocks, 792)
self.assertEqual([b["type"] for b in blocks], ["header", "heading", "paragraph", "footer"])
self.assertTrue(blocks[3].get("page_number"))
def test_stacked_table_rows_are_split(self):
from docparse.extract import _split_stacked_rows
rows = [["Class", "Rate"], ["8833\n8837", "1.08\n0.87"], ["Total", "2"]]
self.assertEqual(_split_stacked_rows(rows), [["Class", "Rate"], ["8833", "1.08"], ["8837", "0.87"],
["Total", "2"]])
class LayoutTests(unittest.TestCase):
def test_synthetic_page_layout_with_stub_reader(self):
img, truth = make_page(3, "test")
blocks, meta = analyze(img, StubReader())
kinds = [b["type"] for b in blocks]
self.assertEqual(kinds.count("heading"), sum(b["type"] == "heading" for b in truth))
tables = [b for b in blocks if b["type"] == "table"]
want = sorted((len(b["rows"]), len(b["rows"][0])) for b in truth if b["type"] == "table")
self.assertEqual(sorted((len(t["rows"]), len(t["rows"][0])) for t in tables), want)
# Two-column section: the left column is read before the right column.
paras = [b for b in blocks if b["type"] == "paragraph"]
cols = [p for p in paras if p["bbox"][1] > 290 and p["bbox"][1] < 320]
self.assertEqual(len(cols), 2)
self.assertLess(cols[0]["bbox"][0], cols[1]["bbox"][0])
def test_scanned_page_is_deskewed(self):
img, _ = make_page(11, "test", scan=True)
img = img.rotate(2.5, fillcolor=255)
_, meta = analyze(img, StubReader())
self.assertGreater(abs(meta["deskew_degrees"]), 1.5)
def test_group_merges_paragraph_lines_and_keeps_code_breaks(self):
def line(y, x1, text):
return {"kind": "line", "box": (y, y + 20, 100, x1), "segs": [(y, y + 20, 100, x1)],
"parts": [(text, 0.9)], "xh": 12}
prose = [line(0, 900, "one two three four"), line(28, 905, "five six seven eight"), line(56, 300, "nine")]
out = group(prose, 20)
self.assertEqual([b["text"] for b in out], ["one two three four five six seven eight nine"])
code = [line(0, 300, "import os"), line(28, 340, "x = os.sep"), line(56, 380, "print(x)"),
line(84, 900, "# a long comment line that runs the full width")]
self.assertEqual(group(code, 20)[0]["text"].split("\n")[:3], ["import os", "x = os.sep", "print(x)"])
@unittest.skipUnless(DEFAULT_MODEL.exists(), "trained OCR weights not present")
class ModelTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
from docparse.extract import recognizer
cls.rec = recognizer()
def test_heldout_font_lines(self):
from docparse.ocr.render import dataset
ims, txt = dataset(200, 424242, "test", degrade=False)
res = summary([p for p, _ in self.rec.read(ims)], txt)
self.assertLess(res["cer"], 0.05, res)
def test_image_file_end_to_end(self):
img, truth = make_page(21, "test")
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "page.png"
img.save(p)
doc = extract(p)
text = to_text(doc)
ref = " ".join(b["text"] for b in truth if b["type"] == "heading")
words = [w for w in ref.split() if len(w) > 3]
found = sum(w in text for w in words)
self.assertGreaterEqual(found, 0.8 * len(words), (text[:500], words))
self.assertTrue(all(b["source"] == "ocr" for b in doc["blocks"]))
def test_scanned_pdf_page_uses_ocr(self):
img, _ = make_page(22, "test", scan=True)
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "scan.pdf"
img.convert("RGB").save(p, "PDF", resolution=150)
doc = extract(p)
self.assertTrue(doc["blocks"])
self.assertTrue(all(b["source"] == "ocr" for b in doc["blocks"]))
x0, y0, x1, y1 = doc["blocks"][0]["bbox"]
self.assertLess(x1, 600) # bbox converted to PDF points (A4 width is 595)
if __name__ == "__main__":
random.seed(0)
np.random.seed(0)
unittest.main()