This commit is contained in:
2026-09-14 11:10:05 +03:00
parent 458571576e
commit 181bbc3603
6 changed files with 242 additions and 68 deletions

View File

@@ -1 +1 @@
0.3.2 0.3.3

View File

@@ -1,6 +1,6 @@
# Разделяне на произволен текст на секции # Разделяне на произволен текст на секции
Този документ описва **реалното** поведение на RIP Help System при разделяне на help-файл на секции. Източникът е `help_processor.py` след последните корекции (вкл. `merge_short_sections` / `merge_preamble_sections`). Не е маркетингово резюме. Този документ описва **реалното** поведение на RIP Help System при разделяне на help-файл на секции. Източникът е `help_processor.py` след последните корекции (вкл. `merge_short_sections` / `merge_preamble_sections` / номерирани глави). Не е маркетингово резюме.
Генерирането на ключови думи е **извън обхвата**, освен един ред в края. Генерирането на ключови думи е **извън обхвата**, освен един ред в края.
@@ -29,22 +29,40 @@ ZIP не е формат за разделяне: разопакова се и
## 2. Какво е граница на секция ## 2. Какво е граница на секция
**Граница = открито заглавие ниво 1** (H1 / Heading 1 / Заглавие 1) в HTML и DOCX. Текущата секция се затваря (`flush`) и започва нова. Текстът на заглавието става `title` и **не** се копира в тялото на секцията. В HTML и DOCX граница е едно от:
1. **H1 / Heading 1 / Заглавие 1** (класическа граница); **или**
2. **Номерирана top-level глава** от вида `1. Заглавие` / `2) Заглавие` — дори ако е H2, bold или Normal — когато редът изглежда като заглавие на глава (виж §2.1).
Текстът на границата става `title` и **не** се копира в тялото на секцията.
В HTML/DOCX **не** отварят нова секция: В HTML/DOCX **не** отварят нова секция:
- Word **Title** / **Наименование** / CSS `Title` / `MsoTitle` — корица; става заглавие на преамбюла (или ред в тялото, ако вече има съдържание); - Word **Title** / **Наименование** / CSS `Title` / `MsoTitle` — корица; става заглавие на преамбюла (или ред в тялото, ако вече има съдържание);
- „**Съдържание**“ / Contents / TOC и еквиваленти — остават в тялото; - „**Съдържание**“ / Contents / TOC и еквиваленти — остават в тялото и включват TOC фаза (виж §2.1);
- H2–H6, Subtitle / Подзаглавие, изцяло bold параграфи — влизат като редове в **текущата** секция. - H2–H6, Subtitle / Подзаглавие, изцяло bold параграфи — **освен** ако текстът е номерирана глава (`N. Title`);
- редове от таблици (`a | b | c`), „Фигура N: …“, дълги изречения с номерация.
TXT и PDF запазват своите правила (номерирани редове / по-голям шрифт) — там ниво 2 е нормална граница. TXT и PDF запазват своите правила (номерирани редове / по-голям шрифт) — там ниво 2 е нормална граница.
Всичко, което не е граница — параграфи, списъци, таблици, картинки — се добавя към **текущата** секция. Ако преди първата глава има корица + съдържание, те образуват **преамбюл** (title от корицата/първото H1).
Ако преди първото H1 има съдържание (корица, TOC), то образува преамбюл секция (с title от корицата, ако има).
Ако парсерът не открие нито една секция, файлът става **една** секция без заглавие, с целия извлечен текст. Ако парсерът не открие нито една секция, файлът става **една** секция без заглавие, с целия извлечен текст.
### 2.1. Номерирани глави и TOC
Шаблон: `^\d{1,2}[\.\)]\s+\S` , дължина < 120, ≤ 12 думи след номера, без `|`, без „Фигура N“.
След заглавие „Съдържание“/TOC парсерът влиза в **TOC фаза**:
- първите срещания на `1. …`, `2. …` (Normal / списък) остават в преамбюла;
- **повторно** срещане на същия текст (реалната глава) затваря TOC фазата и отваря нова секция;
- ако TOC е в `<ol>`/`<ul>` или след TOC има не-номериран блок, TOC фазата приключва и следващата номерирана глава пак реже.
Без блок „Съдържание“ всяка номерирана глава реже директно.
**Пример (`atra-manual.docx` / NESPERTCAM):** единственото Heading 1 е заглавието на документа; главите 1–9 са Heading 2. Очакване: **10 секции** — преамбюл (title + Съдържание + TOC) + по една за `1.` … `9.`. Не една мега-секция.
--- ---
## 3. Word (.docx) ## 3. Word (.docx)
@@ -59,12 +77,12 @@ TXT и PDF запазват своите правила (номерирани р
| Токен (примери) | Ниво / роля | | Токен (примери) | Ниво / роля |
|---|---| |---|---|
| Heading 1 / heading1 | 1 — **граница** | | Heading 1 / heading1 | 1 — **граница** (освен ако е само корица преди TOC; виж §2) |
| Heading 2 / 3 | 2 / 3 — в тялото, не граница | | Heading 2 / 3 | 2 / 3 — в тялото, **освен** номерирана глава `N. Title` |
| **Title** / MsoTitle / **Наименование** | корица — не граница | | **Title** / MsoTitle / **Наименование** | корица — не граница |
| Subtitle / Подзаглавие | 2 — в тялото | | Subtitle / Подзаглавие | 2 — в тялото |
| **Заглавие** / **Заглавие 1** | 1 — **граница** | | **Заглавие** / **Заглавие 1** | 1 — **граница** |
| Заглавие 2 / 3 | 2 / 3 — в тялото | | Заглавие 2 / 3 | 2 / 3 — в тялото (освен номерирана глава) |
| Überschrift / Überschrift 1 | 1 — **граница** | | Überschrift / Überschrift 1 | 1 — **граница** |
| MsoHeading1 / 2 / 3 | 1 / 2 / 3 | | MsoHeading1 / 2 / 3 | 1 / 2 / 3 |
@@ -72,16 +90,11 @@ TXT и PDF запазват своите правила (номерирани р
Ако стилът не е heading, има fallback: Word `outlineLvl` 0–2 **и** текст под 120 символа → ниво = `outlineLvl + 1`. Ако стилът не е heading, има fallback: Word `outlineLvl` 0–2 **и** текст под 120 символа → ниво = `outlineLvl + 1`.
### 3.2. Bold като подзаглавие (не граница) ### 3.2. Bold / H2 като подзаглавие
Ако няма стилово ниво 1, параграфът се приема за **текст в тялото** (бивш „heading“ ниво 2), когато: Изцяло bold кратък параграф или H2 **без** `N. Title` остава в тялото.
- видимият текст е непразен и ≤ 120 символа; Ако текстът е номерирана глава (`1. Преглед…`), реже секция дори при Heading 2 / bold / Normal (с TOC правилата от §2.1).
- всички run-ове с текст са **bold**;
- стилът **не** започва с `list`;
- в параграфа **няма** картинки.
Такъв ред **не** отваря нова секция — остава под текущото H1.
### 3.3. Таблици и картинки ### 3.3. Таблици и картинки
@@ -111,10 +124,11 @@ TXT и PDF запазват своите правила (номерирани р
### 5.1. Заглавие ### 5.1. Заглавие
1. Тагове: `h1`→ граница (ниво 1); `h2`–`h6`→ в тялото (ниво 2/3). 1. Тагове: `h1`→ граница (ниво 1); `h2`–`h6`→ в тялото **освен** номерирана глава.
2. CSS class с `heading` / `заглавие` / `msoheading` / `überschrift` (+ опционална цифра). `Title` / `MsoTitle` / `наименование` са **корица**, не граница. `subtitle` / `подзаглавие` → в тялото. 2. CSS class с `heading` / `заглавие` / `msoheading` / `überschrift` (+ опционална цифра). `Title` / `MsoTitle` / `наименование` са **корица**, не граница. `subtitle` / `подзаглавие` → в тялото.
3. `p` или `div` с текст < 120 символа, изцяло обвит в `<b>` / `<strong>` → в тялото (не граница). 3. `p` или `div` с текст < 120 символа, изцяло обвит в `<b>` / `<strong>` → в тялото, **освен** номерирана глава.
4. Текст „Съдържание“ / Contents / TOC (дори като `h1`) → в тялото на преамбюла. 4. Текст „Съдържание“ / Contents / TOC → в тялото + TOC фаза (§2.1).
5. Номерирана глава `N. Title` на `p` / `h2` / bold → граница според TOC правилата.
### 5.2. Съдържание ### 5.2. Съдържание
@@ -167,9 +181,10 @@ TXT и PDF запазват своите правила (номерирани р
Следствия: Следствия:
- Корица + съдържание + първа глава (H1) → **две** секции (не четири от Title / TOC / H1 / H2). - Корица + съдържание + първа глава → **две** секции (не четири от Title / TOC / H2-подзаглавия).
- Пълен документ с номерирани глави 1…N (дори като H2) → преамбюл + N секции.
- Последователни H1 без тяло („Глава 1“ веднага следвано от друг H1) остават **две** секции. - Последователни H1 без тяло („Глава 1“ веднага следвано от друг H1) остават **две** секции.
- H2 / bold подзаглавия („Какво е …?“) не режат секция — остават в тялото на текущото H1. - H2 / bold подзаглавия без номерация („Какво е …?“) не режат секция.
- Untitled текст в началото на файла остава отделна секция (докато не е под прага *и* няма към какво да се слее — първият елемент не се слива). - Untitled текст в началото на файла остава отделна секция (докато не е под прага *и* няма към какво да се слее — първият елемент не се слива).
При запис в `process_file()`: При запис в `process_file()`:
@@ -185,8 +200,8 @@ TXT и PDF запазват своите правила (номерирани р
Типични случаи: Типични случаи:
- Увод преди първия Heading 1. - Увод преди първата граница.
- HTML/DOCX без нито едно разпознато заглавие → целият файл е една untitled секция. - HTML/DOCX без нито едно разпознато заглавие/номерирана глава → целият файл е една untitled секция.
- TXT без markdown/номерация. - TXT без markdown/номерация.
- Къс untitled остатък след заглавие се слива с предишната секция (§8). - Къс untitled остатък след заглавие се слива с предишната секция (§8).

Binary file not shown.

View File

@@ -605,6 +605,76 @@ def _is_toc_heading(text: str) -> bool:
return bool(_TOC_HEADING_RE.match((text or "").strip())) return bool(_TOC_HEADING_RE.match((text or "").strip()))
# Top-level chapter: "1. Title" / "2) Title" — short line, not body/table/figure.
_NUMBERED_CHAPTER_RE = re.compile(
r"^(\d{1,2})[\.\)]\s+(\S.{0,100})$"
)
_FIGURE_CAPTION_RE = re.compile(
r"^(фигура|figure|abb\.?|рис\.?)\s*\d+",
re.I,
)
def _normalize_chapter_key(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
def _is_numbered_chapter_heading(text: str) -> bool:
"""True for top-level numbered chapter titles (not TOC prose, figures, tables)."""
t = (text or "").strip()
if not t or len(t) >= 120:
return False
if "|" in t:
return False
if _is_toc_heading(t):
return False
if _FIGURE_CAPTION_RE.match(t):
return False
m = _NUMBERED_CHAPTER_RE.match(t)
if not m:
return False
rest = m.group(2).strip()
words = rest.split()
if not words or len(words) > 12:
return False
# Body-like numbered sentences: "1. Copy the files into the folder."
if rest.endswith((".", "!", "?")) and len(words) > 4:
return False
return True
class _TocSplitState:
"""Tracks Съдържание / TOC so first '1. Title' stays in preamble, second starts a section."""
__slots__ = ("phase", "had_toc", "titles")
def __init__(self) -> None:
self.phase = False
self.had_toc = False
self.titles: set[str] = set()
def note_toc_heading(self) -> None:
self.phase = True
self.had_toc = True
def leave_phase(self) -> None:
self.phase = False
def handle_numbered(self, text: str) -> str:
"""Return 'toc' (keep in body), 'split' (new section), or 'no' (not numbered chapter)."""
if not _is_numbered_chapter_heading(text):
return "no"
key = _normalize_chapter_key(text)
if self.phase:
if key in self.titles:
self.phase = False
return "split"
self.titles.add(key)
return "toc"
# After TOC list/block, or docs without TOC: numbered chapter opens a section.
return "split"
def _heading_level_from_token(token: str) -> Optional[int]: def _heading_level_from_token(token: str) -> Optional[int]:
t = _compact_style_token(token) t = _compact_style_token(token)
if not t: if not t:
@@ -779,6 +849,7 @@ def parse_html(path: Path) -> list[Section]:
sec_html: list[str] = [] sec_html: list[str] = []
sec_images: list[ImageRef] = [] sec_images: list[ImageRef] = []
img_counter = [0] img_counter = [0]
toc_state = _TocSplitState()
def flush(): def flush():
if current_title or sec_text or sec_html or sec_images: if current_title or sec_text or sec_html or sec_images:
@@ -798,6 +869,13 @@ def parse_html(path: Path) -> list[Section]:
except Exception: except Exception:
pass pass
def start_section(title: str, level: int = 1):
nonlocal current_title, current_level, sec_text, sec_html, sec_images
flush()
current_title = title
current_level = level
sec_text, sec_html, sec_images = [], [], []
for el in blocks: for el in blocks:
txt = el.get_text(" ", strip=True) txt = el.get_text(" ", strip=True)
is_cover = _html_is_cover_title(el) is_cover = _html_is_cover_title(el)
@@ -809,23 +887,42 @@ def parse_html(path: Path) -> list[Section]:
current_title = txt current_title = txt
current_level = 1 current_level = 1
else: else:
if toc_state.phase:
toc_state.leave_phase()
append_block_as_body(el) append_block_as_body(el)
continue continue
if txt and _is_toc_heading(txt):
toc_state.note_toc_heading()
append_block_as_body(el)
continue
# Номерирана глава (H1/H2/bold/plain) — с TOC: първото срещане в съдържанието остава в преамбюла
numbered_action = toc_state.handle_numbered(txt) if txt else "no"
if numbered_action == "toc":
append_block_as_body(el)
continue
if numbered_action == "split":
start_section(txt, heading_lvl or 1)
continue
if heading_lvl: if heading_lvl:
if not txt: if not txt:
continue continue
# TOC и H2+ остават в тялото — граница само при H1 / Заглавие 1 # H2+ без номерация остават в тялото; H1 / Заглавие 1 режат
if _is_toc_heading(txt) or heading_lvl >= 2: if heading_lvl >= 2:
if toc_state.phase:
toc_state.leave_phase()
append_block_as_body(el) append_block_as_body(el)
continue continue
flush() if toc_state.phase:
current_title = txt toc_state.leave_phase()
current_level = heading_lvl start_section(txt, heading_lvl)
sec_text, sec_html, sec_images = [], [], []
continue continue
if el.name == "img": if el.name == "img":
if toc_state.phase:
toc_state.leave_phase()
# самостоятелен <img> (не вътре в блок) # самостоятелен <img> (не вътре в блок)
_swap_imgs_in_block(el.parent if el.parent and el.parent.name else el, _swap_imgs_in_block(el.parent if el.parent and el.parent.name else el,
base_dir, sec_images, img_counter) base_dir, sec_images, img_counter)
@@ -836,6 +933,11 @@ def parse_html(path: Path) -> list[Section]:
sec_html.append(f"<p>{img_txt}</p>") sec_html.append(f"<p>{img_txt}</p>")
continue continue
if toc_state.phase and (el.name or "").lower() in _HTML_PLAIN_NL_TAGS:
# <ol>/<ul> TOC списък — остава в преамбюла, приключва TOC фазата
toc_state.leave_phase()
elif toc_state.phase and txt and not _is_numbered_chapter_heading(txt):
toc_state.leave_phase()
append_block_as_body(el) append_block_as_body(el)
flush() flush()
@@ -911,6 +1013,7 @@ def parse_docx(path: Path) -> list[Section]:
buf: list[str] = [] buf: list[str] = []
sec_images: list[ImageRef] = [] sec_images: list[ImageRef] = []
img_counter = [0] img_counter = [0]
toc_state = _TocSplitState()
def flush(): def flush():
if current_title or buf or sec_images: if current_title or buf or sec_images:
@@ -918,8 +1021,26 @@ def parse_docx(path: Path) -> list[Section]:
sec.images = list(sec_images) sec.images = list(sec_images)
sections.append(sec) sections.append(sec)
def append_para(text: str, para_imgs: list[ImageRef]):
if text:
buf.append(text)
for im in para_imgs:
img_counter[0] += 1
im.placeholder = f"img_{img_counter[0]:02d}"
sec_images.append(im)
buf.append(f"[IMG: {im.placeholder}]")
def start_section(title: str, level: int = 1):
nonlocal current_title, current_level, buf, sec_images
flush()
buf, sec_images = [], []
current_title = title
current_level = level
for kind, block in _iter_docx_blocks(doc): for kind, block in _iter_docx_blocks(doc):
if kind == "tbl": if kind == "tbl":
if toc_state.phase:
toc_state.leave_phase()
for line in _table_lines(block): for line in _table_lines(block):
buf.append(line) buf.append(line)
continue continue
@@ -948,48 +1069,40 @@ def parse_docx(path: Path) -> list[Section]:
current_title = text current_title = text
current_level = 1 current_level = 1
else: else:
buf.append(text) if toc_state.phase:
for im in para_imgs: toc_state.leave_phase()
img_counter[0] += 1 append_para(text, para_imgs)
im.placeholder = f"img_{img_counter[0]:02d}"
sec_images.append(im)
buf.append(f"[IMG: {im.placeholder}]")
continue continue
# TOC и H2+/bold остават в тялото — граница само при Heading 1 / Заглавие 1
if text and _is_toc_heading(text): if text and _is_toc_heading(text):
buf.append(text) toc_state.note_toc_heading()
for im in para_imgs: append_para(text, para_imgs)
img_counter[0] += 1
im.placeholder = f"img_{img_counter[0]:02d}"
sec_images.append(im)
buf.append(f"[IMG: {im.placeholder}]")
continue continue
numbered_action = toc_state.handle_numbered(text) if text else "no"
if numbered_action == "toc":
append_para(text, para_imgs)
continue
if numbered_action == "split":
start_section(text, level or 1)
continue
# H2+/bold без номерация на глава — в тялото
if (level or 0) >= 2 or is_bold_heading: if (level or 0) >= 2 or is_bold_heading:
if text: if toc_state.phase:
buf.append(text) toc_state.leave_phase()
for im in para_imgs: append_para(text, para_imgs)
img_counter[0] += 1
im.placeholder = f"img_{img_counter[0]:02d}"
sec_images.append(im)
buf.append(f"[IMG: {im.placeholder}]")
continue continue
if level == 1: if level == 1:
flush() if toc_state.phase:
buf, sec_images = [], [] toc_state.leave_phase()
current_title = text start_section(text, 1)
current_level = 1
continue continue
if text: if toc_state.phase and text and not _is_numbered_chapter_heading(text):
buf.append(text) toc_state.leave_phase()
for im in para_imgs: append_para(text, para_imgs)
img_counter[0] += 1
im.placeholder = f"img_{img_counter[0]:02d}"
sec_images.append(im)
buf.append(f"[IMG: {im.placeholder}]")
flush() flush()

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<!-- Като atra-manual.docx: единственото H1 е заглавието; главите са H2 с номерация. -->
<h1>NESPERTCAM Launcher - Пълно ръководство на потребителя</h1>
<h3>Съдържание</h3>
<p>1. Преглед на приложението</p>
<p>2. Инсталация и настройка</p>
<p>3. Конфигурация и настройки</p>
<h2>1. Преглед на приложението</h2>
<h3>Какво е NESPERTCAM Launcher?</h3>
<p>NESPERTCAM Launcher е специализирано Windows приложение за стартиране на NESPERTCAM64.exe.</p>
<p><b>Основни предимства</b></p>
<p>Преносимост и пълен екран интерфейс.</p>
<p>Фигура 1: Главен интерфейс на NESPERTCAM Launcher с пълен екран режим</p>
<h2>2. Инсталация и настройка</h2>
<h3>Системни изисквания</h3>
<table>
<tr><td>Компонент</td><td>Минимални изисквания</td><td>Препоръчителни</td></tr>
<tr><td>Операционна система</td><td>Windows 10</td><td>Windows 11</td></tr>
</table>
<p>Стъпки за инсталация следват тук с достатъчно текст за тяло на секцията.</p>
</body>
</html>

View File

@@ -1,4 +1,4 @@
"""Корица + Съдържание + глава 1 → 2 секции (не 4).""" """Корица + Съдържание + глави: преамбюл + по една секция на номерирана глава."""
from pathlib import Path from pathlib import Path
from help_processor import ( from help_processor import (
@@ -8,7 +8,9 @@ from help_processor import (
Section, Section,
) )
FIXTURE = Path(__file__).parent / "fixtures" / "nespertcam_launcher_preamble.html" FIXTURES = Path(__file__).parent / "fixtures"
PREAMBLE = FIXTURES / "nespertcam_launcher_preamble.html"
CHAPTERS = FIXTURES / "nespertcam_launcher_chapters.html"
def _titles(sections): def _titles(sections):
@@ -16,7 +18,7 @@ def _titles(sections):
def test_nespertcam_preamble_yields_two_sections(): def test_nespertcam_preamble_yields_two_sections():
sections = merge_preamble_sections(merge_short_sections(parse_html(FIXTURE))) sections = merge_preamble_sections(merge_short_sections(parse_html(PREAMBLE)))
assert len(sections) == 2 assert len(sections) == 2
assert sections[0].title == "NESPERTCAM Launcher - Пълно ръководство на потребителя" assert sections[0].title == "NESPERTCAM Launcher - Пълно ръководство на потребителя"
assert sections[1].title == "1. Преглед на приложението" assert sections[1].title == "1. Преглед на приложението"
@@ -26,8 +28,27 @@ def test_nespertcam_preamble_yields_two_sections():
assert "NESPERTCAM64.exe" in sections[1].text assert "NESPERTCAM64.exe" in sections[1].text
def test_nespertcam_h2_chapters_split_not_one_section():
"""Като atra-manual.docx: H1=корица, TOC като Normal, глави като H2 → ≥3 секции."""
sections = merge_preamble_sections(merge_short_sections(parse_html(CHAPTERS)))
assert len(sections) >= 3
assert sections[0].title == "NESPERTCAM Launcher - Пълно ръководство на потребителя"
assert "Съдържание" in sections[0].text
assert "1. Преглед на приложението" in sections[0].text
assert sections[1].title == "1. Преглед на приложението"
assert sections[2].title == "2. Инсталация и настройка"
assert "Какво е NESPERTCAM Launcher?" in sections[1].text
assert "Основни предимства" in sections[1].text
assert "Фигура 1:" in sections[1].text
assert "Системни изисквания" in sections[2].text
assert "Windows 10" in sections[2].text
# H3/bold/фигура не отварят собствени секции
assert "Какво е NESPERTCAM Launcher?" not in _titles(sections)
assert "Основни предимства" not in _titles(sections)
def test_h2_and_bold_do_not_split_inside_chapter(): def test_h2_and_bold_do_not_split_inside_chapter():
html = Path(__file__).parent / "fixtures" / "_tmp_h2.html" html = FIXTURES / "_tmp_h2.html"
html.write_text( html.write_text(
"""<!DOCTYPE html><html><body> """<!DOCTYPE html><html><body>
<h1>Глава А</h1> <h1>Глава А</h1>
@@ -64,7 +85,7 @@ def test_toc_heading_as_h1_merges_into_cover():
def test_heading1_chapters_still_split(): def test_heading1_chapters_still_split():
html = Path(__file__).parent / "fixtures" / "_tmp_chapters.html" html = FIXTURES / "_tmp_chapters.html"
html.write_text( html.write_text(
"""<!DOCTYPE html><html><body> """<!DOCTYPE html><html><body>
<h1>1. Първа</h1><p>Аа аа аа.</p> <h1>1. Първа</h1><p>Аа аа аа.</p>