Skip to content

docx: a bare < in a text run silently truncates the document and returns OK #187

Open

Description

@seannetlife

Summary

When a docx text run contains an unescaped < (e.g. <w:t>Pump A < pump B</w:t>), toMarkdownBytes returns OK with the text up to the < and nothing after it — the rest of that paragraph and every later paragraph are dropped. If the < sits in the first paragraph, the result is OK and empty. No error, no warning, so a caller cannot tell the conversion lost content.

A bare < makes document.xml ill-formed, so this is bad input — but it does occur in documents written by non-Word generators, and anydoc already treats the sibling case strictly: a bare & with no later ; is rejected as malformed document: unparseable xml. The < case should behave the same way (an error), or recover the text.

Reproduction

A minimal docx with five paragraphs, the third holding a bare < (Python stdlib only):

import zipfile

def docx(path, paragraphs):
    body = "".join(f'<w:p><w:r><w:t xml:space="preserve">{p}</w:t></w:r></w:p>' for p in paragraphs)
    parts = {
        "[Content_Types].xml": '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
        '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
        '<Default Extension="xml" ContentType="application/xml"/>'
        '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
        "</Types>",
        "_rels/.rels": '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
        '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
        "</Relationships>",
        "word/document.xml": '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
        '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
        f"<w:body>{body}</w:body></w:document>",
    }
    with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
        for name, xml in parts.items():
            z.writestr(name, xml)

before = ["First paragraph.", "Second paragraph."]
after = ["Fourth paragraph.", "Fifth paragraph."]
docx("escaped-lt.docx", before + ["Pump A &lt; pump B in flow rate."] + after)
docx("bare-lt.docx", before + ["Pump A < pump B in flow rate."] + after)
docx("bare-lt-lead.docx", ["< First paragraph.", "Second paragraph.", "Third paragraph."] + after)
docx("bare-amp.docx", before + ["Pump A & pump B in flow rate."] + after)
import { readFileSync } from "node:fs"
import { toMarkdownBytes } from "@firecrawl/anydoc"

for (const f of ["escaped-lt.docx", "bare-lt.docx", "bare-lt-lead.docx", "bare-amp.docx"]) {
  try {
    console.log(f, JSON.stringify(await toMarkdownBytes(new Uint8Array(readFileSync(f)), "docx")))
  } catch (e) {
    console.log(f, "threw", e.message)
  }
}

Output on 0.2.4:

escaped-lt.docx   "First paragraph.\n\nSecond paragraph.\n\nPump A < pump B in flow rate.\n\nFourth paragraph.\n\nFifth paragraph.\n"
bare-lt.docx      "First paragraph.\n\nSecond paragraph.\n\nPump A\n"
bare-lt-lead.docx ""
bare-amp.docx     threw malformed document: unparseable xml: ill-formed document: entity or character reference not closed: `;` not found before end of input

xmllint --noout on bare-lt.docx's word/document.xml reports StartTag: invalid element name, so the part is ill-formed; anydoc appears to stop reading at that point and return what it has so far as a success.

Expected

Either:

  1. an error, as for the bare & (malformed document: unparseable xml: …), so the caller can fall back to another converter; or
  2. recovery: read the < as a literal character and keep the remaining text.

Silently returning a truncated or empty document as success is the one outcome a caller cannot detect.

Also seen

  • pptx: a bare < in an <a:t> loses the rest of that paragraph, also without an error.
  • xlsx: a bare < in xl/sharedStrings.xml returns OK with later shared strings run together into one cell.
  • docx w:altChunk: a body whose content is an altChunk (imported HTML part) converts to OK and empty, also without an error. Possibly a separate issue — happy to split it out.

Version

@firecrawl/anydoc 0.2.4 (Node binding, darwin-arm64), Node 24.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions