From dcd07168131093eab9c2bc0c718de9ac09f90d2c Mon Sep 17 00:00:00 2001 From: bm Date: Wed, 23 Sep 2026 06:27:21 -0700 Subject: [PATCH] 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. --- tests/test_formatter.py | 82 ++++++++++++++++++++++++++++++++++++++++ umbraformat/formatter.py | 76 ++++++++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 1a933b4..2b14031 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -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): diff --git a/umbraformat/formatter.py b/umbraformat/formatter.py index cc4a2b8..bad252a 100644 --- a/umbraformat/formatter.py +++ b/umbraformat/formatter.py @@ -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)