Skip to content

ODT: nested list numbering continues across parents (2.1 renders as 2.3); annotations are inlined into the anchored word #203

Open

Description

@serjdrej

Summary

Two problems with ODT, both seen on files Word 365 saves as ODT and reproduced below with minimal hand-made files:

  1. Nested list numbering continues across parents. A second-level list under item 2 starts at 3 instead of 1: 2.1. renders as 2.3.. Word writes every nested text:list with text:continue-numbering="true"; anydoc appears to continue the level-2 counter from the previous sibling's sublist. In ODF a lower level restarts when a higher level advances (LibreOffice and Word both show 2.1. here). For contracts this turns a clause number into a different, existing clause number.
  2. office:annotation is inlined into the text. The comment's creator, date and body are pasted into the middle of the word it is anchored to: Lamb + Author2026-01-01T00:00:00COMMENTTEXT + daword.

Reproduction (anydoc 0.2.4)

--- odt-list
1. Subject
   - 1.1. Duty A
   - 1.2. Duty B
2. Price
   - 2.3. Payment
--- odt-annotation
Text with a comment on LambAuthor2026-01-01T00:00:00COMMENTTEXTdaword here.

Expected: 2.1. Payment, and Text with a comment on Lambdaword here. (with the annotation dropped, as DOCX comments are today — #191 — or rendered separately).

The script is the same as in the tracked-changes issue; the relevant part builds an ODT from content.xml only:

repro.py
"""Minimal reproductions for anydoc 0.2.4: tracked changes and ODT lists/annotations."""
import io, zipfile, anydoc

W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
def docx(body):
    b = io.BytesIO()
    with zipfile.ZipFile(b, "w") as z:
        z.writestr("[Content_Types].xml", '<?xml version="1.0"?><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>')
        z.writestr("_rels/.rels", '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>')
        z.writestr("word/document.xml", f'<?xml version="1.0"?><w:document {W}><w:body>{body}</w:body></w:document>')
    return b.getvalue()

tracked = ('<w:p><w:r><w:t xml:space="preserve">The parties agreed </w:t></w:r>'
           '<w:del w:id="1" w:author="A" w:date="2026-01-01T00:00:00Z"><w:r><w:delText xml:space="preserve">OLDTERM </w:delText></w:r></w:del>'
           '<w:ins w:id="2" w:author="A" w:date="2026-01-01T00:00:00Z"><w:r><w:t xml:space="preserve">NEWTERM </w:t></w:r></w:ins>'
           '<w:r><w:t>terms.</w:t></w:r></w:p>')
rtf = (r"{\rtf1\ansi\deff0{\fonttbl{\f0 Times New Roman;}}{\*\revtbl{Unknown;}{A;}}"
       r"\f0 The parties agreed {\deleted\revauthdel1 OLDTERM }{\revised\revauth1 NEWTERM }terms.\par}").encode()

ODT_NS = ('xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" '
          'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" '
          'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" '
          'xmlns:dc="http://purl.org/dc/elements/1.1/"')
def odt(text):
    styles = (f'<office:automatic-styles><text:list-style style:name="L1">'
              '<text:list-level-style-number text:level="1" style:num-format="1" style:num-suffix="."/>'
              '<text:list-level-style-number text:level="2" style:num-format="1" style:num-suffix="." text:display-levels="2"/>'
              '</text:list-style></office:automatic-styles>')
    content = (f'<?xml version="1.0" encoding="UTF-8"?><office:document-content {ODT_NS} office:version="1.2">'
               f'{styles}<office:body><office:text>{text}</office:text></office:body></office:document-content>')
    b = io.BytesIO()
    with zipfile.ZipFile(b, "w") as z:
        z.writestr(zipfile.ZipInfo("mimetype"), "application/vnd.oasis.opendocument.text")
        z.writestr("META-INF/manifest.xml", '<?xml version="1.0"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/></manifest:manifest>')
        z.writestr("content.xml", content)
    return b.getvalue()

# The shape Word writes when it saves a two-level list as ODT: every nested list
# carries text:continue-numbering="true".
lists = ('<text:list text:style-name="L1" text:continue-numbering="true">'
         '<text:list-item><text:p>Subject</text:p><text:list text:continue-numbering="true">'
         '<text:list-item><text:p>Duty A</text:p></text:list-item><text:list-item><text:p>Duty B</text:p></text:list-item></text:list></text:list-item>'
         '<text:list-item><text:p>Price</text:p><text:list text:continue-numbering="true">'
         '<text:list-item><text:p>Payment</text:p></text:list-item></text:list></text:list-item></text:list>')
annot = ('<text:p>Text with a comment on Lamb<office:annotation office:name="0"><dc:creator>Author</dc:creator>'
         '<dc:date>2026-01-01T00:00:00</dc:date><text:p>COMMENTTEXT</text:p></office:annotation>daword'
         '<office:annotation-end office:name="0"/> here.</text:p>')

from importlib.metadata import version
print("anydoc", version("firecrawl-anydoc"))
for name, data in [("docx", docx(tracked)), ("rtf", rtf), ("odt-list", odt(lists)), ("odt-annotation", odt(annot))]:
    path = f"repro-{name}." + name.split("-")[0]
    open(path, "wb").write(data)
    print(f"--- {name}\n{anydoc.to_markdown(path)}")

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