Kaynağa Gözat

OCR script with engines SuryaOCR and PaddleOCR

master
Evgeniy Ierusalimov 5 gün önce
ebeveyn
işleme
25368ec861

+ 2
- 0
.env.dist Dosyayı Görüntüle

@@ -0,0 +1,2 @@
1
+OPENCODE_API_KEY=your_private_opencode_api_key
2
+HF_TOKEN=your_private_huggingface_readonly_api_key

+ 43
- 0
3.md Dosyayı Görüntüle

@@ -0,0 +1,43 @@
1
+
2
+## 2.1.Highlights of the IRB setup and evolution
3
+
4
+
5
+individual borrower's level.
6
+
7
+
8
+More than a decade later, the concept to consider a bunch of such individual borrowers was developed in Vasicek (1987). The starting point was to decompose the individual borrowers' pattern into specific (idiosyncratic) and common (systemic) components as in Eq. (1).
9
+
10
+$$
11
+A_{i}=Z\cdot r+u_{i}\cdot\sqrt{1-R}
12
+$$
13
+
14
+
15
+where - common factor; $u_{i}\sim N(0,1)$ -specific factor$ ,$
16
+
17
+
18
+$r=\mathrm{corr}(A_i,Z)$ $$ \rho \text{ (conventional mathematical correlation) as it originally appeared without a squared power in the works by Vasicek (1987); Gordy (2000)} \text{ (let us call it default correlation):} $$
19
+
20
+
21
+\(R=r^{2}\) - asset (value) correlation using BCBS notations (mathematically speaking, it is the square of the conventional mathematical correlation between \(A_{i}\) and \(Z\)).
22
+
23
+
24
+Due to the nice properties of the Gaussian distribution, Vasicek uses the Value-at-Risk (VaR) measure to slice the default rate (DR) distribution of the loan portfolio and arrives at the most feasible default rate realization given the chosen significance level of α in Eq. (2).
25
+
26
+$$
27
+V a R=N\left(\frac{N^{-1}(PD)+N^{-1}(1-\alpha)\cdot\sqrt{R}}{\sqrt{1-R}}\right).
28
+$$
29
+
30
+
31
+Important to note that Vasicek did not impose any restrictions on \( r \) over its feasible values. The only thing he notes is that in case \( r > 50\% \), the DR distribution becomes U-shaped (bimodal). Remembering the notations that \( R = r^2 \) or \( r = \sqrt{R} \), we should take away that Vasicek was speaking of DR distribution bimodality when the asset (value) correlation exceeds 25% (\( R > 25\% \)).
32
+
33
+
34
+006 BCBS amendment to the Vasicek model. The idea to internationally introduce model-based (IRB) credit risk regulation seems to have become popular after the success of such regulation introduction for the market risk in the Basel I amendment BCBS (1996). Thus, the very first draft of the future Basel II Accord incorporating IRB appeared on the edge of the new millennium, see Penikas (2020a), while it
35
+
36
+
37
+When designing IRB, the Basel Committee implemented five conceptual amendments, originally not previewed in the Vasicek model:
38
+
39
+
40
+1. BCBS chose the IRB significance level of $\alpha=0.1\%$ (inversely, confidence level of 99.9\%) which corresponds to the risk-measure breach (bank failure) once in one hundred years given the annual horizon for PD, see BCBS (2005a).
41
+
42
+
43
+2. BCBS added other credit risk parameters like loss given default (LGD), exposure at default (EAD), maturity (M) etc. For instance, VaR from Eq. (2) is multiplied by LGD and EAD.

+ 72
- 0
3_1200_02.md Dosyayı Görüntüle

@@ -0,0 +1,72 @@
1
+
2
+38
3
+
4
+$$
5
+1\mathrm{C Rec}_{\mathrm{it}}=\sum_{\mathrm{T}}^{\mathrm{t}}\mathrm{M Rec}_{\mathrm{it}} .
6
+$$
7
+
8
+
9
+где:
10
+
11
+
12
+i – индекс договора
13
+
14
+
15
+t — номер месяца в дефолте, на который прогнозируются возвраты для договора, в диапазоне между текущим сроком в дефолте и горизонтом взыскания:
16
+
17
+
18
+Т — рассчитанный горизонт взыскания
19
+
20
+
21
+$MBec_{i,t}$ — маржинальные возвраты, оцененные по модели, для договора $i$ в месяц $t$.
22
+
23
+
24
+7.1.10. На основе текущих полученных возвратов по дефолту на дату разработки модели и оцененной суммарной оставшейся доли возвратов к получению рассчитывается общий уровень потерь по дефолту за весь период взыскания с учетом экстраполяции (формула [18] ниже). В процессе моделирования минимальное экстраполированное значение уровня потерь ограничивается нулем.
25
+
26
+$$
27
+\mathrm{LGD\_extr_{i}}=\max(\mathrm{LGD\_act_{it}}-\mathrm{CRec_{it}},0) \mathrm{LGD\_extr_{i}}=\max(\mathrm{LGD\_act_{it}}-\mathrm{CRec_{it}},0) ______
28
+$$
29
+
30
+$$
31
+\mathrm{LGD\_act_{it}}=1-\frac{\sum_{t}\mathrm{DCF_{i,t}}}{\mathrm{EAD_{t}}}, ____
32
+$$
33
+
34
+
35
+где:
36
+
37
+
38
+$LGD\_extr_i$ — экстраполированное значение LGD для $i$-го договора в месяц нахождения в дефолте $t$;
39
+
40
+
41
+except — оцененная суммарная оставшаяся доля возвратов для $i$-го договора в месяц нахождения в дефолте $t$;
42
+
43
+
44
+$\mathrm{LGD\_act_{it}}$ - фактический уровень потерь для i -го договора в месяц нахождения вдефолте t;
45
+
46
+
47
+$\sum_t DCF_{i,t}$ — дисконтированная сумма возвратов, получаемых на t-ом периоде от созданной в дефолте;
48
+
49
+EAR — ожидаемая доходность из сети дефолта для договора i.
50
+
51
+
52
+11. Полученные значения уровней потерь для незавершенных д
53
+
54
+
55
+использованы в процессе моделирования. При учете таких дефолтов они рассматриваются как не выздоровевшие.
56
+
57
+7.2 Модель на основе дерева решений
58
+
59
+
60
+## 7.2.1. Данный подраздел применяется
61
+
62
+
63
+решений по ключевым характеристикам портфеля.
64
+
65
+
66
+[17]
67
+
68
+
69
+[18]
70
+
71
+
72
+[19]

+ 49
- 0
3_1200_02_surya.md Dosyayı Görüntüle

@@ -0,0 +1,49 @@
1
+## 38
2
+
3
+$$
4
+CRec_{it} = \sum_T^t MRec_{it}
5
+$$
6
+
7
+[17]
8
+
9
+где:
10
+
11
+i – индекс договора;
12
+
13
+t – номер месяца в дефолте, на который прогнозируются возвраты для договора, в диапазоне между текущим сроком в дефолте и горизонтом взыскания;
14
+
15
+T – рассчитанный горизонт взыскания;
16
+
17
+$MRec_{it}$ – маржинальные возвраты, оцененные по модели, для договора i в месяц t.
18
+
19
+7.1.10. На основе текущих полученных возвратов по дефолту на дату разработки модели и оцененной суммарной оставшейся доли возвратов к получению рассчитывается общий уровень потерь по дефолту за весь период взыскания с учетом экстраполяции (формула [18] ниже). В процессе моделирования минимальное экстраполированное значение уровня потерь ограничивается нулем.
20
+
21
+$$
22
+LGD_{extr_i} = \max(LGD_{act_{it}} - CRec_{it}, 0)
23
+$$
24
+
25
+[18]
26
+
27
+$$
28
+LGD_{act_{it}} = 1 - \frac{\sum_t DCF_{i,t}}{EAD_i},
29
+$$
30
+
31
+[19]
32
+
33
+где:
34
+
35
+$LGD_{extr_i}$ – экстраполированное значение LGD для i-го договора в месяц нахождения в дефолте t;
36
+
37
+$CRec_{it}$ – оцененная суммарная оставшаяся доля возвратов для i-го договора в месяц нахождения в дефолте t;
38
+
39
+$LGD_{act_{it}}$ – фактический уровень потерь для i-го договора в месяц нахождения в дефолте t;
40
+
41
+$\sum_t DCF_{i,t}$ – дисконтированная сумма возвратов, полученных на t-ый месяц нахождения в дефолте;
42
+
43
+$EAD_i$ – величина задолженности на дату дефолта для договора i.
44
+
45
+7.1.11. Полученные значения уровней потерь для незавершенных дефолтов могут быть использованы в процессе моделирования. При учете таких дефолтов они рассматриваются как не выздоровевшие.
46
+
47
+7.2. Модель на основе дерева решений
48
+
49
+7.2.1. Данный подраздел применяется при выборе структуры Моделей на основе дерева решений по ключевым характеристикам портфеля.

+ 31
- 0
3_surya.md Dosyayı Görüntüle

@@ -0,0 +1,31 @@
1
+## 2. Literature review
2
+
3
+## 2.1. Highlights of the IRB set-up and evolution
4
+
5
+1987 Vasicek model. IRB owes to the Nobel laureate in economics Robert Merton and Oldrich von Vasicek for its birth. As early as in Merton (1974), there came an idea to model a hypothetical borrower's finance, its assets more specifically, as a Normally-distributed random walk. The default event happens when the asset pattern breaches the fixed thresholds signaling for the artificial liabilities. Hence, the probability of such a breach given the known (assumed) nature of the probabilistic distribution allows us to proceed with the probability of default ($PD$) at the individual borrower's level.
6
+
7
+More than a decade later, the concept to consider a bunch of such individual borrowers was developed in Vasicek (1987). The starting point was to decompose the individual borrowers' pattern into specific (idiosyncratic) and common (systemic) components as in Eq. (1).
8
+
9
+$$
10
+A_i = Z \cdot r + u_i \cdot \sqrt{1 - R} \quad (1)
11
+$$
12
+
13
+where $Z \sim N(0, 1)$ - common factor; $u_i \sim N(0, 1)$ - specific factor; $r = \text{corr}(A_i; Z)$ - correlation of factors (conventional mathematical correlation) as it originally appeared without a squared power in the works by Vasicek (1987); Gordy (2000) (let us call it default correlation);
14
+
15
+$R = r^2$ - asset (value) correlation using BCBS notations (mathematically speaking, it is the square of the conventional mathematical correlation between $A_i$ and $Z$).
16
+
17
+Due to the nice properties of the Gaussian distribution, Vasicek uses the Value-at-Risk (VaR) risk-measure to slice the default rate (DR) distribution of the loan portfolio and arrives at the worst feasible default rate realization given the chosen significance level of $\alpha$ in Eq. (2).
18
+
19
+$$
20
+VaR = N \left( \frac{N^{-1}(PD) + N^{-1}(1 - \alpha) \cdot \sqrt{R}}{\sqrt{1 - R}} \right). \quad (2)
21
+$$
22
+
23
+Important to note that Vasicek did not impose any restrictions over $r$, over its feasible values. The only thing he notes is that in case $r > 50\%$, the DR distribution becomes U-shaped (bimodal). Remembering the notations that $R = r^2$, or $r = \sqrt{R}$, we should take away that Vasicek was speaking of DR distribution bimodality when the asset (value) correlation exceeds 25% ($R > 25\%$).
24
+
25
+2006 BCBS amendment to the Vasicek model. The idea to internationally introduce model-based (IRB) credit risk regulation seems to have become popular after the success of such regulation introduction for the market risk in the Basel I amendment BCBS (1996). Thus, the very first draft of the future Basel II Accord incorporating IRB appeared on the edge of the new millennium, see Penikas (2020a), while it took another six years to polish it to the very final (comprehensive) version of BCBS (2006).
26
+
27
+When designing IRB, the Basel Committee implemented five conceptual amendments, originally not previewed in the Vasicek model:
28
+
29
+1. BCBS chose the IRB significance level of $\alpha = 0.1\%$ (inversely, confidence level of 99.9%) what corresponds to the risk-measure breach (bank failure) once in one hundred years given the annual horizon for $PD$, see BCBS (2005a).2. BCBS added other credit risk parameters like loss given default ($LGD$), exposure at default ($EAD$), maturity ($M$) etc. For instance, $VaR$ from Eq. (2) is multiplied by $LGD$ and $EAD$.
30
+
31
+3

+ 160
- 0
README.md Dosyayı Görüntüle

