Skip to content

Commit 18f7388

Browse files
committed
refactor: polish issue #9 fix (DRY, xml:space guard, focused unit test)
Addresses the three LOW items the previous reviewer pass flagged but that had been deferred: * Extract the "concat all TEXT_NODE children" pattern into a single module-level helper `xml_editor.get_text_node_data`. Replace the four inline copies (build_text_map, add_xml_space_to_t, _parse_comment, RevisionManager._get_node_text) with calls into it. The method on RevisionManager stays as a thin delegate so call sites and any external subclasses remain unchanged. * `_get_element_text` now treats a whitespace-only TEXT_NODE child as content when its parent carries `xml:space="preserve"`, not only when it sits alongside a meaningful-text sibling. Closes the edge case where a lone-space `<w:t xml:space="preserve"> </w:t>` was incorrectly returned as the empty string by `contains=` search. * New focused unit test for `_set_node_text` asserts the contract directly: starting from a multi-TEXT_NODE state, ends with exactly one TEXT_NODE carrying the new content. Catches future "simplifications" that re-introduce the `firstChild.data =` pattern without an integration regression. * New test covering the `xml:space="preserve"` branch of `_get_element_text`.
1 parent 82df9ee commit 18f7388

5 files changed

Lines changed: 67 additions & 35 deletions

File tree

‎docx_editor/comments.py‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from pathlib import Path
1111

1212
from .exceptions import CommentError, TextNotFoundError
13-
from .xml_editor import DocxXMLEditor, _generate_hex_id
13+
from .xml_editor import DocxXMLEditor, _generate_hex_id, get_text_node_data
1414

1515
# Path to template files
1616
TEMPLATE_DIR = Path(__file__).parent / "ooxml" / "templates"
@@ -412,12 +412,7 @@ def _parse_comment(self, comment_elem) -> Comment | None:
412412
except ValueError:
413413
date = None
414414

415-
# Extract text content from w:t elements. Concatenate all TEXT_NODE
416-
# children — minidom can split a single <w:t> across multiple text
417-
# nodes (issue #9), so firstChild.data alone would truncate.
418-
text_parts = []
419-
for t_elem in comment_elem.getElementsByTagName("w:t"):
420-
text_parts.append("".join(c.data for c in t_elem.childNodes if c.nodeType == c.TEXT_NODE))
415+
text_parts = [get_text_node_data(t_elem) for t_elem in comment_elem.getElementsByTagName("w:t")]
421416

422417
return Comment(
423418
id=int(comment_id),

‎docx_editor/track_changes.py‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
build_text_map,
2828
compute_paragraph_hash,
2929
find_in_text_map,
30+
get_text_node_data,
3031
)
3132

3233

@@ -737,16 +738,10 @@ def _get_non_text_children_xml(self, run) -> str:
737738
def _get_node_text(self, node) -> str:
738739
"""Get text content of a w:t node by concatenating ALL child text nodes.
739740
740-
minidom may store the text of a single ``<w:t>`` element across
741-
multiple TEXT_NODE children — e.g. when Word documents contain
742-
smart quotes (U+2018/U+2019). Reading only ``firstChild.data``
743-
would return just the first fragment (issue #9).
741+
Thin wrapper around :func:`get_text_node_data` — kept as a method so
742+
existing call sites (and external subclasses) don't have to change.
744743
"""
745-
text = ""
746-
for child in node.childNodes:
747-
if child.nodeType == child.TEXT_NODE:
748-
text += child.data
749-
return text
744+
return get_text_node_data(node)
750745

