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:
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user