Fix Word-open failure: strict OOXML element ordering in sectPr and settings

sectPr children were emitted with headerReference/footerReference after
pgSz..mirrorMargins (SECTPR_ORDER), and w:settings had evenAndOddHeaders /
autoHyphenation appended after decimalSymbol/listSeparator. LibreOffice
tolerates out-of-order XML; Word validates CT_SectPr and CT_Settings
strictly and refuses the file.

- Move headerReference/footerReference to the head of SECTPR_ORDER per
  ECMA-376 CT_SectPr.
- Add SETTINGS_ORDER + _reorder_settings() using the full CT_Settings
  sequence (mathPr in the m: namespace) and apply it in Pass 2.
- Add regression tests asserting monotonic CT_SectPr and CT_Settings
  order for every section. 53 tests pass.
This commit is contained in:
bm
2026-09-23 06:27:21 -07:00
parent 0c32168231
commit dcd0716813
2 changed files with 156 additions and 2 deletions

View File

@@ -544,6 +544,88 @@ class RunningHeaderTests(unittest.TestCase):
d = self._format()
self.assertIsNotNone(d.settings.element.find(qn("w:autoHyphenation")))
def test_sectpr_child_order_is_ooxml_valid(self):
# Word validates CT_SectPr child order strictly (ECMA-376); LibreOffice
# ignores it. headerReference/footerReference must come FIRST.
d = self._format()
# Independent canonical sequence (subset actually produced here).
order = [
"headerReference", "footerReference", "footnotePr", "endnotePr",
"type", "pgSz", "pgMar", "paperSrc", "pgBorders", "lnNumType",
"pgNumType", "cols", "formProt", "vAlign", "noEndnote",
"titlePg", "mirrorMargins", "textDirection", "bidi", "rtlGutter",
"docGrid", "printerSettings", "sectPrChange",
]
rank = {qn("w:" + t): i for i, t in enumerate(order)}
for sec in d.sections:
tags = [c.tag for c in sec._sectPr]
ranks = [rank[t] for t in tags]
self.assertEqual(
ranks, sorted(ranks),
"sectPr children out of CT_SectPr order: "
+ ", ".join(t.split("}")[-1] for t in tags))
# References must precede every layout element.
self.assertLess(tags.index(qn("w:headerReference")), tags.index(qn("w:pgSz")))
self.assertLess(tags.index(qn("w:footerReference")), tags.index(qn("w:pgSz")))
def test_settings_child_order_is_ooxml_valid(self):
# Word validates CT_Settings child order strictly (ECMA-376).
d = self._format()
order = [
"writeProtection", "view", "zoom", "removePersonalInformation",
"doNotDisplayPageBoundaries", "displayBackgroundShape",
"printPostScriptOverText", "printFractionalCharacterWidth",
"printFormsData", "embedTrueTypeFonts", "embedSystemFonts",
"saveSubsetFonts", "saveFormsData", "mirrorMargins",
"alignBordersAndEdges", "bordersDoNotSurroundHeader",
"bordersDoNotSurroundFooter", "gutterAtTop", "hideSpellingErrors",
"hideGrammaticalErrors", "activeWritingStyle", "proofState",
"formsDesign", "attachedTemplate", "linkStyles",
"stylePaneFormatFilter", "stylePaneSortMethod", "documentType",
"mailMerge", "revisionView", "trackChanges", "doNotTrackMoves",
"doNotTrackFormatting", "documentProtection", "autoFormatOverride",
"styleLockTheme", "styleLockQFSet", "defaultTabStop",
"autoHyphenation", "consecutiveHyphenLimit", "hyphenationZone",
"doNotHyphenateCaps", "showEnvelope", "summaryLength",
"clickAndTypeStyle", "defaultTableStyle", "evenAndOddHeaders",
"bookFoldRevPrinting", "bookFoldPrinting",
"bookFoldPrintingSheets", "drawingGridHorizontalSpacing",
"drawingGridVerticalSpacing",
"displayHorizontalDrawingGridEvery",
"displayVerticalDrawingGridEvery",
"doNotUseMarginsForDrawingGridOrigin",
"drawingGridHorizontalOrigin", "drawingGridVerticalOrigin",
"doNotShadeFormData", "noPunctuationKerning",
"characterSpacingControl", "printTwoOnOne",
"strictFirstAndLastChars", "noLineBreaksAfter",
"noLineBreaksBefore", "savePreviewPicture",
"doNotValidateAgainstSchema", "saveInvalidXml",
"ignoreMixedContent", "alwaysShowPlaceholderText",
"doNotDemarcateInvalidXml", "saveXmlDataOnly",
"useXSLTWhenSaving", "saveThroughXslt", "showXMLTags",
"alwaysMergeEmptyNamespace", "updateFields", "hdrShapeDefaults",
"footnotePr", "endnotePr", "compat", "rsids", "mathPr",
"themeFontLang", "clrSchemeMapping",
"doNotIncludeSubdocsInStats", "doNotAutoCompressPictures",
"forceUpgrade", "captions", "readModeInkLockDown",
"smartTagType", "shapeDefaults", "doNotEmbedSmartTags",
"decimalSymbol", "listSeparator",
]
rank = {qn("w:" + t): i for i, t in enumerate(order)}
# w:mathPr lives in the math namespace, not w:.
rank['{http://schemas.openxmlformats.org/officeDocument/2006/math}mathPr'] = rank.pop(qn("w:mathPr"))
settings = d.settings.element
tags = [c.tag for c in settings]
# Cross-namespace extensions (e.g. w14:docId from the source doc) are
# interleaved and tolerated by Word; only the canonical w:/m: sequence
# must be monotonic.
canonical = [t for t in tags if t in rank]
ranks = [rank[t] for t in canonical]
self.assertEqual(
ranks, sorted(ranks),
"w:settings children out of CT_Settings order: "
+ ", ".join(t.split("}")[-1] for t in canonical))
class WidowOrphanTests(unittest.TestCase):
def setUp(self):