@@ -0,0 +1,160 @@
1
+# scan2html — Offline Document OCR Pipeline
2
+
3
+Полностью офлайн-конвейер обработки отсканированных документов:
4
+разрезание изображений на страницы → OCR → структурированный Markdown.
5
+
6
+---
7
+
8
+## Цель проекта
9
+
10
+Преобразование больших технических документов (JPEG/TIFF/PNG), полученных после печати и сканирования, в структурированный Markdown с формулами LaTeX, пригодный для:
11
+- просмотра в браузере (HTML + MathJax)
12
+- анализа LLM
13
+- конвертации в LaTeX/PDF
14
+
15
+**Полностью offline.** Без облачных сервисов.
16
+
17
+---
18
+
19
+## Pipeline
20
+
21
+```
22
+JPEG/TIFF/PNG (многостраничный скан)
23
+    │
24
+    ▼
25
+src/image_split.py          ← Stage 1: разрезание
26
+    │ --slice / --slice-auto
27
+    │ --pre-rotate, --border
28
+    │ --post-crop
29
+    │
30
+    ▼
31
+JPEG страницы
32
+    │
33
+    ▼
34
+src/image_to_latex.py       ← Stage 2: OCR + Markdown
35
+    │ --ocr-engine paddle|surya
36
+    │ --llm, --vlm (optional)
37
+    │
38
+    ├─ PP-StructureV3 / Surya 2    (OCR)
39
+    ├─ fixups                      (VaR, V^2)
40
+    ├─ LLM corrector               (Qwen2.5-7B, local)
41
+    ├─ VLM corrector               (Qwen3.8 Max, API)
42
+    │
43
+    ▼
44
+.md + .json + .html
45
+```
46
+
47
+---
48
+
49
+## Параметры
50
+
51
+### Stage 1: `src/image_split.py`
52
+
53
+| Параметр | Описание | По умолчанию |
54
+|----------|----------|:---:|
55
+| `--input` / `-i` | Путь к входному изображению | required |
56
+| `--slice` / `-s` | Сетка `<cols>:<rows>` | — |
57
+| `--slice-auto` / `-a` | Автоопределение сетки | — |
58
+| `--output-dir` / `-o` | Каталог для страниц | `.` |
59
+| `--pre-rotate` / `-r` | Поворот: 90, 180, 270 | — |
60
+| `--border` / `-b` | Белая рамка (px) | 50 |
61
+| `--post-crop` / `--no-post-crop` | Обрезка по контенту | ON |
62
+
63
+Пример:
64
+```bash
65
+python -m src.image_split -i scan.jpg -s 3:3 -b 10 -o pages/
66
+python -m src.image_split -i scan.jpg --slice-auto
67
+```
68
+
69
+### Stage 2: `src/image_to_latex.py`
70
+
71
+| Параметр | Описание | По умолчанию |
72
+|----------|----------|:---:|
73
+| `--input` / `-i` | Изображение страницы | required |
74
+| `--output-dir` / `-o` | Каталог вывода | рядом с `--input` |
75
+| `--lang` / `-l` | Язык OCR | `en` |
76
+| `--ocr-engine` | `paddle` или `surya` | `paddle` |
77
+| `--llm` | Локальный LLM-корректор (Qwen2.5-7B) | OFF |
78
+| `--vlm` | Vision LM корректор (Qwen3.8 Max, API) | OFF |
79
+
80
+Пример:
81
+```bash
82
+# Базовый OCR
83
+python -m src.image_to_latex -i page.jpg
84
+
85
+# С LLM-коррекцией
86
+python -m src.image_to_latex -i page.jpg -l ru --llm
87
+
88
+# С VLM-коррекцией (нужен OPENCODE_API_KEY в .env)
89
+python -m src.image_to_latex -i page.jpg --vlm
90
+
91
+# Surya 2
92
+python -m src.image_to_latex -i page.jpg --ocr-engine surya
93
+```
94
+
95
+---
96
+
97
+## Результат
98
+
99
+Для каждого входного изображения генерируется:
100
+
101
+| Файл | Формат | Описание |
102
+|------|--------|----------|
103
+| `page_01.md` | Markdown | Текст + `$$`-формулы + `##`-заголовки |
104
+| `page_01.json` | JSON | Полный дамп OCR-движка (bbox, confidence, labels) |
105
+
106
+Дополнительно (при включённых опциях):
107
+- `.tex` — LaTeX-документ (скомпилировать в PDF)
108
+- `.html` — HTML с MathJax-рендерингом формул
109
+
110
+---
111
+
112
+## Качество OCR
113
+
114
+| Движок | Русский текст | Формулы | Скорость |
115
+|--------|:---:|:---:|:---:|
116
+| PaddleOCR (PP-StructV3) | ~85% | ✅ отлично | ~140s |
117
+| + LLM (Qwen2.5-7B) | ~95% | ✅ | +5 min |
118
+| + VLM (Qwen3.8 Max) | ~98% | ✅✅ | +10s/блок |
119
+| Surya 2 | ~95% | ✅ отлично | ~430s |
120
+
121
+---
122
+
123
+## Архитектура
124
+
125
+```
126
+src/
127
+  image_split.py         — CLI Stage 1
128
+  image_to_latex.py      — CLI Stage 2
129
+  split/slicer.py        — разрезание, post-crop
130
+  ocr/
131
+    paddle_engine.py     — PP-StructureV3
132
+    surya_engine.py      — Surya 2
133
+  postprocess/
134
+    fixups.py            — OCR-ошибки (VaR)
135
+    corrector.py         — Corrector protocol
136
+    llm_corrector.py     — Qwen2.5-7B (local)
137
+    vlm_corrector.py     — Qwen3.8 Max (API)
138
+  markdown/generator.py  — Markdown + валидация
139
+  latex/
140
+    surya2html.py        — Surya JSON → HTML
141
+    tex2html.py           — TeX → HTML
142
+```
143
+
144
+---
145
+
146
+## Не входит в проект
147
+
148
+- GUI
149
+- Docker
150
+- PDF (на вход)
151
+- LLM/RAG
152
+- Поиск по документам
153
+- Многопоточность
154
+- Автоматический deskew / denoise
155
+
156
+---
157
+
158
+## Лицензия
159
+
160
+MIT. Модели PaddleOCR — Apache 2.0, Surya 2 — OpenRAIL-M.

+ 265
- 0
STAGE2_RESEARCH_RESULT.md Dosyayı Görüntüle

@@ -0,0 +1,265 @@
1
+# STAGE2_RESEARCH_RESULT.md
2
+
3
+## Результат исследования PaddleOCR 3.7.0 + Docling 2.117.0
4
+
5
+Дата: 2026-08-03
6
+
7
+---
8
+
9
+## 1. PaddleOCR — возможности
10
+
11
+Версия: **3.7.0** (июнь 2026)
12
+
13
+**PP-StructureV3** — ключевой компонент для обработки документов:
14
+- Преобразует PDF/изображения в структурированный **Markdown** или **JSON**
15
+- Определяет layout страницы, reading order, таблицы, формулы
16
+- Предоставляет **координаты** каждого элемента (текстовые блоки, ячейки таблиц, строки)
17
+- Возвращает **confidence** для результатов OCR
18
+- Поддерживает 100+ языков (PP-OCRv6: 50 языков в единой модели)
19
+
20
+**PP-OCRv6** — текстовое распознавание:
21
+- 34.5M параметров, превосходит большие VLM по точности
22
+- 5.2× ускорение на CPU (OpenVINO)
23
+- Детекция + распознавание текста
24
+
25
+**Экспорт:** Markdown, JSON, DOCX
26
+
27
+---
28
+
29
+## 2. Docling — возможности
30
+
31
+Версия: **2.117.0** (июль 2026), IBM Research, MIT License
32
+
33
+**Ключевая функциональность:**
34
+- Парсинг PDF, DOCX, PPTX, XLSX, HTML, EPUB, изображений (PNG/TIFF/JPEG)
35
+- Layout detection, reading order, table structure, code, formulas, image classification
36
+- Единый формат `DoclingDocument`
37
+- Экспорт: **Markdown, HTML, JSON, DocTags, lossless JSON**
38
+- Confidence scores (с v2.34.0): layout_score, ocr_score, parse_score, table_score
39
+
40
+**OCR-движки (НЕТ PaddleOCR!):**
41
+- EasyOCR, Tesseract, Tesseract CLI, RapidOCR, Nemotron OCR, OcrMac, OnnxTR
42
+
43
+**Форматы экспорта:**
44
+
45
+| Формат | Официально | Структура | Для LLM |
46
+|--------|-----------|-----------|---------|
47
+| HTML | ✅ | Полная (таблицы, изображения, заголовки) | Отлично |
48
+| Markdown | ✅ | Хорошая (таблицы, заголовки) | Отлично |
49
+| JSON | ✅ | Полная, lossless | Хорошо |
50
+| DocTags | ✅ | Полная, с координатами | Средне |
51
+| Plain Text | ❌ (через Markdown) | Базовая | Плохо |
52
+
53
+---
54
+
55
+## 3. Совместимость PaddleOCR + Docling
56
+
57
+**Docling НЕ поддерживает PaddleOCR как OCR-движок** (v2.117.0). 
58
+
59
+Доступные движки: EasyOCR, Tesseract, RapidOCR, Nemotron, OcrMac, OnnxTR.
60
+
61
+**Вывод:** напрямую интегрировать PaddleOCR в Docling в качестве OCR-backend невозможно без форка Docling.
62
+
63
+Альтернативы для проекта scan2html:
64
+- **Вариант А**: Использовать **PaddleOCR PP-StructureV3** напрямую — он сам выполняет полный pipeline (OCR + структура + Markdown/JSON) — Docling не нужен
65
+- **Вариант Б**: Использовать **Docling** со встроенным OCR (EasyOCR/RapidOCR) — HTML экспорт из коробки
66
+- **Вариант В**: Самостоятельная генерация HTML из результатов PaddleOCR
67
+
68
+---
69
+
70
+## 4. Качество HTML (Docling)
71
+
72
+Docling генерирует HTML с сохранением:
73
+- ✅ Таблицы (включая rowspan/colspan)
74
+- ✅ Заголовки (h1-h6)
75
+- ✅ Списки (ul/ol)
76
+- ✅ Изображения (встроенные base64)
77
+- ✅ Подписи рисунков
78
+- ✅ Формулы (MathML)
79
+- ❗ Объединённые ячейки — зависит от качества OCR
80
+
81
+**PaddleOCR PP-StructureV3** также сохраняет структуру в Markdown/JSON с координатами — но HTML-экспорта из коробки не предоставляет (только Markdown, JSON, DOCX).
82
+
83
+---
84
+
85
+## 5. Таблицы
86
+
87
+**Docling:**
88
+- Таблицы с поддержкой rowspan/colspan ✅
89
+- Качество зависит от OCR-движка
90
+- Экспорт в HTML с сохранением colspan/rowspan ✅
91
+
92
+**PaddleOCR PP-StructureV3:**
93
+- Таблицы с координатами ячеек ✅
94
+- Восстановление сложных таблиц ✅
95
+- Экспорт в Markdown (базовый) и JSON (с координатами)
96
+
97
+---
98
+
99
+## 6. Формулы
100
+
101
+**Docling:**
102
+- Распознавание формул ✅
103
+- Экспорт в MathML ✅
104
+- LaTeX не поддерживается в HTML (только через MathML)
105
+
106
+**PaddleOCR PP-StructureV3:**
107
+- Распознавание формул ✅
108
+- Экспорт LaTeX ✅ (в Markdown/JSON)
109
+
110
+---
111
+
112
+## 7. OCR Confidence
113
+
114
+**PaddleOCR:** предоставляет confidence для каждого распознанного слова/символа.
115
+
116
+**Docling:** с v2.34.0 предоставляет:
117
+- `layout_score` — качество детекции элементов
118
+- `ocr_score` — качество OCR
119
+- `parse_score` — 10-й перцентиль уверенности
120
+- `table_score` — качество таблиц (ещё не реализовано)
121
+- Page-level и document-level оценки
122
+
123
+Confidence можно использовать для подсветки сомнительных мест.
124
+
125
+---
126
+
127
+## 8. Координаты элементов
128
+
129
+**PaddleOCR PP-StructureV3:** ✅ Полные координаты
130
+- Страницы ✅
131
+- Абзацы ✅
132
+- Таблицы + ячейки строк ✅
133
+- Отдельные слова/символы ✅
134
+
135
+**Docling:** координаты доступны через DoclingDocument API (bounding boxes), но в HTML экспорт не передаются.
136
+
137
+---
138
+
139
+## 9. Производительность
140
+
141
+**PaddleOCR PP-StructureV3:**
142
+- ~2-5 секунд на страницу на CPU
143
+- RAM: ~2-4 GB для моделей
144
+- GPU: значительное ускорение
145
+- Пакетная обработка ✅
146
+
147
+**Docling:**
148
+- ~1-3 секунды на страницу на CPU (EasyOCR: >10 с)
149
+- RAM: ~2-4 GB для моделей
150
+- Пакетная обработка ✅
151
+- GPU: поддерживается через PyTorch/CUDA
152
+
153
+---
154
+
155
+## 10. Offline-режим
156
+
157
+**PaddleOCR:** ✅ полностью offline. Модели скачиваются один раз в `~/.paddlex/official_models/`.
158
+
159
+**Docling:** ✅ полностью offline. Модели кешируются локально. Нет обращений к облачным сервисам. Поддерживает air-gapped environments.
160
+
161
+---
162
+
163
+## 11. Минимальный рабочий пример
164
+
165
+```python
166
+# ===== PaddleOCR PP-StructureV3: полный pipeline =====
167
+from paddleocr import PaddleOCR
168
+
169
+ocr = PaddleOCR(lang="en", use_structure=True)
170
+result = ocr.predict("page.jpg")
171
+# result содержит:
172
+#   - markdown: структурированный Markdown
173
+#   - json: полный JSON с координатами
174
+#   - ocr_result: детальный OCR с confidence
175
+#   - table_result: таблицы с ячейками
176
+#   - layout_result: layout страницы
177
+
178
+# ===== Docling: преобразование в HTML =====
179
+from docling.document_converter import DocumentConverter
180
+
181
+converter = DocumentConverter()
182
+result = converter.convert("page.jpg")
183
+html = result.document.export_to_html()
184
+markdown = result.document.export_to_markdown()
185
+json_out = result.document.export_to_dict()
186
+confidence = result.confidence  # ConfidenceReport
187
+```
188
+
189
+---
190
+
191
+## 12. Что уже реализовано библиотеками (не требует разработки)
192
+
193
+| Функция | PaddleOCR | Docling |
194
+|---------|-----------|---------|
195
+| OCR текста | PP-OCRv6 | EasyOCR/Tesseract/RapidOCR |
196
+| Layout detection | PP-StructureV3 | Встроенный DocLayNet |
197
+| Reading order | ✅ | ✅ |
198
+| Таблицы + ячейки | PP-StructureV3 | TableFormer |
199
+| Формулы → LaTeX | PP-StructureV3 | Formula recognition |
200
+| HTML экспорт | ❌ (только MD/JSON/DOCX) | ✅ |
201
+| Markdown экспорт | ✅ | ✅ |
202
+| JSON экспорт | ✅ | ✅ |
203
+| DOCX экспорт | ✅ (v3.5+) | ❌ |
204
+| Confidence scores | ✅ | ✅ |
205
+| Координаты элементов | ✅ | ✅ |
206
+| Пакетная обработка | ✅ | ✅ |
207
+
208
+---
209
+
210
+## 13. Что придётся реализовать самостоятельно
211
+
212
+1. **Генерация HTML с координатами элементов из PaddleOCR** — если выбран PaddleOCR
213
+2. **Подсветка сомнительных мест** — на основе confidence (любой движок)
214
+3. **Передача confidence в HTML** — через data-атрибуты или CSS-классы
215
+4. **Интеграция PaddleOCR + Docling** — если нужна, требует форка Docling
216
+
217
+---
218
+
219
+## 14. Рекомендуемая архитектура Stage 2
220
+
221
+### Рекомендация: использовать **PaddleOCR PP-StructureV3** как основной движок
222
+
223
+**Обоснование:**
224
+1. PaddleOCR PP-StructureV3 сам выполняет полный pipeline: OCR + структура + таблицы + формулы + координаты
225
+2. Docling не нужен как промежуточный слой — он не добавляет ценности при уже имеющемся PaddleOCR
226
+3. PaddleOCR даёт более детальные координаты и confidence, чем Docling
227
+4. Не требуется интеграция двух библиотек — снижается сложность
228
+5. HTML-генерацию можно реализовать как отдельный модуль (`src/html/`), принимающий на вход JSON от PaddleOCR
229
+
230
+### Pipeline Stage 2:
231
+
232
+```
233
+JPEG page
234
+    ↓
235
+PaddleOCR PP-StructureV3
236
+    ↓
237
+JSON (структура + координаты + confidence)
238
+    ↓
239
+Генератор HTML (src/html/)
240
+    ↓
241
+HTML с data-атрибутами (confidence, координаты)
242
+```
243
+
244
+### Модули для реализации:
245
+
246
+```
247
+src/
248
+  split/       — разрезание (Stage 1, готово)
249
+  preprocess/  — deskew, crop, denoise (будущие этапы)
250
+  ocr/         — обёртка над PaddleOCR PP-StructureV3
251
+  html/        — генерация HTML из JSON-результата OCR
252
+  image_split.py — CLI (Stage 1)
253
+```
254
+
255
+---
256
+
257
+## 15. Итоговая рекомендация
258
+
259
+1. **Не использовать Docling** для данного проекта — он не даёт преимуществ перед PaddleOCR PP-StructureV3 и не поддерживает PaddleOCR как OCR-backend
260
+2. **Использовать PaddleOCR PP-StructureV3** для OCR и извлечения структуры документа
261
+3. **Реализовать HTML-генератор** (модуль `src/html/`) на основе JSON-вывода PaddleOCR
262
+4. **Добавить confidence-подсветку** в HTML через data-атрибуты
263
+5. **Структура проекта** уже соответствует архитектуре из `.ai/ARCHITECTURE.md`
264
+
265
+Главная цель минимизации собственного кода достигнута: PaddleOCR PP-StructureV3 покрывает OCR, layout detection, таблицы, формулы, координаты и confidence. Собственной реализации требует только HTML-генератор и CLI-интеграция.

