Skip to content

rdocx and rpptx: validate misses schema, reference and package faults, and damaged inputs open or are refused wrongly #435

Closed

Description

@hadim

Found while comparing rdocx and rpptx with other Office libraries on synthetic tasks, all on main 24af95d (v0.16.4, rpptx 0.14.4), Linux x86_64. Each case has a script that builds its own input, prints PASS or FAIL per check and exits 1 on any FAIL. The scripts find the rdocx and rpptx CLIs on PATH.

Case What happens Expected
1 rdocx validate exits 0 on WordprocessingML that breaks the schema (order, unknown element, enum, required attribute, negative extent) non-zero exit naming the part
2 rdocx validate exits 0 on references to a relationship or a footnote that does not exist, and on w:t inside w:del. rdocx.Document() refuses one of these files non-zero exit naming the part and the dangling id
3 Both validators exit 0 on a docProps/app.xml that is not well-formed and on a ZIP holding a part twice. Both libraries open the duplicate silently and read the last copy non-zero exit naming the part, and a refusal or a warning on open
4 rpptx validate exits 0 on an unknown element, a missing required name, misordered presentation.xml children, a missing content type and a short table row non-zero exit naming the part
5 A part holding U+0001 opens, passes validate, and save() writes it back raw validate reports the part as not well-formed, and open refuses it or save writes a well-formed part
6 rdocx.Document(), rdocx text and rdocx render refuse a document in which two bookmarks share a w:id the file opens and renders, at most a warning
7 add_textbox and add_shape accept a negative size and save a deck rpptx cannot open again a refusal at the call, as add_table does

1. rdocx validate does no schema check of WordprocessingML

rdocx validate ("Validate OOXML conformance") checks that the related parts are well-formed and that the style ids in use are defined, but no content-model or value rule of the schema. The script makes one copy of a base document per fault: w:pPr after w:r, w:rPr after w:t, an unknown w:bogus, w:jc w:val="middle" (not in ST_Jc), w:color w:val="#FF0000" (not an ST_HexColor), text directly in w:r, a w:sectPr that is not the last child of w:body, a w:ins without its required w:id, a negative wp:extent (ST_PositiveCoordinate) and an m:rad without its required m:deg. All exit 0. Two of these shapes are written by rdocx itself (add_equation for \sqrt{x} omits m:deg, add_footnote puts w:rPr after w:footnoteRef). They are filed separately as writer bugs, so the script injects both faults by hand. A caller reads "conformance" as a schema check and ships the file.

A full schema pass is a large piece of work. A subset of rules (child order and allowed children of w:body, w:p, w:r, w:pPr, w:rPr, the common enums and required attributes, extents) would already catch every case here.

Acceptance criterion: rdocx validate exits non-zero on each fault, naming the part (word/document.xml), while the base document still validates. If a schema check is out of scope, the help says what is checked (package, well-formedness, style references) so that callers know to run a schema validator as well.

"""`rdocx validate` ("Validate OOXML conformance") passes WordprocessingML that breaks the schema.
Builds one base document with rdocx, then one fault per copy of word/document.xml, written with zipfile."""
import io, struct, subprocess, sys, zipfile, zlib
import rdocx


def png_1x1():
    chunk = lambda kind, data: struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
            + chunk(b"IDAT", zlib.compress(b"\x00\xff\xff\xff")) + chunk(b"IEND", b""))


doc = rdocx.Document()
doc.add_paragraph("Hello")
doc.add_picture(png_1x1(), "dot.png", width=rdocx.Inches(1), height=rdocx.Inches(1))
doc.add_paragraph("World")
base = doc.to_bytes()
xml = zipfile.ZipFile(io.BytesIO(base)).read("word/document.xml").decode()
RUN = "<w:r><w:t>Hello</w:t></w:r>"
RAD = ('<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><m:rad><m:radPr>'
       '<m:degHide m:val="1"/></m:radPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:rad></m:oMath>')
