Skip to content

pagesmith.HtmlPageSplitter

Split HTML into pages, preserving HTML tags while respecting the original document structure.

Source code in src/pagesmith/html_page_splitter.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
class HtmlPageSplitter:
    """Split HTML into pages,
    preserving HTML tags while respecting the original document structure.
    """

    def __init__(
        self,
        content: str = "",
        *,
        root: etree._Element | None = None,
        target_length: int = PAGE_LENGTH_TARGET,
        error_tolerance: float = PAGE_LENGTH_ERROR_TOLERANCE,
    ) -> None:
        """Initialize the HTML page splitter.

        Args:
            content: HTML content string to split (optional if root is provided)
            root: Parsed lxml element tree root (optional if content is provided)
            target_length: Target size for each page in characters
            error_tolerance: Tolerance for page size variation (default 0.25)
        """
        self.target_page_size = target_length
        self.max_size = int(target_length * (1 + error_tolerance))
        self.max_error = self.max_size - target_length

        if root is not None:
            self.root = root
        elif content is not None and content.strip():
            self.root = parse_partial_html(content)
        else:
            self.root = None

    def pages(self) -> Iterator[str]:
        """Split content into pages."""
        if self.root is None:
            return
        current_page: list[etree.Element] = []
        current_size = 0

        for element in self._split_element(self.root):
            element_size = self._get_element_size(element)

            # Check if adding this element would exceed page size
            if current_size + element_size > self.max_size and current_page:
                yield self._render_page(current_page)
                current_page = []
                current_size = 0

            current_page.append(element)
            current_size += element_size

        # Yield final page if there's content
        if current_page:
            yield self._render_page(current_page)

    def _split_element(self, element: etree._Element) -> Iterator[etree._Element]:  # noqa: PLR0915,PLR0912,C901
        """Recursively split an element if it's too large."""
        element_size = self._get_element_size(element)

        # If element is small enough, yield it as is
        if element_size <= self.max_size:
            # Special handling for body elements - yield their children instead
            if getattr(element, "tag", None) == "body":
                for child in element:
                    yield copy.deepcopy(child)
                return
            yield copy.deepcopy(element)
            return

        # Element is too large - try to split it

        if getattr(element, "tag", None) == "body":
            # Treat body's children as if they were at the root level
            for child in element:
                yield from self._split_element(child)
            return

        # If element has no children, it must have large text content or tail
        if len(element) == 0:
            yield from self._split_text_element(element)
            return

        # Element has children - we need to handle:
        # 1. element.text (before first child)
        # 2. each child and its tail text
        # We'll build up a list of content items and then group them into pages

        content_items: list[tuple[str, Any]] = []

        # Handle text before first child
        if element.text and element.text.strip():
            if len(element.text) > self.max_size:
                # Split the text
                text_chunks = self._split_text(element.text)
                content_items.extend(("text", chunk) for chunk in text_chunks)
            else:
                content_items.append(("text", element.text))

        # Process each child and its tail
        for child in element:
            # Recursively split the child
            content_items.extend(
                ("element", split_child) for split_child in self._split_element(child)
            )
            # Handle tail text of the original child
            if child.tail and child.tail.strip():
                if len(child.tail) > self.target_page_size:
                    # Split the tail text
                    tail_chunks = self._split_text(child.tail)
                    content_items.extend(("tail", chunk) for chunk in tail_chunks)
                else:
                    content_items.append(("tail", child.tail))

        # Now group content items into pages
        nsmap = _get_nsmap(element.attrib)
        current_shell = etree.Element(element.tag, nsmap=nsmap)
        _set_attributes(current_shell, element.attrib, nsmap)
        current_size = 0

        for item_type, item_content in content_items:
            item_size = 0  # initialized here only to satisfy type checker
            if item_type == "text":
                # This is text that goes at the beginning of the element
                if not current_shell.text:
                    current_shell.text = item_content
                else:
                    current_shell.text += item_content
                item_size = len(item_content)
            elif item_type == "element":
                # This is a child element
                item_size = self._get_element_size(item_content)
                if current_size + item_size > self.target_page_size and (
                    len(current_shell) > 0 or current_shell.text
                ):
                    # Yield current shell and start a new one
                    yield current_shell
                    current_shell = etree.Element(element.tag, nsmap=nsmap)
                    _set_attributes(current_shell, element.attrib, nsmap)
                    current_size = 0
                current_shell.append(item_content)
            elif item_type == "tail":
                # This is tail text that should follow the last child
                item_size = len(item_content)
                if current_size + item_size > self.target_page_size and (
                    len(current_shell) > 0 or current_shell.text
                ):
                    # Yield current shell and start a new one
                    yield current_shell
                    current_shell = etree.Element(element.tag, nsmap=nsmap)
                    _set_attributes(current_shell, element.attrib, nsmap)
                    current_size = 0

                if len(current_shell) > 0:
                    # Add as tail to the last child
                    last_child = current_shell[-1]
                    if last_child.tail:
                        last_child.tail += item_content
                    else:
                        last_child.tail = item_content
                # No children yet, add as text
                elif current_shell.text:
                    current_shell.text += item_content
                else:
                    current_shell.text = item_content

            current_size += item_size

        # Yield final shell if it has content
        if len(current_shell) > 0 or current_shell.text:
            yield current_shell

    def _split_text_element(self, element: etree._Element) -> Iterator[etree._Element]:
        """Split an element that has only text content."""
        text = element.text or ""
        if not text:
            yield copy.deepcopy(element)
            return

        chunks = self._split_text(text)
        nsmap = _get_nsmap(element.attrib)
        for chunk in chunks:
            new_elem = etree.Element(element.tag, nsmap=nsmap)
            _set_attributes(new_elem, element.attrib, nsmap)
            new_elem.text = chunk
            yield new_elem

    def _split_text(self, text: str) -> list[str]:
        """Split text into chunks that fit within page size."""
        if not text or len(text) <= self.target_page_size:
            return [text]

        chunks = []
        start = 0

        while start < len(text):
            # Find end position for this chunk
            end = start + self.target_page_size

            if end >= len(text):
                chunks.append(text[start:])
                break

            # Find a good break point
            break_pos = self._find_break_point(text, start, end)
            chunks.append(text[start:break_pos])
            start = break_pos

        return chunks

    def _find_break_point(self, text: str, start: int, target_end: int) -> int:
        """Find the best position to break text."""
        if target_end >= len(text):
            return len(text)

        # Look for break points in order of preference
        break_sequences = [
            "\n\n",  # Paragraph break
            ".\n",  # Sentence end with newline
            "!\n",  # Exclamation with newline
            "?\n",  # Question with newline
            ". ",  # Sentence end
            "! ",  # Exclamation
            "? ",  # Question
            "\n",  # Line break
            "; ",  # Semicolon
            ", ",  # Comma
            " ",  # Space
        ]

        search_start = max(start, target_end - self.max_error)
        search_end = min(len(text), target_end + self.max_error)

        for break_seq in break_sequences:
            # Search backward from target
            for pos in range(target_end, search_start, -1):
                if text[pos : pos + len(break_seq)] == break_seq:
                    return pos + len(break_seq)

            # Search forward from target (but not too far to avoid exceeding limits)
            for pos in range(target_end, search_end):
                if text[pos : pos + len(break_seq)] == break_seq:
                    return pos + len(break_seq)

        # No good break point found - break at target
        return target_end

    def _get_element_size(self, element: etree._Element) -> int:
        """Calculate the size of an element in characters."""
        return len(etree.tostring(element, method="text", encoding="unicode").strip())

    def _render_page(self, elements: list[etree._Element]) -> str:
        """Render a page from a list of elements."""
        if len(elements) == 1:
            return etree_to_str(elements[0])
        return "".join(etree_to_str(elem) for elem in elements)