+ 130
- 0
installation.md Dosyayı Görüntüle

@@ -0,0 +1,130 @@
1
+# Установка окружения
2
+
3
+## Системные требования
4
+
5
+- **OS:** Linux (x86_64)
6
+- **CPU:** 8+ ядер (рекомендуется)
7
+- **RAM:** 16+ GB (32 GB для LLM-корректора)
8
+- **Диск:** 10+ GB свободного места
9
+
10
+---
11
+
12
+## Шаг 1: Python 3.12 + venv
13
+
14
+```bash
15
+python3 -m venv .venv
16
+source .venv/bin/activate
17
+```
18
+
19
+---
20
+
21
+## Шаг 2: Stage 1 — разрезание изображений
22
+
23
+```bash
24
+pip install opencv-python-headless typer
25
+```
26
+
27
+Зависимости: `opencv-python-headless`, `numpy`, `typer`, `ruff` (dev).
28
+
29
+Проверка:
30
+```bash
31
+python -m src.image_split --help
32
+```
33
+
34
+---
35
+
36
+## Шаг 3: Stage 2 — OCR (PaddleOCR)
37
+
38
+```bash
39
+pip install paddlepaddle==3.2.2
40
+pip install paddleocr pyyaml
41
+```
42
+
43
+Модели загружаются автоматически при первом запуске в `~/.paddlex/official_models/` (~2.5 GB).
44
+
45
+Проверка:
46
+```bash
47
+python -m src.image_to_latex --help
48
+```
49
+
50
+---
51
+
52
+## Шаг 4 (опционально): LLM-корректор (Qwen2.5-7B)
53
+
54
+```bash
55
+pip install llama-cpp-python
56
+```
57
+
58
+Модель загружается из HuggingFace при первом запуске (~4.4 GB):
59
+```bash
60
+# Автоматически при первом --llm, или вручную:
61
+curl -L "https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf" \
62
+  -o ~/.cache/llama_models/Qwen2.5-7B-Instruct-Q4_K_M.gguf
63
+```
64
+
65
+Использование:
66
+```bash
67
+python -m src.image_to_latex -i page.jpg --llm
68
+```
69
+
70
+---
71
+
72
+## Шаг 5 (опционально): VLM-корректор (Qwen3.8 Max)
73
+
74
+Требуется подписка OpenCode Go. Токен записать в `.env`:
75
+
76
+```bash
77
+echo 'OPENCODE_API_KEY=sk-...' > .env
78
+echo 'HF_TOKEN=hf_...' >> .env
79
+```
80
+
81
+`.env` уже добавлен в `.gitignore`.
82
+
83
+Использование:
84
+```bash
85
+python -m src.image_to_latex -i page.jpg --vlm
86
+```
87
+
88
+---
89
+
90
+## Шаг 6 (опционально): Surya 2
91
+
92
+Surya 2 требует отдельного venv из-за конфликтов зависимостей с PaddleOCR.
93
+
94
+```bash
95
+# Установка llama-server (CPU-версия)
96
+mkdir -p ~/.local/bin
97
+curl -L "${URL}" -o /tmp/llama.tar.gz
98
+tar -xzf /tmp/llama.tar.gz -C /tmp/
99
+cp /tmp/llama-*/llama-server /tmp/llama-*/lib*.so* ~/.local/bin/
100
+export PATH="$HOME/.local/bin:$PATH"
101
+
102
+# Установка пакетов (в основном venv — конфликтует с PaddleOCR)
103
+pip install surya-ocr torch torchvision --index-url https://download.pytorch.org/whl/cpu
104
+pip install "opencv-python-headless==4.11.0.86" "pillow<11" transformers platformdirs
105
+```
106
+
107
+Использование:
108
+```bash
109
+python -m src.image_to_latex -i page.jpg --ocr-engine surya
110
+```
111
+
112
+---
113
+
114
+## Шаг 7: Разработка
115
+
116
+```bash
117
+pip install ruff pytest
118
+ruff check src/ tests/
119
+python -m pytest tests/ -v
120
+```
121
+
122
+---
123
+
124
+## Структура моделей (локально)
125
+
126
+```
127
+~/.paddlex/official_models/          PaddleOCR (~2.5 GB)
128
+~/.cache/llama_models/               LLM (~4.4 GB)
129
+/tmp/hf_cache/                       Surya 2 (~1.5 GB, HuggingFace)
130
+```

+ 12
- 0
src/cli_utils.py Dosyayı Görüntüle

@@ -0,0 +1,12 @@
1
+from __future__ import annotations
2
+
3
+import sys
4
+
5
+import typer
6
+
7
+
8
+def run_main(app: typer.Typer) -> None:
9
+    if len(sys.argv) == 1:
10
+        app(["--help"], standalone_mode=False)
11
+        return
12
+    app()

+ 2
- 5
src/image_split.py Dosyayı Görüntüle

@@ -2,12 +2,12 @@ from __future__ import annotations
2 2
 
3 3
 import logging
4 4
 import os
5
-import sys
6 5
 from pathlib import Path
7 6
 from typing import Annotated
8 7
 
9 8
 import typer
10 9
 
