From df84b407c9d9fbd8acc7673c13a6defa0cdef104 Mon Sep 17 00:00:00 2001 From: Christopher Krah <48338417+chrikrah@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:07:26 +0000 Subject: [PATCH 1/3] fix(msword): keep the sign of a negative drawing position _get_paragraph_position reads a floating drawing's offset from attributes like y, top and positionY, then strips everything that is not a digit or a period: clean_value = re.sub(r"[^0-9.]", "", value) That removes a leading minus. A drawing anchored above or left of its reference point carries a legal negative offset, and it comes back positive: y="-45.5" -> 45.5 The value feeds the top-to-bottom sort of floating textbox paragraphs at :2031, :2045 and :2058, so such a drawing sorts as though it sat that far below the anchor instead of above it. Tests: 67 passed in tests/test_backend_msword.py. Five new cases, four covering the position attributes and one pinning that a unit suffix like 45.5pt still parses. Reverting msword_backend.py alone gives 4 failed, 63 passed. ruff check and ruff format --check are clean on both files. Signed-off-by: Christopher Krah <48338417+chrikrah@users.noreply.github.com> --- docling/backend/msword_backend.py | 8 ++++++-- tests/test_backend_msword.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index 827f0e0e9a..7378e31dae 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -2325,8 +2325,12 @@ def _get_paragraph_position(self, paragraph_element): value = elem.get(attr_name) if value: try: - # Remove any non-numeric characters (like 'pt', 'px', etc.) - clean_value = re.sub(r"[^0-9.]", "", value) + # Remove any non-numeric characters (like 'pt', 'px', + # etc.), keeping a leading sign. A shape anchored above + # or left of its reference point carries a legal + # negative offset, and dropping the minus turns it into + # a position further down. + clean_value = re.sub(r"[^0-9.\-]", "", value) if clean_value: return float(clean_value) except (ValueError, TypeError): diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index 0b0aea4014..661c6a54a2 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -2226,3 +2226,37 @@ def test_footnotes_and_endnotes_are_not_dropped(): assert len(footnote_items) == 2, ( "expected exactly one real footnote and one real endnote, no placeholders" ) + + +@pytest.mark.parametrize("attr", ["y", "top", "positionY", "y-position"]) +def test_get_paragraph_position_keeps_a_negative_sign(attr: str): + """A shape anchored above its reference point carries a legal negative offset. + + The position feeds the top-to-bottom sort of floating textbox paragraphs, so + dropping the sign moves such a shape to the wrong end of the document. + """ + from lxml import etree + + from docling.backend.msword_backend import MsWordDocumentBackend + + backend = MsWordDocumentBackend.__new__(MsWordDocumentBackend) + elem = etree.fromstring( + f'

' + ) + + assert MsWordDocumentBackend._get_paragraph_position(backend, elem) == -45.5 + + +def test_get_paragraph_position_still_strips_units(): + from lxml import etree + + from docling.backend.msword_backend import MsWordDocumentBackend + + backend = MsWordDocumentBackend.__new__(MsWordDocumentBackend) + elem = etree.fromstring( + '

' + ) + + assert MsWordDocumentBackend._get_paragraph_position(backend, elem) == 45.5 From d14083fa628e90b4702727f7a1896441ef32ffe7 Mon Sep 17 00:00:00 2001 From: Christopher Krah <48338417+chrikrah@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:19:12 +0000 Subject: [PATCH 2/3] fix(msword): match the number instead of deleting what is not one dolfim-ibm is right about `[^0-9.\-]` on #4292. The class keeps a minus wherever it sits, so an exponent collapses into something float() rejects and the except swallows it. Measured on the branch head: '1e-5' -> clean_value='1-5' -> ValueError -> offset dropped '1.5e-3in' -> clean_value='1.5-3' -> ValueError -> offset dropped The drawing then falls back to its XML source line. Before this branch it was not better, only wrong in the other direction: `[^0-9.]` read '1e-5' as 15.0. So the value is matched now instead of sieved, and the try/except goes with it. The pattern can only match a valid float literal, so float() cannot raise there, and "no number in the attribute" is the `if match` branch rather than a caught exception. One deviation from the suggested `-?\d+(?:\.\d+)?`: it needs a digit before the period, so on '.5' it matches the '5' and returns 5.0, ten times too large, and +5.0 for '-.5'. Both old versions read that form correctly, and a decimal without its leading zero is ordinary in a length attribute, so the pattern is `-?\d*\.?\d+`. Two test cases pin it. value [^0-9.] (pre-PR) [^0-9.\-] (head) -?\d*\.?\d+ '-45.5' 45.5 -45.5 -45.5 '45.5pt' 45.5 45.5 45.5 '.5' 0.5 0.5 0.5 '-.5' 0.5 -0.5 -0.5 '2e-5' 25.0 dropped 2.0 'auto' dropped dropped dropped The five position tests now go through one helper instead of building the element each time. Tests: 72 passed, 1 skipped in tests/test_backend_msword.py, the skip being the one that needs LibreOffice. Reverting msword_backend.py alone to the branch head gives 3 failed, 69 passed, 1 skipped; reverting it to cea4b450, the state before this branch, gives 8 failed, 64 passed, 1 skipped. ruff check and ruff format --check are clean on both files. Signed-off-by: Christopher Krah <48338417+chrikrah@users.noreply.github.com> --- docling/backend/msword_backend.py | 20 ++++----- tests/test_backend_msword.py | 70 +++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/docling/backend/msword_backend.py b/docling/backend/msword_backend.py index 7378e31dae..902c6b19d2 100644 --- a/docling/backend/msword_backend.py +++ b/docling/backend/msword_backend.py @@ -2324,17 +2324,15 @@ def _get_paragraph_position(self, paragraph_element): for attr_name in ["y", "top", "positionY", "y-position", "position"]: value = elem.get(attr_name) if value: - try: - # Remove any non-numeric characters (like 'pt', 'px', - # etc.), keeping a leading sign. A shape anchored above - # or left of its reference point carries a legal - # negative offset, and dropping the minus turns it into - # a position further down. - clean_value = re.sub(r"[^0-9.\-]", "", value) - if clean_value: - return float(clean_value) - except (ValueError, TypeError): - pass + # Read the leading number and ignore a unit suffix like + # 'pt' or 'px'. The sign is part of it: a drawing anchored + # above or left of its reference point carries a legal + # negative offset, and dropping the minus turns it into a + # position further down. A value with no number at all + # falls through to the next candidate. + match = re.search(r"-?\d*\.?\d+", value) + if match: + return float(match.group()) # Check for position in transform attribute transform = elem.get("transform") diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index 661c6a54a2..6fc8979b47 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -2228,13 +2228,8 @@ def test_footnotes_and_endnotes_are_not_dropped(): ) -@pytest.mark.parametrize("attr", ["y", "top", "positionY", "y-position"]) -def test_get_paragraph_position_keeps_a_negative_sign(attr: str): - """A shape anchored above its reference point carries a legal negative offset. - - The position feeds the top-to-bottom sort of floating textbox paragraphs, so - dropping the sign moves such a shape to the wrong end of the document. - """ +def _position_of(value: str, attr: str = "y") -> float | int | None: + """Read one position attribute back through _get_paragraph_position.""" from lxml import etree from docling.backend.msword_backend import MsWordDocumentBackend @@ -2242,21 +2237,62 @@ def test_get_paragraph_position_keeps_a_negative_sign(attr: str): backend = MsWordDocumentBackend.__new__(MsWordDocumentBackend) elem = etree.fromstring( f'