__init__(content='', *, root=None, target_length=PAGE_LENGTH_TARGET, error_tolerance=PAGE_LENGTH_ERROR_TOLERANCE)

Initialize the HTML page splitter.

Parameters:

Name Type Description Default
content str

HTML content string to split (optional if root is provided)

''
root _Element | None

Parsed lxml element tree root (optional if content is provided)

None
target_length int

Target size for each page in characters

PAGE_LENGTH_TARGET
error_tolerance float

Tolerance for page size variation (default 0.25)

PAGE_LENGTH_ERROR_TOLERANCE
Source code in src/pagesmith/html_page_splitter.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def __init__(
    self,
    content: str = "",
    *,
    root: etree._Element | None = None,
    target_length: int = PAGE_LENGTH_TARGET,
    error_tolerance: float = PAGE_LENGTH_ERROR_TOLERANCE,
) -> None:
    """Initialize the HTML page splitter.

    Args:
        content: HTML content string to split (optional if root is provided)
        root: Parsed lxml element tree root (optional if content is provided)
        target_length: Target size for each page in characters
        error_tolerance: Tolerance for page size variation (default 0.25)
    """
    self.target_page_size = target_length
    self.max_size = int(target_length * (1 + error_tolerance))
    self.max_error = self.max_size - target_length

    if root is not None:
        self.root = root
    elif content is not None and content.strip():
        self.root = parse_partial_html(content)
    else:
        self.root = None

pages()

Split content into pages.

Source code in src/pagesmith/html_page_splitter.py
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def pages(self) -> Iterator[str]:
    """Split content into pages."""
    if self.root is None:
        return
    current_page: list[etree.Element] = []
    current_size = 0

    for element in self._split_element(self.root):
        element_size = self._get_element_size(element)

        # Check if adding this element would exceed page size
        if current_size + element_size > self.max_size and current_page:
            yield self._render_page(current_page)
            current_page = []
            current_size = 0

        current_page.append(element)
        current_size += element_size

    # Yield final page if there's content
    if current_page:
        yield self._render_page(current_page)

pagesmith.PageSplitter

Split pure text into pages at natural break points such as paragraphs or sentences.

