diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index dfcb50c99c..67e33ba087 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -2056,12 +2056,20 @@ def _get_children_recursive(node): ) if isinstance(item, Hyperlink): + # Hyperlink.text and Hyperlink.runs only read the direct w:r + # children, so also read the runs nested in the same wrappers + # as above (e.g. a tracked insertion inside the link). + runs = [ + Run(r_el, paragraph) + for r_el in _get_children_recursive(child) + if r_el.tag == f"{_W_NS_CLARK}r" + ] content.append( ( - item.text, + "".join(run.text for run in runs), ( - self._get_format_from_run(item.runs[0], paragraph) - if item.runs and len(item.runs) > 0 + self._get_format_from_run(runs[0], paragraph) + if runs else None ), self._get_hyperlink_target(item), diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index 05a4a63d2b..d7fc7ed536 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -1472,6 +1472,35 @@ def test_malformed_hyperlink_does_not_abort_conversion(tmp_path): assert hyperlinks == [] +@pytest.mark.parametrize("wrapper", ["ins", "smartTag", "customXml", "fldSimple"]) +def test_hyperlink_keeps_runs_nested_in_a_wrapper(tmp_path, wrapper): + """Runs inside a hyperlink can sit in the same wrappers as in a paragraph, + e.g. a tracked insertion made inside the link text. python-docx's + ``Hyperlink.text`` only joins the direct ``w:r`` children, so the nested + text used to be dropped. + """ + + doc = Document() + para = doc.add_paragraph("See ") + r_id = doc.part.relate_to("https://example.com/", RT.HYPERLINK, is_external=True) + hyperlink = etree.SubElement(para._p, qn("w:hyperlink")) + hyperlink.set(qn("r:id"), r_id) + run = etree.SubElement(hyperlink, qn("w:r")) + etree.SubElement(run, qn("w:t")).text = "the " + wrapped = etree.SubElement(hyperlink, qn(f"w:{wrapper}")) + run = etree.SubElement(wrapped, qn("w:r")) + etree.SubElement(run, qn("w:t")).text = "docs" + + result = _convert_built(doc, tmp_path) + + links = [ + (item.text, str(item.hyperlink)) + for item, _ in result.iterate_items() + if isinstance(item, TextItem) and item.hyperlink is not None + ] + assert links == [("the docs", "https://example.com/")] + + def test_trailing_whitespace_run_keeps_paragraph_formatting(tmp_path): """A whitespace-only trailing run must not overwrite the paragraph's formatting.