' + f' {attr}="{value}"/>' ) - assert MsWordDocumentBackend._get_paragraph_position(backend, elem) == -45.5 + return MsWordDocumentBackend._get_paragraph_position(backend, elem) + + +@pytest.mark.parametrize("attr", ["y", "top", "positionY", "y-position"]) +def test_get_paragraph_position_keeps_a_negative_sign(attr: str): + """A drawing anchored above its reference point has a legal negative offset. + + The position feeds the top-to-bottom sort of floating textbox paragraphs, so + dropping the sign moves that drawing to the wrong end of the document. + """ + assert _position_of("-45.5", attr=attr) == -45.5 def test_get_paragraph_position_still_strips_units(): - from lxml import etree + assert _position_of("45.5pt") == 45.5 - from docling.backend.msword_backend import MsWordDocumentBackend - backend = MsWordDocumentBackend.__new__(MsWordDocumentBackend) - elem = etree.fromstring( - '

' - ) +@pytest.mark.parametrize( + ("value", "expected"), [("2e-5", 2.0), ("-2e-5", -2.0), ("1.5e-3in", 1.5)] +) +def test_get_paragraph_position_reads_exponent_notation_as_its_mantissa( + value: str, expected: float +): + """A value in exponent notation is read, not dropped and not inflated. + + Deleting every character outside the digits, the period and the minus leaves + "2-5" for "2e-5", which float() rejects, so the offset was swallowed by the + except and the drawing fell back to its source line. Deleting the minus too, + as the code did before this branch, leaves "25": an offset a million times + too large. Matching the number reads what is written, sign included. Word + writes these lengths with a unit suffix rather than an exponent, so this is + about the parser staying honest on input it did not expect. + """ + assert _position_of(value) == expected + - assert MsWordDocumentBackend._get_paragraph_position(backend, elem) == 45.5 +@pytest.mark.parametrize(("value", "expected"), [(".5", 0.5), ("-.5", -0.5)]) +def test_get_paragraph_position_keeps_a_decimal_without_its_leading_zero( + value: str, expected: float +): + r"""A decimal written as ".5" keeps its magnitude and its sign. + + This one guards the pattern rather than the fix: `-?\d+(?:\.\d+)?` requires a + digit before the period, so it would match the "5" in ".5" and return 5.0, + ten times too large and positive for "-.5". + """ + assert _position_of(value) == expected + + +def test_get_paragraph_position_falls_through_a_value_with_no_number(): + """An attribute like y="auto" holds no offset, so the next hint decides. + + re.search returns None there, and the source line is the documented last + resort of this method. A single parsed element is on line 1. + """ + assert _position_of("auto") == 1 From 885d5e3ce2348535c30524eeee6d00b293051484 Mon Sep 17 00:00:00 2001 From: Christopher Krah <48338417+chrikrah@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:03:38 +0200 Subject: [PATCH 3/3] style(tests): format test_backend_msword.py --- tests/test_backend_msword.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_backend_msword.py b/tests/test_backend_msword.py index 26a0f9b784..69a726cec6 100644 --- a/tests/test_backend_msword.py +++ b/tests/test_backend_msword.py @@ -2540,6 +2540,8 @@ def test_get_paragraph_position_falls_through_a_value_with_no_number(): resort of this method. A single parsed element is on line 1. """ assert _position_of("auto") == 1 + + @pytest.mark.parametrize( ("note_body", "expected"), [