OCR-слой: моно-признак не по флагу, кегль округляется, листинги склеиваются

Невидимый слой tesseract набран GlyphLessFont с выставленным моно-битом, и
is_mono() опознавала книгу как один сплошной листинг: измерено на Ansible после
OCR — pre 10498, p 0. Кегль в таком слое подгоняется под рамку слова и гуляет
внутри абзаца (9.0, 9.2, 9.4), из-за чего гистограмма размазывалась и основной
кегль книги определялся неверно; в OCR-слое он округляется до целого. Каждая
строка кода приходит отдельным блоком — соседние pre склеиваются переводом
строки, иначе листинг рассыпается на десяток однострочных.

Признак OCR-слоя считается по выборке страниц, обычные PDF идут прежним путём.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chesirecatt
2026-08-24 19:42:14 +03:00
parent 1a2c03f999
commit 356b9ddb22
+45 -4
View File
@@ -40,6 +40,7 @@ MAX_IMG_SHARE = 0.6 # доля площади страницы: больше
# кегль мельче основного текста плюс пунктуация, которой в прозе не бывает. # кегль мельче основного текста плюс пунктуация, которой в прозе не бывает.
CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->") CODEY = re.compile(r"[{}();\[\]<>=*/#]|::|->")
SOFT_HYPHEN = "\u00ad" SOFT_HYPHEN = "\u00ad"
OCR = False
def style(font, flags=0): def style(font, flags=0):
@@ -51,10 +52,40 @@ def style(font, flags=0):
def is_mono(s): def is_mono(s):
"""Моноширинный шрифт = листинг кода. Признак из флагов, а не из имени: """Моноширинный шрифт = листинг кода. Признак из флагов, а не из имени:
имена у каждого издательства свои, флаг одинаковый.""" имена у каждого издательства свои, флаг одинаковый.
Исключение — невидимый слой Tesseract: он весь набран GlyphLessFont с
выставленным моно-битом, и без этой проверки книга целиком опознаётся как
один сплошной листинг (измерено на Ansible после OCR: pre 10498, p 0).
В таком слое листинги ловятся кеглем и пунктуацией, см. block_kind().
"""
if "glyphless" in s["font"].lower():
return False
return bool(s["flags"] & MONO) return bool(s["flags"] & MONO)
def ocr_layer(doc, step=23):
"""Текст сделан OCR-ом: у всего слоя один невидимый шрифт. Кегль в таком
слое подгоняется под рамку слова и гуляет (9.0, 9.2, 9.4 в одном абзаце),
поэтому дальше он округляется до целого — иначе гистограмма размазывается
и основной кегль книги определяется неверно."""
glyphless = total = 0
for pno in range(1, doc.page_count, step):
for b in doc[pno].get_text("dict")["blocks"]:
for l in b.get("lines", []):
for s in l["spans"]:
n = len(s["text"])
total += n
if "glyphless" in s["font"].lower():
glyphless += n
return total > 0 and glyphless / total > 0.9
def size_of(span):
"""Кегль спана. В OCR-слое округляется до целого: см. ocr_layer()."""
return round(span["size"]) if OCR else round(span["size"], 1)
def profile(doc, step=7): def profile(doc, step=7):
"""(кегль текста, x0 красной строки, кегль глав) по выборке страниц. """(кегль текста, x0 красной строки, кегль глав) по выборке страниц.
@@ -74,9 +105,9 @@ def profile(doc, step=7):
for l in b["lines"]: for l in b["lines"]:
x0[round(l["bbox"][0])] += 1 x0[round(l["bbox"][0])] += 1
for sp in spans: for sp in spans:
size_chars[round(sp["size"], 1)] += len(sp["text"]) size_chars[size_of(sp)] += len(sp["text"])
top = max(spans, key=lambda sp: len(sp["text"])) top = max(spans, key=lambda sp: len(sp["text"]))
head_blocks[round(top["size"], 1)] += 1 head_blocks[size_of(top)] += 1
if not size_chars: if not size_chars:
sys.exit("в PDF нет извлекаемого текста — нужен OCR, этот скрипт не поможет") sys.exit("в PDF нет извлекаемого текста — нужен OCR, этот скрипт не поможет")
body = size_chars.most_common(1)[0][0] body = size_chars.most_common(1)[0][0]
@@ -118,7 +149,7 @@ def block_kind(b, body, h1_size):
if not spans: if not spans:
return None return None
top = max(spans, key=lambda s: len(s["text"])) top = max(spans, key=lambda s: len(s["text"]))
sz = top["size"] sz = size_of(top)
if all(is_mono(sp) for sp in spans): if all(is_mono(sp) for sp in spans):
return ("pre", None) return ("pre", None)
if sz <= body * 0.93 and looks_like_code(spans): if sz <= body * 0.93 and looks_like_code(spans):
@@ -169,7 +200,10 @@ def block_text(b, pre=False):
doc = pymupdf.open(SRC) doc = pymupdf.open(SRC)
OCR = ocr_layer(doc)
BODY, INDENT_X, H1_SIZE = profile(doc) BODY, INDENT_X, H1_SIZE = profile(doc)
if OCR:
print("слой распознан OCR-ом: моно-признак отключён, кегль округляется")
print("кегль текста %.1f, глав %.1f, красная строка от x0=%.0f" print("кегль текста %.1f, глав %.1f, красная строка от x0=%.0f"
% (BODY, H1_SIZE, INDENT_X)) % (BODY, H1_SIZE, INDENT_X))
parts = [] parts = []
@@ -224,6 +258,13 @@ for pno, page in enumerate(doc):
join = "" if open_para[2].endswith("-") else " " join = "" if open_para[2].endswith("-") else " "
open_para = (tag, cls, open_para[2] + join + txt, open_para[3]) open_para = (tag, cls, open_para[2] + join + txt, open_para[3])
continue continue
# Соседние листинги склеиваются в один блок: OCR-слой отдаёт каждую
# строку кода отдельным блоком, и без склейки листинг рассыпается на
# десяток однострочных <pre> подряд. Строки внутри блока значимы,
# поэтому соединяются переводом строки, а не пробелом.
if OCR and tag == "pre" and open_para and open_para[0] == "pre":
open_para = (tag, cls, open_para[2] + "\n" + txt, open_para[3])
continue
if open_para: if open_para:
parts.append(open_para) parts.append(open_para)
open_para = (tag, cls, txt, at_top) open_para = (tag, cls, txt, at_top)