diff --git a/VERSION b/VERSION
index d15723f..1c09c74 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.2
+0.3.3
diff --git a/docs/razdeljane-na-sekcii.md b/docs/razdeljane-na-sekcii.md
index dd541a5..84bba0b 100644
--- a/docs/razdeljane-na-sekcii.md
+++ b/docs/razdeljane-na-sekcii.md
@@ -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. Какво е граница на секция
-**Граница = открито заглавие ниво 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 **не** отварят нова секция:
- Word **Title** / **Наименование** / CSS `Title` / `MsoTitle` — корица; става заглавие на преамбюла (или ред в тялото, ако вече има съдържание);
-- „**Съдържание**“ / Contents / TOC и еквиваленти — остават в тялото;
-- H2–H6, Subtitle / Подзаглавие, изцяло bold параграфи — влизат като редове в **текущата** секция.
+- „**Съдържание**“ / Contents / TOC и еквиваленти — остават в тялото и включват TOC фаза (виж §2.1);
+- H2–H6, Subtitle / Подзаглавие, изцяло bold параграфи — **освен** ако текстът е номерирана глава (`N. Title`);
+- редове от таблици (`a | b | c`), „Фигура N: …“, дълги изречения с номерация.
TXT и PDF запазват своите правила (номерирани редове / по-голям шрифт) — там ниво 2 е нормална граница.
-Всичко, което не е граница — параграфи, списъци, таблици, картинки — се добавя към **текущата** секция.
-
-Ако преди първото H1 има съдържание (корица, TOC), то образува преамбюл секция (с title от корицата, ако има).
+Ако преди първата глава има корица + съдържание, те образуват **преамбюл** (title от корицата/първото H1).
Ако парсерът не открие нито една секция, файлът става **една** секция без заглавие, с целия извлечен текст.
+### 2.1. Номерирани глави и TOC
+
+Шаблон: `^\d{1,2}[\.\)]\s+\S` , дължина < 120, ≤ 12 думи след номера, без `|`, без „Фигура N“.
+
+След заглавие „Съдържание“/TOC парсерът влиза в **TOC фаза**:
+
+- първите срещания на `1. …`, `2. …` (Normal / списък) остават в преамбюла;
+- **повторно** срещане на същия текст (реалната глава) затваря TOC фазата и отваря нова секция;
+- ако TOC е в `
`/`` или след TOC има не-номериран блок, TOC фазата приключва и следващата номерирана глава пак реже.
+
+Без блок „Съдържание“ всяка номерирана глава реже директно.
+
+**Пример (`atra-manual.docx` / NESPERTCAM):** единственото Heading 1 е заглавието на документа; главите 1–9 са Heading 2. Очакване: **10 секции** — преамбюл (title + Съдържание + TOC) + по една за `1.` … `9.`. Не една мега-секция.
+
---
## 3. Word (.docx)
@@ -59,12 +77,12 @@ TXT и PDF запазват своите правила (номерирани р
| Токен (примери) | Ниво / роля |
|---|---|
-| Heading 1 / heading1 | 1 — **граница** |
-| Heading 2 / 3 | 2 / 3 — в тялото, не граница |
+| Heading 1 / heading1 | 1 — **граница** (освен ако е само корица преди TOC; виж §2) |
+| Heading 2 / 3 | 2 / 3 — в тялото, **освен** номерирана глава `N. Title` |
| **Title** / MsoTitle / **Наименование** | корица — не граница |
| Subtitle / Подзаглавие | 2 — в тялото |
| **Заглавие** / **Заглавие 1** | 1 — **граница** |
-| Заглавие 2 / 3 | 2 / 3 — в тялото |
+| Заглавие 2 / 3 | 2 / 3 — в тялото (освен номерирана глава) |
| Überschrift / Überschrift 1 | 1 — **граница** |
| MsoHeading1 / 2 / 3 | 1 / 2 / 3 |
@@ -72,16 +90,11 @@ TXT и PDF запазват своите правила (номерирани р
Ако стилът не е heading, има fallback: Word `outlineLvl` 0–2 **и** текст под 120 символа → ниво = `outlineLvl + 1`.
-### 3.2. Bold като подзаглавие (не граница)
+### 3.2. Bold / H2 като подзаглавие
-Ако няма стилово ниво 1, параграфът се приема за **текст в тялото** (бивш „heading“ ниво 2), когато:
+Изцяло bold кратък параграф или H2 **без** `N. Title` остава в тялото.
-- видимият текст е непразен и ≤ 120 символа;
-- всички run-ове с текст са **bold**;
-- стилът **не** започва с `list`;
-- в параграфа **няма** картинки.
-
-Такъв ред **не** отваря нова секция — остава под текущото H1.
+Ако текстът е номерирана глава (`1. Преглед…`), реже секция дори при Heading 2 / bold / Normal (с TOC правилата от §2.1).
### 3.3. Таблици и картинки
@@ -111,10 +124,11 @@ TXT и PDF запазват своите правила (номерирани р
### 5.1. Заглавие
-1. Тагове: `h1`→ граница (ниво 1); `h2`–`h6`→ в тялото (ниво 2/3).
+1. Тагове: `h1`→ граница (ниво 1); `h2`–`h6`→ в тялото **освен** номерирана глава.
2. CSS class с `heading` / `заглавие` / `msoheading` / `überschrift` (+ опционална цифра). `Title` / `MsoTitle` / `наименование` са **корица**, не граница. `subtitle` / `подзаглавие` → в тялото.
-3. `p` или `div` с текст < 120 символа, изцяло обвит в `` / `` → в тялото (не граница).
-4. Текст „Съдържание“ / Contents / TOC (дори като `h1`) → в тялото на преамбюла.
+3. `p` или `div` с текст < 120 символа, изцяло обвит в `` / `` → в тялото, **освен** номерирана глава.
+4. Текст „Съдържание“ / Contents / TOC → в тялото + TOC фаза (§2.1).
+5. Номерирана глава `N. Title` на `p` / `h2` / bold → граница според TOC правилата.
### 5.2. Съдържание
@@ -167,9 +181,10 @@ TXT и PDF запазват своите правила (номерирани р
Следствия:
-- Корица + съдържание + първа глава (H1) → **две** секции (не четири от Title / TOC / H1 / H2).
+- Корица + съдържание + първа глава → **две** секции (не четири от Title / TOC / H2-подзаглавия).
+- Пълен документ с номерирани глави 1…N (дори като H2) → преамбюл + N секции.
- Последователни H1 без тяло („Глава 1“ веднага следвано от друг H1) остават **две** секции.
-- H2 / bold подзаглавия („Какво е …?“) не режат секция — остават в тялото на текущото H1.
+- H2 / bold подзаглавия без номерация („Какво е …?“) не режат секция.
- Untitled текст в началото на файла остава отделна секция (докато не е под прага *и* няма към какво да се слее — първият елемент не се слива).
При запис в `process_file()`:
@@ -185,8 +200,8 @@ TXT и PDF запазват своите правила (номерирани р
Типични случаи:
-- Увод преди първия Heading 1.
-- HTML/DOCX без нито едно разпознато заглавие → целият файл е една untitled секция.
+- Увод преди първата граница.
+- HTML/DOCX без нито едно разпознато заглавие/номерирана глава → целият файл е една untitled секция.
- TXT без markdown/номерация.
- Къс untitled остатък след заглавие се слива с предишната секция (§8).
diff --git a/docs/razdeljane-na-sekcii.pdf b/docs/razdeljane-na-sekcii.pdf
index 993b2b7..6bfd839 100644
Binary files a/docs/razdeljane-na-sekcii.pdf and b/docs/razdeljane-na-sekcii.pdf differ
diff --git a/help_processor.py b/help_processor.py
index 2b595eb..68122dd 100644
--- a/help_processor.py
+++ b/help_processor.py
@@ -605,6 +605,76 @@ def _is_toc_heading(text: str) -> bool:
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]:
t = _compact_style_token(token)
if not t:
@@ -779,6 +849,7 @@ def parse_html(path: Path) -> list[Section]:
sec_html: list[str] = []
sec_images: list[ImageRef] = []
img_counter = [0]
+ toc_state = _TocSplitState()
def flush():
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:
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:
txt = el.get_text(" ", strip=True)
is_cover = _html_is_cover_title(el)
@@ -809,23 +887,42 @@ def parse_html(path: Path) -> list[Section]:
current_title = txt
current_level = 1
else:
+ if toc_state.phase:
+ toc_state.leave_phase()
append_block_as_body(el)
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 not txt:
continue
- # TOC и H2+ остават в тялото — граница само при H1 / Заглавие 1
- if _is_toc_heading(txt) or heading_lvl >= 2:
+ # H2+ без номерация остават в тялото; H1 / Заглавие 1 режат
+ if heading_lvl >= 2:
+ if toc_state.phase:
+ toc_state.leave_phase()
append_block_as_body(el)
continue
- flush()
- current_title = txt
- current_level = heading_lvl
- sec_text, sec_html, sec_images = [], [], []
+ if toc_state.phase:
+ toc_state.leave_phase()
+ start_section(txt, heading_lvl)
continue
if el.name == "img":
+ if toc_state.phase:
+ toc_state.leave_phase()
# самостоятелен
(не вътре в блок)
_swap_imgs_in_block(el.parent if el.parent and el.parent.name else el,
base_dir, sec_images, img_counter)
@@ -836,6 +933,11 @@ def parse_html(path: Path) -> list[Section]:
sec_html.append(f"{img_txt}
")
continue
+ if toc_state.phase and (el.name or "").lower() in _HTML_PLAIN_NL_TAGS:
+ # / 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)
flush()
@@ -911,6 +1013,7 @@ def parse_docx(path: Path) -> list[Section]:
buf: list[str] = []
sec_images: list[ImageRef] = []
img_counter = [0]
+ toc_state = _TocSplitState()
def flush():
if current_title or buf or sec_images:
@@ -918,8 +1021,26 @@ def parse_docx(path: Path) -> list[Section]:
sec.images = list(sec_images)
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):
if kind == "tbl":
+ if toc_state.phase:
+ toc_state.leave_phase()
for line in _table_lines(block):
buf.append(line)
continue
@@ -948,48 +1069,40 @@ def parse_docx(path: Path) -> list[Section]:
current_title = text
current_level = 1
else:
- 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}]")
+ if toc_state.phase:
+ toc_state.leave_phase()
+ append_para(text, para_imgs)
continue
- # TOC и H2+/bold остават в тялото — граница само при Heading 1 / Заглавие 1
if text and _is_toc_heading(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}]")
+ toc_state.note_toc_heading()
+ append_para(text, para_imgs)
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 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}]")
+ if toc_state.phase:
+ toc_state.leave_phase()
+ append_para(text, para_imgs)
continue
if level == 1:
- flush()
- buf, sec_images = [], []
- current_title = text
- current_level = 1
+ if toc_state.phase:
+ toc_state.leave_phase()
+ start_section(text, 1)
continue
- 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}]")
+ if toc_state.phase and text and not _is_numbered_chapter_heading(text):
+ toc_state.leave_phase()
+ append_para(text, para_imgs)
flush()
diff --git a/tests/fixtures/nespertcam_launcher_chapters.html b/tests/fixtures/nespertcam_launcher_chapters.html
new file mode 100644
index 0000000..5cbdb2f
--- /dev/null
+++ b/tests/fixtures/nespertcam_launcher_chapters.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+NESPERTCAM Launcher - Пълно ръководство на потребителя
+Съдържание
+1. Преглед на приложението
+2. Инсталация и настройка
+3. Конфигурация и настройки
+1. Преглед на приложението
+Какво е NESPERTCAM Launcher?
+NESPERTCAM Launcher е специализирано Windows приложение за стартиране на NESPERTCAM64.exe.
+Основни предимства
+Преносимост и пълен екран интерфейс.
+Фигура 1: Главен интерфейс на NESPERTCAM Launcher с пълен екран режим
+2. Инсталация и настройка
+Системни изисквания
+
+| Компонент | Минимални изисквания | Препоръчителни |
+| Операционна система | Windows 10 | Windows 11 |
+
+Стъпки за инсталация следват тук с достатъчно текст за тяло на секцията.
+
+
diff --git a/tests/test_section_split_preamble.py b/tests/test_section_split_preamble.py
index f63fd75..746da79 100644
--- a/tests/test_section_split_preamble.py
+++ b/tests/test_section_split_preamble.py
@@ -1,4 +1,4 @@
-"""Корица + Съдържание + глава 1 → 2 секции (не 4)."""
+"""Корица + Съдържание + глави: преамбюл + по една секция на номерирана глава."""
from pathlib import Path
from help_processor import (
@@ -8,7 +8,9 @@ from help_processor import (
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):
@@ -16,7 +18,7 @@ def _titles(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 sections[0].title == "NESPERTCAM Launcher - Пълно ръководство на потребителя"
assert sections[1].title == "1. Преглед на приложението"
@@ -26,8 +28,27 @@ def test_nespertcam_preamble_yields_two_sections():
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():
- html = Path(__file__).parent / "fixtures" / "_tmp_h2.html"
+ html = FIXTURES / "_tmp_h2.html"
html.write_text(
"""
Глава А
@@ -64,7 +85,7 @@ def test_toc_heading_as_h1_merges_into_cover():
def test_heading1_chapters_still_split():
- html = Path(__file__).parent / "fixtures" / "_tmp_chapters.html"
+ html = FIXTURES / "_tmp_chapters.html"
html.write_text(
"""
1. Първа
Аа аа аа.