MUTATIONS = {  # label: (old, new) on word/document.xml
    "w:pPr after w:r": (RUN, RUN + '<w:pPr><w:jc w:val="left"/></w:pPr>'),
    "w:rPr after w:t": (RUN, "<w:r><w:t>Hello</w:t><w:rPr><w:b/></w:rPr></w:r>"),
    "unknown element w:bogus": (RUN, "<w:bogus/>" + RUN),
    'w:jc w:val="middle"': (RUN, '<w:pPr><w:jc w:val="middle"/></w:pPr>' + RUN),
    'w:color w:val="#FF0000"': (RUN, '<w:r><w:rPr><w:color w:val="#FF0000"/></w:rPr><w:t>Hello</w:t></w:r>'),
    "text directly in w:r": (RUN, "<w:r>Hello</w:r>"),
    "w:sectPr not last in w:body": ("<w:sectPr>", "<w:sectPr></w:sectPr><w:p><w:r><w:t>after</w:t></w:r></w:p><w:sectPr>"),
    "w:ins without w:id": (RUN, f'<w:ins w:author="A">{RUN}</w:ins>'),
    "negative wp:extent": ('<wp:extent cx="914400"', '<wp:extent cx="-914400"'),
    "m:rad without m:deg": (RUN, RUN + RAD),
}


def validate(text):
    out, zin = io.BytesIO(), zipfile.ZipFile(io.BytesIO(base))
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for item in zin.infolist():
            z.writestr(item, text.encode() if item.filename == "word/document.xml" else zin.read(item))
    open("case.docx", "wb").write(out.getvalue())
    v = subprocess.run(["rdocx", "validate", "case.docx"], capture_output=True, text=True)
    return v.returncode, v.stdout + v.stderr


failed = False
code, _ = validate(xml)
print(f"PASS base document validates" if code == 0 else f"FAIL base document: got exit {code}, expected 0")
failed |= code != 0
for label, (old, new) in MUTATIONS.items():
    assert old in xml, old
    code, msg = validate(xml.replace(old, new, 1))
    if code != 0 and "document.xml" in msg:
        print(f"PASS {label}: validate exit {code}, names the part")
    else:
        failed = True
        print(f"FAIL {label}: got exit {code}, expected non-zero naming word/document.xml")
sys.exit(1 if failed else 0)

Output on 24af95d:

PASS base document validates
FAIL w:pPr after w:r: got exit 0, expected non-zero naming word/document.xml
FAIL w:rPr after w:t: got exit 0, expected non-zero naming word/document.xml
FAIL unknown element w:bogus: got exit 0, expected non-zero naming word/document.xml
FAIL w:jc w:val="middle": got exit 0, expected non-zero naming word/document.xml
FAIL w:color w:val="#FF0000": got exit 0, expected non-zero naming word/document.xml
FAIL text directly in w:r: got exit 0, expected non-zero naming word/document.xml
FAIL w:sectPr not last in w:body: got exit 0, expected non-zero naming word/document.xml
FAIL w:ins without w:id: got exit 0, expected non-zero naming word/document.xml
FAIL negative wp:extent: got exit 0, expected non-zero naming word/document.xml
FAIL m:rad without m:deg: got exit 0, expected non-zero naming word/document.xml

2. rdocx validate passes dangling references

A picture whose a:blip r:embed names no relationship, a w:headerReference whose r:id names no relationship, a w:footnoteReference to a footnote id that word/footnotes.xml does not hold, and deleted text in w:t instead of w:delText inside w:del all pass rdocx validate with exit 0. For the header case rdocx.Document() itself refuses the file ("Header reference rId998 has no valid internal relationship"), so the validator passes a file the library cannot open.

Acceptance criterion: each case exits non-zero with a message naming the part and the dangling id. At the least, validate fails every file that rdocx.Document() refuses to open.

"""`rdocx validate` passes references that point at nothing (a picture, a header, a footnote) and deleted text
in w:t instead of w:delText. rdocx itself refuses to open the header case. Builds one base document with rdocx,
then one fault per copy of word/document.xml, written with zipfile."""
import io, re, struct, subprocess, sys, zipfile, zlib
import rdocx


def png_1x1():
    chunk = lambda kind, data: struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
    return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0))
            + chunk(b"IDAT", zlib.compress(b"\x00\xff\xff\xff")) + chunk(b"IEND", b""))


