This commit is contained in:
2026-09-11 15:04:49 +03:00
parent 1d06e8c7a4
commit abe4220508
5 changed files with 500 additions and 3 deletions

286
docs/md_to_pdf.py Normal file
View File

@@ -0,0 +1,286 @@
"""Генерира docs/razdeljane-na-sekcii.pdf от съседния .md (кирилица, Calibri)."""
from __future__ import annotations
import html
import re
from pathlib import Path
from reportlab.lib.colors import HexColor
from reportlab.lib.enums import TA_JUSTIFY, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
HRFlowable,
ListFlowable,
ListItem,
Paragraph,
Preformatted,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
HERE = Path(__file__).resolve().parent
MD_PATH = HERE / "razdeljane-na-sekcii.md"
PDF_PATH = HERE / "razdeljane-na-sekcii.pdf"
FONT_DIR = Path(r"C:\Windows\Fonts")
BODY = "Calibri"
MONO = "ConsolasDoc"
def _register_fonts() -> None:
pairs = [
(BODY, "", "calibri.ttf"),
(BODY, "Bold", "calibrib.ttf"),
(BODY, "Italic", "calibrii.ttf"),
(BODY, "BoldItalic", "calibriz.ttf"),
]
for family, face, fname in pairs:
path = FONT_DIR / fname
if not path.is_file():
raise SystemExit(f"Липсва шрифт: {path}")
name = family if not face else f"{family}-{face}"
pdfmetrics.registerFont(TTFont(name, str(path)))
pdfmetrics.registerFontFamily(
BODY,
normal=BODY,
bold=f"{BODY}-Bold",
italic=f"{BODY}-Italic",
boldItalic=f"{BODY}-BoldItalic",
)
consola = FONT_DIR / "consola.ttf"
if consola.is_file():
pdfmetrics.registerFont(TTFont(MONO, str(consola)))
else:
pdfmetrics.registerFont(TTFont(MONO, str(FONT_DIR / "arial.ttf")))
def _inline(text: str) -> str:
parts: list[str] = []
i = 0
code_re = re.compile(r"`([^`]+)`")
bold_re = re.compile(r"\*\*([^*]+)\*\*")
while i < len(text):
m_code = code_re.search(text, i)
m_bold = bold_re.search(text, i)
candidates = [m for m in (m_code, m_bold) if m]
if not candidates:
parts.append(html.escape(text[i:]))
break
m = min(candidates, key=lambda x: x.start())
parts.append(html.escape(text[i:m.start()]))
if m is m_code:
inner = html.escape(m.group(1))
parts.append(f'<font name="{MONO}" size="8">{inner}</font>')
else:
parts.append(f"<b>{html.escape(m.group(1))}</b>")
i = m.end()
return "".join(parts)
def _styles() -> dict[str, ParagraphStyle]:
accent = HexColor("#1e4a8c")
muted = HexColor("#4a4a58")
text = HexColor("#1a1a1f")
return {
"h1": ParagraphStyle(
"H1", fontName=f"{BODY}-Bold", fontSize=16, leading=20,
textColor=accent, spaceAfter=10, spaceBefore=0,
),
"h2": ParagraphStyle(
"H2", fontName=f"{BODY}-Bold", fontSize=13, leading=17,
textColor=accent, spaceBefore=12, spaceAfter=6,
),
"h3": ParagraphStyle(
"H3", fontName=f"{BODY}-Bold", fontSize=11.5, leading=15,
textColor=HexColor("#2d5fb0"), spaceBefore=8, spaceAfter=4,
),
"body": ParagraphStyle(
"Body", fontName=BODY, fontSize=10, leading=14,
textColor=text, alignment=TA_JUSTIFY, spaceAfter=6,
),
"li": ParagraphStyle(
"Li", fontName=BODY, fontSize=10, leading=14,
textColor=text, alignment=TA_LEFT, leftIndent=4,
),
"code": ParagraphStyle(
"Code", fontName=MONO, fontSize=8, leading=11,
textColor=text, backColor=HexColor("#eef0f4"),
leftIndent=6, rightIndent=6, spaceBefore=4, spaceAfter=8,
),
"caption": ParagraphStyle(
"Cap", fontName=BODY, fontSize=8, leading=11,
textColor=muted, alignment=TA_LEFT, spaceAfter=10,
),
"th": ParagraphStyle(
"Th", fontName=f"{BODY}-Bold", fontSize=9, leading=12, textColor=text,
),
"td": ParagraphStyle(
"Td", fontName=BODY, fontSize=9, leading=12, textColor=text,
),
"footer": ParagraphStyle(
"Foot", fontName=BODY, fontSize=8, leading=10, textColor=muted,
),
}
def _split_table_row(line: str) -> list[str]:
cells = [c.strip() for c in line.strip().strip("|").split("|")]
return cells
def _is_table_sep(line: str) -> bool:
s = line.strip().strip("|").replace(" ", "")
return bool(s) and all(set(c) <= {"-", ":"} and "-" in c for c in s.split("|"))
def md_to_flowables(md: str, styles: dict[str, ParagraphStyle]) -> list:
lines = md.replace("\r\n", "\n").split("\n")
story: list = []
i = 0
n = len(lines)
def flush_para(buf: list[str]) -> None:
text = " ".join(x.strip() for x in buf if x.strip())
if text:
story.append(Paragraph(_inline(text), styles["body"]))
while i < n:
line = lines[i]
stripped = line.strip()
if stripped.startswith("```"):
i += 1
block: list[str] = []
while i < n and not lines[i].strip().startswith("```"):
block.append(lines[i])
i += 1
i += 1
story.append(Preformatted("\n".join(block) or " ", styles["code"]))
continue
if stripped == "---":
story.append(Spacer(1, 4))
story.append(HRFlowable(width="100%", thickness=0.4, color=HexColor("#b7c9e3")))
story.append(Spacer(1, 8))
i += 1
continue
if stripped.startswith("# "):
story.append(Paragraph(_inline(stripped[2:]), styles["h1"]))
i += 1
continue
if stripped.startswith("## "):
story.append(Paragraph(_inline(stripped[3:]), styles["h2"]))
i += 1
continue
if stripped.startswith("### "):
story.append(Paragraph(_inline(stripped[4:]), styles["h3"]))
i += 1
continue
if stripped.startswith("|") and i + 1 < n and _is_table_sep(lines[i + 1]):
headers = _split_table_row(stripped)
i += 2
rows = [[Paragraph(_inline(h), styles["th"]) for h in headers]]
while i < n and lines[i].strip().startswith("|"):
cells = _split_table_row(lines[i])
while len(cells) < len(headers):
cells.append("")
rows.append([Paragraph(_inline(c), styles["td"]) for c in cells[: len(headers)]])
i += 1
col_w = (A4[0] - 36 * mm) / max(len(headers), 1)
tbl = Table(rows, colWidths=[col_w] * len(headers), hAlign="LEFT")
tbl.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), HexColor("#e8f0fa")),
("GRID", (0, 0), (-1, -1), 0.3, HexColor("#d8dce3")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]))
story.append(tbl)
story.append(Spacer(1, 8))
continue
if stripped.startswith(("- ", "* ")):
items: list[ListItem] = []
while i < n and lines[i].strip().startswith(("- ", "* ")):
items.append(ListItem(Paragraph(_inline(lines[i].strip()[2:]), styles["li"])))
i += 1
story.append(ListFlowable(
items, bulletType="bullet", leftIndent=16, bulletFontName=BODY,
bulletFontSize=10, spaceAfter=6,
))
continue
if re.match(r"^\d+\.\s+", stripped):
items = []
while i < n and re.match(r"^\d+\.\s+", lines[i].strip()):
text = re.sub(r"^\d+\.\s+", "", lines[i].strip())
items.append(ListItem(Paragraph(_inline(text), styles["li"])))
i += 1
story.append(ListFlowable(
items, bulletType="1", leftIndent=18, bulletFontName=BODY,
bulletFontSize=10, spaceAfter=6,
))
continue
if not stripped:
i += 1
continue
buf = [line]
i += 1
while i < n:
nxt = lines[i]
ns = nxt.strip()
if (not ns or ns.startswith("#") or ns.startswith("|")
or ns.startswith("- ") or ns.startswith("* ")
or ns.startswith("```") or ns == "---"
or re.match(r"^\d+\.\s+", ns)):
break
buf.append(nxt)
i += 1
flush_para(buf)
return story
def _footer(canvas, doc) -> None:
canvas.saveState()
canvas.setFillColor(HexColor("#6a6a78"))
canvas.setFont(BODY, 8)
canvas.drawString(18 * mm, 12 * mm, "RIP Help System — разделяне на секции")
canvas.drawRightString(A4[0] - 18 * mm, 12 * mm, f"{doc.page}")
canvas.restoreState()
def build_pdf() -> Path:
_register_fonts()
md = MD_PATH.read_text(encoding="utf-8")
styles = _styles()
doc = SimpleDocTemplate(
str(PDF_PATH),
pagesize=A4,
leftMargin=18 * mm,
rightMargin=18 * mm,
topMargin=16 * mm,
bottomMargin=18 * mm,
title="Разделяне на произволен текст на секции",
author="RIP Help System",
)
story = md_to_flowables(md, styles)
doc.build(story, onFirstPage=_footer, onLaterPages=_footer)
return PDF_PATH
if __name__ == "__main__":
out = build_pdf()
print(out)