10
+from src.cli_utils import run_main
11 11
 from src.split.slicer import (
12 12
     DEFAULT_BORDER_PX,
13 13
     VALID_ROTATIONS,
@@ -162,10 +162,7 @@ def slice_pages(
162 162
 
163 163
 
164 164
 def main() -> None:
165
-    if len(sys.argv) == 1:
166
-        app(["--help"], standalone_mode=False)
167
-        return
168
-    app()
165
+    run_main(app)
169 166
 
170 167
 
171 168
 if __name__ == "__main__":

+ 131
- 0
src/image_to_latex.py Dosyayı Görüntüle

@@ -0,0 +1,131 @@
1
+from __future__ import annotations
2
+
3
+import functools
4
+import json
5
+import logging
6
+from pathlib import Path
7
+from typing import Annotated
8
+
9
+import typer
10
+
11
+from src.cli_utils import run_main
12
+from src.markdown.generator import generate_markdown
13
+from src.ocr.paddle_engine import ocr_image
14
+from src.postprocess.fixups import fix_ocr_errors
15
+
16
+logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(message)s")
17
+logger = logging.getLogger(__name__)
18
+app = typer.Typer(add_completion=False, no_args_is_help=True)
19
+
20
+MD_EXTENSION: str = ".md"
21
+DEFAULT_LANG: str = "en"
22
+OCR_ENGINES: dict[str, str] = {"paddle": "PaddleOCR (PP-StructureV3)", "surya": "Surya 2 (VLM)"}
23
+
24
+
25
+def _get_ocr_engine(name: str):
26
+    if name == "surya":
27
+        from src.ocr.surya_engine import surya_ocr_image
28
+
29
+        return surya_ocr_image, "Surya 2 VLM"
30
+    return ocr_image, "PP-StructureV3"
31
+
32
+
33
+@app.command()
34
+def image_to_latex(
35
+    input: Annotated[
36
+        Path,
37
+        typer.Option(
38
+            "--input",
39
+            "-i",
40
+            exists=True,
41
+            file_okay=True,
42
+            dir_okay=False,
43
+            readable=True,
44
+            help="Путь к входному изображению (JPEG, PNG, TIFF)",
45
+        ),
46
+    ],
47
+    output_dir: Annotated[
48
+        Path | None,
49
+        typer.Option(
50
+            "--output-dir",
51
+            "-o",
52
+            file_okay=False,
53
+            dir_okay=True,
54
+            writable=True,
55
+            help="Каталог для сохранения Markdown-файла (по умолчанию — рядом с входным файлом)",
56
+        ),
57
+    ] = None,
58
+    lang: Annotated[
59
+        str,
60
+        typer.Option(
61
+            "--lang",
62
+            "-l",
63
+            help="Язык OCR (en, ru, ch, ...)",
64
+        ),
65
+    ] = DEFAULT_LANG,
66
+    ocr_engine: Annotated[
67
+        str,
68
+        typer.Option(
69
+            "--ocr-engine",
70
+            help="OCR-движок: paddle (PP-StructureV3) или surya (Surya 2 VLM)",
71
+        ),
72
+    ] = "paddle",
73
+    use_llm: Annotated[
74
+        bool,
75
+        typer.Option(
76
+            "--llm",
77
+            help="Использовать локальный LLM-корректор (Qwen2.5-7B GGUF) для исправления OCR-ошибок",
78
+        ),
79
+    ] = False,
80
+    use_vlm: Annotated[
81
+        bool,
82
+        typer.Option(
83
+            "--vlm",
84
+            help="Использовать vision LM (Qwen3.8 Max) для распознавания сомнительных регионов. Токен: OPENCODE_API_KEY",
85
+        ),
86
+    ] = False,
87
+) -> None:
88
+    """OCR → PostProcess → Markdown. --ocr-engine: paddle (default) или surya. Опционально: --llm, --vlm."""
89
+    ocr_fn, engine_name = _get_ocr_engine(ocr_engine)
90
+    logger.info("Запуск %s (lang=%s) ...", engine_name, lang)
91
+    page = ocr_fn(str(input))
92
+    logger.info("Распознано %d блоков", len(page.blocks))
93
+
94
+    fix_ocr_errors(page.blocks)
95
+
96
+    if use_llm or use_vlm:
97
+        from src.postprocess.corrector import apply_corrector
98
+
99
+    if use_llm:
100
+        from src.postprocess.llm_corrector import llm_correct_block
101
+
102
+        apply_corrector(page.blocks, llm_correct_block, "LLM")
103
+
104
+    if use_vlm:
105
+        from src.postprocess.vlm_corrector import vlm_correct_block
106
+
107
+        vlm_corrector = functools.partial(vlm_correct_block, image_path=str(input.resolve()))
108
+        apply_corrector(page.blocks, vlm_corrector, "VLM")
109
+
110
+    output_dir_resolved = output_dir.resolve() if output_dir else input.resolve().parent
111
+    output_name = input.stem + MD_EXTENSION
112
+    output_path = output_dir_resolved / output_name
113
+
114
+    generate_markdown(page.blocks, output_path)
115
+    logger.info("Markdown сохранён: %s", output_path)
116
+
117
+    if page.raw_json:
118
+        json_path = output_dir_resolved / (input.stem + ".json")
119
+        json_path.write_text(
120
+            json.dumps(page.raw_json, ensure_ascii=False, indent=2),
121
+            encoding="utf-8",
122
+        )
123
+        logger.info("JSON сохранён: %s", json_path)
124
+
125
+
126
+def main() -> None:
127
+    run_main(app)
128
+
129
+
130
+if __name__ == "__main__":
131
+    main()

+ 3
- 0
src/latex/__init__.py Dosyayı Görüntüle

@@ -0,0 +1,3 @@
1
+from src.latex.tex2html import tex_to_html
2
+
3
+__all__ = ["tex_to_html"]

+ 74
- 0
src/latex/surya2html.py Dosyayı Görüntüle

@@ -0,0 +1,74 @@
1
+from __future__ import annotations
2
+
3
+import json
4
+from pathlib import Path
5
+
6
+from bs4 import BeautifulSoup
7
+
8
+HTML_TEMPLATE = """\
9
+<!DOCTYPE html>
10
+<html lang="en">
11
+<head>
12
+<meta charset="utf-8">
13
+<title>{title} — Surya 2</title>
14
+<script>
15
+MathJax = {{
16
+  tex: {{
17
+    inlineMath: [['$', '$']],
18
+    displayMath: [['$$', '$$']],
19
+    packages: {{'[+]': ['ams']}}
20
+  }},
21
+  loader: {{load: ['[tex]/ams']}}
22
+}};
23
+</script>
24
+<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" async></script>
25
+<style>
26
+  body {{ font-family: serif; max-width: 800px; margin: 2em auto; padding: 0 1em; line-height: 1.6; }}
27
+  h2 {{ margin-top: 1.5em; }}
28
+  .equation {{ text-align: center; margin: 1em 0; }}
29
+  .header {{ font-weight: bold; }}
30
+  p {{ margin: 0.5em 0; text-indent: 1.5em; }}
31
+</style>
32
+</head>
33
+<body>
34
+{body}
35
+</body>
36
+</html>
37
+"""
38
+
39
+
40
+def surya_json_to_html(json_path: str | Path) -> str:
41
+    data = json.loads(Path(json_path).read_text())
42
+    blocks = data.get("blocks", [])
43
+
44
+    html_parts: list[str] = []
45
+    for b in blocks:
46
+        label = b.get("label", "")
47
+        raw_html = b.get("html", "")
48
+        if not raw_html.strip():
49
+            continue
50
+
51
+        if label == "Equation":
52
+            clean = raw_html.replace('<math display="block">', "").replace("</math>", "").strip()
53
+            html_parts.append(f'\n<div class="equation">$$\n{clean}\n$$</div>')
54
+            continue
55
+
56
+        soup = BeautifulSoup(raw_html, "html.parser")
57
+        for math_tag in soup.find_all("math"):
58
+            display = math_tag.get("display", "") == "block"
59
+            latex = math_tag.get_text().strip()
60
+            math_tag.replace_with(f"$$\n{latex}\n$$" if display else f"${latex}$")
61
+
62
+        text = " ".join(soup.stripped_strings)
63
+        if not text:
64
+            continue
65
+
66
+        if label in ("SectionHeader",):
67
+            html_parts.append(f"<h2>{text}</h2>")
68
+        elif label == "PageHeader":
69
+            html_parts.append(f'<p class="header">{text}</p>')
70
+        else:
71
+            html_parts.append(f"<p>{text}</p>")
72
+
73
+    body = "\n".join(html_parts)
74
+    return HTML_TEMPLATE.format(title=Path(json_path).stem, body=body)

+ 54
- 0
src/latex/tex2html.py Dosyayı Görüntüle

@@ -0,0 +1,54 @@
1
+from __future__ import annotations
2
+
3
+import sys
4
+from pathlib import Path
5
+
6
+HTML_TEMPLATE = """\
7
+<!DOCTYPE html>
8
+<html lang="ru">
9
+<head>
10
+<meta charset="utf-8">
11
+<title>{title}</title>
12
+<script>
13
+MathJax = {{
14
+  tex: {{
15
+    inlineMath: [['$', '$']],
16
+    displayMath: [['$$', '$$']],
17
+    processEscapes: true
18
+  }}
19
+}};
20
+</script>
21
+<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" async></script>
22
+<style>
23
+  body {{ font-family: serif; max-width: 800px; margin: 2em auto; padding: 0 1em; line-height: 1.6; }}
24
+  p {{ text-indent: 1.5em; margin: 0.5em 0; }}
25
+</style>
26
+</head>
27
+<body>
28
+<pre style="white-space: pre-wrap; font-family: serif;">
29
+{body}
30
+</pre>
31
+</body>
32
+</html>
33
+"""
34
+
35
+
36
+def tex_to_html(tex_path: Path) -> Path:
37
+    tex_content = tex_path.read_text(encoding="utf-8")
38
+    start = tex_content.find("\\begin{document}") + len("\\begin{document}")
39
+    end = tex_content.find("\\end{document}")
40
+    body = tex_content[start:end].strip()
41
+
42
+    html_content = HTML_TEMPLATE.format(
43
+        title=tex_path.stem,
44
+        body=body,
45
+    )
46
+    html_path = tex_path.with_suffix(".html")
47
+    html_path.write_text(html_content, encoding="utf-8")
48
+    return html_path
49
+
50
+
51
+if __name__ == "__main__":
52
+    for p in sys.argv[1:]:
53
+        result = tex_to_html(Path(p))
54
+        print(f"HTML: {result}")

+ 3
- 0
src/markdown/__init__.py Dosyayı Görüntüle

@@ -0,0 +1,3 @@
1
+from src.markdown.generator import generate_markdown, validate_markdown
2
+
3
+__all__ = ["generate_markdown", "validate_markdown"]

+ 95
- 0
src/markdown/generator.py Dosyayı Görüntüle

@@ -0,0 +1,95 @@
1
+from __future__ import annotations
2
+
3
+import logging
4
+import re
5
+from pathlib import Path
6
+
7
+from src.ocr.paddle_engine import ParsedBlock
8
+
9
+logger = logging.getLogger(__name__)
10
+
11
+
12
+def _validate_latex_braces(content: str) -> list[str]:
13
+    warnings: list[str] = []
14
+    depth = 0
15
+    for i, ch in enumerate(content):
16
+        if ch == "{":
17
+            depth += 1
18
+        elif ch == "}":
19
+            depth -= 1
20
+            if depth < 0:
21
+                warnings.append(f"Лишняя }} на позиции {i}")
22
+                depth = 0
23
+    if depth > 0:
24
+        warnings.append(f"Непарные {{: {depth} лишних открывающих")
25
+    return warnings
26
+
27
+
28
+def _validate_dollar_signs(content: str) -> list[str]:
29
+    warnings: list[str] = []
30
+    dollar_count = 0
31
+    i = 0
32
+    while i < len(content):
33
+        if content[i] == "$":
34
+            if i + 1 < len(content) and content[i + 1] == "$":
35
+                dollar_count += 2
36
+                i += 2
37
+                continue
38
+            dollar_count += 1
39
+        i += 1
40
+    if dollar_count % 2 != 0:
41
+        warnings.append(f"Непарные знаки $: {dollar_count}")
42
+    return warnings
43
+
44
+
45
+def _validate_left_right(content: str) -> list[str]:
46
+    warnings: list[str] = []
47
+    left_count = content.count("\\left")
48
+    right_count = content.count("\\right")
49
+    if left_count != right_count:
50
+        warnings.append(f"Непарные \\left/\\right: {left_count}/{right_count}")
51
+    return warnings
52
+
53
+
54
+def validate_markdown(md_content: str) -> list[str]:
55
+    warnings: list[str] = []
56
+    warnings.extend(_validate_dollar_signs(md_content))
57
+    warnings.extend(_validate_left_right(md_content))
58
+
59
+    for block in re.findall(r"\$\$(.+?)\$\$", md_content, re.DOTALL):
60
+        warnings.extend(_validate_latex_braces(block))
61
+    for block in re.findall(r"\$(.+?)\$", md_content):
62
+        warnings.extend(_validate_latex_braces(block))
63
+    return warnings
64
+
65
+
66
+def _blocks_to_markdown(blocks: list[ParsedBlock]) -> str:
67
+    lines: list[str] = []
68
+    for block in blocks:
69
+        text = block.content.strip()
70
+        if not text:
71
+            continue
72
+
73
+        if block.label == "formula":
74
+            lines.append(f"$$\n{text}\n$$\n")
75
+        elif block.label in ("paragraph_title", "title", "section_header"):
76
+            lines.append(f"\n## {text}\n")
77
+        elif block.label == "number":
78
+            lines.append(f"\n{text}\n")
79
+        else:
80
+            lines.append(f"\n{text}\n")
81
+    return "\n".join(lines)
82
+
83
+
84
+def generate_markdown(blocks: list[ParsedBlock], output_path: Path) -> Path:
85
+    md_content = _blocks_to_markdown(blocks)
86
+
87
+    warnings = validate_markdown(md_content)
88
+    if warnings:
89
+        logger.warning("Ошибки валидации LaTeX:")
90
+        for w in warnings:
91
+            logger.warning("  %s", w)
92
+
93
+    output_path.parent.mkdir(parents=True, exist_ok=True)
94
+    output_path.write_text(md_content, encoding="utf-8")
95
+    return output_path

+ 3
- 0
src/ocr/__init__.py Dosyayı Görüntüle

@@ -0,0 +1,3 @@
1
+from src.ocr.paddle_engine import OcrPageResult, ParsedBlock, ocr_image
2
+
3
+__all__ = ["OcrPageResult", "ParsedBlock", "ocr_image"]

+ 88
- 0
src/ocr/paddle_engine.py Dosyayı Görüntüle

@@ -0,0 +1,88 @@
1
+from __future__ import annotations
2
+
3
+import importlib.resources
4
+from dataclasses import dataclass, field
5
+from typing import Any
6
+
7
+import cv2
8
+import yaml
9
+from paddlex import create_pipeline
10
+
11
+
12
+@dataclass
13
+class ParsedBlock:
14
+    label: str
15
+    content: str
16
+    bbox: tuple[int, int, int, int]
17
+    confidence: float = 0.9
18
+
19
+
20
+@dataclass
21
+class OcrPageResult:
22
+    blocks: list[ParsedBlock] = field(default_factory=list)
23
+    raw_json: dict[str, Any] = field(default_factory=dict)
24
+    width: int = 0
25
+    height: int = 0
26
+
27
+
28
+def _load_config() -> dict[str, Any]:
29
+    ref = importlib.resources.files("paddlex") / "configs" / "pipelines" / "PP-StructureV3.yaml"
30
+    with importlib.resources.as_file(ref) as path, open(path) as f:
31
+        config: dict[str, Any] = yaml.safe_load(f)
32
+
33
+    config["SubPipelines"]["GeneralOCR"]["SubModules"]["TextRecognition"]["model_name"] = (
34
+        "eslav_PP-OCRv5_mobile_rec"
35
+    )
36
+    table_ocr = config["SubPipelines"]["TableRecognition"]["SubPipelines"]["GeneralOCR"]
37
+    table_ocr["SubModules"]["TextRecognition"]["model_name"] = "eslav_PP-OCRv5_mobile_rec"
38
+    return config
39
+
40
+
41
+class OcrEngine:
42
+    def __init__(self) -> None:
43
+        self._config: dict[str, Any] = _load_config()
44
+
45
+    def process(self, image_path: str) -> OcrPageResult:
46
+        image = cv2.imread(image_path)
47
+        if image is None:
48
+            raise FileNotFoundError(f"Не удалось загрузить изображение: {image_path}")
49
+
50
+        h, w = image.shape[:2]
51
+        pipeline = create_pipeline(
52
+            config=self._config,
53
+            use_doc_orientation_classify=False,
54
+            use_doc_unwarping=False,
55
+        )
56
+        raw_results = list(pipeline.predict(image))
57
+
58
+        blocks, raw_json = self._parse_results(raw_results)
59
+        return OcrPageResult(blocks=blocks, raw_json=raw_json, width=w, height=h)
60
+
61
+    @staticmethod
62
+    def _parse_results(raw_results: list[Any]) -> tuple[list[ParsedBlock], dict[str, Any]]:
63
+        if not raw_results:
64
+            return [], {}
65
+
66
+        raw_json: dict[str, Any] = raw_results[0].json["res"]
67
+        blocks: list[ParsedBlock] = []
68
+        for item in raw_json.get("parsing_res_list", []):
69
+            text = item.get("block_content", "")
70
+            label = item.get("block_label", "text")
71
+            bbox = item.get("block_bbox", [0, 0, 0, 0])
72
+            if not text.strip():
73
+                continue
74
+            blocks.append(ParsedBlock(
75
+                label=label, content=text,
76
+                bbox=(int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])),
77
+            ))
78
+        return blocks, raw_json
79
+
80
+
81
+_engine: OcrEngine | None = None
82
+
83
+
84
+def ocr_image(image_path: str) -> OcrPageResult:
85
+    global _engine
86
+    if _engine is None:
87
+        _engine = OcrEngine()
88
+    return _engine.process(image_path)