original = rdocx.Document()
original.add_paragraph("Pay within thirty days.")
edited = rdocx.Document()
edited.add_paragraph("Pay within sixty days.")
original.compare(edited, "Reviewer", "2026-10-30T12:00:00Z", granularity="word")  # one w:del, one w:ins
original.set_header("Header text")
original.add_footnote(original.add_paragraph("Footnoted."), "Note text")
original.add_picture(png_1x1(), "dot.png", width=rdocx.Inches(1), height=rdocx.Inches(1))
base = original.to_bytes()
xml = zipfile.ZipFile(io.BytesIO(base)).read("word/document.xml").decode()
MUTATIONS = {  # label: (pattern, replacement, text the message must hold)
    "a:blip r:embed names no relationship": (r'(<a:blip r:embed=")[^"]+"', r'\1rId999"', "rId999"),
    "w:headerReference r:id names no relationship": (r'(<w:headerReference [^>]*r:id=")[^"]+"', r'\1rId998"', "rId998"),
    "w:footnoteReference to no footnote": (r'(<w:footnoteReference w:id=")\d+"', r'\g<1>99"', "99"),
    "w:t inside w:del (not w:delText)": (r"<w:delText([^>]*)>([^<]*)</w:delText>", r"<w:t\1>\2</w:t>", "document.xml"),
}


def validate(text):
    out, zin = io.BytesIO(), zipfile.ZipFile(io.BytesIO(base))
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for item in zin.infolist():
            z.writestr(item, text.encode() if item.filename == "word/document.xml" else zin.read(item))
    open("case.docx", "wb").write(out.getvalue())
    v = subprocess.run(["rdocx", "validate", "case.docx"], capture_output=True, text=True)
    try:
        rdocx.Document("case.docx")
        opened = "opens"
    except Exception as e:
        opened = f"rdocx.Document() raises {type(e).__name__}: {e}"
    return v.returncode, v.stdout + v.stderr, opened


failed = False
code, _, _ = validate(xml)
print(f"PASS base document validates" if code == 0 else f"FAIL base document: got exit {code}, expected 0")
failed |= code != 0
for label, (pattern, repl, needle) in MUTATIONS.items():
    changed = re.sub(pattern, repl, xml, count=1)
    assert changed != xml, label
    code, msg, opened = validate(changed)
    if code != 0 and needle in msg:
        print(f"PASS {label}: validate exit {code}, names {needle}")
    else:
        failed = True
        print(f"FAIL {label}: got exit {code} ({opened}), expected non-zero naming {needle}")
sys.exit(1 if failed else 0)

Output on 24af95d:

PASS base document validates
FAIL a:blip r:embed names no relationship: got exit 0 (opens), expected non-zero naming rId999
FAIL w:headerReference r:id names no relationship: got exit 0 (rdocx.Document() raises RdocxError: Header reference rId998 has no valid internal relationship), expected non-zero naming rId998
FAIL w:footnoteReference to no footnote: got exit 0 (opens), expected non-zero naming 99
FAIL w:t inside w:del (not w:delText): got exit 0 (opens), expected non-zero naming document.xml

3. Package faults: a malformed docProps/app.xml and a part stored twice

Two package-level faults pass both validators with exit 0. The first is a docProps/app.xml that is not well-formed, with an unescaped & (<Company>Smith & Sons</Company>). rpptx validate does read that part (it reports a duplicated Company element), but not this. LibreOffice refuses such a deck outright. The second is a ZIP with two entries of the same name (word/document.xml or a slide, twice). Readers disagree on which entry is the part: rdocx and rpptx read the last one silently, other readers read the first, so a review made with one tool and an edit made with another see different text.

Acceptance criterion: both validators exit non-zero on the four files, naming the part. On opening, rdocx and rpptx refuse a duplicate entry name, or warn and say which copy they read.

"""`rdocx validate` and `rpptx validate` pass two package faults: a docProps/app.xml that is not well-formed
(an unescaped "&"), and a ZIP that holds a part twice under one name. rdocx and rpptx open the duplicate
silently and read the last copy, where other readers read the first. Builds a document and a deck with
rdocx / rpptx, then one fault per copy, written with zipfile."""
import io, re, subprocess, sys, warnings, zipfile
import rdocx
import rpptx

warnings.simplefilter("ignore")  # zipfile warns on the duplicate name


