humanizer / document.py
jialinyyzz's picture
Import .docx/PDF, Create fact and the language guard (as in app 0.3.1/0.3.2)
9da9b01 verified
Raw History Blame Contribute Delete
13.5 kB
# -*- coding: utf-8 -*-
"""Text from a .docx or PDF for the draft box. A port of the desktop app's importer
(app/internal/launcher/document.go and document_pdf.go), same rules:
* .docx: the text runs of word/document.xml; a blank line between paragraphs (the drafts the model was
trained on look like that), one line per table row with tab-separated cells.
* PDF: pdfminer.six parses the file and decodes the text (ToUnicode first, which keeps Chinese PDFs
printed from Chrome readable); the layout is ours. Glyphs on one baseline make a line; a larger line
gap, a change of font size or a short line that ends a sentence starts a new paragraph; a short line
that does not end a sentence (a sign-off, an address, a list item) keeps its line break; English lines
inside a paragraph are joined with a space (a trailing hyphen joins directly), Chinese lines directly.
* Code points that look the same but differ are fixed: radicals Chrome writes for common hanzi
(U+2F00 -> 一, U+2ECB -> 车), ligatures (fi -> fi), soft hyphens. Text that is still mostly control,
replacement or private-use characters is rejected instead of being loaded into the draft.
Nothing is written to disk here: the caller hands in bytes and gets text or a DocError back.
"""
from __future__ import annotations
import io
import logging
import math
import re
import unicodedata
import zipfile
import xml.etree.ElementTree as ET
MAX_BYTES = 10 * 1024 * 1024 # upload limit on this page (the app takes 20 MB; the Space reads on a shared CPU)
MAX_TEXT = 2 * 1024 * 1024 # extracted text limit, as in the app
MAX_PAGES = 100 # PDF pages read (the app: 500); one run here takes about 2,100 words anyway
logging.getLogger("pdfminer").setLevel(logging.ERROR) # it warns on every font without a FontBBox
class DocError(Exception):
"""code: invalid (not .docx/.pdf) | large (file) | long (text) | pages | empty | unreadable | garbled"""
def __init__(self, code: str):
super().__init__(code)
self.code = code
def document_text(data: bytes, name: str) -> str:
"""The text of a .docx or .pdf file, stripped; DocError when it can't be read."""
ext = name.lower().rsplit(".", 1)[-1] if "." in name else ""
if ext not in ("docx", "pdf"):
raise DocError("invalid")
if len(data) > MAX_BYTES:
raise DocError("large")
try:
text = _docx_text(data) if ext == "docx" else _pdf_text(data)
except DocError:
raise
except Exception: # malformed files raise all kinds of things inside the parsers
raise DocError("unreadable")
text = text.strip()
if not text:
raise DocError("empty")
return text
# ───────────────────────────────────────────────────────────── .docx
_W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
def _docx_text(data: bytes) -> str:
with zipfile.ZipFile(io.BytesIO(data)) as z:
try:
info = z.getinfo("word/document.xml")
except KeyError:
raise DocError("unreadable")
if info.file_size > MAX_TEXT:
raise DocError("long")
with z.open(info) as fh:
raw = fh.read(MAX_TEXT + 1)
if len(raw) > MAX_TEXT:
raise DocError("long")
if b"<!DOCTYPE" in raw or b"<!ENTITY" in raw: # Word never writes these; refuse entity tricks outright
raise DocError("unreadable")
out, cells = [], 0 # cells: depth inside table cells (a cell's paragraphs are joined with a space)
for event, el in ET.iterparse(io.BytesIO(raw), events=("start", "end")):
if not el.tag.startswith(_W):
continue
tag = el.tag[len(_W):]
if event == "start":
if tag == "tab":
out.append("\t")
elif tag in ("br", "cr"):
out.append("\n")
elif tag == "noBreakHyphen":
out.append("-")
elif tag == "tc":
cells += 1
else:
if tag == "t":
out.append(el.text or "")
elif tag == "p":
out.append(" " if cells > 0 else "\n\n")
elif tag == "tc":
cells -= 1
out.append("\t")
elif tag == "tr":
out.append("\n")
elif tag == "tbl":
out.append("\n")
el.clear()
return _tidy("".join(out))
_TRAIL_WS = re.compile(r"[ \t]+\n")
_SPACE_TAB = re.compile(r" +\t")
_MANY_BLANK = re.compile(r"\n{3,}")
def _tidy(s: str) -> str:
s = _TRAIL_WS.sub("\n", s)
s = _SPACE_TAB.sub("\t", s)
s = _MANY_BLANK.sub("\n\n", s)
return normalize_runes(s)
# ───────────────────────────────────────────────────────────── PDF
def _pdf_text(data: bytes) -> str:
from pdfminer.pdfdevice import PDFTextDevice
from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdffont import PDFUnicodeNotDefined
from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager
from pdfminer.pdfpage import PDFPage
from pdfminer.pdfparser import PDFParser
from pdfminer.utils import apply_matrix_pt
class Glyphs(PDFTextDevice):
"""Collects (x, y, end_x, size, text) per glyph in content-stream order."""
def __init__(self, rsrc):
super().__init__(rsrc)
self.out = []
def render_char(self, matrix, font, fontsize, scaling, rise, cid, ncs, graphicstate):
try:
text = font.to_unichr(cid)
except PDFUnicodeNotDefined:
text = "\ufffd" if font.is_multibyte() else "" # a code with no text: counted as garbled
except Exception:
text = ""
try:
adv = font.char_width(cid) * fontsize * scaling
except Exception:
adv = 0.0
x, y = apply_matrix_pt(matrix, (0, rise))
end, _ = apply_matrix_pt(matrix, (adv, rise))
if text:
self.out.append((x, y, end, abs(fontsize) * math.hypot(matrix[2], matrix[3]), text))
return adv
doc = PDFDocument(PDFParser(io.BytesIO(data)))
rsrc = PDFResourceManager(caching=True)
pages, size = [], 0
for n, page in enumerate(PDFPage.create_pages(doc), 1):
if n > MAX_PAGES:
raise DocError("pages")
dev = Glyphs(rsrc)
PDFPageInterpreter(rsrc, dev).process_page(page)
paras = _page_paragraphs(_page_lines(dev.out))
size += sum(len(p) + 2 for p in paras)
if size > MAX_TEXT:
raise DocError("long")
pages.append(paras)
text = _join_pages(pages)
if garbled(text):
raise DocError("garbled")
return text
_CJK = re.compile("[\u1100-\u11ff\u2e80-\u2fdf\u3000-\u303f\u3040-\u30ff\u3130-\u318f\u31f0-\u31ff"
"\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef\U00020000-\U0003134f]")
SENTENCE_END = ".!?:;。!?:;…”\"'’))"
def _is_cjk(ch: str) -> bool:
return bool(ch) and bool(_CJK.match(ch))
def _last(s: str) -> str:
s = s.rstrip(" ")
return s[-1] if s else ""
def _first(s: str) -> str:
s = s.lstrip(" ")
return s[0] if s else ""
def _page_lines(glyphs):
"""[(y, x0, x1, size, text)]: glyphs on one baseline, left to right as written."""
lines, cur, buf = [], None, []
def flush():
if cur is not None:
text = "".join(buf).strip()
if text:
lines.append((cur[0], cur[1], cur[2], cur[3], text))
for x, y, end, size, text in glyphs:
sz = size if size > 0 else 10.0
if cur is not None and (abs(y - cur[0]) > 0.5 * max(sz, cur[3]) or x < cur[2] - 2 * sz):
flush()
cur, buf = None, []
if cur is None:
cur = [y, x, end, sz]
else:
prev, nxt = _last("".join(buf[-4:])), _first(text)
gap = x - cur[2]
if (gap > 0.2 * sz and prev and nxt and not buf[-1].endswith(" ")
and not (_is_cjk(prev) and _is_cjk(nxt) and gap < 0.6 * sz)):
buf.append(" ")
cur[2] = max(cur[2], end)
cur[3] = max(cur[3], sz)
buf.append(text)
flush()
return lines
def _join_line(a: str, b: str) -> str:
pa, nb = _last(a), _first(b)
if not pa:
return b
if _is_cjk(pa) or _is_cjk(nb): # Chinese lines have no space between them (also mixed line ends)
return a + b
if pa == "-" and nb.islower():
return a + b
return a + " " + b
def _page_paragraphs(lines):
if not lines:
return []
gaps = [lines[i - 1][0] - l[0] for i, l in enumerate(lines) if i and 0 < lines[i - 1][0] - l[0] < 3 * l[3]]
lead = sorted(gaps)[len(gaps) // 2] if gaps else 0.0
min_x, max_x = min(l[1] for l in lines), max(l[2] for l in lines)
paras, cur = [], lines[0][4]
for i in range(1, len(lines)):
prev, l = lines[i - 1], lines[i]
d = prev[0] - l[0]
# a line that stops short: ending a sentence = end of paragraph; otherwise a deliberate line break
short = prev[2] < min_x + 0.8 * (max_x - min_x)
ends = _last(prev[4]) in SENTENCE_END and _last(prev[4]) != ""
if d <= 0 or (lead > 0 and d > 1.35 * lead) or abs(l[3] - prev[3]) > 0.15 * prev[3] or (short and ends):
paras.append(cur)
cur = l[4]
elif short:
cur += "\n" + l[4]
else:
cur = _join_line(cur, l[4])
paras.append(cur)
return paras
def _join_pages(pages) -> str:
"""A page that ends mid-sentence and a next page that starts in lower case (or with hanzi) are one paragraph."""
out = []
for paras in pages:
for i, p in enumerate(paras):
if i == 0 and out:
pr, nr = _last(out[-1]), _first(p)
if pr not in SENTENCE_END and (nr.islower() or (_is_cjk(pr) and _is_cjk(nr))):
out[-1] = _join_line(out[-1], p)
continue
out.append(p)
return normalize_runes("\n\n".join(out))
# ───────────────────────────────────────────────────────────── code points
# radical glyph -> the equivalent unified ideograph, in pairs (Kangxi radicals U+2F00 and the radicals
# supplement U+2E80), from Unicode's EquivalentUnifiedIdeograph.txt; the same table as the app.
_RADICAL_PAIRS = "⺁厂⺂乛⺃乚⺄乙⺅亻⺆冂⺇𠘨⺈刀⺉刂⺊卜⺋㔾⺌小⺍小⺎兀⺏尣⺐尢⺑𡯂⺒巳⺓幺⺔彑⺕𫜹⺖忄⺗心⺘扌⺙攵⺛旡⺜日⺝月⺞歺⺟母⺠民⺡氵⺢氺⺣灬⺤爫⺥爫⺦丬⺧牛⺨犭⺩王⺪𤴔⺫目⺬示⺭礻⺮𥫗⺯糹⺰纟⺱罓⺲罒⺳㓁⺴冗⺵𦉫⺶羊⺷𦍌⺸𦍋⺹耂⺺肀⺻聿⺼肉⺽𦥑⺾艹⺿艹⻀艹⻁虎⻂衤⻃覀⻄西⻅见⻆角⻇𧢲⻈讠⻉贝⻊𧾷⻋车⻌辶⻍辶⻎辶⻏邑⻐钅⻑長⻒镸⻓长⻔门⻕𨸏⻖阝⻗雨⻘青⻙韦⻚页⻛风⻜飞⻝食⻞𩙿⻟飠⻠饣⻡𩠐⻢马⻣骨⻤鬼⻥鱼⻦鸟⻧卤⻨麦⻩黄⻪黾⻫斉⻬齐⻭歯⻮齿⻯竜⻰龙⻱龜⻲亀⻳龟⼀一⼁丨⼂丶⼃丿⼄乙⼅亅⼆二⼇亠⼈人⼉儿⼊入⼋八⼌冂⼍冖⼎冫⼏几⼐凵⼑刀⼒力⼓勹⼔匕⼕匚⼖匸⼗十⼘卜⼙卩⼚厂⼛厶⼜又⼝口⼞囗⼟土⼠士⼡夂⼢夊⼣夕⼤大⼥女⼦子⼧宀⼨寸⼩小⼪尢⼫尸⼬屮⼭山⼮巛⼯工⼰己⼱巾⼲干⼳幺⼴广⼵廴⼶廾⼷弋⼸弓⼹彐⼺彡⼻彳⼼心⼽戈⼾戶⼿手⽀支⽁攴⽂文⽃斗⽄斤⽅方⽆无⽇日⽈曰⽉月⽊木⽋欠⽌止⽍歹⽎殳⽏毋⽐比⽑毛⽒氏⽓气⽔水⽕火⽖爪⽗父⽘爻⽙爿⽚片⽛牙⽜牛⽝犬⽞玄⽟玉⽠瓜⽡瓦⽢甘⽣生⽤用⽥田⽦疋⽧疒⽨癶⽩白⽪皮⽫皿⽬目⽭矛⽮矢⽯石⽰示⽱禸⽲禾⽳穴⽴立⽵竹⽶米⽷糸⽸缶⽹网⽺羊⽻羽⽼老⽽而⽾耒⽿耳⾀聿⾁肉⾂臣⾃自⾄至⾅臼⾆舌⾇舛⾈舟⾉艮⾊色⾋艸⾌虍⾍虫⾎血⾏行⾐衣⾑襾⾒見⾓角⾔言⾕谷⾖豆⾗豕⾘豸⾙貝⾚赤⾛走⾜足⾝身⾞車⾟辛⾠辰⾡辵⾢邑⾣酉⾤釆⾥里⾦金⾧長⾨門⾩阜⾪隶⾫隹⾬雨⾭靑⾮非⾯面⾰革⾱韋⾲韭⾳音⾴頁⾵風⾶飛⾷食⾸首⾹香⾺馬⾻骨⾼高⾽髟⾾鬥⾿鬯⿀鬲⿁鬼⿂魚⿃鳥⿄鹵⿅鹿⿆麥⿇麻⿈黃⿉黍⿊黑⿋黹⿌黽⿍鼎⿎鼓⿏鼠⿐鼻⿑齊⿒齒⿓龍⿔龜⿕龠"
_FIX = {ord(_RADICAL_PAIRS[i]): _RADICAL_PAIRS[i + 1] for i in range(0, len(_RADICAL_PAIRS) - 1, 2)}
_FIX.update({0xFB00: "ff", 0xFB01: "fi", 0xFB02: "fl", 0xFB03: "ffi", 0xFB04: "ffl", 0xFB05: "st", 0xFB06: "st",
0x00AD: None})
def normalize_runes(s: str) -> str:
return s.translate(_FIX)
def garbled(s: str) -> bool:
"""Control, replacement or private-use characters make up 5% or more of the visible characters."""
total = bad = 0
for ch in s:
if ch in "\n\t ":
continue
total += 1
if ch == "\ufffd" or unicodedata.category(ch) == "Cc" or "\ue000" <= ch <= "\uf8ff":
bad += 1
return total >= 20 and bad * 20 > total