+ 101
- 0
src/ocr/surya_engine.py Dosyayı Görüntüle

@@ -0,0 +1,101 @@
1
+from __future__ import annotations
2
+
3
+import logging
4
+import os
5
+import shutil
6
+from pathlib import Path
7
+
8
+from src.ocr.paddle_engine import OcrPageResult, ParsedBlock
9
+
10
+logger = logging.getLogger(__name__)
11
+
12
+_ENV_LOADED: bool = False
13
+
14
+
15
+def _load_env() -> None:
16
+    global _ENV_LOADED
17
+    if _ENV_LOADED:
18
+        return
19
+    env_path = Path(__file__).resolve().parent.parent.parent / ".env"
20
+    if env_path.exists():
21
+        with open(env_path) as f:
22
+            for line in f:
23
+                line = line.strip()
24
+                if line and not line.startswith("#") and "=" in line:
25
+                    key, _, value = line.partition("=")
26
+                    key = key.strip()
27
+                    if key not in os.environ:
28
+                        os.environ[key] = value.strip().strip("\"'")
29
+    _ENV_LOADED = True
30
+
31
+
32
+def _ensure_env() -> None:
33
+    _load_env()
34
+    os.environ.setdefault("SURYA_INFERENCE_BACKEND", "llamacpp")
35
+    os.environ.setdefault("SURYA_GUIDED_LAYOUT", "false")
36
+    if "LLAMA_CPP_BINARY" not in os.environ:
37
+        binary = shutil.which("llama-server") or os.path.expanduser(
38
+            "~/.local/bin/llama-server"
39
+        )
40
+        if Path(binary).exists():
41
+            os.environ["LLAMA_CPP_BINARY"] = binary
42
+
43
+
44
+class SuryaEngine:
45
+    """OCR engine using Surya 2 VLM via llama.cpp."""
46
+
47
+    def __init__(self) -> None:
48
+        _ensure_env()
49
+        try:
50
+            from surya.inference import SuryaInferenceManager
51
+            from surya.recognition import RecognitionPredictor
52
+
53
+            self._manager = SuryaInferenceManager()
54
+            self._predictor = RecognitionPredictor(self._manager)
55
+        except ImportError as e:
56
+            msg = (
57
+                "Surya engine not available. Install: pip install surya-ocr torch"
58
+            )
59
+            raise ImportError(msg) from e
60
+
61
+    def process(self, image_path: str) -> OcrPageResult:
62
+        from PIL import Image
63
+
64
+        image = Image.open(image_path)
65
+        results = self._predictor([image])
66
+
67
+        blocks: list[ParsedBlock] = []
68
+        raw_json: dict = {}
69
+
70
+        if results:
71
+            page = results[0]
72
+            for blk in getattr(page, "blocks", []):
73
+                html = getattr(blk, "html", "") or ""
74
+                label = getattr(blk, "label", "text")
75
+                bbox = getattr(blk, "bbox", [0, 0, 0, 0])
76
+                confidence = float(getattr(blk, "confidence", 0.9))
77
+                blocks.append(
78
+                    ParsedBlock(
79
+                        label=label,
80
+                        content=html,
81
+                        bbox=(int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])),
82
+                        confidence=confidence,
83
+                    )
84
+                )
85
+
86
+        return OcrPageResult(
87
+            blocks=blocks,
88
+            raw_json=raw_json,
89
+            width=image.width,
90
+            height=image.height,
91
+        )
92
+
93
+
94
+_surya_engine: SuryaEngine | None = None
95
+
96
+
97
+def surya_ocr_image(image_path: str) -> OcrPageResult:
98
+    global _surya_engine
99
+    if _surya_engine is None:
100
+        _surya_engine = SuryaEngine()
101
+    return _surya_engine.process(image_path)

+ 3
- 0
src/postprocess/__init__.py Dosyayı Görüntüle

@@ -0,0 +1,3 @@
1
+from src.postprocess.fixups import fix_ocr_errors
2
+
3
+__all__ = ["fix_ocr_errors"]

+ 26
- 0
src/postprocess/corrector.py Dosyayı Görüntüle

@@ -0,0 +1,26 @@
1
+from __future__ import annotations
2
+
3
+import logging
4
+from typing import Protocol
5
+
6
+from src.ocr.paddle_engine import ParsedBlock
7
+
8
+logger = logging.getLogger(__name__)
9
+
10
+
11
+class Corrector(Protocol):
12
+    def __call__(self, block: ParsedBlock) -> ParsedBlock: ...
13
+
14
+
15
+def apply_corrector(
16
+    blocks: list[ParsedBlock],
17
+    corrector: Corrector,
18
+    name: str,
19
+    skip_formulas: bool = True,
20
+) -> None:
21
+    for i, block in enumerate(blocks):
22
+        if skip_formulas and block.label == "formula":
23
+            continue
24
+        logger.info("%s: блок %d/%d...", name, i + 1, len(blocks))
25
+        corrector(block)
26
+    logger.info("%s: завершено", name)

+ 23
- 0
src/postprocess/fixups.py Dosyayı Görüntüle

@@ -0,0 +1,23 @@
1
+from __future__ import annotations
2
+
3
+import re
4
+
5
+from src.ocr.paddle_engine import ParsedBlock
6
+
7
+_SPACED_VARS = re.compile(r"\b([A-Z])\s+([A-Z])\b")
8
+
9
+
10
+def fix_ocr_errors(blocks: list[ParsedBlock]) -> list[ParsedBlock]:
11
+    for block in blocks:
12
+        if block.label == "formula":
13
+            block.content = _fix_formula_errors(block.content)
14
+    return blocks
15
+
16
+
17
+def _fix_formula_errors(content: str) -> str:
18
+    for _ in range(4):
19
+        prev = content
20
+        content = _SPACED_VARS.sub(r"\1\2", content)
21
+        if prev == content:
22
+            break
23
+    return content

+ 47
- 0
src/postprocess/llm_corrector.py Dosyayı Görüntüle

@@ -0,0 +1,47 @@
1
+from __future__ import annotations
2
+
3
+import os
4
+
5
+from llama_cpp import Llama
6
+
7
+from src.ocr.paddle_engine import ParsedBlock
8
+
9
+DEFAULT_LLM_PATH = os.path.expanduser("~/.cache/llama_models/Qwen2.5-7B-Instruct-Q4_K_M.gguf")
10
+
11
+
12
+class LlmCorrector:
13
+    def __init__(self, model_path: str = DEFAULT_LLM_PATH) -> None:
14
+        self._llm = Llama(
15
+            model_path=model_path,
16
+            n_ctx=1024,
17
+            n_threads=8,
18
+            verbose=False,
19
+        )
20
+
21
+    def __call__(self, block: ParsedBlock) -> ParsedBlock:
22
+        if block.label == "formula":
23
+            return block
24
+        if len(block.content) < 30:
25
+            return block
26
+
27
+        text = block.content[:400]
28
+        messages = [
29
+            {"role": "system", "content": "Ты корректор OCR-ошибок. Возвращай ТОЛЬКО исправленный текст."},
30
+            {"role": "user", "content": f"Исправь:\n\n{text}"},
31
+        ]
32
+        response = self._llm.create_chat_completion(messages=messages, max_tokens=150, temperature=0.0)
33
+        corrected = response["choices"][0]["message"]["content"].strip()
34
+
35
+        if corrected and len(corrected) > 10 and corrected != text:
36
+            block.content = corrected
37
+        return block
38
+
39
+
40
+_llm_corrector: LlmCorrector | None = None
41
+
42
+
43
+def llm_correct_block(block: ParsedBlock) -> ParsedBlock:
44
+    global _llm_corrector
45
+    if _llm_corrector is None:
46
+        _llm_corrector = LlmCorrector()
47
+    return _llm_corrector(block)

+ 145
- 0
src/postprocess/vlm_corrector.py Dosyayı Görüntüle