def rebuild(data, part=None, edit=None, duplicate=None):
    zin, out = zipfile.ZipFile(io.BytesIO(data)), io.BytesIO()
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for item in zin.infolist():
            x = zin.read(item)
            z.writestr(item, edit(x) if item.filename == part else x)
            if duplicate and item.filename == duplicate:
                z.writestr(item.filename, x.replace(b"First copy", b"Second copy"))  # same name again
    return out.getvalue()


def company(x):
    """Set the Company property to 'Smith & Sons' with the ampersand left unescaped."""
    x = re.sub(rb"<Company>[^<]*</Company>|<Company/>", b"", x)
    return x.replace(b"</Properties>", b"<Company>Smith & Sons</Company></Properties>")


doc = rdocx.Document()
doc.add_paragraph("First copy")
prs = rpptx.Presentation()
prs.slides.add_slide(prs.slide_layouts[5]).shapes.title.text = "First copy"
failed = False
for tool, ext, data, main, open_ in (
        ("rdocx", "docx", doc.to_bytes(), "word/document.xml", lambda p: rdocx.Document(p).paragraphs[0].text),
        ("rpptx", "pptx", prs.to_bytes(), "ppt/slides/slide1.xml",
         lambda p: rpptx.Presentation(p).slides[0].shapes.title.text)):
    path = f"case.{ext}"
    for label, part, bad in (("docProps/app.xml with an unescaped '&'", "app.xml", rebuild(data, "docProps/app.xml", company)),
                             (f"{main} stored twice", main, rebuild(data, duplicate=main))):
        open(path, "wb").write(bad)
        v = subprocess.run([tool, "validate", path], capture_output=True, text=True)
        if v.returncode != 0 and part in v.stdout + v.stderr:
            print(f"PASS {tool} validate, {label}: exit {v.returncode}, names the part")
        else:
            failed = True
            print(f"FAIL {tool} validate, {label}: got exit {v.returncode}, expected non-zero naming {part}")
    with warnings.catch_warnings(record=True) as caught:  # path holds the duplicate case now
        warnings.simplefilter("always")
        try:
            text = open_(path)
        except Exception as e:
            print(f"PASS {tool} open, {main} stored twice: refused ({type(e).__name__}: {e})")
            continue
    if any(main in str(w.message) for w in caught):
        print(f"PASS {tool} open, {main} stored twice: warns, reads {text!r}")
    else:
        failed = True
        print(f"FAIL {tool} open, {main} stored twice: got a silent open reading {text!r} (the last copy), "
              "expected a refusal or a warning naming the part")
sys.exit(1 if failed else 0)

Output on 24af95d:

FAIL rdocx validate, docProps/app.xml with an unescaped '&': got exit 0, expected non-zero naming app.xml
FAIL rdocx validate, word/document.xml stored twice: got exit 0, expected non-zero naming word/document.xml
FAIL rdocx open, word/document.xml stored twice: got a silent open reading 'Second copy' (the last copy), expected a refusal or a warning naming the part
FAIL rpptx validate, docProps/app.xml with an unescaped '&': got exit 0, expected non-zero naming app.xml
FAIL rpptx validate, ppt/slides/slide1.xml stored twice: got exit 0, expected non-zero naming ppt/slides/slide1.xml
FAIL rpptx open, ppt/slides/slide1.xml stored twice: got a silent open reading 'Second copy' (the last copy), expected a refusal or a warning naming the part

4. rpptx validate gaps

rpptx validate ("Validate package and PresentationML invariants") exits 0 on an unknown p:bogus element in a slide's shape tree, a p:cNvPr without its required name, children of presentation.xml out of the schema's order (p:notesMasterIdLst after p:sldIdLst, as some other writers produce it, and p:sldSz before p:sldIdLst), a slide whose Override is removed from [Content_Types].xml so that the part falls back to application/xml, and a table row with fewer a:tc than the grid has a:gridCol. It does catch value faults such as a negative extent (case 7).

Acceptance criterion: each fault exits non-zero with a message naming the part, while the base deck still validates.

"""`rpptx validate` ("Validate package and PresentationML invariants") passes slide and package faults.
Builds one deck with rpptx (a slide with a table and a note), then one fault per copy, written with zipfile."""
import io, re, subprocess, sys, zipfile
import rpptx
from rpptx.util import Inches

