Fix Word refusal: dangling style references and pPr order in TOC

Root cause found by comparing the full-book output (Word refused) with the
new mini-book fixture (Word opened): the output's styles.xml was replaced
by the template's, yet deep-copied manuscript paragraphs still referenced
four KDP paragraph styles (WorstChapter, WrstNrmlChapText, WrstSection,
WAChpNrm) and the Hyperlink character style. Word validates every
referenced styleId and refuses files with dangling references; LibreOffice
silently falls back to Normal.

- Add _merge_missing_styles(): copy any w:style referenced by the output
  body but missing from styles.xml out of the source manuscript, resolving
  basedOn/next/link chains. Runs at the end of Pass 2.
- Fix TOC entry construction to use paragraph_format.tab_stops (python-docx
  inserts w:tabs in the schema-correct CT_PPr position instead of appending
  after w:jc/w:spacing).
- Regression tests: test_style_references_resolve (no undefined pStyle/
  rStyle) and test_body_pPr_children_schema_ordered (CT_PPr sequence).
  55 tests pass. Full-book output regenerated (332 pages; merged styles
  present; 0 undefined style refs; 0 pPr violations).
This commit is contained in:
bm
2026-09-23 07:26:32 -07:00
parent 005712e103
commit 9e35f24627
2 changed files with 102 additions and 13 deletions

View File

@@ -626,6 +626,50 @@ class RunningHeaderTests(unittest.TestCase):
"w:settings children out of CT_Settings order: "
+ ", ".join(t.split("}")[-1] for t in canonical))
def test_style_references_resolve(self):
# Word rejects documents whose paragraphs reference styles that are
# not defined in styles.xml (LO silently falls back to Normal).
d = self._format()
styles = d.styles.element
defined = set()
for s in styles.findall(qn("w:style")):
sid = s.get(qn("w:styleId"))
if sid:
defined.add(sid)
missing = set()
for el in d.element.body.iter():
if el.tag in (qn("w:pStyle"), qn("w:rStyle")):
val = el.get(qn("w:val"))
if val and val not in defined:
missing.add(val)
self.assertEqual(missing, set())
def test_body_pPr_children_schema_ordered(self):
# Generated paragraphs (e.g. TOC entries) must keep CT_PPr order so
# Word does not flag the section properties or paragraph properties.
d = self._format()
order = [
"pStyle", "keepNext", "keepLines", "pageBreakBefore", "framePr",
"widowControl", "numPr", "suppressLineNumbers", "pBdr", "shd",
"tabs", "suppressAutoHyphens", "kinsoku", "wordWrap",
"overflowPunct", "topLinePunct", "autoSpaceDE", "autoSpaceDN",
"bidi", "adjustRightInd", "snapToGrid", "spacing", "ind",
"contextualSpacing", "mirrorIndents", "suppressOverlap", "jc",
"textDirection", "textAlignment", "textboxTightWrap", "outlineLvl",
"divId", "cnfStyle", "rPr", "sectPr", "pPrChange",
]
rank = {qn("w:" + t): i for i, t in enumerate(order)}
bad = []
for p in d.paragraphs:
ppr = p._p.find(qn("w:pPr"))
if ppr is None:
continue
tags = [c.tag for c in ppr]
ranks = [rank[t] for t in tags]
if ranks != sorted(ranks):
bad.append(", ".join(t.split("}")[-1] for t in tags))
self.assertEqual(bad, [])
class WidowOrphanTests(unittest.TestCase):
def setUp(self):

View File

@@ -2,10 +2,13 @@
import io, os, shutil, subprocess, tempfile
from copy import deepcopy
from copy import deepcopy
from docx import Document
from docx.shared import Inches, Pt, Emu, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.enum.text import (
WD_ALIGN_PARAGRAPH, WD_LINE_SPACING, WD_TAB_ALIGNMENT, WD_TAB_LEADER,
)
from docx.enum.section import WD_SECTION_START
from docx.oxml.ns import qn, nsdecls
from docx.oxml import parse_xml, OxmlElement
@@ -707,18 +710,11 @@ def _build_toc(out, chapters, tab_pos_twips, heading="Table of Contents"):
pf.space_before = Pt(0)
pf.space_after = Pt(0)
# Right tab stop with dot leader at the inner text edge
pPr = new_p._p.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
new_p._p.insert(0, pPr)
tabs_el = OxmlElement('w:tabs')
tab_stop = OxmlElement('w:tab')
tab_stop.set(qn('w:val'), 'right')
tab_stop.set(qn('w:pos'), str(tab_pos_twips))
tab_stop.set(qn('w:leader'), 'dot')
tabs_el.append(tab_stop)
pPr.append(tabs_el)
# Right tab stop with dot leader at the inner text edge. Using the
# python-docx API keeps w:tabs in the schema-correct CT_PPr position.
new_p.paragraph_format.tab_stops.add_tab_stop(
Emu(int(tab_pos_twips) * 635),
WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS)
# Chapter label (left-aligned)
r_title = new_p.add_run(f"{ci + 1}. {ch['title']}")
@@ -1978,10 +1974,59 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
for sec in secs:
_reorder_sect_pr(sec._sectPr)
# Word rejects documents whose paragraphs reference styles that are not
# defined in styles.xml (LibreOffice silently falls back to Normal). Copy
# the manuscript's custom style definitions that our output still uses.
_merge_missing_styles(out2, doc)
out2.save(output_path)
return output_path
def _merge_missing_styles(out2, src_doc):
"""Copy w:style definitions referenced by the output but missing from its
styles.xml, taken from the source manuscript's styles part. Word requires
every referenced styleId (paragraph, character or linked) to be defined."""
out_styles = out2.styles.element
defined = set()
for s_ in out_styles.findall(qn('w:style')):
sid = s_.get(qn('w:styleId'))
if sid:
defined.add(sid)
referenced = set()
for el in out2.element.body.iter():
if el.tag == qn('w:pStyle') or el.tag == qn('w:rStyle'):
val = el.get(qn('w:val'))
if val:
referenced.add(val)
src_styles = src_doc.styles.element
src_by_id = {}
for s_ in src_styles.findall(qn('w:style')):
sid = s_.get(qn('w:styleId'))
if sid:
src_by_id[sid] = s_
missing = set()
def collect(sid):
if sid in defined or sid in missing or sid not in src_by_id:
return
missing.add(sid)
s_ = src_by_id[sid]
for dep_tag in ('w:basedOn', 'w:next', 'w:link'):
dep = s_.find(qn(dep_tag))
if dep is not None and dep.get(qn('w:val')):
collect(dep.get(qn('w:val')))
for sid in sorted(referenced):
collect(sid)
for sid in sorted(missing):
out_styles.append(deepcopy(src_by_id[sid]))
return sorted(missing)
def convert_to_pdf(docx_path, pdf_path=None, pad_multiple_of_4=False):
"""Convert a DOCX to PDF using LibreOffice headless.