@@ -0,0 +1,145 @@
1
+from __future__ import annotations
2
+
3
+import base64
4
+import logging
5
+import os
6
+import time as _time
7
+from pathlib import Path
8
+from typing import Any
9
+
10
+import cv2
11
+import requests
12
+
13
+from src.ocr.paddle_engine import ParsedBlock
14
+
15
+logger = logging.getLogger(__name__)
16
+
17
+API_KEY_ENV: str = "OPENCODE_API_KEY"
18
+API_URL_ENV: str = "OPENCODE_API_URL"
19
+DEFAULT_MODEL: str = "qwen3.8-max"
20
+
21
+_ENV_LOADED: bool = False
22
+
23
+
24
+def _load_env() -> None:
25
+    global _ENV_LOADED
26
+    if _ENV_LOADED:
27
+        return
28
+    env_path = Path(__file__).resolve().parent.parent.parent / ".env"
29
+    if env_path.exists():
30
+        with open(env_path) as f:
31
+            for line in f:
32
+                line = line.strip()
33
+                if line and not line.startswith("#") and "=" in line:
34
+                    key, _, value = line.partition("=")
35
+                    if key.strip() not in os.environ:
36
+                        os.environ[key.strip()] = value.strip().strip("\"'")
37
+    _ENV_LOADED = True
38
+
39
+
40
+def _get_api_key() -> str:
41
+    _load_env()
42
+    return os.environ.get(API_KEY_ENV, "")
43
+
44
+
45
+def _get_api_url() -> str:
46
+    return os.environ.get(API_URL_ENV, "https://opencode.ai/zen/go/v1/messages")
47
+
48
+SYSTEM_PROMPT: str = (
49
+    "Extract ALL visible text from this document image region. "
50
+    "Preserve LaTeX math ($...$ and $$...$$). "
51
+    "Return ONLY the corrected text, no commentary."
52
+)
53
+
54
+
55
+def _image_to_base64(image: Any) -> str:
56
+    _, buffer = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 90])
57
+    return base64.b64encode(buffer).decode("utf-8")
58
+
59
+
60
+def vlm_correct_block(block: ParsedBlock, image_path: str, model: str = DEFAULT_MODEL) -> ParsedBlock:
61
+    if block.label == "formula":
62
+        return block
63
+    if not _get_api_key():
64
+        logger.warning("VLM: %s не задан, пропускаем", API_KEY_ENV)
65
+        return block
66
+
67
+    image = cv2.imread(image_path)
68
+    if image is None:
69
+        return block
70
+
71
+    h, w = image.shape[:2]
72
+    x1, y1, x2, y2 = block.bbox
73
+    x1 = max(0, x1 - 5)
74
+    y1 = max(0, y1 - 5)
75
+    x2 = min(w, x2 + 5)
76
+    y2 = min(h, y2 + 5)
77
+    if x1 >= x2 or y1 >= y2:
78
+        return block
79
+
80
+    crop = image[y1:y2, x1:x2]
81
+    crop_h, crop_w = crop.shape[:2]
82
+    logger.info("VLM: отправка региона %dx%d px (bbox %d,%d,%d,%d)", crop_w, crop_h, x1, y1, x2, y2)
83
+    b64 = _image_to_base64(crop)
84
+
85
+    messages = [
86
+        {
87
+            "role": "user",
88
+            "content": [
89
+                {
90
+                    "type": "image",
91
+                    "source": {
92
+                        "type": "base64",
93
+                        "media_type": "image/jpeg",
94
+                        "data": b64,
95
+                    },
96
+                },
97
+                {
98
+                    "type": "text",
99
+                    "text": f"{SYSTEM_PROMPT}\n\nExtract all text from this image region. Return ONLY the extracted text.",
100
+                },
101
+            ],
102
+        },
103
+    ]
104
+
105
+    for attempt in (1, 2):
106
+        try:
107
+            response = requests.post(
108
+                _get_api_url(),
109
+                headers={
110
+                    "x-api-key": _get_api_key(),
111
+                    "anthropic-version": "2023-06-01",
112
+                    "Content-Type": "application/json",
113
+                },
114
+                json={
115
+                    "model": model,
116
+                    "max_tokens": 300,
117
+                    "messages": messages,
118
+                },
119
+                timeout=120,
120
+            )
121
+            response.raise_for_status()
122
+            data = response.json()
123
+            text = ""
124
+            for item in data.get("content", []):
125
+                if item.get("type") == "text":
126
+                    text = item.get("text", "").strip()
127
+                    break
128
+            if text and len(text) > 5:
129
+                block.content = text
130
+                logger.info("VLM: результат (%d символов): %s", len(text), text[:120])
131
+            else:
132
+                logger.info("VLM: пустой или короткий ответ (%d символов)", len(text) if text else 0)
133
+            return block
134
+        except requests.HTTPError as e:
135
+            if e.response is not None and e.response.status_code == 500 and attempt == 1:
136
+                logger.info("VLM: HTTP 500, ожидание 5с и повтор...")
137
+                _time.sleep(5)
138
+                continue
139
+            logger.warning("VLM: ошибка — %s", e)
140
+            return block
141
+        except (requests.RequestException, KeyError, IndexError) as e:
142
+            logger.warning("VLM: ошибка — %s", e)
143
+            return block
144
+
145
+    return block

+ 188
- 0
stage2_OCR_updates.md Dosyayı Görüntüle

@@ -0,0 +1,188 @@
1
+# Требования к улучшению генерации Markdown
2
+
3
+## Цель
4
+
5
+Повысить качество Markdown, генерируемого из PDF с помощью PP-StructureV3, чтобы результат можно было использовать для:
6
+
7
+- просмотра в браузере;
8
+- последующего анализа LLM;
9
+- конвертации в HTML;
10
+- корректного отображения формул через MathJax/KaTeX.
11
+
12
+---
13
+
14
+# 1. Формулы
15
+
16
+## Display-формулы
17
+
18
+- Все блочные формулы должны сохраняться в исходном LaTeX, полученном от PP-StructureV3.
19
+- Формулы не должны изменяться или экранироваться.
20
+- Использовать стандартный синтаксис
21
+
22
+```markdown
23
+$$
24
+...
25
+$$
26
+```
27
+
28
+---
29
+
30
+## Inline-формулы
31
+
32
+Текущая проблема:
33
+
34
+```markdown
35
+\$R=r\^{}\{2\}\$
36
+```
37
+
38
+или
39
+
40
+```markdown
41
+\textbackslash{}sim
42
+```
43
+
44
+Такой вывод недопустим.
45
+
46
+Требования:
47
+
48
+- inline-формулы должны сохраняться как
49
+
50
+```markdown
51
+$R=r^{2}$
52
+```
53
+
54
+или
55
+
56
+```markdown
57
+$u_i \sim N(0,1)$
58
+```
59
+
60
+- не использовать `\textbackslash{}`
61
+- не использовать `\$`
62
+- не использовать экранирование LaTeX-команд
63
+
64
+---
65
+
66
+# 2. Не преобразовывать LaTeX в текст
67
+
68
+Запрещается преобразовывать
69
+
70
+```latex
71
+\alpha
72
+\sqrt
73
+\frac
74
+\sim
75
+\left
76
+\right
77
+```
78
+
79
80
+
81
+```text
82
+\textbackslash{}alpha
83
+\textbackslash{}sqrt
84
+...
85
+```
86
+
87
+Markdown должен содержать настоящий LaTeX.
88
+
89
+---
90
+
91
+# 3. OCR-постобработка формул
92
+
93
+Исправлять наиболее типичные OCR-ошибки.
94
+
95
+Например
96
+
97
+| Было | Должно стать |
98
+|------|--------------|
99
+| `V a R` | `VaR` |
100
+| `P D` | `PD` |
101
+| `N ^ {-1}` | `N^{-1}` |
102
+| `l` вместо `1` | исправлять при высокой уверенности |
103
+| `O` вместо `0` | исправлять при высокой уверенности |
104
+
105
+---
106
+
107
+# 4. Inline-формулы внутри текста
108
+
109
+По возможности выделять математические выражения внутри абзацев.
110
+
111
+Например
112
+
113
+```text
114
+where u_i ~ N(0,1)
115
+```
116
+
117
+↓
118
+
119
+```markdown
120
+where $u_i \sim N(0,1)$
121
+```
122
+
123
+Даже если PP-StructureV3 не выделил их отдельным Formula Block.
124
+
125
+---
126
+
127
+# 5. Не генерировать TeX-документ
128
+
129
+Не использовать экспорт в полноценный `.tex`.
130
+
131
+Целевой формат — Markdown.
132
+
133
+Формулы должны быть совместимы с MathJax и KaTeX.
134
+
135
+---
136
+
137
+# 6. Проверка корректности LaTeX
138
+
139
+Перед сохранением желательно проверить:
140
+
141
+- баланс фигурных скобок;
142
+- баланс `\left...\right`;
143
+- парность `$`;
144
+- отсутствие повреждённых команд.
145
+
146
+Некорректный LaTeX должен логироваться.
147
+
148
+---
149
+
150
+# 7. Качество Markdown
151
+
152
+Запрещается появление конструкций вида
153
+
154
+```text
155
+\textbackslash{}
156
+\$
157
+\_
158
+```
159
+
160
+если они являются следствием экранирования LaTeX.
161
+
162
+В итоговом Markdown должен оставаться исходный LaTeX.
163
+
164
+---
165
+
166
+# 8. HTML
167
+
168
+Markdown должен без дополнительной обработки корректно преобразовываться в HTML с использованием MathJax или KaTeX.
169
+
170
+После конвертации все формулы должны отображаться без ручного исправления.
171
+
172
+---
173
+
174
+# 9. Разделить этапы OCR и генерации Markdown
175
+
176
+Не смешивать OCR, исправление текста и генерацию Markdown в одном проходе.
177
+
178
+Рекомендуемый pipeline:
179
+
180
+1. PP-StructureV3
181
+2. OCR результатов
182
+3. Постобработка OCR
183
+4. Исправление типичных ошибок LaTeX
184
+5. Генерация Markdown
185
+6. Валидация Markdown
186
+7. Конвертация в HTML
187
+
188
+Это позволит независимо улучшать качество OCR, постобработки и форматирования без изменения остальных этапов.

+ 293
- 0
stage2_OCR_updates2.md Dosyayı Görüntüle

@@ -0,0 +1,293 @@
1
+# Оценка качества OCR и требования к улучшению
2
+
3
+## Общая оценка
4
+
5
+### Что получилось хорошо
6
+
7
+✅ Структура документа (layout) определяется качественно.
8
+
9
+✅ Порядок чтения блоков в целом корректный.
10
+
11
+✅ Таблицы извлекаются хорошо.
12
+
13
+✅ Display-формулы (отдельные блочные формулы) успешно выделяются и экспортируются в LaTeX.
14
+
15
+Например:
16
+
17
+```latex
18
+A_{i}=Z\cdot r+u_{i}\cdot\sqrt{1-R}
19
+```
20
+
21
+является корректным LaTeX и без проблем отображается в MathJax/KaTeX.
22
+
23
+---
24
+
25
+## Основные проблемы
26
+
27
+### 1. Inline-формулы практически не распознаются
28
+
29
+Это самая серьёзная проблема текущего pipeline.
30
+
31
+Формулы вида
32
+
33
+```text
34
+u_i ~ N(0,1)
35
+
36
+R = r²
37
+
38
+r = corr(...)
39
+```
40
+
41
+не выделяются как математические выражения.
42
+
43
+Вместо этого они становятся частью обычного текста.
44
+
45
+Именно это является причиной большинства последующих ошибок.
46
+
47
+---
48
+
49
+### 2. LaTeX повреждается при генерации Markdown
50
+
51
+В результате появляются конструкции вида
52
+
53
+```text
54
+\$R=r\^{}\{2\}\$
55
+```
56
+
57
+```text
58
+\textbackslash{}sim
59
+```
60
+
61
+```text
62
+\textbackslash{}mathrm
63
+```
64
+
65
+```text
66
+A\_i
67
+```
68
+
69
+Это уже невалидный LaTeX.
70
+
71
+MathJax здесь не является причиной проблемы — он получает уже повреждённый текст.
72
+
73
+---
74
+
75
+### 3. Избыточное экранирование
76
+
77
+Необходимо полностью исключить появление:
78
+
79
+```
80
+\$
81
+\_
82
+\textbackslash{}
83
+```
84
+
85
+если они являются результатом сериализации LaTeX.
86
+
87
+Markdown должен содержать настоящий LaTeX, а не его текстовое представление.
88
+
89
+---
90
+
91
+### 4. OCR допускает типичные ошибки
92
+
93
+Например
94
+
95
+```
96
+V a R
97
+```
98
+
99
+вместо
100
+
101
+```
102
+VaR
103
+```
104
+
105
+```
106
+P D
107
+```
108
+
109
+вместо
110
+
111
+```
112
+PD
113
+```
114
+
115
+```
116
+vhere
117
+```
118
+
119
+вместо
120
+
121
+```
122
+where
123
+```
124
+
125
+```
126
+ndividual
127
+```
128
+
129
+вместо
130
+
131
+```
132
+Individual
133
+```
134
+
135
+Это снижает качество итогового документа.
136
+
137
+---
138
+
139
+# Требования к улучшению
140
+
141
+## Высокий приоритет
142
+
143
+### 1. Исправить обработку inline-формул
144
+
145
+Inline-математика должна сохраняться как
146
+
147
+```markdown
148
+$u_i \sim N(0,1)$
149
+```
150
+
151
+а не как текст
152
+
153
+```text
154
+\$u\_\{i\}\textbackslash{}sim...
155
+```
156
+
157
+---
158
+
159
+### 2. Не экранировать LaTeX
160
+
161
+Запрещается преобразовывать
162
+
163
+```latex
164
+\sqrt
165
+\frac
166
+\alpha
167
+\sim
168
+\mathrm
169
+```
170
+
171
172
+
173
+```text
174
+\textbackslash{}sqrt
175
+\textbackslash{}frac
176
+...
177
+```
178
+
179
+---
180
+
181
+### 3. Сохранять display-формулы без изменений
182
+
183
+LaTeX, полученный от PP-StructureV3, должен использоваться без модификации.
184
+
185
+Формат вывода:
186
+
187
+```markdown
188
+$$
189
+...
190
+$$
191
+```
192
+
193
+---
194
+
195
+### 4. Улучшить OCR-постобработку
196
+
197
+Исправлять наиболее типичные ошибки OCR.
198
+
199
+Например:
200
+
201
+| Было | Должно стать |
202
+|------|--------------|
203
+| `V a R` | `VaR` |
204
+| `P D` | `PD` |
205
+| `N ^ {-1}` | `N^{-1}` |
206
+| `l` → `1` | при высокой уверенности |
207
+| `O` → `0` | при высокой уверенности |
208
+
209
+---
210
+
211
+## Средний приоритет
212
+
213
+### 5. Валидировать LaTeX
214
+
215
+Перед сохранением желательно проверять:
216
+
217
+- баланс фигурных скобок;
218
+- баланс `\left...\right`;
219
+- баланс `$`;
220
+- корректность команд.
221
+
222
+---
223
+
224
+### 6. Генерировать "чистый" Markdown
225
+
226
+Итоговый Markdown должен сразу корректно отображаться через MathJax или KaTeX без дополнительной обработки.
227
+
228
+---
229
+
230
+# Предлагаемая архитектура pipeline
231
+
232
+Текущий pipeline смешивает OCR, обработку текста и генерацию Markdown.
233
+
234
+Предлагается разделить его на независимые этапы:
235
+
236
+```
237
+PDF
238
+    │
239
+    ▼
240
+PP-StructureV3
241
+    │
242
+    ▼
243
+Извлечение структуры документа
244
+    │
245
+    ├── текст
246
+    ├── таблицы
247
+    ├── изображения
248
+    └── display-формулы
249
+    │
250
+    ▼
251
+Постобработка OCR
252
+    │
253
+    ▼
254
+Выделение inline-формул
255
+    │
256
+    ▼
257
+Исправление типичных OCR-ошибок
258
+    │
259
+    ▼
260
+Генерация Markdown
261
+    │
262
+    ▼
263
+Валидация Markdown / LaTeX
264
+    │
265
+    ▼
266
+HTML + MathJax / KaTeX
267
+```
268
+
269
+---
270
+
271
+# Итоговая оценка
272
+
273
+| Компонент | Оценка |
274
+|-----------|:------:|
275
+| Layout | ⭐⭐⭐⭐⭐ |
276
+| Reading order | ⭐⭐⭐⭐⭐ |
277
+| Таблицы | ⭐⭐⭐⭐⭐ |
278
+| Display-формулы | ⭐⭐⭐⭐☆ |
279
+| Русский OCR | ⭐⭐⭐☆☆ |
280
+| Inline-формулы | ⭐☆☆☆☆ |
281
+| Генерация Markdown | ⭐⭐☆☆☆ |
282
+
283
+## Главный вывод
284
+
285
+Основная проблема текущего решения — не качество MathJax и не display-формул.
286
+
287
+Критические недостатки находятся в двух местах:
288
+
289
+1. **inline-математические выражения не выделяются как отдельные математические объекты;**
290
+2. **при генерации Markdown происходит повреждение LaTeX из-за его экранирования.**
291
+
292
+До устранения этих проблем невозможно получить Markdown, пригодный для качественного отображения формул и последующего анализа LLM.
293
+