Source code in src/pagesmith/page_splitter.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
class PageSplitter:
    """Split pure text into pages at natural break points such as paragraphs or sentences."""

    def __init__(
        self,
        text: str,
        *,
        start: int = 0,
        end: int = 0,
        target_length: int = PAGE_LENGTH_TARGET,
        error_tolerance: float = PAGE_LENGTH_ERROR_TOLERANCE,
    ):
        self.text = text
        self.start = start
        self.end = len(text) if end == 0 else end
        assert 0 < error_tolerance < 1
        self.target_length = target_length
        self.error_tolerance = error_tolerance
        self.min_length = int(
            self.target_length * (1 - self.error_tolerance),
        )
        self.max_length = int(
            self.target_length * (1 + self.error_tolerance),
        )

    def pages(self) -> Iterator[str]:
        """Split a text into pages of approximately equal length.

        Also clear headings and recollect them during pages generation.
        """
        chapter_detector = ChapterDetector()
        start = self.start
        page_num = 0
        while start < self.end:
            page_num += 1
            end = self.find_nearest_page_end(start)

            # Check for chapters near the end of the current page segment
            chapter_search_text = self.text[start + self.min_length : start + self.max_length]
            if chapters := chapter_detector.get_chapters(chapter_search_text):
                # Find the chapter position that is nearest to the target page size
                target_position = start + self.target_length
                # Use the position field from ChapterMatch to find the nearest chapter
                nearest_chapter_idx = min(
                    range(len(chapters)),
                    key=lambda i: abs(
                        (start + self.min_length + chapters[i].position) - target_position,
                    ),
                )
                # Set end to the start of the nearest chapter
                end = start + self.min_length + chapters[nearest_chapter_idx].position

            yield self.normalize(self.text[start:end])
            assert end > start
            start = end

    def normalize(self, text: str) -> str:
        text = re.sub(r"\r", "", text)
        return re.sub(r"[ \t]+", " ", text)

    def find_nearest_page_end_match(
        self,
        page_start_index: int,
        pattern: re.Pattern[str],
    ) -> int | None:
        """Find the nearest regex match around expected end of page.

        In no such match in the vicinity, return None.
        Calculate the vicinity based on the expected PAGE_LENGTH_TARGET
        and PAGE_LENGTH_ERROR_TOLERANCE.
        """
        end_pos = min(
            page_start_index + int(self.max_length),
            self.end,
        )
        start_pos = max(
            page_start_index + int(self.min_length),
            self.start,
        )
        ends = [match.end() for match in pattern.finditer(self.text, start_pos, end_pos)]
        return (
            min(ends, key=lambda x: abs(x - (page_start_index + self.target_length)))
            if ends
            else None
        )

    def find_nearest_page_end(self, page_start_index: int) -> int:
        """Find the nearest page end."""
        patterns = [  # sorted by priority
            re.compile(r"(\r?\n|\u2028|\u2029)(\s*(\r?\n|\u2028|\u2029))+"),  # Paragraph end
            re.compile(r"(\r?\n|\u2028|\u2029)"),  # Line end
            re.compile(r"[^\s.!?]*\w[^\s.!?]*[.!?]\s"),  # Sentence end
            re.compile(r"\w+\b"),  # Word end
        ]

        for pattern in patterns:
            if nearest_page_end := self.find_nearest_page_end_match(
                page_start_index,
                pattern,
            ):
                return self.handle_p_tag_split(page_start_index, nearest_page_end)

        # If no suitable end found, return the maximum allowed length
        return min(page_start_index + self.end, page_start_index + self.target_length)

    def handle_p_tag_split(self, page_start_index: int, nearest_page_end: int) -> int:
        """Find the position of the last closing </p> tag before the split."""
        # todo: remove that - we work with with plain text here
        last_open_p_tag_pos = self.text.find("<p", page_start_index, nearest_page_end)
        last_close_p_tag_pos = self.text.rfind("</p>", page_start_index, nearest_page_end)

        if last_open_p_tag_pos != -1 and (
            last_close_p_tag_pos == -1 or last_close_p_tag_pos < last_open_p_tag_pos
        ):
            # Split <p> between pages
            self.text = f"{self.text[:nearest_page_end]}</p><p>{self.text[nearest_page_end:]}"
            nearest_page_end += len("</p>")

        return nearest_page_end

find_nearest_page_end(page_start_index)

Find the nearest page end.

Source code in src/pagesmith/page_splitter.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def find_nearest_page_end(self, page_start_index: int) -> int:
    """Find the nearest page end."""
    patterns = [  # sorted by priority
        re.compile(r"(\r?\n|\u2028|\u2029)(\s*(\r?\n|\u2028|\u2029))+"),  # Paragraph end
        re.compile(r"(\r?\n|\u2028|\u2029)"),  # Line end
        re.compile(r"[^\s.!?]*\w[^\s.!?]*[.!?]\s"),  # Sentence end
        re.compile(r"\w+\b"),  # Word end
    ]

    for pattern in patterns:
        if nearest_page_end := self.find_nearest_page_end_match(
            page_start_index,
            pattern,
        ):
            return self.handle_p_tag_split(page_start_index, nearest_page_end)

    # If no suitable end found, return the maximum allowed length
    return min(page_start_index + self.end, page_start_index + self.target_length)

