Repository navigation
Description
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 prints PASS or FAIL per check and exits 1 on a FAIL. Scripts build their own input, write only in the current folder and find the rdocx CLI on PATH.
| Case | What happens | Expected |
|---|---|---|
1. add_equation writes a square root with no m:deg |
A square root from \sqrt{x} or <msqrt> is an m:rad with no m:deg |
Every m:rad holds m:deg (empty for a square root) |
2. Note number run has w:rPr after the reference mark, no separator notes |
<w:r><w:footnoteRef/><w:rPr>...</w:rPr></w:r>, no separator notes, no space after the number |
w:rPr first, the two separator notes, a space after the number |
| 3. Hyperlinks get no Hyperlink style, colour or underline | The link run has no run properties | The run carries w:rStyle w:val="Hyperlink" |
4. Cell.merge keeps the empty paragraph of an empty cell |
Merging "Total" with an empty cell gives ['Total', ''] |
['Total'] |
5. insert_column and add_row drop paragraph and run formatting |
New cells copy the shading but not w:jc nor the paragraph-mark bold |
New cells keep the source cell's w:pPr and w:pPr/w:rPr |
| 6. A numbering definition id goes stale after a footnote or a picture | After add_footnote or add_picture, add_numbering_instance(bullets) makes a decimal list |
The id names its definition for the life of the document |
7. rebuild_toc() refuses a TOC that insert_toc() added before the headings |
rebuild_toc() raises "table of contents result must span paragraph boundaries" |
Entries ['Introduction', 'Methods'] in both orders |
8. rdocx replace and try_replace_text also rewrite chart labels |
Chart cache labels are replaced and counted, rdocx text does not show them, the workbook keeps the old label |
Replace and text agree on what is searched |
1. add_equation writes a square root with no m:deg
Paragraph.add_equation writes a square root, from LaTeX \sqrt{...} or MathML <msqrt>, as an m:rad that holds m:radPr (with m:degHide m:val="1") and m:e but no m:deg.
The OMML schema requires it: ECMA-376 Part 1, 22.1.2.88, and CT_Rad in shared-math.xsd (radPr?, deg, e).
Word writes an empty <m:deg/> for a square root. An n-th root (\sqrt[3]{x}) is already written correctly.
LibreOffice draws the square root of x+1 as an empty box, and rdocx validate passes the file.
Acceptance criterion. Every m:rad that add_equation writes holds an m:deg after m:radPr, empty for a square root.
case_1_sqrt_deg.py:
"""A square root written by add_equation has no m:deg (ECMA-376 Part 1, 22.1.2.88, CT_Rad: radPr?, deg, e)."""
import re
import sys
import rdocx
doc = rdocx.Document()
cases = [("latex \\sqrt{x}", r"\sqrt{x}", "latex"),
("latex \\sqrt[3]{x}", r"\sqrt[3]{x}", "latex"),
("mathml <msqrt>", '<math xmlns="http://www.w3.org/1998/Math/MathML"><msqrt><mi>x</mi></msqrt></math>',
"mathml")]
for _, src, fmt in cases:
doc.add_paragraph("").add_equation(src, format=fmt)
xml = doc.part_xml("word/document.xml")
failed = False
for (label, _, _), m in zip(cases, re.finditer(r"<m:rad>.*?</m:rad>", xml, re.S)):
rad = re.sub(r">\s+<", "><", m.group(0))
if re.search(r"(?:</m:radPr>|<m:rad>)<m:deg[ />]", rad):
print(f"PASS {label}: m:rad holds m:deg after m:radPr")
else:
failed = True
print(f"FAIL {label}: got {rad}, expected an m:deg after m:radPr")
sys.exit(1 if failed else 0)Output on 24af95d:
FAIL latex \sqrt{x}: got <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>, expected an m:deg after m:radPr
PASS latex \sqrt[3]{x}: m:rad holds m:deg after m:radPr
FAIL mathml <msqrt>: got <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>, expected an m:deg after m:radPr
(exit status 1)
2. Note number run has w:rPr after the reference mark, no separator notes
In the note itself, the run that shows the note number is written <w:r><w:footnoteRef/><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr></w:r>.
CT_R wants w:rPr first, so footnotes.xml breaks the schema. endnotes.xml is the same with w:endnoteRef. The reference in the body is correct.
Two smaller differences from the notes Word writes. The notes part has no separator and continuation-separator notes (w:type="separator" and "continuationSeparator", which Word writes and names in the w:footnotePr / w:endnotePr of settings.xml). rdocx's first note takes id 2, as if they were there.
The note text also follows the number with no space ("1Source note"), where Word puts a space run after the reference mark.
Acceptance criterion. The number run of each note is <w:r><w:rPr><w:rStyle .../></w:rPr><w:footnoteRef/></w:r> (w:endnoteRef for endnotes), the notes parts hold the two separator notes, and a space separates the number from the text.
case_2_note_ref_run.py:
"""add_footnote / add_endnote write w:rPr after w:footnoteRef / w:endnoteRef (CT_R wants w:rPr first),
no separator notes, and no space between the number and the note text."""
import re
import sys
import rdocx
doc = rdocx.Document()
p = doc.add_paragraph("Body text")
doc.add_footnote(p, "Source note")
doc.add_endnote(p, "End note")
failed = False
def check(ok, what, got, expected):
global failed
failed |= not ok
print(f"PASS {what}" if ok else f"FAIL {what}: got {got}, expected {expected}")
for part, tag in (("word/footnotes.xml", "footnote"), ("word/endnotes.xml", "endnote")):
xml = doc.part_xml(part)
run = re.sub(r">\s+<", "><", re.search(rf"<w:r>(?:(?!</w:r>).)*<w:{tag}Ref/>.*?</w:r>", xml, re.S).group(0))
check("<w:rPr>" in run and run.index("<w:rPr>") < run.index(f"<w:{tag}Ref/>"), f"{part} number run has w:rPr first",
run, f"<w:r><w:rPr>...</w:rPr><w:{tag}Ref/></w:r>")
seps = len(re.findall(r'w:type="(?:separator|continuationSeparator)"', xml))
check(seps == 2, f"{part} holds the separator and continuation separator notes", seps, 2)
text = "".join(re.findall(r"<w:t(?: [^>]*)?>([^<]*)</w:t>", xml))
check(text.startswith(" "), f"{part} note text follows the number after a space", repr(text), "a leading space")
sys.exit(1 if failed else 0)Output on 24af95d:
FAIL word/footnotes.xml number run has w:rPr first: got <w:r><w:footnoteRef/><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr></w:r>, expected <w:r><w:rPr>...</w:rPr><w:footnoteRef/></w:r>
FAIL word/footnotes.xml holds the separator and continuation separator notes: got 0, expected 2
FAIL word/footnotes.xml note text follows the number after a space: got 'Source note', expected a leading space
FAIL word/endnotes.xml number run has w:rPr first: got <w:r><w:endnoteRef/><w:rPr><w:rStyle w:val="EndnoteReference"/></w:rPr></w:r>, expected <w:r><w:rPr>...</w:rPr><w:endnoteRef/></w:r>
FAIL word/endnotes.xml holds the separator and continuation separator notes: got 0, expected 2
FAIL word/endnotes.xml note text follows the number after a space: got 'End note', expected a leading space
(exit status 1)
3. Hyperlinks get no Hyperlink style, colour or underline
Paragraph.add_hyperlink, Document.add_heading_hyperlink, Document.from_markdown (also with a template that defines the Hyperlink character style, and rdocx convert x.md -o x.docx) and Document.from_html write <w:hyperlink><w:r><w:t>...</w:t></w:r></w:hyperlink> with no run properties.
There is no w:rStyle w:val="Hyperlink", no colour and no underline. The link works but reads as plain body text in Word and LibreOffice.
Word gives the runs of a link it inserts the Hyperlink character style.
Acceptance criterion. The run of a link written by these calls carries <w:rStyle w:val="Hyperlink"/>, the built-in Hyperlink style being added when the document lacks it (or at least a colour and an underline).
case_3_hyperlink_style.py:
"""Hyperlinks written by rdocx carry no run formatting: no Hyperlink character style, colour or underline."""
import re
import sys
import rdocx
def with_hyperlink_style():
doc = rdocx.Document()
doc.add_style("Hyperlink", style_type="character", color="0563C1", underline=True)
return doc
def link_xml(doc):
found = re.search(r"<w:hyperlink\b.*?</w:hyperlink>", doc.part_xml("word/document.xml"), re.S)
return re.sub(r">\s+<", "><", found.group(0))
cases = {}
doc = with_hyperlink_style()
doc.add_paragraph("See ").add_hyperlink("the site", "https://example.com")
cases["Paragraph.add_hyperlink"] = doc
doc = with_hyperlink_style()
doc.add_heading("Target", 1)
doc.add_paragraph("See ")
doc.add_heading_hyperlink(doc.paragraphs[1], doc.paragraphs[0], "Target")
cases["Document.add_heading_hyperlink"] = doc
cases["Document.from_markdown(template=...)"] = rdocx.Document.from_markdown(
"See [the site](https://example.com).", template=with_hyperlink_style())[0]
cases["Document.from_html"] = rdocx.Document.from_html('<p>See <a href="https://example.com">the site</a>.</p>')[0]
failed = False
for name, doc in cases.items():
xml = link_xml(doc)
if re.search(r'<w:rStyle w:val="Hyperlink"/>|<w:color |<w:u ', xml):
print(f"PASS {name}: link run is formatted")
else:
failed = True
print(f'FAIL {name}: got {xml}, expected a run with <w:rStyle w:val="Hyperlink"/> (or a colour and underline)')
sys.exit(1 if failed else 0)Output on 24af95d:
FAIL Paragraph.add_hyperlink: got <w:hyperlink r:id="rId1"><w:r><w:t>the site</w:t></w:r></w:hyperlink>, expected a run with <w:rStyle w:val="Hyperlink"/> (or a colour and underline)
FAIL Document.add_heading_hyperlink: got <w:hyperlink w:anchor="Heading_1"><w:r><w:t>Target</w:t></w:r></w:hyperlink>, expected a run with <w:rStyle w:val="Hyperlink"/> (or a colour and underline)
FAIL Document.from_markdown(template=...): got <w:hyperlink r:id="rId1"><w:r><w:t>the site</w:t></w:r></w:hyperlink>, expected a run with <w:rStyle w:val="Hyperlink"/> (or a colour and underline)
FAIL Document.from_html: got <w:hyperlink r:id="rId1"><w:r><w:t>the site</w:t></w:r></w:hyperlink>, expected a run with <w:rStyle w:val="Hyperlink"/> (or a colour and underline)
(exit status 1)
4. Cell.merge keeps the empty paragraph of an empty cell
Merging two cells when one of them is empty (horizontally or vertically, the first or the second) leaves the merged cell with two paragraphs: the text and an empty one.
The merged row is then one line taller than its text needs.
Word drops the lone empty paragraph of an empty cell when it merges, and set_cell_grid_span gives one paragraph.
Acceptance criterion. An empty cell's lone empty paragraph is not carried into the merged cell. The "second cell empty" cases give ['Total'], the "first cell empty" case ['Sum'], and two cells with text keep both paragraphs.
case_4_merge_empty_para.py:
"""Cell.merge keeps the lone empty paragraph of an empty cell it absorbs."""
import sys
import rdocx
cases = [ # (label, shape, first cell text, second cell text, expected)
("horizontal, second cell empty", (1, 2), "Total", None, ["Total"]),
("horizontal, first cell empty", (1, 2), None, "Sum", ["Sum"]),
("horizontal, both with text", (1, 2), "Total", "Sum", ["Total", "Sum"]),
("vertical, second cell empty", (2, 1), "Total", None, ["Total"]),
]
failed = False
for label, (rows, cols), a, b, expected in cases:
doc = rdocx.Document()
t = doc.add_table(rows, cols)
second = (0, 1) if cols == 2 else (1, 0)
if a:
t.cell(0, 0).text = a
if b:
t.cell(*second).text = b
doc.tables[0].cell(0, 0).merge(doc.tables[0].cell(*second))
got = [p.text for p in doc.tables[0].cell(0, 0).paragraphs]
if got == expected:
print(f"PASS {label}: merged cell paragraphs {got}")
else:
failed = True
print(f"FAIL {label}: got paragraphs {got}, expected {expected}")
sys.exit(1 if failed else 0)Output on 24af95d:
FAIL horizontal, second cell empty: got paragraphs ['Total', ''], expected ['Total']
FAIL horizontal, first cell empty: got paragraphs ['', 'Sum'], expected ['Sum']
PASS horizontal, both with text: merged cell paragraphs ['Total', 'Sum']
FAIL vertical, second cell empty: got paragraphs ['Total', ''], expected ['Total']
(exit status 1)
5. insert_column and add_row drop paragraph and run formatting
A cell made by insert_column next to a shaded, centred, bold header cell gets the shading (w:tcPr) but no paragraph properties (w:jc) and no run properties, not even on its paragraph mark.
Text set into it afterwards is left-aligned and regular. add_row does the same for a new row.
Word's Insert Column and Insert Row copy the paragraph formatting and the paragraph-mark formatting of the neighbouring cell, so text typed into the new cell matches its column or row. clone_row keeps everything, being a copy.
Acceptance criterion. A cell made by insert_column or add_row gets the source cell's paragraph properties and the run properties of its paragraph mark (w:pPr/w:rPr): w:jc w:val="center" and w:b in the script.
case_5_new_cells_format.py:
"""Table.insert_column and Table.add_row copy the source cell's shading but not its paragraph formatting
or the run properties of its paragraph mark."""
import re
import sys
import rdocx
from rdocx.enum.text import WD_ALIGN_PARAGRAPH
doc = rdocx.Document()
doc.add_table(2, 2)
for r in range(2):
for c in range(2):
cell = doc.tables[0].cell(r, c)
cell.text = f"r{r}c{c}"
cell.shading = "D9E2F3"
cell.paragraphs[0].alignment = WD_ALIGN_PARAGRAPH.CENTER
cell.paragraphs[0].runs[0].font.bold = True
doc.tables[0].insert_column(1, before=False)
doc.tables[0].add_row()
failed = False
for label, r, c in (("insert_column(1, before=False) cell (0, 2)", 0, 2), ("add_row() cell (2, 1)", 2, 1)):
xml = doc.tables[0].cell(r, c).xml
m = re.search(r"<w:pPr>.*?</w:pPr>", xml, re.S)
ppr = re.sub(r">\s+<", "><", m.group(0)) if m else None
for what, ok in (('w:jc w:val="center"', ppr is not None and '<w:jc w:val="center"/>' in ppr),
("w:b on the paragraph mark (w:pPr/w:rPr)",
ppr is not None and re.search(r"<w:rPr>(?:(?!</w:rPr>).)*<w:b/>", ppr) is not None)):
if ok:
print(f"PASS {label} keeps {what}")
else:
failed = True
print(f"FAIL {label} keeps {what}: got pPr {ppr}, expected {what} as in the source cell")
sys.exit(1 if failed else 0)Output on 24af95d:
FAIL insert_column(1, before=False) cell (0, 2) keeps w:jc w:val="center": got pPr None, expected w:jc w:val="center" as in the source cell
FAIL insert_column(1, before=False) cell (0, 2) keeps w:b on the paragraph mark (w:pPr/w:rPr): got pPr None, expected w:b on the paragraph mark (w:pPr/w:rPr) as in the source cell
FAIL add_row() cell (2, 1) keeps w:jc w:val="center": got pPr None, expected w:jc w:val="center" as in the source cell
FAIL add_row() cell (2, 1) keeps w:b on the paragraph mark (w:pPr/w:rPr): got pPr None, expected w:b on the paragraph mark (w:pPr/w:rPr) as in the source cell
(exit status 1)
6. A numbering definition id goes stale after a footnote or a picture
add_numbering_definition returns an id that add_numbering_instance takes.
Once paragraphs use instances of two definitions, the abstract definitions are renumbered by first use (here the numbered definition, made second, becomes w:abstractNumId="0").
The returned id keeps naming its definition until an edit that retires every handle (add_footnote or add_picture here, add_comment_on_text does not).
After it, add_numbering_instance(bullets) makes an instance of the numbered definition, so the next bullet list is numbered. The other way round, a numbered list gets bullets, and a later call can fail with "document content references numbering instance 3 at missing level 1".
Acceptance criterion. A definition id stays valid for the life of the document (or the renumbering keeps a map the call follows): every line gives a bullet instance.
case_6_numbering_id.py:
"""The id add_numbering_definition returns goes stale after an edit such as add_footnote or add_picture:
add_numbering_instance(bullets) then makes an instance of the numbered definition."""
import re
import struct
import sys
import 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""))
def comment(doc):
doc.add_paragraph("text")
doc.add_comment_on_text("text", author="A", text="a comment")
EDITS = {
"no other edit": lambda d: None,
"add_footnote": lambda d: d.add_footnote(d.add_paragraph("text"), "a note"),
"add_picture": lambda d: d.add_picture(png_1x1(), "dot.png", width=rdocx.Inches(1), height=rdocx.Inches(1)),
"add_comment_on_text": comment,
}
failed = False
for label, edit in EDITS.items():
doc = rdocx.Document()
bullets = doc.add_numbering_definition([rdocx.ListLevel(format="bullet", text="o")])
numbers = doc.add_numbering_definition([rdocx.ListLevel(format="decimal", text="%1.")])
n1 = doc.add_numbering_instance(numbers)
doc.add_paragraph("numbered item", style="List Paragraph").numbering = (n1, 0)
b1 = doc.add_numbering_instance(bullets)
doc.add_paragraph("bullet item", style="List Paragraph").numbering = (b1, 0)
edit(doc)
b2 = doc.add_numbering_instance(bullets)
xml = doc.part_xml("word/numbering.xml")
fmt = dict(re.findall(r'<w:abstractNum w:abstractNumId="(\d+)".*?<w:numFmt w:val="(\w+)"', xml, re.S))
num = dict(re.findall(r'<w:num w:numId="(\d+)"><w:abstractNumId w:val="(\d+)"/>', xml))
got = fmt[num[str(b2)]]
if got == "bullet":
print(f"PASS {label}: add_numbering_instance(bullets) -> abstractNum {num[str(b2)]} (bullet)")
else:
failed = True
print(f"FAIL {label}: add_numbering_instance(bullets) got abstractNum {num[str(b2)]} ({got}), expected bullet")
sys.exit(1 if failed else 0)Output on 24af95d:
PASS no other edit: add_numbering_instance(bullets) -> abstractNum 1 (bullet)
FAIL add_footnote: add_numbering_instance(bullets) got abstractNum 0 (decimal), expected bullet
FAIL add_picture: add_numbering_instance(bullets) got abstractNum 0 (decimal), expected bullet
PASS add_comment_on_text: add_numbering_instance(bullets) -> abstractNum 1 (bullet)
(exit status 1)
7. rebuild_toc() refuses a TOC that insert_toc() added before the headings
With no heading in the document yet, insert_toc() writes the whole TOC field (begin, separate and end marks) in one paragraph.
rebuild_toc(), called once the headings are written, then raises "table of contents result must span paragraph boundaries".
The same calls in the other order (headings first, then insert_toc) work. Building a report from the top (title, contents, chapters), the natural order, hits it.
Acceptance criterion. rebuild_toc() fills a table of contents whatever was in the document when insert_toc() wrote it.
case_7_toc_before_headings.py:
"""rebuild_toc() refuses a table of contents that insert_toc() wrote before the document had headings."""
import re
import sys
import rdocx
def report(toc_first):
doc = rdocx.Document()
doc.add_paragraph("Contents")
if toc_first:
doc.insert_toc(1, max_level=3)
doc.add_heading("Introduction", 1)
doc.add_paragraph("Body text.")
doc.add_heading("Methods", 1)
if not toc_first:
doc.insert_toc(1, max_level=3)
return doc
failed = False
expected = ["Introduction", "Methods"]
for label, toc_first in (("insert_toc after the headings", False), ("insert_toc before the headings", True)):
doc = report(toc_first)
try:
doc.rebuild_toc()
got = re.findall(r'<w:hyperlink w:anchor="_Toc\w*"[^>]*><w:r><w:t>([^<]*)', doc.part_xml("word/document.xml"))
except Exception as e:
got = f"{type(e).__name__}: {e}"
if got == expected:
print(f"PASS {label}: rebuild_toc() entries {got}")
else:
failed = True
print(f"FAIL {label}: rebuild_toc() got {got}, expected entries {expected}")
sys.exit(1 if failed else 0)Output on 24af95d:
PASS insert_toc after the headings: rebuild_toc() entries ['Introduction', 'Methods']
FAIL insert_toc before the headings: rebuild_toc() got RdocxError: table of contents result must span paragraph boundaries, expected entries ['Introduction', 'Methods']
(exit status 1)
8. rdocx replace and try_replace_text also rewrite chart labels
A replacement also changes the matching strings in each Word chart's cache (categories, series names), so its count differs from what rdocx text shows.
An --expect taken from the text is refused ("expected 1 replacement(s) of "North", found 2"). On a 66-page report with 4 charts the counts were 112 in the text against 120 replaced.
After the replacement the chart's cache no longer matches its embedded workbook, which still holds the old label.
Acceptance criterion. Replacement and text agree on what is searched. Either charts are left alone (the count is 1 and the CLI accepts --expect 1), or rdocx text shows chart labels, the count says where each occurrence is, and the workbook is updated with the cache.
case_8_replace_chart_labels.py:
"""`rdocx replace` and Document.try_replace_text also rewrite the cached labels of Word charts, which
`rdocx text` does not show, and leave the chart's embedded workbook with the old label."""
import io
import re
import subprocess
import sys
import zipfile
import rdocx
doc = rdocx.Document()
doc.add_paragraph("Sales in the North region rose.")
data = rdocx.CategoryChartData()
data.categories = ["North", "South"]
data.add_series("Sales", [3, 4])
doc.add_chart(rdocx.XL_CHART_TYPE.COLUMN_CLUSTERED, data, rdocx.Inches(4), rdocx.Inches(3))
doc.save("chart.docx")
failed = False
def check(ok, what, got, expected):
global failed
failed |= not ok
print(f"PASS {what}" if ok else f"FAIL {what}: got {got}, expected {expected}")
shown = subprocess.run(["rdocx", "text", "chart.docx"], capture_output=True, text=True).stdout.count("North")
r = subprocess.run(["rdocx", "replace", "chart.docx", "-p", "North", "-v", "NORTH", "--expect", str(shown),
"-o", "out.docx"], capture_output=True, text=True)
check(r.returncode == 0, f"rdocx replace --expect {shown} (the count `rdocx text` shows)",
f"exit {r.returncode} {r.stderr.strip()}", "exit 0")
python_doc = rdocx.Document("chart.docx")
count = python_doc.try_replace_text("North", "NORTH")
check(count == shown, "try_replace_text count matches `rdocx text`", count, shown)
z = zipfile.ZipFile(io.BytesIO(python_doc.to_bytes()))
chart = next(n for n in z.namelist() if re.fullmatch(r"word/charts/chart\d+\.xml", n))
cache = re.findall(r"<c:pt idx=\"\d\"><c:v>([A-Za-z]+)</c:v>", z.read(chart).decode())
book = zipfile.ZipFile(io.BytesIO(z.read(next(n for n in z.namelist() if n.startswith("word/embeddings/")))))
in_book = "North" in "".join(book.read(n).decode() for n in book.namelist()
if n == "xl/sharedStrings.xml" or n.startswith("xl/worksheets/"))
check(("North" in cache) == in_book, "chart cache and embedded workbook agree after the replacement",
f"cache {cache}, workbook holds 'North': {in_book}", "the same labels in both")
sys.exit(1 if failed else 0)Output on 24af95d:
FAIL rdocx replace --expect 1 (the count `rdocx text` shows): got exit 1 Error: pair 0: expected 1 replacement(s) of "North", found 2, expected exit 0
FAIL try_replace_text count matches `rdocx text`: got 2, expected 1
FAIL chart cache and embedded workbook agree after the replacement: got cache ['Sales', 'NORTH', 'South'], workbook holds 'North': True, expected the same labels in both
(exit status 1)
Activity
fix(table): let a grid span absorb a cell that holds only paragraph p…
mantissaman commentedon Nov 9, 2026
Released in v0.17.0 (tag).
The Word release includes adapted markup and import repairs: schema-ordered radicals, explicit authored note markers and special records, hyperlink and numbering identities, merged-cell paragraph handling, inherited paragraph-mark formatting, TOC insertion-order handling, and chart-cache/workbook exclusions from visible-text replacement. Imported omissions and unsupported cases retain the documented preservation and refusal contracts.
This adapts the markup/import reference work in PR441.
Thank you @hadim for the report and reference work.
mantissaman commentedon Nov 9, 2026
Closing the eight reported authoring/search cases in Word 0.17.0. The integrated changes cover required radical degree markup, ordered note reference properties/separators/spacing, Hyperlink styles, merged-cell empty-paragraph handling, inherited paragraph-mark formatting, stable numbering IDs after package edits, TOC insertion before headings, and visible-text replacement that leaves chart caches and workbooks alone.
F-X277 contract and evidence and its children preserve all eight acceptance rows. The integrated regression coverage, schema/native qualification and successful integrated CI cover the integrated result. Thank you @hadim for the scripts and #441 reference work. Imported omissions keep their documented preservation contract.