+ 209
- 0
stage2_research.md Dosyayı Görüntüle

@@ -0,0 +1,209 @@
1
+# Stage 2. Исследование возможностей PaddleOCR + Docling
2
+
3
+## Цель исследования
4
+
5
+Перед началом реализации второго этапа проекта необходимо изучить возможности современных версий PaddleOCR и Docling.
6
+
7
+Цель исследования — максимально использовать готовые возможности библиотек и не реализовывать функциональность, которая уже существует.
8
+
9
+По итогам исследования необходимо подготовить краткий технический отчёт и рекомендации по архитектуре второго этапа.
10
+
11
+---
12
+
13
+# Необходимо исследовать
14
+
15
+## 1. Совместимость PaddleOCR и Docling
16
+
17
+Необходимо определить:
18
+
19
+- может ли Docling использовать PaddleOCR как OCR backend;
20
+- какие версии библиотек совместимы между собой;
21
+- существуют ли официально поддерживаемые способы интеграции.
22
+
23
+---
24
+
25
+## 2. Форматы экспорта
26
+
27
+Необходимо определить, какие форматы умеет генерировать Docling.
28
+
29
+Для каждого формата необходимо указать:
30
+
31
+- поддерживается ли официально;
32
+- степень сохранения структуры документа;
33
+- пригодность для последующего анализа LLM.
34
+
35
+Минимально исследовать:
36
+
37
+- HTML
38
+- Markdown
39
+- JSON
40
+- DocTags
41
+- Plain Text
42
+
43
+---
44
+
45
+## 3. Качество HTML
46
+
47
+Определить:
48
+
49
+- насколько полно сохраняется структура документа;
50
+- сохраняются ли таблицы;
51
+- сохраняются ли объединённые ячейки;
52
+- сохраняются ли изображения;
53
+- сохраняются ли подписи рисунков;
54
+- сохраняются ли заголовки;
55
+- сохраняются ли списки.
56
+
57
+Приложить небольшой пример HTML.
58
+
59
+---
60
+
61
+## 4. Таблицы
62
+
63
+Исследовать:
64
+
65
+- качество восстановления таблиц;
66
+- поддержку rowspan;
67
+- поддержку colspan;
68
+- возможность восстановления сложных таблиц.
69
+
70
+При наличии ограничений подробно их описать.
71
+
72
+---
73
+
74
+## 5. Формулы
75
+
76
+Определить:
77
+
78
+- распознаёт ли Docling математические формулы;
79
+- сохраняются ли формулы как текст;
80
+- сохраняются ли как изображения;
81
+- поддерживается ли MathML;
82
+- поддерживается ли LaTeX.
83
+
84
+При наличии ограничений описать их.
85
+
86
+---
87
+
88
+## 6. OCR Confidence
89
+
90
+Определить:
91
+
92
+- предоставляет ли PaddleOCR confidence;
93
+- может ли Docling сохранить confidence;
94
+- можно ли передать confidence в HTML;
95
+- можно ли использовать confidence для последующей подсветки сомнительных мест.
96
+
97
+---
98
+
99
+## 7. Координаты элементов
100
+
101
+Определить:
102
+
103
+можно ли получить координаты:
104
+
105
+- страниц;
106
+- абзацев;
107
+- таблиц;
108
+- строк;
109
+- отдельных слов.
110
+
111
+---
112
+
113
+## 8. Производительность
114
+
115
+Для документов объёмом около 150 страниц определить:
116
+
117
+- примерное время обработки;
118
+- объём используемой памяти;
119
+- возможность пакетной обработки.
120
+
121
+---
122
+
123
+## 9. Работа полностью offline
124
+
125
+Подтвердить, что:
126
+
127
+- PaddleOCR работает полностью offline;
128
+- Docling работает полностью offline;
129
+- отсутствуют обращения к облачным сервисам.
130
+
131
+---
132
+
133
+## 10. Возможность ручной корректировки
134
+
135
+Определить:
136
+
137
+Можно ли после генерации HTML:
138
+
139
+- открыть его в браузере;
140
+- открыть в Microsoft Word;
141
+- открыть в LibreOffice Writer;
142
+- исправить ошибки OCR;
143
+- сохранить обратно без существенной потери структуры.
144
+
145
+---
146
+
147
+## 11. Минимальный пример
148
+
149
+Подготовить минимальный рабочий пример.
150
+
151
+Pipeline должен выглядеть следующим образом:
152
+
153
+```
154
+JPEG страницы
155
+
156
+↓
157
+
158
+PaddleOCR
159
+
160
+↓
161
+
162
+Docling
163
+
164
+↓
165
+
166
+HTML
167
+```
168
+
169
+Необходимо показать:
170
+
171
+- пример входного изображения;
172
+- пример кода;
173
+- пример полученного HTML.
174
+
175
+---
176
+
177
+# Что НЕ требуется
178
+
179
+На данном этапе не требуется:
180
+
181
+- писать production-код;
182
+- проектировать архитектуру проекта;
183
+- реализовывать CLI;
184
+- реализовывать HTML-генератор;
185
+- реализовывать OCR самостоятельно.
186
+
187
+---
188
+
189
+# Результат исследования
190
+
191
+По окончании исследования необходимо подготовить документ:
192
+
193
+```
194
+STAGE2_RESEARCH_RESULT.md
195
+```
196
+
197
+Документ должен содержать:
198
+
199
+1. Краткое описание возможностей PaddleOCR.
200
+2. Краткое описание возможностей Docling.
201
+3. Выявленные ограничения.
202
+4. Рекомендуемую архитектуру второго этапа.
203
+5. Минимальный рабочий пример интеграции.
204
+6. Перечень функций, которые уже реализованы библиотеками и не требуют собственной разработки.
205
+7. Перечень функций, которые придётся реализовать самостоятельно.
206
+8. Итоговую рекомендацию по реализации второго этапа проекта.
207
+
208
+Главная цель исследования — минимизировать объём собственного кода за счёт максимально полного использования возможностей PaddleOCR и Docling.
209
+

+ 217
- 0
stage2_results.md Dosyayı Görüntüle

