diff --git a/RIP_Help_system.sln b/RIP_Help_system.sln index 53395b4..f9b5930 100644 --- a/RIP_Help_system.sln +++ b/RIP_Help_system.sln @@ -46,6 +46,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{BC30BA VERSION = VERSION EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{8F4C2A91-6B7E-4D13-9A50-E1C8B7D4F620}" + ProjectSection(SolutionItems) = preProject + docs\md_to_pdf.py = docs\md_to_pdf.py + docs\razdeljane-na-sekcii.md = docs\razdeljane-na-sekcii.md + docs\razdeljane-na-sekcii.pdf = docs\razdeljane-na-sekcii.pdf + EndProjectSection +EndProject Global GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/md_to_pdf.py b/docs/md_to_pdf.py new file mode 100644 index 0000000..ec5022d --- /dev/null +++ b/docs/md_to_pdf.py @@ -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'{inner}') + else: + parts.append(f"{html.escape(m.group(1))}") + 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) diff --git a/docs/razdeljane-na-sekcii.md b/docs/razdeljane-na-sekcii.md new file mode 100644 index 0000000..731be5e --- /dev/null +++ b/docs/razdeljane-na-sekcii.md @@ -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 `
`.
+3. `p` или `div` с текст < 120 символа, изцяло обвит в `` / `` → ниво 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 | Целият `` е блок в текущата секция |
+| TXT / PDF | Няма отделен table parser; таблицата е просто текст/редове |
+
+---
+
+## 11. Ключови думи (един ред)
+
+След като секциите са вече разделени, всяка получава заглавие и ключови думи чрез Claude или fallback; ръчните думи от таб „Сканиране“ се записват първи във всяка секция.
diff --git a/docs/razdeljane-na-sekcii.pdf b/docs/razdeljane-na-sekcii.pdf
new file mode 100644
index 0000000..5f4479f
Binary files /dev/null and b/docs/razdeljane-na-sekcii.pdf differ
diff --git a/webapp/templates/viewer.html b/webapp/templates/viewer.html
index aa536e9..10dfdd8 100644
--- a/webapp/templates/viewer.html
+++ b/webapp/templates/viewer.html
@@ -240,8 +240,15 @@
max-width: min(920px, 100%); height: auto; border-radius: var(--radius-lg);
border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0,0,0,.08);
}
- .rescan-box {
+ .scan-cluster {
width: min(560px, 100%);
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+ .rescan-box {
+ width: 100%;
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 16px 18px;
}
@@ -274,7 +281,7 @@
}
.rescan-filemeta:empty { display: none; }
.rescan-filemeta.warn { color: var(--danger); }
- .wipe-box { margin-top: 16px; }
+ .wipe-box { margin-top: 0; }
.wipe-box input[type=text] { width: 100%; font-family: var(--mono); }
.rescan-kw-input { width: 100%; }
.rescan-hint { margin-bottom: 10px; line-height: 1.45; }
@@ -347,7 +354,8 @@
{% endif %}
Help файлове