From eff42e4ec78fe464b0489e26cbe1c76db55bcfc9 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 12:47:52 +0200 Subject: [PATCH] fix(docx): read the text inside a DrawingML text box A text box keeps its own paragraphs in a `w:txbxContent`. Mammoth reads that element, but only reaches it through the legacy VML path (`w:pict` -> `v:shape` -> `v:textbox`). A modern text box is a DrawingML shape instead -- `w:drawing` -> `wp:inline` -> `wps:wsp` -> `wps:txbx` -- and `wp:inline` is read as a picture, so the shape's text is dropped with no warning and nothing in the messages. Callouts, pull quotes, sidebars and diagram labels simply are not in the output. Measured on a document whose only text box is a bare `w:drawing`: before: 'PARAGRAPH TEXT\n\nAFTER' after: 'PARAGRAPH TEXT\n\nCALLOUT\n\nAFTER' This is the same kind of repair the file already performs for Mammoth: a copy of the `w:txbxContent` is inserted after the `w:drawing` that holds it, wrapped in `w:pict`/`v:shape`/`v:textbox`, and the original is removed so the text cannot be read twice. Mammoth treats a `w:pict` as extra content following the paragraph it sits in, which is where a reader of the page sees the text box anyway. Text boxes inside `mc:AlternateContent` are left alone. Word writes the same text twice there -- a DrawingML shape under `mc:Choice` and a VML shape under `mc:Fallback` -- and Mammoth reads the fallback, so promoting the choice as well would duplicate it. The second test pins that it appears exactly once. --- .../converter_utils/docx/pre_process.py | 66 ++++++++++++++- .../markitdown/tests/test_docx_text_boxes.py | 83 +++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 packages/markitdown/tests/test_docx_text_boxes.py diff --git a/packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py b/packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py index c6dc303414..500829fa9f 100644 --- a/packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py +++ b/packages/markitdown/src/markitdown/converter_utils/docx/pre_process.py @@ -125,6 +125,66 @@ def _pre_process_strike(content: bytes) -> bytes: return str(soup).encode() +def _pre_process_text_boxes(content: bytes) -> bytes: + """ + Rewrites DrawingML text boxes into the VML form Mammoth already reads. + + A text box keeps its own paragraphs in a ``w:txbxContent``. Mammoth reads that + element, but only reaches it through the legacy VML path + (``w:pict`` -> ``v:shape`` -> ``v:textbox``). A modern text box is a DrawingML + shape instead — ``w:drawing`` -> ``wp:inline`` -> ``wps:wsp`` -> ``wps:txbx`` — + and ``wp:inline`` is read as a picture, so the shape's text is dropped with no + warning. Callouts, pull quotes, sidebars and diagram labels vanish. + + A copy of the ``w:txbxContent`` is therefore inserted after the ``w:drawing`` + that holds it, wrapped in ``w:pict``/``v:shape``/``v:textbox``, and the + original is removed so the text cannot be read twice. Mammoth treats a + ``w:pict`` as extra content that follows the paragraph containing it, which is + where a reader of the page sees the text box anyway. + + Text boxes inside ``mc:AlternateContent`` are left alone: Mammoth reads the + ``mc:Fallback`` branch there, which Word fills with the same text as a VML + shape, so promoting the ``mc:Choice`` branch as well would duplicate it. + + Args: + content (bytes): The XML content of the DOCX file as bytes. + + Returns: + bytes: The processed content, encoded as bytes. + """ + # Parsing and reserializing is expensive on large documents, so skip the + # round-trip when there is no text box to promote. + if b"txbxContent" not in content: + return content + + soup = BeautifulSoup(content.decode(), features="xml") + changed = False + + for text_box in soup.find_all("txbxContent"): + ancestors = {parent.name for parent in text_box.parents} + # Already reachable, or handled through the fallback branch. + if "pict" in ancestors or "AlternateContent" in ancestors: + continue + + drawing = text_box.find_parent("drawing") + if drawing is None: + continue + + pict = soup.new_tag("pict", nsprefix="w") + shape = soup.new_tag("shape", nsprefix="v") + textbox = soup.new_tag("textbox", nsprefix="v") + pict.append(shape) + shape.append(textbox) + textbox.append(text_box.extract()) + drawing.insert_after(pict) + changed = True + + if not changed: + return content + + return str(soup).encode() + + def _pre_process_math(content: bytes) -> bytes: """ Pre-processes the math content in a DOCX -> XML file by converting OMML (Office Math Markup Language) elements to LaTeX. @@ -267,7 +327,11 @@ def pre_process_docx(input_docx: BinaryIO) -> BinaryIO: output_docx = BytesIO() # The pre-processing steps to apply to each file in the .docx pre_process_enable_files = { - "word/document.xml": (_pre_process_strike, _pre_process_math), + "word/document.xml": ( + _pre_process_strike, + _pre_process_text_boxes, + _pre_process_math, + ), "word/footnotes.xml": (_pre_process_strike, _pre_process_math), "word/endnotes.xml": (_pre_process_strike, _pre_process_math), "word/styles.xml": (_pre_process_styles,), diff --git a/packages/markitdown/tests/test_docx_text_boxes.py b/packages/markitdown/tests/test_docx_text_boxes.py new file mode 100644 index 0000000000..8959e2e4f3 --- /dev/null +++ b/packages/markitdown/tests/test_docx_text_boxes.py @@ -0,0 +1,83 @@ +"""A DrawingML text box keeps its text in the document and must not be dropped. + +Mammoth reaches a ``w:txbxContent`` only through the legacy VML path +(``w:pict`` -> ``v:shape`` -> ``v:textbox``). A modern text box is a DrawingML +shape instead, and the element that holds it is read as a picture, so its text is +dropped silently. ``_pre_process_text_boxes`` rewrites it into the form Mammoth +reads. +""" + +import io +import re +import zipfile +from pathlib import Path + +from markitdown import MarkItDown + +TEST_DOCX = Path(__file__).parent / "test_files" / "test.docx" + +DRAWING_TEXT_BOX = ( + "" + "" + "" + "" + "CALLOUT MARKER" + "" + "" +) + +VML_TEXT_BOX = ( + "" + "CALLOUT MARKER" + "" +) + +ALTERNATE_CONTENT_TEXT_BOX = ( + "" + f"{DRAWING_TEXT_BOX}" + f"{VML_TEXT_BOX}" + "" +) + + +def _docx_with(markup: str) -> io.BytesIO: + """Return test.docx with `markup` appended to its first paragraph.""" + fixture = io.BytesIO() + with zipfile.ZipFile(TEST_DOCX) as source, zipfile.ZipFile(fixture, "w") as target: + for item in source.infolist(): + content = source.read(item) + if item.filename == "word/document.xml": + document = content.decode() + paragraph = re.search(r"]*>.*?", document, re.DOTALL) + assert paragraph is not None + patched = paragraph.group(0).replace( + "", markup.replace("'", '"') + "", 1 + ) + document = document.replace(paragraph.group(0), patched, 1) + content = document.encode() + target.writestr(item, content) + fixture.seek(0) + return fixture + + +def test_a_drawing_text_box_is_read() -> None: + markdown = MarkItDown().convert(_docx_with(DRAWING_TEXT_BOX)).markdown + + assert markdown.count("CALLOUT MARKER") == 1 + + +def test_an_alternate_content_text_box_is_read_once() -> None: + """Word writes the same text twice, as a choice and as a VML fallback. + + Mammoth reads the fallback, so promoting the choice as well would double it. + """ + markdown = MarkItDown().convert(_docx_with(ALTERNATE_CONTENT_TEXT_BOX)).markdown + + assert markdown.count("CALLOUT MARKER") == 1 + + +def test_a_document_without_a_text_box_is_unchanged() -> None: + expected = MarkItDown().convert(TEST_DOCX).markdown + + assert MarkItDown().convert(_docx_with("")).markdown == expected