find_nearest_page_end_match(page_start_index, pattern)

Find the nearest regex match around expected end of page.

In no such match in the vicinity, return None. Calculate the vicinity based on the expected PAGE_LENGTH_TARGET and PAGE_LENGTH_ERROR_TOLERANCE.

Source code in src/pagesmith/page_splitter.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def find_nearest_page_end_match(
    self,
    page_start_index: int,
    pattern: re.Pattern[str],
) -> int | None:
    """Find the nearest regex match around expected end of page.

    In no such match in the vicinity, return None.
    Calculate the vicinity based on the expected PAGE_LENGTH_TARGET
    and PAGE_LENGTH_ERROR_TOLERANCE.
    """
    end_pos = min(
        page_start_index + int(self.max_length),
        self.end,
    )
    start_pos = max(
        page_start_index + int(self.min_length),
        self.start,
    )
    ends = [match.end() for match in pattern.finditer(self.text, start_pos, end_pos)]
    return (
        min(ends, key=lambda x: abs(x - (page_start_index + self.target_length)))
        if ends
        else None
    )

handle_p_tag_split(page_start_index, nearest_page_end)

Find the position of the last closing

tag before the split.

Source code in src/pagesmith/page_splitter.py
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def handle_p_tag_split(self, page_start_index: int, nearest_page_end: int) -> int:
    """Find the position of the last closing </p> tag before the split."""
    # todo: remove that - we work with with plain text here
    last_open_p_tag_pos = self.text.find("<p", page_start_index, nearest_page_end)
    last_close_p_tag_pos = self.text.rfind("</p>", page_start_index, nearest_page_end)

    if last_open_p_tag_pos != -1 and (
        last_close_p_tag_pos == -1 or last_close_p_tag_pos < last_open_p_tag_pos
    ):
        # Split <p> between pages
        self.text = f"{self.text[:nearest_page_end]}</p><p>{self.text[nearest_page_end:]}"
        nearest_page_end += len("</p>")

    return nearest_page_end

pages()

Split a text into pages of approximately equal length.

Also clear headings and recollect them during pages generation.

Source code in src/pagesmith/page_splitter.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
def pages(self) -> Iterator[str]:
    """Split a text into pages of approximately equal length.

    Also clear headings and recollect them during pages generation.
    """
    chapter_detector = ChapterDetector()
    start = self.start
    page_num = 0
    while start < self.end:
        page_num += 1
        end = self.find_nearest_page_end(start)

        # Check for chapters near the end of the current page segment
        chapter_search_text = self.text[start + self.min_length : start + self.max_length]
        if chapters := chapter_detector.get_chapters(chapter_search_text):
            # Find the chapter position that is nearest to the target page size
            target_position = start + self.target_length
            # Use the position field from ChapterMatch to find the nearest chapter
            nearest_chapter_idx = min(
                range(len(chapters)),
                key=lambda i: abs(
                    (start + self.min_length + chapters[i].position) - target_position,
                ),
            )
            # Set end to the start of the nearest chapter
            end = start + self.min_length + chapters[nearest_chapter_idx].position

        yield self.normalize(self.text[start:end])
        assert end > start
        start = end

pagesmith.ChapterDetector

Detect chapters in pure text to create a Table of Contents.