View File

@@ -0,0 +1,195 @@
# Разделяне на произволен текст на секции
Този документ описва **реалното** поведение на RIP Help System при разделяне на help-файл на секции. Източникът е `help_processor.py` след последните корекции (вкл. `merge_short_sections`). Не е маркетингово резюме.
Генерирането на ключови думи е **извън обхвата**, освен един ред в края.
За да прегенерираш PDF-а:
```
python docs/md_to_pdf.py
```
---
## 1. Място в обработката
За всеки файл `process_file()` прави:
1. Избира парсер по разширение (`.html` / `.htm`, `.docx`, `.doc`, `.pdf`, `.txt`).
2. Парсерът връща списък от `Section(title, text, level, images, html_text)`.
3. Върху списъка се вика `merge_short_sections()`.
4. Празните блокове се филтрират при запис; заглавие без тяло се запазва (виж §8).
ZIP не е формат за разделяне: разопакова се и всеки вътрешен файл минава по същия път.
Нива: `1` = H1, `2` = H2, `3` = H3 (по-дълбоки HTML/Word заглавия се режат до 3), `0` = без заглавие.
---
## 2. Какво е граница на секция
**Граница = открито заглавие.** Текущата секция се затваря (`flush`) и започва нова. Текстът на заглавието става `title` и **не** се копира в тялото на секцията.
Всичко, което не е заглавие — параграфи, списъци, таблици, картинки — се добавя към **текущата** секция. Таблица никога не отваря нова секция.
Ако преди първото заглавие има съдържание, то образува секция с **празно** `title`.
Ако парсерът не открие нито една секция, файлът става **една** секция без заглавие, с целия извлечен текст.
---
## 3. Word (.docx)
Обхождат се децата на `w:body` в document-order: параграфи **и** таблици (`_iter_docx_blocks`). Стандартният `Document.paragraphs` пропуска таблиците, затова не се ползва за сегментиране.
### 3.1. Стилово заглавие
`_docx_heading_level()` гледа `style_id` и `style.name`, после `base_style` нагоре по веригата. Токенът се нормализира (без интервали, `_`, `-`, без значение на регистъра).
Разпознават се поне:
| Токен (примери) | Ниво |
|---|---|
| Heading 1 / heading1 | 1 |
| Heading 2 / 3 | 2 / 3 |
| Title | 1 |
| Subtitle | 2 |
| **Заглавие** / **Заглавие 1** | 1 |
| Заглавие 2 / 3 | 2 / 3 |
| Подзаглавие | 2 |
| Наименование | 1 |
| Überschrift / Überschrift 1 | 1 |
| MsoHeading1 / 2 / 3 | 1 / 2 / 3 |
Число над 3 се ограничава до 3. `Subtitle` / `Подзаглавие` без цифра → ниво 2; останалите именувани стилове без цифра → ниво 1.
Ако стилът не е heading, има fallback: Word `outlineLvl` 0–2 **и** текст под 120 символа → ниво = `outlineLvl + 1`.
### 3.2. Bold като заглавие
Ако няма стилово ниво, параграфът се приема за заглавие ниво 2, когато:
- видимият текст е непразен и ≤ 120 символа;
- всички run-ове с текст са **bold**;
- стилът **не** започва с `list`;
- в параграфа **няма** картинки.
### 3.3. Таблици и картинки
Таблица: всеки ред се сплесква до `клетка | клетка | клетка` и редовете се добавят в тялото на текущата секция.
Картинки в параграф: записват се като `[IMG: img_NN]` в същото тяло.
Празен параграф без картинка се пропуска.
### 3.4. Fallback
Ако след обхождането няма секции: една секция от всички непразни параграфи; ако и те са празни — от таблиците.
---
## 4. Стар .doc
Конвертира се към `.docx` с LibreOffice (`soffice`) или, на Windows, MS Word през COM. После се вика същият `parse_docx`. Ако и двете конверсии пропаднат, файлът се чете като обикновен текст (`parse_txt`).
---
## 5. HTML / HTM (вкл. Word „Запиши като уеб“)
Премахват се `script`, `style`, `nav`, `footer`, `header`, `noscript`.
Събират се **top-level** блокове: `h1`–`h6`, `p`, `ul`, `ol`, `table`, `dl`, `pre`, `blockquote`, `figure`, `hr`, самостоятелни `img`, и `div` **само** ако изглежда като заглавие. Вложен блок не се обработва втори път.
### 5.1. Заглавие
1. Тагове: `h1`→1, `h2`→2, `h3`–`h6`→3.
2. CSS class съдържа `heading`, `заглавие`, `title`, `subtitle`, `msoheading` или `überschrift`, с опционална цифра. Така `MsoHeading1` / `MsoNormal` **не** се бъркат: само heading-класовете режат секция. Пример: Word HTML `<p class="MsoHeading1">`.
3. `p` или `div` с текст < 120 символа, изцяло обвит в `<b>` / `<strong>` → ниво 2.
### 5.2. Съдържание
Списъци и таблици влизат в текущата секция. В plain text редовете им са с нов ред (не сплескани в един ред). Декоративни атрибути (`class`, `style`, `on*`, `data-*` …) се махат от запазения HTML.
Картинки: локални и `data:` URI; HTTP(S) се пропускат. Дребни иконки под 50×50 px се изхвърлят.
Ако няма нито една секция: цялото `body` като една секция без заглавие.
---
## 6. Обикновен текст (.txt)
Кодирането се детектира с chardet.
Ред е граница, ако след `strip()`:
- съвпада с Markdown заглавие `#{1,3} текст` — нивото е броят `#`, заглавието е текстът след тях; **или**
- съвпада с номерирано заглавие: `1. …`, `2) …`, `I. …`, `А. …` (1–2 цифри / римски / една главна буква, после `.` или `)`), дължина на реда < 120.
Всички останали редове отиват в тялото (десният край се реже, водещите интервали се пазят).
Ако няма такива редове: **една** секция с целия файл.
---
## 7. PDF
Няма стилове. Текстът се събира като визуални редове по Y (не по PDF stream), буквите от drop-cap се слепват, сплескани списъци от вида `1 Foo 2 Bar` се разбиват на редове.
Граница: група редове с почти еднакъв размер на шрифта, ако размерът е **по-голям от предишния с повече от 1 pt** и текстът е **под 150 символа** → нова секция, ниво 2.
Това е по-ненадеждно от Word/HTML. Картинките се кропват от страницата и се редят в текущата секция по вертикална позиция.
---
## 8. Сливане на кратки секции (`merge_short_sections`)
Константа: `MIN_SECTION_TOKENS = 60` (думи в **тялото**, `str.split()`).
Правило след последните корекции:
- Секция **с непразно заглавие** **не се слива**, дори тялото да е късо или празно.
- Секция **без заглавие** и с под 60 думи се залепва към предишната (текст, картинки, HTML).
- Няма предишна секция → късият untitled блок остава сам.
Следствия:
- Последователни заглавия без тяло („Глава 1“ веднага следвано от „1.1 Увод“) остават **две** секции.
- Къс абзац без заглавие след секция се присъединява към нея, вместо да стане отделен къс запис.
- Untitled текст в началото на файла остава отделна секция (докато не е под прага *и* няма към какво да се слее — първият елемент не се слива).
При запис в `process_file()`:
- Ако няма текст, картинки и HTML, но има заглавие → тялото става самото заглавие. Затова „голо“ заглавие оцелява като секция.
- Ако няма нито текст, нито заглавие, нито картинки → секцията се пропуска.
`clean_text()` срива поредици от интервали/табове, но пази нови редове (списъци и абзаци).
---
## 9. Текст без заглавие
Типични случаи:
- Увод преди първия Heading 1.
- HTML/DOCX без нито едно разпознато заглавие → целият файл е една untitled секция.
- TXT без markdown/номерация.
- Къс untitled остатък след заглавие се слива с предишната секция (§8).
Празното `title` по-късно се попълва от класификатора (извън този документ).
---
## 10. Таблици — обобщение
| Формат | Поведение |
|---|---|
| DOCX | Редове `a \| b \| c` в текущата секция; не са граница |
| HTML | Целият `<table>` е блок в текущата секция |
| TXT / PDF | Няма отделен table parser; таблицата е просто текст/редове |
---
## 11. Ключови думи (един ред)
След като секциите са вече разделени, всяка получава заглавие и ключови думи чрез Claude или fallback; ръчните думи от таб „Сканиране“ се записват първи във всяка секция.

Binary file not shown.