|
| 1 | +# Copyright (c) 2026, Center for Digital Humanities, Princeton University |
| 2 | +# SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +"""Identify and remove running headers from page-level text. |
| 5 | +
|
| 6 | +Running headers are particularly troublesome when comparing volumes: OCR sees |
| 7 | +them as page content, and a header that occurs on every page can dominate |
| 8 | +text-reuse results. This module deliberately only considers the first two |
| 9 | +substantial lines of a page. That keeps repeated phrases in the body text |
| 10 | +from being mistaken for headers. |
| 11 | +
|
| 12 | +The detection approach is adapted from the running-header cleanup code |
| 13 | +originally contributed by Wouter Haverals in ``ppa-nlp-archive``, which in |
| 14 | +turn was inspired by Ted Underwood's ``HeaderFinder``. |
| 15 | +""" |
| 16 | + |
| 17 | +import re |
| 18 | +from collections.abc import Iterable, Mapping, Sequence |
| 19 | +from difflib import SequenceMatcher |
| 20 | +from typing import Any |
| 21 | + |
| 22 | +import polars as pl |
| 23 | + |
| 24 | + |
| 25 | +def _substantial_lines(text: str, limit: int = 2) -> list[tuple[int, str]]: |
| 26 | + """Return the first ``limit`` non-empty, non-numeric lines.""" |
| 27 | + lines = text.splitlines() |
| 28 | + result = [] |
| 29 | + # Keep the original line number so removal can preserve all other lines |
| 30 | + # and their line endings; a regex would obscure that two-step operation. |
| 31 | + for number, line in enumerate(lines): |
| 32 | + stripped = line.strip() |
| 33 | + if len(stripped) < 5 or stripped.isdigit(): |
| 34 | + continue |
| 35 | + result.append((number, line)) |
| 36 | + if len(result) == limit: |
| 37 | + break |
| 38 | + return result |
| 39 | + |
| 40 | + |
| 41 | +def _comparison_text(line: str) -> str: |
| 42 | + """Normalize a line for comparison, ignoring page numbers and punctuation.""" |
| 43 | + return re.sub(r"[^a-zA-Z]+", "", line).casefold() |
| 44 | + |
| 45 | + |
| 46 | +def _text_value(value: Any) -> str: |
| 47 | + """Convert a nullable dataframe/page value to text for processing.""" |
| 48 | + return "" if value is None else str(value) |
| 49 | + |
| 50 | + |
| 51 | +def identify_headers( |
| 52 | + pages: Sequence[Mapping[str, Any]], |
| 53 | + *, |
| 54 | + text_key: str = "page_text", |
| 55 | + window: int = 2, |
| 56 | + similarity_threshold: float = 0.8, |
| 57 | +) -> dict[int, set[str]]: |
| 58 | + """Identify likely running headers in a sequence of page dictionaries. |
| 59 | +
|
| 60 | + Pages are compared only to the preceding and following ``window`` pages. |
| 61 | + The return value maps each page index to the exact line(s) identified on |
| 62 | + that page, making it possible to remove a header without removing the |
| 63 | + same phrase from another page's body text. |
| 64 | + """ |
| 65 | + if window < 1: |
| 66 | + raise ValueError("window must be at least 1") |
| 67 | + if not 0 <= similarity_threshold <= 1: |
| 68 | + raise ValueError("similarity_threshold must be between 0 and 1") |
| 69 | + |
| 70 | + candidates = [] |
| 71 | + for page in pages: |
| 72 | + lines = _substantial_lines(_text_value(page.get(text_key, "")), limit=3) |
| 73 | + # On short pages, do not classify the only body line as a header just |
| 74 | + # because two pages happen to contain the same short text. |
| 75 | + candidates.append(lines[:2] if len(lines) == 3 else lines[:1]) |
| 76 | + headers: dict[int, set[str]] = {} |
| 77 | + for index, lines in enumerate(candidates): |
| 78 | + start = max(0, index - window) |
| 79 | + stop = min(len(candidates), index + window + 1) |
| 80 | + for _, line in lines: |
| 81 | + normalized = _comparison_text(line) |
| 82 | + if not normalized: |
| 83 | + continue |
| 84 | + for other_index in range(start, stop): |
| 85 | + if other_index == index: |
| 86 | + continue |
| 87 | + if any( |
| 88 | + SequenceMatcher( |
| 89 | + None, normalized, _comparison_text(other_line) |
| 90 | + ).ratio() |
| 91 | + >= similarity_threshold |
| 92 | + for _, other_line in candidates[other_index] |
| 93 | + ): |
| 94 | + headers.setdefault(index, set()).add(line) |
| 95 | + break |
| 96 | + return headers |
| 97 | + |
| 98 | + |
| 99 | +def remove_headers( |
| 100 | + pages: Sequence[Mapping[str, Any]], |
| 101 | + headers: dict[int, set[str]] | None = None, |
| 102 | + *, |
| 103 | + text_key: str = "page_text", |
| 104 | + **identify_kwargs: Any, |
| 105 | +) -> list[dict[str, Any]]: |
| 106 | + """Return copies of ``pages`` with identified leading headers removed.""" |
| 107 | + if headers is None: |
| 108 | + headers = identify_headers(pages, text_key=text_key, **identify_kwargs) |
| 109 | + |
| 110 | + cleaned = [] |
| 111 | + for index, page in enumerate(pages): |
| 112 | + result = dict(page) |
| 113 | + remove = headers.get(index, set()) |
| 114 | + if remove: |
| 115 | + lines = _text_value(result.get(text_key, "")).splitlines(keepends=True) |
| 116 | + substantial_seen = 0 |
| 117 | + output = [] |
| 118 | + for line in lines: |
| 119 | + if len(line.strip()) >= 5 and not line.strip().isdigit(): |
| 120 | + substantial_seen += 1 |
| 121 | + if substantial_seen <= 2 and line.rstrip("\r\n") in remove: |
| 122 | + continue |
| 123 | + output.append(line) |
| 124 | + result[text_key] = "".join(output) |
| 125 | + cleaned.append(result) |
| 126 | + return cleaned |
| 127 | + |
| 128 | + |
| 129 | +def cleanup_pages( |
| 130 | + pages: Iterable[Mapping[str, Any]], |
| 131 | + *, |
| 132 | + text_key: str = "text", |
| 133 | + **identify_kwargs: Any, |
| 134 | +) -> list[dict[str, Any]]: |
| 135 | + """Identify and remove running headers in an iterable of page dictionaries.""" |
| 136 | + page_list = list(pages) |
| 137 | + return remove_headers(page_list, text_key=text_key, **identify_kwargs) |
| 138 | + |
| 139 | + |
| 140 | +def cleanup_dataframe( |
| 141 | + dataframe: pl.DataFrame, |
| 142 | + *, |
| 143 | + text_column: str = "text", |
| 144 | + group_column: str | None = "work_id", |
| 145 | + **identify_kwargs: Any, |
| 146 | +) -> Any: |
| 147 | + """Return a Polars DataFrame with running headers removed. |
| 148 | +
|
| 149 | + Header detection is performed independently for each work, so the last |
| 150 | + page of one work cannot cause a false match on the first page of the next. |
| 151 | + The input row order and all columns are preserved. |
| 152 | + """ |
| 153 | + if text_column not in dataframe.columns: |
| 154 | + raise ValueError(f"DataFrame has no {text_column!r} column") |
| 155 | + |
| 156 | + if group_column is None: |
| 157 | + groups = [(None, list(range(dataframe.height)))] |
| 158 | + else: |
| 159 | + if group_column not in dataframe.columns: |
| 160 | + raise ValueError(f"DataFrame has no {group_column!r} column") |
| 161 | + groups = [] |
| 162 | + rows_with_index = dataframe.with_row_index("_row") |
| 163 | + for value in dataframe.get_column(group_column).unique(maintain_order=True): |
| 164 | + groups.append( |
| 165 | + ( |
| 166 | + value, |
| 167 | + rows_with_index.filter(pl.col(group_column) == value)["_row"].to_list(), |
| 168 | + ) |
| 169 | + ) |
| 170 | + |
| 171 | + texts = dataframe.get_column(text_column).to_list() |
| 172 | + for _, row_indices in groups: |
| 173 | + pages = [{text_column: texts[index]} for index in row_indices] |
| 174 | + cleaned = cleanup_pages(pages, text_key=text_column, **identify_kwargs) |
| 175 | + for index, page in zip(row_indices, cleaned): |
| 176 | + texts[index] = page[text_column] |
| 177 | + return dataframe.with_columns(pl.Series(text_column, texts)) |
0 commit comments