Source code in src/pagesmith/chapter_detector.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
class ChapterDetector:
    """Detect chapters in pure text to create a Table of Contents."""

    def __init__(self, min_chapter_distance: int = MIN_CHAPTER_DISTANCE) -> None:
        """
        min_chapter_distance: Minimum character distance required between chapters.
            Chapter signatures detected within this threshold from previously detected chapters
            will be ignored. Default is 20 characters.
        """
        self.min_chapter_distance = min_chapter_distance

    def get_chapters(self, page_text: str) -> list[Chapter]:
        """Detect chapter headings in the text.

        Return a list of Chapter objects containing:
        - title: The chapter title
        - position: The character position in the text where the chapter starts
        """
        patterns = self.prepare_chapter_patterns()
        chapters: list[Chapter] = []
        for pattern in patterns:
            for match in pattern.finditer(page_text):
                title = re.sub(r"([\s\r\t\n]|<br/>)+", " ", match.group("title")).strip()
                position = match.start("title")
                chapters.append(
                    Chapter(
                        title=title,
                        position=position,
                    ),
                )
        return self._deduplicate_chapters(chapters)

    def _deduplicate_chapters(self, chapters: list[Chapter]) -> list[Chapter]:
        """Deduplicate chapters based on position and title."""
        if not chapters:
            return []
        sorted_chapters = sorted(chapters, key=lambda c: c.position)

        deduplicated: list[Chapter] = []
        seen_titles = set()

        for chapter in sorted_chapters:
            if chapter.title in seen_titles:
                continue
            if (
                not deduplicated
                or abs(deduplicated[-1].position - chapter.position) >= self.min_chapter_distance
            ):
                deduplicated.append(chapter)
                seen_titles.add(chapter.title)
        return deduplicated

    def prepare_chapter_patterns(self) -> list[re.Pattern[str]]:  # pylint: disable=too-many-locals
        """Prepare regex patterns for detecting chapter headings."""
        # Form 1: Chapter I, Chapter 1, Chapter the First, CHAPTER 1
        # Ways of enumerating chapters, e.g.
        space = r"[ \t]"
        line_sep = rf"{space}*(\r?\n|\u2028|\u2029|{space}*<br\/>{space}*)"
        arabic_numerals = r"\d+"
        roman_numerals = "(?=[MDCLXVI])M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})"
        number_words_by_tens_list = [
            "twenty",
            "thirty",
            "forty",
            "fifty",
            "sixty",
            "seventy",
            "eighty",
            "ninety",
        ]
        number_words_list = [
            "one",
            "two",
            "three",
            "four",
            "five",
            "six",
            "seven",
            "eight",
            "nine",
            "ten",
            "eleven",
            "twelve",
            "thirteen",
            "fourteen",
            "fifteen",
            "sixteen",
            "seventeen",
            "eighteen",
            "nineteen",
        ] + number_words_by_tens_list
        number_word = "(" + "|".join(number_words_list) + ")"
        ordinal_number_words_by_tens_list = [
            "twentieth",
            "thirtieth",
            "fortieth",
            "fiftieth",
            "sixtieth",
            "seventieth",
            "eightieth",
            "ninetieth",
        ] + number_words_by_tens_list
        ordinal_number_words_list = (
            [
                "first",
                "second",
                "third",
                "fourth",
                "fifth",
                "sixth",
                "seventh",
                "eighth",
                "ninth",
                "twelfth",
                "last",
            ]
            + [f"{numberWord}th" for numberWord in number_words_list]
        ) + ordinal_number_words_by_tens_list
        ordinal_word = "(the )?(" + "|".join(ordinal_number_words_list) + ")"
        enumerators = rf"({arabic_numerals}|{roman_numerals}|{number_word}|{ordinal_word})"
        chapter_name = r"[\w \t '`\"\.’\?!:\/-]{1,120}"
        name_line = rf"{line_sep}{space}*{chapter_name}{space}*"

        templ_key_word = (
            rf"(chapter|glava|глава|часть|том){space}+"
            rf"({enumerators}(\.|{space}){space}*)?({space}*{chapter_name})?({name_line})?"
        )
        templ_numbered = (
            rf"({arabic_numerals}|{roman_numerals})\.{space}*({chapter_name})?({name_line})?"
        )
        templ_numbered_dbl_empty_line = (
            rf"({arabic_numerals}|{roman_numerals})"
            rf"(\.|{space}){space}*({chapter_name})?({name_line})?{line_sep}"
        )
        prefix = rf"({line_sep}{line_sep}|\A)"
        return [
            re.compile(
                rf"{prefix}(?P<title>{templ_key_word}){line_sep}{line_sep}",
                re.IGNORECASE,
            ),
            re.compile(
                rf"{prefix}(?P<title>{templ_numbered}){line_sep}{line_sep}",
                re.IGNORECASE,
            ),
            re.compile(
                rf"{prefix}(?P<title>{templ_numbered_dbl_empty_line}){line_sep}{line_sep}",
                re.IGNORECASE,
            ),
        ]

__init__(min_chapter_distance=MIN_CHAPTER_DISTANCE)

Minimum character distance required between chapters.

Chapter signatures detected within this threshold from previously detected chapters will be ignored. Default is 20 characters.

Source code in src/pagesmith/chapter_detector.py
23
24
25
26
27
28
29
def __init__(self, min_chapter_distance: int = MIN_CHAPTER_DISTANCE) -> None:
    """
    min_chapter_distance: Minimum character distance required between chapters.
        Chapter signatures detected within this threshold from previously detected chapters
        will be ignored. Default is 20 characters.
    """
    self.min_chapter_distance = min_chapter_distance

get_chapters(page_text)

Detect chapter headings in the text.

Return a list of Chapter objects containing: - title: The chapter title - position: The character position in the text where the chapter starts

Source code in src/pagesmith/chapter_detector.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def get_chapters(self, page_text: str) -> list[Chapter]:
    """Detect chapter headings in the text.

    Return a list of Chapter objects containing:
    - title: The chapter title
    - position: The character position in the text where the chapter starts
    """
    patterns = self.prepare_chapter_patterns()
    chapters: list[Chapter] = []
    for pattern in patterns:
        for match in pattern.finditer(page_text):
            title = re.sub(r"([\s\r\t\n]|<br/>)+", " ", match.group("title")).strip()
            position = match.start("title")
            chapters.append(
                Chapter(
                    title=title,
                    position=position,
                ),
            )
    return self._deduplicate_chapters(chapters)

prepare_chapter_patterns()

Prepare regex patterns for detecting chapter headings.