prs = rpptx.Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
slide.shapes.title.text = "Hello"
slide.shapes.add_table(2, 2, Inches(1), Inches(2), Inches(4), Inches(1))
slide.notes_slide.notes_text_frame.text = "A note"
base = prs.to_bytes()
SLIDE, PRES, TYPES = "ppt/slides/slide1.xml", "ppt/presentation.xml", "[Content_Types].xml"
MUTATIONS = {  # label: (part, edit, part named in the message)
    "unknown element p:bogus in p:spTree": (SLIDE, lambda x: x.replace("</p:spTree>", "<p:bogus/></p:spTree>", 1), "slide1.xml"),
    "p:cNvPr without its required name": (SLIDE, lambda x: re.sub(r'(<p:cNvPr id="\d+") name="Title 1"', r"\1", x, count=1), "slide1.xml"),
    "p:notesMasterIdLst after p:sldIdLst": (PRES, lambda x: re.sub(
        r"(<p:notesMasterIdLst>.*?</p:notesMasterIdLst>)(<p:sldIdLst>.*?</p:sldIdLst>)", r"\2\1", x, count=1, flags=re.S), "presentation.xml"),
    "p:sldSz before p:sldIdLst": (PRES, lambda x: re.sub(
        r"(<p:sldIdLst>.*?</p:sldIdLst>)(<p:sldSz [^>]*/>)", r"\2\1", x, count=1, flags=re.S), "presentation.xml"),
    "slide Override removed from [Content_Types].xml": (TYPES, lambda x: re.sub(
        r'\s*<Override PartName="/ppt/slides/slide1.xml"[^>]*/>', "", x, count=1), "slide1.xml"),
    "table row with 1 a:tc for 2 a:gridCol": (SLIDE, lambda x: re.sub(
        r"(<a:tr [^>]*>)(<a:tc>.*?</a:tc>)(<a:tc>.*?</a:tc>)(</a:tr>)", r"\1\2\4", x, count=1, flags=re.S), "slide1.xml"),
}


def run(part=None, edit=None):
    zin, out = zipfile.ZipFile(io.BytesIO(base)), io.BytesIO()
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for item in zin.infolist():
            data = zin.read(item)
            if item.filename == part:
                new = edit(data.decode())
                assert new != data.decode(), part
                data = new.encode()
            z.writestr(item, data)
    open("case.pptx", "wb").write(out.getvalue())
    v = subprocess.run(["rpptx", "validate", "case.pptx"], capture_output=True, text=True)
    return v.returncode, v.stdout + v.stderr


failed = False
code, _ = run()
print(f"PASS base deck validates" if code == 0 else f"FAIL base deck: got exit {code}, expected 0")
failed |= code != 0
for label, (part, edit, needle) in MUTATIONS.items():
    code, msg = run(part, edit)
    if code != 0 and needle in msg:
        print(f"PASS {label}: validate exit {code}, names {needle}")
    else:
        failed = True
        print(f"FAIL {label}: got exit {code}, expected non-zero naming {needle}")
sys.exit(1 if failed else 0)

Output on 24af95d:

PASS base deck validates
FAIL unknown element p:bogus in p:spTree: got exit 0, expected non-zero naming slide1.xml
FAIL p:cNvPr without its required name: got exit 0, expected non-zero naming slide1.xml
FAIL p:notesMasterIdLst after p:sldIdLst: got exit 0, expected non-zero naming presentation.xml
FAIL p:sldSz before p:sldIdLst: got exit 0, expected non-zero naming presentation.xml
FAIL slide Override removed from [Content_Types].xml: got exit 0, expected non-zero naming slide1.xml
FAIL table row with 1 a:tc for 2 a:gridCol: got exit 0, expected non-zero naming slide1.xml

5. A part holding U+0001 opens, validates and is saved back raw

XML 1.0 does not allow U+0001, so a part holding it is not well-formed (Python's XML parser stops at it). rdocx and rpptx open such a document or deck without a diagnostic and give the character back in the text. rdocx validate and rpptx validate exit 0 (rdocx's validate does check the related parts for well-formedness, and fails a truncated one). save() writes the character back out as it is. Through the API, rdocx refuses the character (add_paragraph) and rpptx escapes it as _x0001_ (the ST_Xstring escape), so only a damaged input propagates it.