View File

@@ -1489,7 +1489,11 @@ def _add_section_break(doc):
return doc.add_section()
# Child order of w:sectPr per ECMA-376 CT_SectPr. Word requires this exact
# sequence (header/footer references FIRST); LibreOffice tolerates any order.
SECTPR_ORDER = [
qn('w:headerReference'),
qn('w:footerReference'),
qn('w:footnotePr'),
qn('w:endnotePr'),
qn('w:type'),
@@ -1508,8 +1512,6 @@ SECTPR_ORDER = [
qn('w:textDirection'),
qn('w:bidi'),
qn('w:rtlGutter'),
qn('w:headerReference'),
qn('w:footerReference'),
qn('w:docGrid'),
qn('w:printerSettings'),
qn('w:sectPrChange'),
@@ -1531,6 +1533,73 @@ def _reorder_sect_pr(sect_pr):
sect_pr.append(child)
# Child order of w:settings per ECMA-376 CT_Settings. Same strictness rule:
# Word rejects out-of-order children, LibreOffice ignores the order.
SETTINGS_ORDER = [
qn('w:writeProtection'), qn('w:view'), qn('w:zoom'),
qn('w:removePersonalInformation'), qn('w:doNotDisplayPageBoundaries'),
qn('w:displayBackgroundShape'), qn('w:printPostScriptOverText'),
qn('w:printFractionalCharacterWidth'), qn('w:printFormsData'),
qn('w:embedTrueTypeFonts'), qn('w:embedSystemFonts'),
qn('w:saveSubsetFonts'), qn('w:saveFormsData'), qn('w:mirrorMargins'),
qn('w:alignBordersAndEdges'), qn('w:bordersDoNotSurroundHeader'),
qn('w:bordersDoNotSurroundFooter'), qn('w:gutterAtTop'),
qn('w:hideSpellingErrors'), qn('w:hideGrammaticalErrors'),
qn('w:activeWritingStyle'), qn('w:proofState'), qn('w:formsDesign'),
qn('w:attachedTemplate'), qn('w:linkStyles'),
qn('w:stylePaneFormatFilter'), qn('w:stylePaneSortMethod'),
qn('w:documentType'), qn('w:mailMerge'), qn('w:revisionView'),
qn('w:trackChanges'), qn('w:doNotTrackMoves'),
qn('w:doNotTrackFormatting'), qn('w:documentProtection'),
qn('w:autoFormatOverride'), qn('w:styleLockTheme'),
qn('w:styleLockQFSet'), qn('w:defaultTabStop'), qn('w:autoHyphenation'),
qn('w:consecutiveHyphenLimit'), qn('w:hyphenationZone'),
qn('w:doNotHyphenateCaps'), qn('w:showEnvelope'),
qn('w:summaryLength'), qn('w:clickAndTypeStyle'),
qn('w:defaultTableStyle'), qn('w:evenAndOddHeaders'),
qn('w:bookFoldRevPrinting'), qn('w:bookFoldPrinting'),
qn('w:bookFoldPrintingSheets'), qn('w:drawingGridHorizontalSpacing'),
qn('w:drawingGridVerticalSpacing'),
qn('w:displayHorizontalDrawingGridEvery'),
qn('w:displayVerticalDrawingGridEvery'),
qn('w:doNotUseMarginsForDrawingGridOrigin'),
qn('w:drawingGridHorizontalOrigin'),
qn('w:drawingGridVerticalOrigin'), qn('w:doNotShadeFormData'),
qn('w:noPunctuationKerning'), qn('w:characterSpacingControl'),
qn('w:printTwoOnOne'), qn('w:strictFirstAndLastChars'),
qn('w:noLineBreaksAfter'), qn('w:noLineBreaksBefore'),
qn('w:savePreviewPicture'), qn('w:doNotValidateAgainstSchema'),
qn('w:saveInvalidXml'), qn('w:ignoreMixedContent'),
qn('w:alwaysShowPlaceholderText'), qn('w:doNotDemarcateInvalidXml'),
qn('w:saveXmlDataOnly'), qn('w:useXSLTWhenSaving'),
qn('w:saveThroughXslt'), qn('w:showXMLTags'),
qn('w:alwaysMergeEmptyNamespace'), qn('w:updateFields'),
qn('w:hdrShapeDefaults'), qn('w:footnotePr'), qn('w:endnotePr'),
qn('w:compat'), qn('w:rsids'),
'{http://schemas.openxmlformats.org/officeDocument/2006/math}mathPr',
qn('w:themeFontLang'), qn('w:clrSchemeMapping'), qn('w:doNotIncludeSubdocsInStats'),
qn('w:doNotAutoCompressPictures'), qn('w:forceUpgrade'),
qn('w:captions'), qn('w:readModeInkLockDown'), qn('w:smartTagType'),
qn('w:shapeDefaults'), qn('w:doNotEmbedSmartTags'),
qn('w:decimalSymbol'), qn('w:listSeparator'),
]
def _reorder_settings(settings):
"""Reorder w:settings children to match the CT_Settings schema sequence.
Word rejects documents with settings elements in wrong order."""
children = list(settings)
ordered = []
for tag in SETTINGS_ORDER:
for child in children[:]:
if child.tag == tag:
ordered.append(child)
children.remove(child)
ordered.extend(children)
for child in ordered:
settings.append(child)
def format_document(filepath, output_path=None, font_name="Caladea", font_size=11,
inner_margin=0.500, trim_size="5.5x8.5", bleed=0.0,
author="", imprint="", footer_mode="headers",
@@ -1823,6 +1892,9 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
# w:settings per OOXML; Word honors it in modern layout compat mode.
if settings.find(qn('w:autoHyphenation')) is None:
settings.append(OxmlElement('w:autoHyphenation'))
# Word requires w:settings children in strict CT_Settings order, so fix up
# the position of the two elements just added (LibreOffice would not care).
_reorder_settings(settings)
for idx, sec in enumerate(secs):
sec.page_width = Inches(page_w)