Source code in src/pagesmith/chapter_detector.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def prepare_chapter_patterns(self) -> list[re.Pattern[str]]:  # pylint: disable=too-many-locals
    """Prepare regex patterns for detecting chapter headings."""
    # Form 1: Chapter I, Chapter 1, Chapter the First, CHAPTER 1
    # Ways of enumerating chapters, e.g.
    space = r"[ \t]"
    line_sep = rf"{space}*(\r?\n|\u2028|\u2029|{space}*<br\/>{space}*)"
    arabic_numerals = r"\d+"
    roman_numerals = "(?=[MDCLXVI])M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})"
    number_words_by_tens_list = [
        "twenty",
        "thirty",
        "forty",
        "fifty",
        "sixty",
        "seventy",
        "eighty",
        "ninety",
    ]
    number_words_list = [
        "one",
        "two",
        "three",
        "four",
        "five",
        "six",
        "seven",
        "eight",
        "nine",
        "ten",
        "eleven",
        "twelve",
        "thirteen",
        "fourteen",
        "fifteen",
        "sixteen",
        "seventeen",
        "eighteen",
        "nineteen",
    ] + number_words_by_tens_list
    number_word = "(" + "|".join(number_words_list) + ")"
    ordinal_number_words_by_tens_list = [
        "twentieth",
        "thirtieth",
        "fortieth",
        "fiftieth",
        "sixtieth",
        "seventieth",
        "eightieth",
        "ninetieth",
    ] + number_words_by_tens_list
    ordinal_number_words_list = (
        [
            "first",
            "second",
            "third",
            "fourth",
            "fifth",
            "sixth",
            "seventh",
            "eighth",
            "ninth",
            "twelfth",
            "last",
        ]
        + [f"{numberWord}th" for numberWord in number_words_list]
    ) + ordinal_number_words_by_tens_list
    ordinal_word = "(the )?(" + "|".join(ordinal_number_words_list) + ")"
    enumerators = rf"({arabic_numerals}|{roman_numerals}|{number_word}|{ordinal_word})"
    chapter_name = r"[\w \t '`\"\.’\?!:\/-]{1,120}"
    name_line = rf"{line_sep}{space}*{chapter_name}{space}*"

    templ_key_word = (
        rf"(chapter|glava|глава|часть|том){space}+"
        rf"({enumerators}(\.|{space}){space}*)?({space}*{chapter_name})?({name_line})?"
    )
    templ_numbered = (
        rf"({arabic_numerals}|{roman_numerals})\.{space}*({chapter_name})?({name_line})?"
    )
    templ_numbered_dbl_empty_line = (
        rf"({arabic_numerals}|{roman_numerals})"
        rf"(\.|{space}){space}*({chapter_name})?({name_line})?{line_sep}"
    )
    prefix = rf"({line_sep}{line_sep}|\A)"
    return [
        re.compile(
            rf"{prefix}(?P<title>{templ_key_word}){line_sep}{line_sep}",
            re.IGNORECASE,
        ),
        re.compile(
            rf"{prefix}(?P<title>{templ_numbered}){line_sep}{line_sep}",
            re.IGNORECASE,
        ),
        re.compile(
            rf"{prefix}(?P<title>{templ_numbered_dbl_empty_line}){line_sep}{line_sep}",
            re.IGNORECASE,
        ),
    ]

pagesmith.refine_html

collapse_consecutive_br(root, keep_empty_tags_set, ids_to_keep_set)

From
tags sequence, keep only the first one.

This function searches for consecutive
tags and removes all but the first one in each sequence. Whitespace between
tags is ignored for determining consecutive tags.

Parameters:

Name Type Description Default
root Element

The root element of the lxml tree

required
Source code in src/pagesmith/refine_html.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def collapse_consecutive_br(  # noqa: C901,PLR0912,PLR0915
    root: etree.Element,
    keep_empty_tags_set: set[str],
    ids_to_keep_set: set[str],
) -> None:
    """From <br> tags sequence, keep only the first one.

    This function searches for consecutive <br> tags and removes all but the first one
    in each sequence. Whitespace between <br> tags is ignored for determining consecutive tags.

    Args:
        root: The root element of the lxml tree
    """
    # Process each element in the tree
    for element in root.iter():
        # Skip the root element itself
        if element is root:
            continue

        # Get all children as a list
        children = list(element)
        if not children:
            continue

        # We'll track if we've seen a <br> and which one it was
        last_br = None
        br_tags_to_remove: list[etree.Element] = []

        # Check each child
        for child in children:
            if child.tag == "br":
                if last_br is not None and (not last_br.tail or not last_br.tail.strip()):
                    # This is a consecutive <br>, mark it for removal
                    br_tags_to_remove.append(child)
                else:
                    # This is the first <br> in a potential sequence
                    last_br = child
            # This is not a <br> tag
            # If the child has meaningful text content, reset last_br
            elif (child.text and child.text.strip()) or has_meaningful_content(
                child,
                keep_empty_tags_set,
                ids_to_keep_set,
            ):
                last_br = None

        # Remove consecutive <br> tags and preserve their tail text
        for br_tag in br_tags_to_remove:
            if br_tag.tail:
                # There's text after this <br> that needs to be preserved
                # Add it to the tail of the first <br> or another previous sibling
                if last_br is not None:
                    if last_br.tail:
                        last_br.tail += br_tag.tail
                    else:
                        last_br.tail = br_tag.tail
                # If no first <br> (shouldn't happen), add to parent's text
                elif element.text:
                    element.text += br_tag.tail
                else:
                    element.text = br_tag.tail

            # Now remove the br tag
            parent = br_tag.getparent()
            if parent is not None:
                parent.remove(br_tag)

has_meaningful_content(element, keep_empty_tags_set, ids_to_keep_set, check_tail=True)

Check if element/children has non-whitespace content or in the keep_empty_tags_set.

Source code in src/pagesmith/refine_html.py
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def has_meaningful_content(
    element: etree.Element,
    keep_empty_tags_set: set[str],
    ids_to_keep_set: set[str],
    check_tail: bool = True,
) -> bool:
    """Check if element/children has non-whitespace content or in the `keep_empty_tags_set`."""
    if element.tag in keep_empty_tags_set:
        return True

    element_id = element.get("id", "")
    if element_id in ids_to_keep_set:
        return True

    if element.text and re.sub(r"\s+", " ", element.text).strip():
        return True

    if check_tail and element.tail and re.sub(r"\s+", " ", element.tail).strip():
        return True

    return any(
        has_meaningful_content(child, keep_empty_tags_set, ids_to_keep_set) for child in element
    )

process_class_and_style(root, tags_with_classes)

Remove class and style attributes from elements not in tags_with_classes.

Source code in src/pagesmith/refine_html.py
308
309
310
311
312
313
314
315
316
317
def process_class_and_style(root: etree.Element, tags_with_classes: dict[str, str]) -> None:
    """Remove class and style attributes from elements not in tags_with_classes."""
    for element in root.iter():
        if "class" in element.attrib and element.tag not in tags_with_classes:
            del element.attrib["class"]
        if "style" in element.attrib:
            del element.attrib["style"]

        if element.tag in tags_with_classes:
            element.set("class", tags_with_classes[element.tag])

refine_html(input_html=None, *, root=None, allowed_tags=ALLOWED_TAGS, tags_to_remove_with_content=REMOVE_WITH_CONTENT, keep_empty_tags=KEEP_EMPTY_TAGS, ids_to_keep=(), tags_with_classes=None)

Sanitize and normalize HTML content.

Parameters:

Name Type Description Default
input_html str | None

HTML string to clean

None
root Optional[Element]

Alternatively instead of input_html - lxml tree root element

None
allowed_tags Iterable[str]

Tags that are allowed in the output HTML

ALLOWED_TAGS
tags_to_remove_with_content Iterable[str]

Tags to be completely removed along with their content

REMOVE_WITH_CONTENT
keep_empty_tags Iterable[str]

Tags that should be kept even if they have no content

KEEP_EMPTY_TAGS
ids_to_keep Iterable[str]

IDs that should be kept even if their tags are not in allowed_tags

()
tags_with_classes dict[str, str] | None

Dictionary mapping tag names to class strings to add

None

Returns:

Type Description
str

Cleaned HTML string

Source code in src/pagesmith/refine_html.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
def refine_html(  # noqa: PLR0915,PLR0912,PLR0913,C901
    input_html: str | None = None,
    *,
    root: Optional[etree.Element] = None,  # noqa
    allowed_tags: Iterable[str] = ALLOWED_TAGS,
    tags_to_remove_with_content: Iterable[str] = REMOVE_WITH_CONTENT,
    keep_empty_tags: Iterable[str] = KEEP_EMPTY_TAGS,
    ids_to_keep: Iterable[str] = (),
    tags_with_classes: dict[str, str] | None = None,
) -> str:
    """
    Sanitize and normalize HTML content.

    Args:
        input_html: HTML string to clean
        root: Alternatively instead of input_html - lxml tree root element
        allowed_tags: Tags that are allowed in the output HTML
        tags_to_remove_with_content: Tags to be completely removed along with their content
        keep_empty_tags: Tags that should be kept even if they have no content
        ids_to_keep: IDs that should be kept even if their tags are not in allowed_tags
        tags_with_classes: Dictionary mapping tag names to class strings to add

    Returns:
        Cleaned HTML string
    """
    if not input_html and root is None:
        return ""

    if root is None:
        try:
            root = parse_partial_html(input_html)  # type: ignore[arg-type]  # we already check for None
        except Exception:
            logger.exception("Failed to parse HTML, returning original")
            return input_html or ""

    # Convert to sets for faster lookups
    tags_to_remove_set = set(tags_to_remove_with_content)
    keep_empty_tags_set = set(keep_empty_tags)
    allowed_tags_set = set(allowed_tags) | set(keep_empty_tags)
    ids_to_keep_set = set(ids_to_keep)
    if tags_with_classes is None:
        tags_with_classes = TAGS_WITH_CLASSES

    remove_tags_with_content(root, tags_to_remove_set)
    unwrap_unknow_tags(allowed_tags_set, ids_to_keep_set, root)
    process_class_and_style(root, tags_with_classes)
    remove_empty_elements(ids_to_keep_set, keep_empty_tags_set, root)
    collapse_consecutive_br(root, keep_empty_tags_set, ids_to_keep_set)

    return re.sub(r"\s+", " ", etree_to_str(root)).strip()