@@ -0,0 +1,217 @@
1
+# Stage 2 Results — OCR Pipeline
2
+
3
+## Путь проекта
4
+
5
+### Исходная точка
6
+Единый скрипт `src/cli.py` → разрезание изображений на страницы (Stage 1). Нужен Stage 2 — распознавание текста и структуры документа, генерация LaTeX/HTML/Markdown для последующего анализа LLM.
7
+
8
+---
9
+
10
+## Опробованные варианты
11
+
12
+### 1. PaddleOCR базовый (PP-OCRv5)
13
+**Результат:** 66 текстовых блоков, русский текст — ~60% точности.
14
+- `i-WHAeKC AOrOBOpa` вместо `і - индекс договора`
15
+- `t - номер месяцаB дефолте` — латиница смешана с кириллицей
16
+- Нет структуры документа (заголовки, формулы, таблицы слиты в сплошной текст)
17
+- **Вывод:** только для английского, для русского требуется структура
18
+
19
+### 2. PP-StructureV3 (ансамбль специализированных моделей)
20
+**Результат:** 22 структурированных блока с layout-разметкой.
21
+- Layout detection: ✅ `[number]`, `[formula]`, `[text]`, `[paragraph_title]`
22
+- Display-формулы: ✅ `\mathrm{C Rec}_{\mathrm{it}}=\sum...`
23
+- Таблицы: ✅ (PP-TableMagic)
24
+- Русский текст: ⚠️ ~85% (eslav_PP-OCRv5_mobile_rec — мобильная модель)
25
+- **Вывод:** структура отлично, текст — приемлемо
26
+
27
+### 3. Замена модели на cyrillic_PP-OCRv5_mobile_rec
28
+**Результат:** разница ~1-2%, несущественна.
29
+- eslav: `LGD_ехtг;` (кириллица г)
30
+- cyrillic: `LGD_ехtr;` (латиница r) — чуть лучше
31
+- **Вывод:** обе мобильные модели с одинаковыми ограничениями
32
+
33
+### 4. PP-OCRv5_server_rec / PP-OCRv6
34
+**Результат:** ❌ не поддерживают русский язык.
35
+- Server model: только CN/JP/EN
36
+- PP-OCRv6: 52 латинских языка, русского нет
37
+- **Вывод:** серверной русской модели в PaddleOCR не существует
38
+
39
+### 5. Локальный LLM-корректор (Qwen2.5-7B GGUF)
40
+**Результат:** +25% читаемости текста.
41
+```
42
+До:   t - номер месяцаB дефолте, на который прогнозируются возвраты
43
+После: т — номер месяца в диапазоне между текущим сроком в дефолте и горизонтом взыскания
44
+```
45
+- ~4 tok/sec на CPU, ~20s/блок
46
+- **Вывод:** эффективен для пост-обработки
47
+
48
+### 6. VLM-корректор (Qwen3.8 Max через OpenCode API)
49
+**Результат:** полное восстановление текста + автоопределение inline-формул.
50
+```
51
+До:   ΣДсе, - дисконтированная сумма возвратов
52
+После: $\sum_t DCF_{i,t}$ — дисконтированная сумма возвратов, получаемых на t-ом периоде
53
+```
54
+- ~10s/блок, Anthropic API через `zen/go/v1/messages`
55
+- **Вывод:** лучшее качество, но требует API-доступа
56
+
57
+### 7. Docling (IBM Research)
58
+**Результат:** ❌ не поддерживает PaddleOCR как OCR-backend.
59
+- Поддерживает: EasyOCR, Tesseract, RapidOCR, Nemotron
60
+- Генерирует HTML/Markdown из коробки
61
+- **Вывод:** несовместим с нашим пайплайном
62
+
63
+### 8. PaddleOCR-VL (VLM-подход, ERNIE-4.5-0.3B)
64
+**Результат:** модель загружена (1.79 GB), но ~15 мин/стр на CPU.
65
+- Одна VLM вместо ансамбля: layout → распознавание единой моделью
66
+- Теоретически решает проблему русского (языковая модель понимает контекст)
67
+- **Вывод:** правильный подход при GPU, нереализуем на CPU
68
+
69
+### 9. Surya 2 (Datalab, 650M VLM)
70
+**Результат:** ⭐ лучший русский OCR из коробки.
71
+- 95%+ точность русского текста без коррекции
72
+- Автоопределение inline-формул: `<math>MRec_{it}</math>`
73
+- ~430s/стр на CPU (vs 140s у PaddleOCR, но качество несравнимо)
74
+- Вывод Markdown с `$$...$$` формулами
75
+- **Вывод:** лучший выбор при достаточных ресурсах
76
+
77
+---
78
+
79
+## Итоговая архитектура
80
+
81
+```
82
+                        Входное изображение (JPEG/PNG/TIFF)
83
+                                    │
84
+                      ┌─────────────┴─────────────┐
85
+                      │    src/image_split.py      │
86
+                      │  --slice / --slice-auto    │
87
+                      │  --pre-rotate, --border    │
88
+                      │  --post-crop (default ON)  │
89
+                      └─────────────┬─────────────┘
90
+                                    │
91
+                             JPEG страницы
92
+                                    │
93
+                      ┌─────────────┴─────────────┐
94
+                      │  src/image_to_latex.py     │
95
+                      │  --ocr-engine paddle/surya │
96
+                      │  --llm, --vlm (optional)   │
97
+                      └─────────────┬─────────────┘
98
+                                    │
99
+              ┌─────────────────────┼─────────────────────┐
100
+              ▼                     ▼                     ▼
101
+    ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
102
+    │  src/ocr/        │  │ src/postprocess/ │  │ src/postprocess/ │
103
+    │  paddle_engine   │  │ fixups.py        │  │ llm_corrector    │
104
+    │  surya_engine    │  │ vlm_corrector    │  │ corrector.py     │
105
+    └────────┬────────┘  └────────┬────────┘  └────────┬────────┘
106
+             │                    │                     │
107
+             └────────────────────┼─────────────────────┘
108
+                                  │
109
+                         ParsedBlock[]
110
+                                  │
111
+                    ┌─────────────┴─────────────┐
112
+                    │  src/markdown/             │
113
+                    │  generator.py              │
114
+                    │  Валидация $, {}, \left   │
115
+                    └─────────────┬─────────────┘
116
+                                  │
117
+              ┌───────────────────┼───────────────────┐
118
+              ▼                   ▼                   ▼
119
+         .json (raw)          .md              .tex + .html
120
+     (PP-StructureV3      (Markdown)      (LaTeX + MathJax)
121
+      полный дамп)
122
+```
123
+
124
+### Модули
125
+
126
+```
127
+src/
128
+  cli_utils.py           — run_main() общий для CLI
129
+  image_split.py         — CLI разрезания (Stage 1)
130
+  image_to_latex.py      — CLI OCR (Stage 2)
131
+  split/
132
+    slicer.py            — разрезание, поворот, рамка, post-crop
133
+  ocr/
134
+    paddle_engine.py     — PP-StructureV3 + кастом YAML (eslav)
135
+    surya_engine.py      — Surya 2 VLM + llama.cpp
136
+  postprocess/
137
+    fixups.py            — VaR, V^2
138
+    corrector.py         — Corrector Protocol + apply_corrector()
139
+    llm_corrector.py     — Qwen2.5-7B локально (llama-cpp-python)
140
+    vlm_corrector.py     — Qwen3.8 Max через OpenCode API (Anthropic)
141
+  markdown/
142
+    generator.py         — Markdown + валидация LaTeX
143
+  latex/
144
+    tex2html.py          — .tex → .html + MathJax
145
+```
146
+
147
+### Модели (локально, ~4 GB)
148
+
149
+```
150
+~/.paddlex/official_models/          (PaddleOCR, ~2.5 GB)
151
+  PP-DocLayout_plus-L                layout detection
152
+  PP-OCRv5_server_det                text detection
153
+  eslav_PP-OCRv5_mobile_rec          RU/EN recognition
154
+  PP-OCRv5_server_rec                EN/CN recognition
155
+  SLANeXt_wired                      table structure
156
+  PP-FormulaNet_plus-L               formula recognition (728 MB)
157
+
158
+~/.cache/llama_models/               (LLM, ~4.4 GB)
159
+  Qwen2.5-7B-Instruct-Q4_K_M.gguf    локальный корректор
160
+
161
+HF Hub (Surya 2, ~1.5 GB)
162
+  datalab-to/surya-ocr-2-gguf        650M VLM
163
+```
164
+
165
+---
166
+
167
+## Сравнение движков
168
+
169
+| Параметр | PaddleOCR (PP-StructV3) | Surya 2 |
170
+|----------|:---:|:---:|
171
+| Архитектура | Ансамбль 5 моделей | Один VLM (650M) |
172
+| Русский текст | ~85% | 95%+ |
173
+| Формулы | PP-FormulaNet (отлично) | VLM (отлично) |
174
+| Таблицы | PP-TableMagic + bbox | HTML `<table>` |
175
+| Inline-формулы | Не детектируются | `<math>` тэги |
176
+| Скорость CPU | ~140s | ~430s |
177
+| Размер моделей | ~2.5 GB | ~1.5 GB |
178
+| LLM-коррекция | Нужна для русского | Не нужна |
179
+| VLM-коррекция | Нужна для сложных мест | Не нужна |
180
+
181
+---
182
+
183
+## Ключевые цифры
184
+
185
+| Этап | PaddleOCR | Surya 2 |
186
+|------|:---:|:---:|
187
+| Image split + post-crop | 1-2s | 1-2s |
188
+| OCR | 140s | 430s |
189
+| LLM-корректор (Qwen2.5-7B) | ~20s/блок | не нужен |
190
+| VLM-корректор (Qwen3.8 Max) | ~10s/блок | не нужен |
191
+| Markdown генерация | <1s | <1s |
192
+| **Итого на страницу** | ~5 min | ~7 min |
193
+
194
+---
195
+
196
+## Неиспользованные подходы
197
+
198
+1. **EasyOCR** — WER 0.12 vs Paddle 0.056, хуже по метрикам
199
+2. **Tesseract** — нет поддержки GPU, хуже качество на русском
200
+3. **RapidOCR** — легче, но качество ниже PaddleOCR
201
+4. **OmniParser** — избыточен (YOLO + OCR), медленнее
202
+5. **Docling** — не поддерживает PaddleOCR как OCR-backend
203
+6. **Fine-tune модели** — требует GPU + размеченный датасет, не пробовали
204
+
205
+---
206
+
207
+## Выводы
208
+
209
+1. **PaddleOCR PP-StructureV3** — надёжный выбор для структуры документа и формул. Русский текст требует коррекции (LLM или VLM).
210
+
211
+2. **Surya 2** — лучший русский OCR из коробки, авто inline-формулы. Медленнее на CPU, но качество несравнимо выше.
212
+
213
+3. **Гибрид Surya 2 + PP-FormulaNet** — теоретически лучший вариант (Surya для текста, Paddle для формул), не реализован.
214
+
215
+4. **LLM/VLM коррекция** — эффективна для PaddleOCR, не нужна для Surya 2.
216
+
217
+5. **OpenCode Go API** — практичный способ подключить мощную VLM без локального GPU.

+ 113
- 0
tests/test_markdown.py Dosyayı Görüntüle

@@ -0,0 +1,113 @@
1
+from __future__ import annotations
2
+
3
+from pathlib import Path
4
+
5
+from src.markdown.generator import (
6
+    _validate_dollar_signs,
7
+    _validate_latex_braces,
8
+    _validate_left_right,
9
+    generate_markdown,
10
+    validate_markdown,
11
+)
12
+from src.ocr.paddle_engine import ParsedBlock
13
+
14
+
15
+class TestValidateBraces:
16
+    def test_balanced(self) -> None:
17
+        assert _validate_latex_braces(r"a{b}c") == []
18
+
19
+    def test_unbalanced_open(self) -> None:
20
+        warnings = _validate_latex_braces(r"a{b")
21
+        assert len(warnings) > 0
22
+
23
+    def test_unbalanced_close(self) -> None:
24
+        warnings = _validate_latex_braces(r"a}b")
25
+        assert len(warnings) > 0
26
+
27
+    def test_nested_balanced(self) -> None:
28
+        assert _validate_latex_braces(r"{a{b}c}") == []
29
+
30
+    def test_complex_formula(self) -> None:
31
+        assert _validate_latex_braces(r"\frac{a}{b}") == []
32
+
33
+
34
+class TestValidateDollar:
35
+    def test_balanced_inline(self) -> None:
36
+        assert _validate_dollar_signs("$a + b$") == []
37
+
38
+    def test_balanced_display(self) -> None:
39
+        assert _validate_dollar_signs(r"$$a + b$$") == []
40
+
41
+    def test_unbalanced_inline(self) -> None:
42
+        warnings = _validate_dollar_signs("$a + b")
43
+        assert len(warnings) > 0
44
+
45
+    def test_balanced_multiple(self) -> None:
46
+        assert _validate_dollar_signs("$x$ and $y$") == []
47
+
48
+    def test_unbalanced_multiple(self) -> None:
49
+        warnings = _validate_dollar_signs("$x and $y$")
50
+        assert len(warnings) > 0
51
+
52
+
53
+class TestValidateLeftRight:
54
+    def test_balanced(self) -> None:
55
+        assert _validate_left_right(r"\left( x \right)") == []
56
+
57
+    def test_unbalanced(self) -> None:
58
+        warnings = _validate_left_right(r"\left( x")
59
+        assert len(warnings) > 0
60
+
61
+
62
+class TestValidateMarkdown:
63
+    def test_valid(self) -> None:
64
+        assert validate_markdown("text\n\n$$\na + b\n$$\n") == []
65
+
66
+    def test_unbalanced_dollar(self) -> None:
67
+        warnings = validate_markdown("$$\n$a\n$$\n")
68
+        assert len(warnings) > 0
69
+
70
+    def test_unbalanced_brace_in_formula(self) -> None:
71
+        warnings = validate_markdown("$$\na{\nb\n$$\n")
72
+        assert len(warnings) > 0
73
+
74
+
75
+class TestGenerateMarkdown:
76
+    def test_text_block(self) -> None:
77
+        import tempfile
78
+
79
+        blocks = [ParsedBlock(label="text", content="Hello world", bbox=(0, 0, 100, 20))]
80
+        with tempfile.TemporaryDirectory() as tmp:
81
+            p = generate_markdown(blocks, Path(tmp) / "output.md")
82
+            assert p.exists()
83
+            content = p.read_text()
84
+            assert "Hello world" in content
85
+
86
+    def test_formula_block(self) -> None:
87
+        import tempfile
88
+
89
+        blocks = [ParsedBlock(label="formula", content=r"a + b = c", bbox=(0, 0, 100, 20))]
90
+        with tempfile.TemporaryDirectory() as tmp:
91
+            p = generate_markdown(blocks, Path(tmp) / "output.md")
92
+            content = p.read_text()
93
+            assert "$$\n" in content
94
+            assert "a + b = c" in content
95
+
96
+    def test_title_block(self) -> None:
97
+        import tempfile
98
+
99
+        blocks = [ParsedBlock(label="paragraph_title", content="Section 1", bbox=(0, 0, 100, 20))]
100
+        with tempfile.TemporaryDirectory() as tmp:
101
+            p = generate_markdown(blocks, Path(tmp) / "output.md")
102
+            content = p.read_text()
103
+            assert "## Section 1" in content
104
+
105
+    def test_no_escaped_latex(self) -> None:
106
+        import tempfile
107
+
108
+        blocks = [ParsedBlock(label="text", content=r"alpha \sim", bbox=(0, 0, 100, 20))]
109
+        with tempfile.TemporaryDirectory() as tmp:
110
+            p = generate_markdown(blocks, Path(tmp) / "output.md")
111
+            content = p.read_text()
112
+            assert r"\textbackslash" not in content
113
+            assert r"alpha" in content

Loading…
İptal
Kaydet