Acceptance criterion: validate reports the part as not well-formed, and opening either fails with an error naming the part, or reads the character so that a save writes a well-formed part (as rpptx's own text setter does, with _x0001_). This holds for both libraries.

"""rdocx and rpptx open a part holding U+0001 (XML 1.0 does not allow it, so the part is not well-formed),
`validate` passes it, and save() writes it back raw. Builds a document and a deck with rdocx / rpptx, the
byte added with zipfile."""
import io, subprocess, sys, zipfile
import xml.etree.ElementTree as ET
import rdocx
import rpptx


def inject(data, part):
    zin, out = zipfile.ZipFile(io.BytesIO(data)), io.BytesIO()
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for item in zin.infolist():
            x = zin.read(item)
            z.writestr(item, x.replace(b">Hello<", b">He\x01llo<") if item.filename == part else x)
    return out.getvalue()


doc = rdocx.Document()
doc.add_paragraph("Hello")
prs = rpptx.Presentation()
prs.slides.add_slide(prs.slide_layouts[5]).shapes.title.text = "Hello"
failed = False
for tool, path, part, data, open_ in (
        ("rdocx", "ctrl.docx", "word/document.xml", doc.to_bytes(), rdocx.Document),
        ("rpptx", "ctrl.pptx", "ppt/slides/slide1.xml", prs.to_bytes(), rpptx.Presentation)):
    open(path, "wb").write(inject(data, part))
    try:
        ET.fromstring(zipfile.ZipFile(path).read(part))
        raise SystemExit(f"setup: {part} parsed, the fault was not injected")
    except ET.ParseError:
        pass
    v = subprocess.run([tool, "validate", path], capture_output=True, text=True)
    if v.returncode != 0 and part.split("/")[-1] in v.stdout + v.stderr:
        print(f"PASS {tool} validate: exit {v.returncode}, names {part}")
    else:
        failed = True
        print(f"FAIL {tool} validate: got exit {v.returncode}, expected non-zero naming {part} (not well-formed)")
    try:
        opened = open_(path)
    except Exception as e:
        ok = part.split("/")[-1] in str(e)
        failed |= not ok
        print(f"{'PASS' if ok else 'FAIL'} {tool} open refuses: {e}" + ("" if ok else ", expected the part named"))
        continue
    opened.save("saved-" + path)
    saved = zipfile.ZipFile("saved-" + path).read(part)
    try:
        ET.fromstring(saved)
        print(f"PASS {tool} opens and save() writes a well-formed {part}")
    except ET.ParseError as e:
        failed = True
        print(f"FAIL {tool} save(): got U+0001 raw in {part} ({e}), expected a well-formed part or a refusal at open")
sys.exit(1 if failed else 0)

Output on 24af95d:

FAIL rdocx validate: got exit 0, expected non-zero naming word/document.xml (not well-formed)
FAIL rdocx save(): got U+0001 raw in word/document.xml (not well-formed (invalid token): line 1, column 394), expected a well-formed part or a refusal at open
FAIL rpptx validate: got exit 0, expected non-zero naming ppt/slides/slide1.xml (not well-formed)
FAIL rpptx save(): got U+0001 raw in ppt/slides/slide1.xml (not well-formed (invalid token): line 1, column 907), expected a well-formed part or a refusal at open

6. Two bookmarks sharing a w:id make the document unreadable

rdocx.Document(), rdocx text and rdocx render (and inspect, convert and validate) fail with "cannot scan identifiers in XML part /word/document.xml: duplicate bookmark id 2 in imported or preserved XML" when two bookmarks of the body share an id, and with the same message on /word/header1.xml when a body bookmark and a header bookmark share one. Word has saved such files (the _GoBack bookmark reusing the id of a _Toc bookmark of the body, or of a bookmark in a header), and Word and LibreOffice open them. The id only pairs a w:bookmarkStart with its w:bookmarkEnd, and the bookmark names stay distinct.

Acceptance criterion: the two duplicate cases open and render (rdocx.Document() returns, rdocx text and rdocx render exit 0). validate may report the duplicate, and a save may renumber one bookmark of the pair (start and end together).

"""rdocx refuses to open a document in which two bookmarks share a w:id (Word has saved files where the
_GoBack bookmark reuses the id of a _Toc bookmark, in the same part or in a header). Word and LibreOffice open
such files. Builds a document with rdocx, the bookmarks added with zipfile."""
import io, subprocess, sys, zipfile
import rdocx

doc = rdocx.Document()
doc.add_paragraph("First heading")
doc.add_paragraph("Some text")
doc.set_header("Header text")
base = doc.to_bytes()
header = next(n for n in zipfile.ZipFile(io.BytesIO(base)).namelist() if n.startswith("word/header"))


def build(edits):
    zin, out = zipfile.ZipFile(io.BytesIO(base)), io.BytesIO()
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for item in zin.infolist():
            data = zin.read(item)
            for part, text, bid, name in edits:
                run = f"<w:r><w:t>{text}</w:t></w:r>".encode()
                if item.filename == part:
                    assert run in data, text
                    data = data.replace(run, f'<w:bookmarkStart w:id="{bid}" w:name="{name}"/>'.encode() + run
                                        + f'<w:bookmarkEnd w:id="{bid}"/>'.encode(), 1)
            z.writestr(item, data)
    return out.getvalue()


cases = {
    "two bookmarks, ids 1 and 2": [("word/document.xml", "First heading", 1, "_Toc1"),
                                   ("word/document.xml", "Some text", 2, "_GoBack")],
    "two bookmarks, both id 2, same part": [("word/document.xml", "First heading", 2, "_Toc1"),
                                            ("word/document.xml", "Some text", 2, "_GoBack")],
    "id 2 in the body and in the header": [("word/document.xml", "First heading", 2, "_Toc1"),
                                           (header, "Header text", 2, "_GoBack")],
}
failed = False
for label, edits in cases.items():
    open("bookmarks.docx", "wb").write(build(edits))
    try:
        rdocx.Document("bookmarks.docx")
        print(f"PASS {label}: rdocx.Document() opens")
    except Exception as e:
        failed = True
        print(f"FAIL {label}: rdocx.Document() got {type(e).__name__}: {e}, expected it to open")
    for args in (["text", "bookmarks.docx"], ["render", "bookmarks.docx", "-o", "pages", "--dpi", "20", "--force"]):
        cli = subprocess.run(["rdocx", *args], capture_output=True, text=True)
        if cli.returncode == 0:
            print(f"PASS {label}: rdocx {args[0]} exit 0")
        else:
            failed = True
            print(f"FAIL {label}: rdocx {args[0]} got exit {cli.returncode} ({cli.stderr.strip()}), expected 0")
sys.exit(1 if failed else 0)

Output on 24af95d:

PASS two bookmarks, ids 1 and 2: rdocx.Document() opens
PASS two bookmarks, ids 1 and 2: rdocx text exit 0
PASS two bookmarks, ids 1 and 2: rdocx render exit 0
FAIL two bookmarks, both id 2, same part: rdocx.Document() got RdocxError: cannot scan identifiers in XML part /word/document.xml: duplicate bookmark id 2 in imported or preserved XML, expected it to open
FAIL two bookmarks, both id 2, same part: rdocx text got exit 1 (Error: cannot scan identifiers in XML part /word/document.xml: duplicate bookmark id 2 in imported or preserved XML), expected 0
FAIL two bookmarks, both id 2, same part: rdocx render got exit 1 (Error: cannot scan identifiers in XML part /word/document.xml: duplicate bookmark id 2 in imported or preserved XML), expected 0
FAIL id 2 in the body and in the header: rdocx.Document() got RdocxError: cannot scan identifiers in XML part /word/header1.xml: duplicate bookmark id 2 in imported or preserved XML, expected it to open
FAIL id 2 in the body and in the header: rdocx text got exit 1 (Error: cannot scan identifiers in XML part /word/header1.xml: duplicate bookmark id 2 in imported or preserved XML), expected 0
FAIL id 2 in the body and in the header: rdocx render got exit 1 (Error: cannot scan identifiers in XML part /word/header1.xml: duplicate bookmark id 2 in imported or preserved XML), expected 0

7. add_textbox and add_shape accept a negative size

shapes.add_textbox(left, top, width, height) with a negative width, and add_shape with a negative height, are accepted and saved as a negative a:ext (cx="-2743200"), which the schema forbids (ST_PositiveCoordinate). rpptx validate then rejects the saved file, and rpptx.Presentation() refuses to open it ("DrawingML ext has invalid @cx"). add_table refuses the same negative width at the call.

Acceptance criterion: add_textbox and add_shape raise at the call for a negative width or height, as add_table does, and nothing is written.

"""add_textbox and add_shape accept a negative size and save it as a negative a:ext, which the schema forbids
(ST_PositiveCoordinate): `rpptx validate` rejects the saved file and rpptx cannot open it again. add_table
refuses the same size at the call. Builds its own input."""
import re, sys, zipfile
import rpptx
from rpptx.enum.shapes import MSO_SHAPE
from rpptx.util import Inches

calls = {
    "add_textbox(width=-3 in)": lambda s: s.shapes.add_textbox(Inches(1), Inches(1), -Inches(3), Inches(1)),
    "add_shape(height=-1 in)": lambda s: s.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(1), Inches(1), Inches(3), -Inches(1)),
    "add_table(width=-3 in)": lambda s: s.shapes.add_table(2, 2, Inches(1), Inches(1), -Inches(3), Inches(1)),
}
failed = False
for label, call in calls.items():
    prs = rpptx.Presentation()
    slide = prs.slides.add_slide(prs.slide_layouts[6])
    try:
        call(slide)
    except Exception as e:
        print(f"PASS {label} refused at the call: {type(e).__name__}: {e}")
        continue
    failed = True
    prs.save("negative.pptx")
    ext = re.findall(r'<a:ext cx="-?\d+" cy="-?\d+"/>', zipfile.ZipFile("negative.pptx").read("ppt/slides/slide1.xml").decode())
    try:
        rpptx.Presentation("negative.pptx")
        reopened = "reopens"
    except Exception as e:
        reopened = f"reopen raises {type(e).__name__}: {e}"
    print(f"FAIL {label}: got accepted and saved {ext} ({reopened}), expected a refusal at the call")
