Skip to content

fix(docx): preserve equations and text across XML encodings - #2602

Open
LeonSGP (LeonSGP43) wants to merge 2 commits into
microsoft:mainfrom
LeonSGP43:codex/docx-xml-encoding
Open

LeonSGP (LeonSGP43) wants to merge 2 commits into
microsoft:mainfrom
LeonSGP43:codex/docx-xml-encoding

Conversation

@LeonSGP43

@LeonSGP43 LeonSGP (LeonSGP43) commented Oct 4, 2026 •

Copy link
Copy Markdown

Summary

A valid DOCX whose word/document.xml is UTF-16 encoded loses its equations because math preprocessing decodes it as UTF-8. Parse the original XML bytes so the declaration and BOM are honored, preserving the original UTF-8 and BOM-prefixed UTF-16 LE/BE regression cases.

The bytes path also exposed a recovery problem: UTF-8 XML incorrectly declaring utf-16, utf-16le, or ascii can be silently truncated or serialized without a document root. Disable lxml recovery for the first BeautifulSoup bytes parse. If it rejects the XML, retain the original strict UTF-8 decoding path. If that decoding also fails, the existing outer fallback preserves the input bytes.

Ten additional real DOCX conversion cases check both inline and block equations with incorrect declarations and valid windows-1252/iso-8859-1 bytes. The ASCII case includes a 5000-character prefix, so a nonempty partial tree cannot satisfy the complete Markdown assertion. Correct legacy encodings continue to use their declaration. No new dependency is added.

Validation

Local macOS, Python 3.12.8, checkout imports verified for both the project .venv and Hatch environment:

  • Original head c3ba1d8 and main 4cc9fa1 source-function comparisons with the same 13 regression cases: each 6 failed, 7 passed, with different failures; the candidate passes all 13 (0.24s). The comparison injects the exact SHA's math function into the same public SDK pipeline; it is not a full clean-main suite run.
  • .venv/bin/python -m pytest packages/markitdown/tests/test_docx.py -q: 114 passed (1.30s), exit 0.
  • From packages/markitdown, GITHUB_ACTIONS=1 PYTHONUTF8=1 UV_CONSTRAINT=/.github/constraints.txt PIP_CONSTRAINT=/.github/constraints.txt ../../.venv/bin/hatch test --python 3.12: 1053 passed, 55 skipped (48.45s), exit 0.
  • From packages/markitdown-ocr, GITHUB_ACTIONS=1 PYTHONUTF8=1 ../../.venv/bin/python -m pytest -q: 108 passed (6.14s), exit 0.
  • From packages/markitdown-mcp, the same command: 21 passed (5.80s), exit 0.
  • .venv/bin/pre-commit run --all-files, compileall for both changed files, and git diff --check: exit 0.

The hosted Python/OS matrix and live cloud OCR/LLM providers were not exercised. Skips are not provider acceptance. CLA still requires the contributor's own action; no maintainer approval is claimed.

Scope and related work

Rechecked all 364 open PR file lists and the bodies/full diffs of the 15 PRs touching DOCX preprocessing, including #2515 and #2504. Their performance and XML-security changes address different causes. This update remains in the existing PR.

A syntactically valid legacy declaration paired with UTF-8 bytes can be ambiguous; this change does not guess an alternative encoding. BOM-less UTF-16 probes retain the baseline prose-preservation behavior but still omit equations. General malformed XML, strike handling, security parsing, and lists are outside this patch.

AI assistance

Developed with Codex using gpt-6.1-sol. Real DOCX reproductions, regression comparisons, checkout imports, complete local package checks, and the patch were verified locally.

@kokokoXUY XU (kokokoXUY) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: honor the XML declaration when converting DOCX equations

I read the patch, rebuilt both sides locally, and exercised the new preprocessing
path beyond the added test. The change is a single line and it does fix the
reported bug. I also found one class of inputs where trusting the declaration is
worse than the previous unconditional UTF-8 decode, so I would add a small
fallback before this ships. Details below.

Note on notation: sample strings that contain non-ASCII characters are written
with ASCII placeholders (<CJK>, <emoji>, <accented>) because this review
body is ASCII-only; every placeholder stands for real text in the local runs.

What the patch does

_pre_process_math passed content.decode() to BeautifulSoup; the decode used
UTF-8 regardless of the XML declaration, so a word/document.xml stored as
UTF-16 raised UnicodeDecodeError, pre_process_docx swallowed the exception
(except Exception: pass, pre_process.py:284-287), and the document was
converted with the raw OMML untouched - the prose survived and the equations
disappeared. Passing the bytes lets the XML parser use the declaration and BOM.

