File size: 5,264 Bytes
0e499ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | #!/usr/bin/env python3
"""Document text extraction: a .docx or .pdf upload -> the text inside it.
The translator only knows text, so a document is read down to its text here
and then goes through the same lilly.translate/reply path as anything typed.
The two readers are lazy imports, loaded on first use like every other model
part: python-docx for .docx, pypdf for .pdf. Neither is a heavyweight or GPU
dependency, and neither is needed to start the server.
Everything is bounded before it reaches a reader, as with every other upload:
the server refuses oversized files on their declared size, and the text kept
out of a document is capped here, because a 400-page PDF could otherwise pour
a novel's worth of text into the translation engine that truncates it anyway.
Usage:
python3 app/document.py document.docx # prints the extracted text
"""
import sys
from pathlib import Path
if __name__ == "__main__":
# Run as a script, sys.path[0] is app/ and `from app.lilly import ...`
# cannot resolve -- the usage above never worked that way. The repository
# goes first, as app/lilly.py does.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.lilly import BadInput # noqa: E402
# The suffixes a document may arrive under. The suffix travels with the temp
# file the server writes, and is checked here rather than against the upload's
# declared name, so a misnamed file is refused for what it is.
SUFFIXES = (".docx", ".pdf")
# How much text one document may ask for. Generous on purpose — a full page of
# prose is a few thousand characters — but finite: the extraction below stops
# reading once this much is out, and the translation engine applies its own
# token budget on top (truncate=True, so the beginning is translated rather
# than the request refused).
MAX_DOC_CHARS = 20_000
# A PDF with hundreds of pages should cost hundreds of page reads at most,
# not an unbounded walk. Even a full page of dense text stays under
# MAX_DOC_CHARS, so this only ever bites on a pathological file.
MAX_PDF_PAGES = 100
class UnreadableDocument(BadInput):
"""Raised when the upload is not a document we can open, or holds no text."""
def _bounded_text() -> tuple:
"""A piecewise text collector that stops accepting once the cap is met."""
parts = []
size = [0]
def add(chunk: str) -> None:
if size[0] >= MAX_DOC_CHARS or not chunk:
return
parts.append(chunk)
size[0] += len(chunk)
return parts, size, add
def _finish(parts: list, size: list) -> str:
text = "\n".join(parts).strip()
if size[0] > MAX_DOC_CHARS:
text = text[:MAX_DOC_CHARS]
if not text:
raise UnreadableDocument("that document has no text we could read")
return text
def _extract_docx(path: str) -> str:
try:
from docx import Document
except ImportError as exc:
# A missing library is not the caller's document and not a crash.
raise FileNotFoundError(
"document reading is not installed -- pip install python-docx") from exc
try:
doc = Document(path)
parts, size, add = _bounded_text()
for paragraph in doc.paragraphs:
if paragraph.text.strip():
add(paragraph.text.strip())
# Tables are their own tree in a docx; skipping them would quietly
# drop half of some documents. Cell text is added after the body, in
# document order, and the cap still governs.
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
if cell.text.strip():
add(cell.text.strip())
return _finish(parts, size)
except BadInput:
raise
except Exception as exc:
raise UnreadableDocument(
"that file is not a document we can read") from exc
def _extract_pdf(path: str) -> str:
try:
from pypdf import PdfReader
except ImportError as exc:
raise FileNotFoundError(
"document reading is not installed -- pip install pypdf") from exc
try:
reader = PdfReader(path)
if reader.is_encrypted:
raise UnreadableDocument(
"that PDF is password-protected and we cannot open it")
parts, size, add = _bounded_text()
for page in reader.pages[:MAX_PDF_PAGES]:
add(page.extract_text() or "")
return _finish(parts, size)
except BadInput:
raise
except Exception as exc:
raise UnreadableDocument(
"that file is not a document we can read") from exc
def extract_text(path: str) -> str:
"""The text of a .docx or .pdf upload, bounded and readable."""
suffix = Path(path).suffix.lower()
if suffix not in SUFFIXES:
raise BadInput("Lilly reads .docx and .pdf documents")
return _extract_docx(path) if suffix == ".docx" else _extract_pdf(path)
def main() -> int:
if len(sys.argv) < 2 or not Path(sys.argv[1]).is_file():
print("usage: python3 app/document.py <document.docx|document.pdf>",
file=sys.stderr)
return 1
print(extract_text(sys.argv[1]))
return 0
if __name__ == "__main__":
raise SystemExit(main())
|