remove_empty_elements(ids_to_keep_set, keep_empty_tags_set, root)

Remove empty elements and divs that contain only
tags and whitespace.

Parameters:

Name Type Description Default
ids_to_keep_set set[str]

Set of element IDs that should be preserved

required
keep_empty_tags_set set[str]

Set of tags that should be kept even when empty

required
root Element

The root element of the lxml tree

required

Returns:

Type Description
None

List of removed elements

Source code in src/pagesmith/refine_html.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def remove_empty_elements(  # noqa: PLR0912,C901,PLR0915
    ids_to_keep_set: set[str],
    keep_empty_tags_set: set[str],
    root: etree.Element,
) -> None:
    """Remove empty elements and divs that contain only <br> tags and whitespace.

    Args:
        ids_to_keep_set: Set of element IDs that should be preserved
        keep_empty_tags_set: Set of tags that should be kept even when empty
        root: The root element of the lxml tree

    Returns:
        List of removed elements
    """
    # We'll iterate until no more elements are removed
    elements_removed = True
    elements_to_remove: list[etree.Element] = []

    while elements_removed:
        elements_removed = False
        elements_to_remove = []

        for element in root.iter():
            if element is root:
                continue

            element_id = element.get("id", "")
            if element_id in ids_to_keep_set:
                continue

            # Skip elements that should be kept even when empty
            if element.tag in keep_empty_tags_set:
                continue

            if not has_meaningful_content(element, keep_empty_tags_set - {"br"}, ids_to_keep_set):
                # Get the parent and prepare to remove this element
                parent = element.getparent()
                if parent is not None:
                    # Preserve any tail text
                    tail = element.tail
                    elements_to_remove.append((element, tail))
                    elements_removed = True
                    continue

            # Check if element is completely empty
            if (  # noqa: SIM102
                not has_meaningful_content(
                    element,
                    keep_empty_tags_set,
                    ids_to_keep_set,
                    check_tail=False,
                )
                and element.getparent() is not None
            ):
                tail = element.tail
                elements_to_remove.append((element, tail))
                elements_removed = True

        # Remove elements identified in this iteration
        for element, tail in elements_to_remove:
            parent = element.getparent()
            if parent is not None:
                # Preserve tail text by adding it to previous sibling or parent
                if tail:
                    previous = element.getprevious()
                    if previous is not None:
                        if previous.tail:
                            previous.tail += tail
                        else:
                            previous.tail = tail
                    elif parent.text:
                        parent.text += tail
                    else:
                        parent.text = tail

                # We need to preserve the content of any children before removing
                for child in element:
                    # If the child has tail text, we need to preserve it
                    if child.tail and child.tail.strip():
                        # Add to parent's text or previous sibling's tail
                        previous = element.getprevious()
                        if previous is not None:
                            if previous.tail:
                                previous.tail += child.tail
                            else:
                                previous.tail = child.tail
                        elif parent.text:
                            parent.text += child.tail
                        else:
                            parent.text = child.tail

                # Remove the element
                parent.remove(element)

        # If we removed elements, we need to check again
        if elements_removed:
            continue

remove_tags_with_content(root, tags_to_remove_set)

Remove specified tags along with their content.

Source code in src/pagesmith/refine_html.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
def remove_tags_with_content(root: etree.Element, tags_to_remove_set: set[str]) -> None:
    """Remove specified tags along with their content."""
    for tag in tags_to_remove_set:
        for element in root.xpath(f"//{tag}"):
            if element is not root:
                parent = element.getparent()
                if parent is not None:
                    # Preserve tail text if present
                    if element.tail and element.tail.strip():
                        prev = element.getprevious()
                        if prev is not None:
                            if prev.tail:
                                prev.tail += element.tail
                            else:
                                prev.tail = element.tail
                        elif parent.text:
                            parent.text += element.tail
                        else:
                            parent.text = element.tail

                    # Remove the element
                    parent.remove(element)

unwrap_unknow_tags(allowed_tags_set, ids_to_keep_set, root)

Unwrap tags that are not in the allowed set but preserve their content.

Source code in src/pagesmith/refine_html.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
def unwrap_unknow_tags(  # noqa: C901,PLR0912
    allowed_tags_set: set[str],
    ids_to_keep_set: set[str],
    root: etree.Element,
) -> None:
    """Unwrap tags that are not in the allowed set but preserve their content."""
    elements_to_unwrap = []
    for element in root.iter():
        if element is root:
            continue

        tag_name = element.tag
        element_id = element.get("id", "")

        # Only unwrap if tag is not allowed AND doesn't have a protected ID
        if tag_name not in allowed_tags_set and (
            not element_id or element_id not in ids_to_keep_set
        ):
            elements_to_unwrap.append(element)

    # Process in reverse order to avoid parent-child issues
    for element in reversed(elements_to_unwrap):
        unwrap_element(element)