What I verified locally

  1. Reported failure reproduced on the base commit. With a BOM-prefixed
    UTF-16LE/BE word/document.xml, _pre_process_math raises
    UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte and the conversion returns Before<CJK><emoji>After.
  2. The fix works for inline and block equations. utf-8, utf-16-le and
    utf-16-be all give Before<CJK><emoji>$x$After; for oMathPara all three
    give Before\n\n$$y=1$$\n\nAfter, where base produced Before\n\nAfter for
    both UTF-16 variants.
  3. No truncation at scale. A 400-paragraph document with one equation per
    paragraph produces byte-identical preprocessed XML for utf-8, utf-16-le and
    utf-16-be (32,911 bytes, 400 $x$ markers, 400 paragraphs) and identical
    8,688-character markdown.
  4. Red/green. Your test against the unchanged base source: 2 failed, 1
    passed (the utf-16-le and utf-16-be cases). Against the patched source: 3
    passed. Whole test_docx.py: base 2 failed / 101 passed / 1 skipped, head
    103 passed / 1 skipped. Whole package suite (minus test_cu.py, which needs
    the azure extra that is not installed here): head 28 failed / 949 passed / 3
    skipped, base 28 failed / 946 passed / 3 skipped, and the failure sets are
    identical - all network or provider dependent (18 in test_cli_vectors, 7 in
    test_cli_misc, 2 in test_docintel, 1 test_speech_transcription).
  5. Formatting and mergeability. black 23.7.0 leaves both changed files
    unchanged. git merge-tree --write-tree main head exits 0 (tree
    3901dfa4). The branch is one commit behind main (#2601), which is what
    mergeable_state: behind reflects.
  6. Two bonus fixes confirmed. Documents whose declaration names a legacy
    single-byte encoding now keep their equations (windows-1252 and iso-8859-1
    declared with cp1252 bytes: base lost the equation, head keeps it), and the
    "declared utf-8 but stored as UTF-16" case that used to fail the whole
    conversion with an ExpatError now converts. Re-serialization normalizes the
    declaration to encoding="utf-8", so downstream Mammoth never sees a stale
    declaration.

One risk I would guard before merging

BeautifulSoup's XML parser recovers from errors instead of raising, so when the
declaration disagrees with the bytes the parser silently drops part of the tree,
and pre_process_docx writes that partial document back into the zip. Four
inputs are worse on the patched code than on base (in all of them the
declaration does not match the bytes):

  • declared utf-16 or utf-16le, bytes are utf-8: base converts
    (Head <CJK>$x$Tail), head writes a 39-byte word/document.xml containing
    only the declaration and fails with
    ExpatError: no element found: line 2, column 0.
  • declared ascii, bytes are utf-8 with non-ASCII text: base returns the full
    text plus math, head returns empty markdown - silent data loss, which is
    worse than an exception.
  • declared iso-8859-1, bytes are utf-8: base correct, head returns mojibake
    for the accented characters.
  • UTF-16LE/BE without BOM (invalid per the XML spec, but produced in the wild):
    base converted the text and dropped only the math, head fails the conversion.

A fallback keeps the benefit and removes the regression, for example:

soup = BeautifulSoup(content, features="xml")
if soup.find() is None or (not soup.get_text().strip() and content.strip()):
    # The parser recovered from a declaration that does not match the bytes by
    # dropping the document. Fall back to the previous UTF-8 decode so such
    # files keep converting.
    soup = BeautifulSoup(content.decode("utf-8", "replace"), features="xml")

A regression test such as "declaration says utf-16, bytes are utf-8, document
still converts" would lock that in. I do not think this needs to block the fix:
those documents are malformed, the old code only coped by ignoring the
declaration, and silently returning empty output is the case I would most like
to see covered.

Smaller notes

  • _pre_process_strike (pre_process.py:119-125) still decodes as UTF-8 and
    its fast path b"dstrike" not in content can never match UTF-16 bytes, so
    double strikethrough is silently not normalized in UTF-16 documents (probe:
    the UTF-16 LE/BE variants render as plain struck <CJK>, the utf-8 variant as
    ~~struck <CJK>~~). The same approach would close that gap; it is a
    pre-existing issue, not something this PR needs to fix.
  • The CLA check is still queued and both workflow runs are action_required
    because this fork has not contributed before, so a maintainer has to approve
    the runs before CI reports anything.

How this was produced

I used local detached worktrees of the base commit and the PR head, rebuilt
.docx fixtures from packages/markitdown/tests/test_files/test.docx with
word/document.xml re-encoded in each encoding, and ran both
_pre_process_math directly and the public MarkItDown().convert_stream path
over every variant above. AI assistance was used for the review and the local
experiments; all numbers quoted come from those runs on this machine.

Verdict

I would merge this: it fixes a real conversion bug with a one-line change,
the tests are honest about what they cover, and the failure mode it removes
(silently dropping equations) is user visible. The fallback above would make it
robust against self-inconsistent XML, and I would keep the _pre_process_strike
gap in mind for a follow-up.

@LeonSGP43 LeonSGP (LeonSGP43) changed the title fix(docx): preserve equations in UTF-16 XML fix(docx): preserve equations and text across XML encodings Oct 6, 2026
@microsoft-github-policy-service

Copy link
Copy Markdown

LeonSGP (@LeonSGP43) please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@microsoft-github-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@microsoft-github-policy-service agree company="Microsoft"
Contributor License Agreement

Contribution License Agreement

This Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
and conveys certain license rights to Microsoft Corporation and its affiliates (“Microsoft”) for Your
contributions to Microsoft open source projects. This Agreement is effective as of the latest signature
date below.

  1. Definitions.
    “Code” means the computer software code, whether in human-readable or machine-executable form,
    that is delivered by You to Microsoft under this Agreement.
    “Project” means any of the projects owned or managed by Microsoft and offered under a license
    approved by the Open Source Initiative (www.opensource.org).
    “Submit” is the act of uploading, submitting, transmitting, or distributing code or other content to any
    Project, including but not limited to communication on electronic mailing lists, source code control
    systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of
    discussing and improving that Project, but excluding communication that is conspicuously marked or
    otherwise designated in writing by You as “Not a Submission.”
    “Submission” means the Code and any other copyrightable material Submitted by You, including any
    associated comments and documentation.
  2. Your Submission. You must agree to the terms of this Agreement before making a Submission to any
    Project. This Agreement covers any and all Submissions that You, now or in the future (except as
    described in Section 4 below), Submit to any Project.
  3. Originality of Work. You represent that each of Your Submissions is entirely Your original work.
    Should You wish to Submit materials that are not Your original work, You may Submit them separately
    to the Project if You (a) retain all copyright and license information that was in the materials as You
    received them, (b) in the description accompanying Your Submission, include the phrase “Submission
    containing materials of a third party:” followed by the names of the third party and any licenses or other
    restrictions of which You are aware, and (c) follow any other instructions in the Project’s written
    guidelines concerning Submissions.
  4. Your Employer. References to “employer” in this Agreement include Your employer or anyone else
    for whom You are acting in making Your Submission, e.g. as a contractor, vendor, or agent. If Your
    Submission is made in the course of Your work for an employer or Your employer has intellectual
    property rights in Your Submission by contract or applicable law, You must secure permission from Your
    employer to make the Submission before signing this Agreement. In that case, the term “You” in this
    Agreement will refer to You and the employer collectively. If You change employers in the future and
    desire to Submit additional Submissions for the new employer, then You agree to sign a new Agreement
    and secure permission from the new employer before Submitting those Submissions.
  5. Licenses.
  • Copyright License. You grant Microsoft, and those who receive the Submission directly or
    indirectly from Microsoft, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license in the
    Submission to reproduce, prepare derivative works of, publicly display, publicly perform, and distribute
    the Submission and such derivative works, and to sublicense any or all of the foregoing rights to third
    parties.
  • Patent License. You grant Microsoft, and those who receive the Submission directly or
    indirectly from Microsoft, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license under
    Your patent claims that are necessarily infringed by the Submission or the combination of the
    Submission with the Project to which it was Submitted to make, have made, use, offer to sell, sell and
    import or otherwise dispose of the Submission alone or with the Project.
  • Other Rights Reserved. Each party reserves all rights not expressly granted in this Agreement.
    No additional licenses or rights whatsoever (including, without limitation, any implied licenses) are
    granted by implication, exhaustion, estoppel or otherwise.
  1. Representations and Warranties. You represent that You are legally entitled to grant the above
    licenses. You represent that each of Your Submissions is entirely Your original work (except as You may
    have disclosed under Section 3). You represent that You have secured permission from Your employer to
    make the Submission in cases where Your Submission is made in the course of Your work for Your
    employer or Your employer has intellectual property rights in Your Submission by contract or applicable
    law. If You are signing this Agreement on behalf of Your employer, You represent and warrant that You
    have the necessary authority to bind the listed employer to the obligations contained in this Agreement.
    You are not expected to provide support for Your Submission, unless You choose to do so. UNLESS
    REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, AND EXCEPT FOR THE WARRANTIES
    EXPRESSLY STATED IN SECTIONS 3, 4, AND 6, THE SUBMISSION PROVIDED UNDER THIS AGREEMENT IS
    PROVIDED WITHOUT WARRANTY OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY OF
    NONINFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
  2. Notice to Microsoft. You agree to notify Microsoft in writing of any facts or circumstances of which
    You later become aware that would make Your representations in this Agreement inaccurate in any
    respect.
  3. Information about Submissions. You agree that contributions to Projects and information about
    contributions may be maintained indefinitely and disclosed publicly, including Your name and other
    information that You submit with Your Submission.
  4. Governing Law/Jurisdiction. This Agreement is governed by the laws of the State of Washington, and
    the parties consent to exclusive jurisdiction and venue in the federal courts sitting in King County,
    Washington, unless no federal subject matter jurisdiction exists, in which case the parties consent to
    exclusive jurisdiction and venue in the Superior Court of King County, Washington. The parties waive all
    defenses of lack of personal jurisdiction and forum non-conveniens.
  5. Entire Agreement/Assignment. This Agreement is the entire agreement between the parties, and
    supersedes any and all prior agreements, understandings or communications, written or oral, between
    the parties relating to the subject matter hereof. This Agreement may be assigned by Microsoft.

ANGULARM0MENTUM added a commit to ANGULARM0MENTUM/markitdown that referenced this pull request Oct 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants