Skip to content

Commit 6a405d7

Browse files
committed
Add find and replace functionality for tracked changes in headers and footers
1 parent 1a610d7 commit 6a405d7

2 files changed

Lines changed: 122 additions & 10 deletions

File tree

src/docx/document.py

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -222,19 +222,32 @@ def reject_all(self) -> None:
222222
change.reject()
223223

224224
def find_and_replace_tracked(
225-
self, search_text: str, replace_text: str, author: str = ""
225+
self,
226+
search_text: str,
227+
replace_text: str,
228+
author: str = "",
229+
*,
230+
include_headers_footers: bool = False,
226231
) -> int:
227-
"""Find and replace in accepted-view text using tracked revisions."""
232+
"""Find and replace in accepted-view text using tracked revisions.
233+
234+
Replacement always operates on `Paragraph.accepted_text`, meaning inserted
235+
text is searchable and deleted text is ignored.
236+
237+
When `include_headers_footers` is |True|, existing header and footer story
238+
parts are included in the traversal. Linked header/footer definitions are not
239+
materialized merely to support this search; only already-defined parts are
240+
traversed.
241+
"""
228242
total_count = 0
229-
for paragraph in self.paragraphs:
243+
for paragraph in _iter_paragraphs_in_container(self._body):
230244
total_count += paragraph.replace_tracked(search_text, replace_text, author=author)
231-
for table in self.tables:
232-
for row in table.rows:
233-
for cell in row.cells:
234-
for paragraph in cell.paragraphs:
235-
total_count += paragraph.replace_tracked(
236-
search_text, replace_text, author=author
237-
)
245+
if include_headers_footers:
246+
for header_footer in _iter_defined_header_footer_containers(self):
247+
for paragraph in _iter_paragraphs_in_container(header_footer):
248+
total_count += paragraph.replace_tracked(
249+
search_text, replace_text, author=author
250+
)
238251
return total_count
239252

240253
@property
@@ -318,3 +331,43 @@ def clear_content(self) -> _Body:
318331
"""
319332
self._body.clear_content()
320333
return self
334+
335+
336+
def _iter_paragraphs_in_container(container: BlockItemContainer) -> Iterator[Paragraph]:
337+
"""Generate paragraphs in `container`, recursing into nested tables."""
338+
yield from container.paragraphs
339+
340+
for table in container.tables:
341+
yield from _iter_paragraphs_in_table(table)
342+
343+
344+
def _iter_paragraphs_in_table(table: Table) -> Iterator[Paragraph]:
345+
"""Generate paragraphs in `table`, recursing into nested tables in cells."""
346+
for row in table.rows:
347+
for cell in row.cells:
348+
yield from _iter_paragraphs_in_container(cell)
349+
350+
351+
def _iter_defined_header_footer_containers(document: Document) -> Iterator[BlockItemContainer]:
352+
"""Generate each existing header/footer container once, without creating parts."""
353+
seen_partnames: set[str] = set()
354+
355+
for section in document.sections:
356+
for header_footer in (
357+
section.header,
358+
section.first_page_header,
359+
section.even_page_header,
360+
section.footer,
361+
section.first_page_footer,
362+
section.even_page_footer,
363+
):
364+
if not header_footer._has_definition: # pyright: ignore[reportPrivateUsage]
365+
continue
366+
367+
definition = header_footer._definition # pyright: ignore[reportPrivateUsage]
368+
partname = str(definition.partname)
369+
if partname in seen_partnames:
370+
continue
371+
372+
seen_partnames.add(partname)
373+
yield header_footer

tests/test_revisions.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,65 @@ def it_can_find_and_replace_with_tracking(self):
9797
assert paragraph.accepted_text == "AlXYa"
9898
assert len(document.track_changes) == 2
9999

100+
def it_can_find_and_replace_with_tracking_in_nested_tables(self):
101+
document = Document()
102+
outer_table = document.add_table(rows=1, cols=1)
103+
inner_table = outer_table.cell(0, 0).add_table(rows=1, cols=1)
104+
paragraph = inner_table.cell(0, 0).paragraphs[0]
105+
paragraph.text = "Alpha"
106+
107+
count = document.find_and_replace_tracked("ph", "XY", author="TestAuthor")
108+
109+
assert count == 1
110+
assert paragraph.text == "Alpha"
111+
assert paragraph.accepted_text == "AlXYa"
112+
113+
def it_does_not_search_headers_or_footers_unless_requested(self):
114+
document = Document()
115+
paragraph = document.sections[0].header.paragraphs[0]
116+
paragraph.text = "Alpha"
117+
118+
count = document.find_and_replace_tracked("ph", "XY", author="TestAuthor")
119+
120+
assert count == 0
121+
assert paragraph.text == "Alpha"
122+
assert paragraph.accepted_text == "Alpha"
123+
124+
def it_can_optionally_find_and_replace_with_tracking_in_headers_and_footers(self):
125+
document = Document()
126+
header_paragraph = document.sections[0].header.paragraphs[0]
127+
footer_paragraph = document.sections[0].footer.paragraphs[0]
128+
header_paragraph.text = "Alpha"
129+
footer_paragraph.text = "Graph"
130+
131+
count = document.find_and_replace_tracked(
132+
"ph",
133+
"XY",
134+
author="TestAuthor",
135+
include_headers_footers=True,
136+
)
137+
138+
assert count == 2
139+
assert header_paragraph.text == "Alpha"
140+
assert header_paragraph.accepted_text == "AlXYa"
141+
assert footer_paragraph.text == "Graph"
142+
assert footer_paragraph.accepted_text == "GraXY"
143+
144+
def it_does_not_create_header_or_footer_parts_when_optionally_searching_them(self):
145+
document = Document()
146+
section = document.sections[0]
147+
sectPr_xml_before = section._sectPr.xml # pyright: ignore[reportPrivateUsage]
148+
149+
count = document.find_and_replace_tracked(
150+
"ph",
151+
"XY",
152+
author="TestAuthor",
153+
include_headers_footers=True,
154+
)
155+
156+
assert count == 0
157+
assert section._sectPr.xml == sectPr_xml_before # pyright: ignore[reportPrivateUsage]
158+
100159
def it_finds_matches_inside_existing_insertions(self):
101160
document = Document()
102161
paragraph = document.add_paragraph("Hello ")

0 commit comments

Comments
 (0)