Skip to content

Commit 2dccbf2

Browse files
committed
Add running header cleanup utility
1 parent 27f5f45 commit 2dccbf2

3 files changed

Lines changed: 251 additions & 0 deletions

File tree

docs/source/code-docs/utils.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,8 @@ Build Text Corpus
2525
=================
2626
.. automodule:: corppa.utils.build_text_corpus
2727
.. Note: not including members for method docs, only top-level script usage
28+
29+
Running Headers
30+
===============
31+
.. automodule:: corppa.utils.running_headers
32+
:members:
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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))
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright (c) 2026, Center for Digital Humanities, Princeton University
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import polars as pl
5+
6+
from corppa.utils.running_headers import (
7+
cleanup_dataframe,
8+
cleanup_pages,
9+
identify_headers,
10+
remove_headers,
11+
)
12+
13+
14+
def test_identify_headers_compares_nearby_pages():
15+
pages = [
16+
{"page_text": "THE POEMS OF BLAIR\n1\nA first page."},
17+
{"page_text": "THE POEMS OF BLAIR\n2\nA second page."},
18+
{"page_text": "THE POEMS OF BLAIR\n3\nA third page."},
19+
]
20+
21+
assert identify_headers(pages) == {
22+
0: {"THE POEMS OF BLAIR"},
23+
1: {"THE POEMS OF BLAIR"},
24+
2: {"THE POEMS OF BLAIR"},
25+
}
26+
27+
28+
def test_remove_headers_preserves_metadata_and_body():
29+
pages = [
30+
{
31+
"work_id": "work",
32+
"page_text": "THE POEMS OF BLAIR\n1\nA repeated phrase in the body.",
33+
},
34+
{"page_text": "THE POEMS OF BLAIR\n2\nMore text."},
35+
]
36+
37+
result = remove_headers(pages)
38+
39+
assert result[0]["work_id"] == "work"
40+
assert result[0]["page_text"] == "1\nA repeated phrase in the body."
41+
assert result[1]["page_text"] == "2\nMore text."
42+
43+
44+
def test_cleanup_pages_accepts_iterators():
45+
pages = ({"text": f"THE HEADER\n{n}\nText."} for n in range(2))
46+
assert [page["text"] for page in cleanup_pages(pages)] == ["0\nText.", "1\nText."]
47+
48+
49+
def test_cleanup_dataframe_groups_by_work_and_preserves_rows():
50+
dataframe = pl.DataFrame(
51+
{
52+
"work_id": ["a", "b", "a", "b"],
53+
"text": [
54+
"HEADER A\n1\nA text.",
55+
"HEADER B\n1\nB text.",
56+
"HEADER A\n2\nA more text.",
57+
"HEADER B\n2\nB more text.",
58+
],
59+
}
60+
)
61+
62+
result = cleanup_dataframe(dataframe)
63+
64+
assert result["text"].to_list() == [
65+
"1\nA text.",
66+
"1\nB text.",
67+
"2\nA more text.",
68+
"2\nB more text.",
69+
]

0 commit comments

Comments
 (0)