751746
def _set_node_text(self, node, text: str) -> None:
752747
"""Replace all text content of a w:t/w:delText element with ``text``.

‎docx_editor/xml_editor.py‎

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,20 @@ def _is_inside_element(node, tag_name: str) -> bool:
131131
return False
132132

133133

134+
def get_text_node_data(elem) -> str:
135+
"""Concatenate all direct TEXT_NODE children of ``elem`` into one string.
136+
137+
minidom can split a single ``<w:t>``'s text across multiple TEXT_NODE
138+
children — reproducibly when smart quotes (U+2018/U+2019) are present
139+
(issue #9). Anywhere code naively read ``elem.firstChild.data`` would
140+
only see the first fragment. This helper returns the complete string.
141+
142+
Does NOT recurse into element children. For recursive extraction across
143+
a subtree see ``XMLEditor._get_element_text``.
144+
"""
145+
return "".join(c.data for c in elem.childNodes if c.nodeType == c.TEXT_NODE)
146+
147+
134148
def build_text_map(paragraph) -> TextMap:
135149
"""Build a text map for a paragraph element.
136150
@@ -150,11 +164,7 @@ def build_text_map(paragraph) -> TextMap:
150164
continue
151165

152166
inside_ins = _is_inside_element(node, "w:ins")
153-
# Get text content from the text node
154-
node_text = ""
155-
for child in node.childNodes:
156-
if child.nodeType == child.TEXT_NODE:
157-
node_text += child.data
167+
node_text = get_text_node_data(node)
158168

159169
for i, char in enumerate(node_text):
160170
text_chars.append(char)
@@ -335,26 +345,28 @@ def find_all_nodes(
335345
def _get_element_text(self, elem) -> str:
336346
"""Recursively extract all text content from an element.
337347
338-
Skips whitespace-only TEXT_NODEs only when they sit between element
339-
siblings (i.e. pretty-print indentation). When an element holds only
340-
TEXT_NODE children — as a ``<w:t>`` split across multiple text nodes
341-
by minidom does (issue #9) — every fragment is preserved, including
342-
ones that happen to be pure whitespace.
348+
A whitespace-only TEXT_NODE child is treated as content (rather than
349+
pretty-print indentation) when any of:
350+
* ``elem`` carries ``xml:space="preserve"`` — the document explicitly
351+
marks its whitespace significant.
352+
* Another TEXT_NODE sibling has non-whitespace content — the lone
353+
whitespace is a fragment of a split text node (issue #9).
354+
Otherwise the whitespace TEXT_NODE is discarded as inter-element
355+
formatting.
343356
344357
Args:
345358
elem: DOM element to extract text from
346359
347360
Returns:
348361
Concatenated text content
349362
"""
350-
# A whitespace-only TEXT_NODE is "real content" when it sits alongside
351-
# a non-whitespace TEXT_NODE sibling (the smart-quote split case).
352-
# Otherwise it's pretty-print indentation and should be skipped.
353-
has_meaningful_text_sibling = any(n.nodeType == n.TEXT_NODE and n.data.strip() for n in elem.childNodes)
363+
preserve_whitespace = elem.getAttribute("xml:space") == "preserve" or any(
364+
n.nodeType == n.TEXT_NODE and n.data.strip() for n in elem.childNodes
365+
)
354366
text_parts = []
355367
for node in elem.childNodes:
356368
if node.nodeType == node.TEXT_NODE:
357-
if not node.data.strip() and not has_meaningful_text_sibling:
369+
if not node.data.strip() and not preserve_whitespace:
358370
continue
359371
text_parts.append(node.data)
360372
elif node.nodeType == node.ELEMENT_NODE:
@@ -618,10 +630,7 @@ def add_comment_extensible_date(elem) -> None:
618630

619631
def add_xml_space_to_t(elem) -> None:
620632
# Add xml:space="preserve" to w:t if text has leading/trailing whitespace.
621-
# Concatenate all TEXT_NODE children — minidom can split a single
622-
# <w:t> across multiple text nodes (issue #9), so firstChild.data
623-
# alone would miss whitespace that sits in a later fragment.
624-
text = "".join(c.data for c in elem.childNodes if c.nodeType == c.TEXT_NODE)
633+
text = get_text_node_data(elem)
625634
if text and (text[0].isspace() or text[-1].isspace()):
626635
if not elem.hasAttribute("xml:space"):
627636
elem.setAttribute("xml:space", "preserve")

‎tests/test_track_changes.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,6 +1681,23 @@ class TestMultiTextNodeWtElements:
16811681
# When paragraph= is passed, control routes through _replace_across_nodes
16821682
# which uses _get_node_text and avoids the bug.
16831683

1684+
def test_set_node_text_consolidates_split_nodes(self, clean_workspace):
1685+
"""Direct contract test for _set_node_text: starts from a multi-TEXT_NODE
1686+
state, ends with exactly one TEXT_NODE carrying the full new content.
1687+
Guards against future "simplifications" that would re-introduce the
1688+
firstChild.data assignment pattern."""
1689+
doc = Document.open(clean_workspace)
1690+
wt = _split_wt_text_nodes(doc, "quick brown fox")
1691+
text_nodes_before = [c for c in wt.childNodes if c.nodeType == c.TEXT_NODE]
1692+
assert len(text_nodes_before) > 1
1693+
1694+
doc._revision_manager._set_node_text(wt, "consolidated")
1695+
1696+
text_nodes_after = [c for c in wt.childNodes if c.nodeType == c.TEXT_NODE]
1697+
assert len(text_nodes_after) == 1
1698+
assert text_nodes_after[0].data == "consolidated"
1699+
doc.close()
1700+
16841701
def test_replace_without_paragraph_arg_succeeds(self, clean_workspace):
16851702
doc = Document.open(clean_workspace)
16861703
wt = _split_wt_text_nodes(doc, "quick brown fox")

‎tests/test_xml_editor.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,22 @@ def test_get_element_text_preserves_split_whitespace(self, tmp_path):
263263

264264
assert editor._get_element_text(item) == "Library Bookshelves"
265265

266+
def test_get_element_text_honors_xml_space_preserve(self, tmp_path):
267+
"""A lone whitespace TEXT_NODE inside an element marked
268+
xml:space="preserve" is significant content and must be returned.
269+
270+
Without the attribute it would be treated as pretty-print indent and
271+
stripped — matching the pre-existing behavior."""
272+
xml_content = '<?xml version="1.0"?><root><keep xml:space="preserve"> </keep><drop> </drop></root>'
273+
xml_path = tmp_path / "preserve.xml"
274+
xml_path.write_text(xml_content)
275+
editor = XMLEditor(xml_path)
276+
277+
keep = editor.dom.getElementsByTagName("keep")[0]
278+
drop = editor.dom.getElementsByTagName("drop")[0]
279+
assert editor._get_element_text(keep) == " "
280+
assert editor._get_element_text(drop) == ""
281+
266282

267283
class TestXMLEditorGetNextRid:
268284
"""Tests for XMLEditor.get_next_rid method."""

0 commit comments

Comments
 (0)