sys.exit(1 if failed else 0)

Output on 24af95d:

FAIL add_textbox(width=-3 in): got accepted and saved ['<a:ext cx="-2743200" cy="914400"/>'] (reopen raises XmlError: malformed PresentationML part /ppt/slides/slide1.xml: invalid value: DrawingML ext has invalid @cx: -2743200), expected a refusal at the call
FAIL add_shape(height=-1 in): got accepted and saved ['<a:ext cx="2743200" cy="-914400"/>'] (reopen raises XmlError: malformed PresentationML part /ppt/slides/slide1.xml: invalid value: DrawingML ext has invalid @cy: -914400), expected a refusal at the call
PASS add_table(width=-3 in) refused at the call: RpptxError: add table failed: invalid value: table width and height must be positive

Activity

  1. mantissaman commented on Nov 9, 2026

    @mantissaman
    Contributor

    Released in rpptx-v0.15.0 (tag), v0.17.0 (tag).

    The releases include bounded stored Word/PowerPoint schema, reference and finite-value validation through existing error channels. Package opening rejects exact duplicate decoded ZIP names and forbidden XML characters. Negative authored dimensions refuse, while valid zero dimensions and valid opaque payloads remain preserved. This is a stated subset, not complete OOXML schema validation or sanitization.

    The stored-validation work is a hardened equivalent of PR439.

    Thank you @hadim for the report and reference work.

  2. mantissaman commented on Nov 9, 2026

    @mantissaman
    Contributor

    Closing the seven reported validation/opening cases in Word 0.17.0 and shared OOXML/PowerPoint 0.15.0. Stored-package validators now name the declared schema/reference faults and malformed XML parts. Both package readers reject duplicate decoded entry names and forbidden XML characters. Shared bookmark IDs remain readable/renderable with physical ownership preserved, and negative textbox/shape dimensions refuse at the call.

    This includes the open/save safety gap left in #439. See F-X280 contract and evidence and its three children, integrated regression coverage and successful integrated CI. Thank you @hadim for the fault-producing scripts and reference work. Validation is the documented finite subset, not complete OOXML schema validation or sanitization.

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