- Spec §4.1: running headers carry titles only (recto: chapter/section title; verso: author name + book title); page-number fields removed from headers. - Spec §4.2: page numbers centered in the footer on both verso and recto pages; front-matter display pages (copyright, dedication, epigraph, TOC) show their Roman numeral on the opening page too. - Spec §2.2: lowercase Roman numbering visible from the copyright page through the front matter; Chapter 1 restarts at Arabic 1. - Chapter/back-matter sections keep running matter on body pages; only the first page of each chapter is suppressed (spec §4.3). - Drop titlePg on single-page front-matter display sections so footer page numbers render in LibreOffice and Word alike. - Preserve <<<>>> scene-break cues (centered, 12pt before/after) and first-word-only capitalization of chapter openers. - UI: remove the obsolete "Page Numbers" selector; footer_mode is accepted-but-ignored for backward compatibility. - Tests updated to the new spec behavior (51 pass).
340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""Analyze a DOCX file and compare its formatting against the B.D. Murphy spec."""
|
|
|
|
from docx import Document
|
|
from docx.shared import Inches, Pt, Emu
|
|
from docx.oxml.ns import qn
|
|
from collections import Counter
|
|
|
|
|
|
SPEC = {
|
|
"page": {"width": 5.5, "height": 8.5, "orientation": "PORTRAIT"},
|
|
"margins": {"top": 0.35, "bottom": 0.25, "inside": 0.55, "outside": 0.40},
|
|
"mirror_margins": True,
|
|
"different_first_page": True,
|
|
"header_distance": 0.50,
|
|
"footer_distance": 0.50,
|
|
"styles": {
|
|
"WA Nrml Pgr": {
|
|
"font": "Caladea",
|
|
"size": 12,
|
|
"line_spacing": 15,
|
|
"line_spacing_rule": "EXACTLY",
|
|
"first_line_indent": 0.30,
|
|
},
|
|
"WA NChp Pgr": {
|
|
"font": "Caladea",
|
|
"size": 12,
|
|
"line_spacing": 14,
|
|
"line_spacing_rule": "EXACTLY",
|
|
},
|
|
"WA Chapter": {
|
|
"font": "Caladea",
|
|
"size": 18,
|
|
"alignment": "CENTER",
|
|
"line_spacing": 4.0,
|
|
"line_spacing_rule": "MULTIPLE",
|
|
},
|
|
"WA Section": {
|
|
"font": "Georgia",
|
|
"size": 16,
|
|
"alignment": "CENTER",
|
|
"space_before": 6,
|
|
"space_after": 12,
|
|
"line_spacing": 1.5,
|
|
"line_spacing_rule": "ONE_POINT_FIVE",
|
|
},
|
|
"WA Scene Break": {
|
|
"font": "Georgia",
|
|
"size": 14,
|
|
"alignment": "CENTER",
|
|
"space_before": 12,
|
|
"space_after": 14,
|
|
"line_spacing": 15,
|
|
"line_spacing_rule": "EXACTLY",
|
|
},
|
|
},
|
|
"character_styles": {
|
|
"WA Thkng": {
|
|
"font": "Caladea",
|
|
"size": 12,
|
|
"italic": True,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def emu_to_in(emu):
|
|
if emu is None:
|
|
return None
|
|
return round(emu / 914400, 4)
|
|
|
|
|
|
def emu_to_pt(emu):
|
|
if emu is None:
|
|
return None
|
|
return round(emu / 12700, 1)
|
|
|
|
|
|
def analyze_document(filepath):
|
|
doc = Document(filepath)
|
|
results = {
|
|
"page_setup": {},
|
|
"margins": {},
|
|
"sections": [],
|
|
"styles_found": {},
|
|
"styles_missing": [],
|
|
"paragraph_count": 0,
|
|
"paragraphs_by_style": {},
|
|
"issues": [],
|
|
"warnings": [],
|
|
}
|
|
|
|
# --- Page Setup ---
|
|
s0 = doc.sections[0]
|
|
results["page_setup"] = {
|
|
"width": round(s0.page_width / 914400, 2),
|
|
"height": round(s0.page_height / 914400, 2),
|
|
"orientation": "LANDSCAPE" if s0.page_width > s0.page_height else "PORTRAIT",
|
|
}
|
|
|
|
if abs(results["page_setup"]["width"] - SPEC["page"]["width"]) > 0.05:
|
|
results["issues"].append(
|
|
f"Page width: {results['page_setup']['width']}in (expected {SPEC['page']['width']}in)"
|
|
)
|
|
if abs(results["page_setup"]["height"] - SPEC["page"]["height"]) > 0.05:
|
|
results["issues"].append(
|
|
f"Page height: {results['page_setup']['height']}in (expected {SPEC['page']['height']}in)"
|
|
)
|
|
|
|
# --- Sections & Margins ---
|
|
for i, s in enumerate(doc.sections):
|
|
sp = s._sectPr
|
|
mirror = sp.find(qn("w:mirrorMargins")) is not None
|
|
title_pg = sp.find(qn("w:titlePg")) is not None
|
|
sec_info = {
|
|
"index": i,
|
|
"top": emu_to_in(s.top_margin),
|
|
"bottom": emu_to_in(s.bottom_margin),
|
|
"left": emu_to_in(s.left_margin),
|
|
"right": emu_to_in(s.right_margin),
|
|
"mirror_margins": mirror,
|
|
"different_first_page": title_pg,
|
|
"header_distance": emu_to_in(
|
|
s.header_distance if s.header_distance else 0
|
|
),
|
|
"footer_distance": emu_to_in(
|
|
s.footer_distance if s.footer_distance else 0
|
|
),
|
|
}
|
|
# Check pgNumType
|
|
pgNumType = sp.find(qn("w:pgNumType"))
|
|
if pgNumType is not None:
|
|
sec_info["page_number_fmt"] = pgNumType.get(qn("w:fmt"))
|
|
sec_info["page_number_start"] = pgNumType.get(qn("w:start"))
|
|
results["sections"].append(sec_info)
|
|
|
|
if i == 0:
|
|
results["margins"] = {
|
|
"top": sec_info["top"],
|
|
"bottom": sec_info["bottom"],
|
|
"inside": sec_info["left"],
|
|
"outside": sec_info["right"],
|
|
"mirror_margins": mirror,
|
|
}
|
|
|
|
# Check margin compliance on section 0
|
|
for k, expected in SPEC["margins"].items():
|
|
actual = results["margins"].get(k)
|
|
if k == "mirror_margins":
|
|
if actual != expected:
|
|
results["issues"].append(f"Mirror margins: {actual} (expected {expected})")
|
|
elif actual is not None and abs(actual - expected) > 0.02:
|
|
results["issues"].append(
|
|
f"Margin '{k}': {actual}in (expected {expected}in)"
|
|
)
|
|
|
|
# Check all sections have consistent margins
|
|
first_margins = (
|
|
results["sections"][0]["top"],
|
|
results["sections"][0]["bottom"],
|
|
)
|
|
for sec in results["sections"][1:]:
|
|
if (sec["top"], sec["bottom"]) != first_margins:
|
|
results["warnings"].append(
|
|
f"Section {sec['index']}: top/bottom margins "
|
|
f"({sec['top']}/{sec['bottom']}) differ from section 0 "
|
|
f"({first_margins[0]}/{first_margins[1]})"
|
|
)
|
|
|
|
# --- Styles Analysis ---
|
|
style_names_seen = set()
|
|
style_counts = Counter()
|
|
for p in doc.paragraphs:
|
|
if p.style:
|
|
style_counts[p.style.name] += 1
|
|
style_names_seen.add(p.style.name)
|
|
results["paragraph_count"] = sum(style_counts.values())
|
|
results["paragraphs_by_style"] = dict(style_counts.most_common())
|
|
|
|
# --- Word Count for Page Estimation ---
|
|
in_chapter_area = False
|
|
chapter_word_count = 0
|
|
has_book_start = False
|
|
has_chapter = False
|
|
for p in doc.paragraphs:
|
|
text = p.text.strip()
|
|
if text == "[book start]":
|
|
has_book_start = True
|
|
if text == "[chapter]":
|
|
has_chapter = True
|
|
in_chapter_area = True
|
|
continue
|
|
if text in ["[back matter]", "[Req review]", "[about author]"]:
|
|
in_chapter_area = False
|
|
continue
|
|
if in_chapter_area and text:
|
|
chapter_word_count += len(text.split())
|
|
results["chapter_word_count"] = chapter_word_count
|
|
|
|
if not has_book_start:
|
|
results["issues"].append("Missing [book start] tag — document cannot be formatted")
|
|
if not has_chapter:
|
|
results["issues"].append("Missing [chapter] tag — document cannot be formatted")
|
|
|
|
# Name mapping: spec names -> expected actual names in documents
|
|
STYLE_NAME_MAP = {
|
|
"WA Nrml Pgr": ["WA Nrml Pgr", "WA Normal", "Wrst Normal - Body Text", "#Wrst Normal - Body Text", "CSP - Front Matter Body Text"],
|
|
"WA NChp Pgr": ["WA NChp Pgr", "WA Npr", "Wrst Normal - Chapter Body Text", "CSP - Chapter Body Text - First Paragraph"],
|
|
"WA Chapter": ["WA Chapter", "WA Chap", "Worst Chapter", "Wrst Chapter - Chapter Title + (Latin) Garamond"],
|
|
"WA Section": ["WA Section", "WA Sectn", "Wst Sectn"],
|
|
"WA Scene Break": ["WA Scene Break", "Worst Scene Break", "Wxrst Scene Break"],
|
|
}
|
|
|
|
# Check each spec style
|
|
for spec_name, spec_props in SPEC["styles"].items():
|
|
aliases = STYLE_NAME_MAP.get(spec_name, [spec_name])
|
|
matching_styles = [sn for sn in style_names_seen if sn in aliases]
|
|
if not matching_styles:
|
|
for alias in aliases:
|
|
try:
|
|
doc.styles[alias]
|
|
matching_styles.append(alias)
|
|
break
|
|
except KeyError:
|
|
pass
|
|
if not matching_styles:
|
|
results["styles_missing"].append(spec_name)
|
|
results["issues"].append(f"Style '{spec_name}': NOT FOUND")
|
|
continue
|
|
|
|
for actual_name in matching_styles:
|
|
try:
|
|
st = doc.styles[actual_name]
|
|
except KeyError:
|
|
continue
|
|
pf = st.paragraph_format
|
|
font = st.font
|
|
info = {
|
|
"name": actual_name,
|
|
"font": font.name,
|
|
"size_pt": emu_to_pt(font.size) if font.size else None,
|
|
"bold": font.bold,
|
|
"italic": font.italic,
|
|
"alignment": str(pf.alignment) if pf.alignment else None,
|
|
"first_line_indent": emu_to_in(pf.first_line_indent),
|
|
"line_spacing": pf.line_spacing,
|
|
"space_before": emu_to_pt(pf.space_before),
|
|
"space_after": emu_to_pt(pf.space_after),
|
|
"left_indent": emu_to_in(pf.left_indent),
|
|
}
|
|
|
|
# Compare
|
|
issues = []
|
|
if spec_props.get("font") and (info["font"] or "").lower() != spec_props["font"].lower():
|
|
issues.append(
|
|
f" font={info['font']} (expected {spec_props['font']})"
|
|
)
|
|
if spec_props.get("size") and info["size_pt"] and abs(info["size_pt"] - spec_props["size"]) > 0.5:
|
|
issues.append(
|
|
f" size={info['size_pt']}pt (expected {spec_props['size']}pt)"
|
|
)
|
|
fi = info.get("first_line_indent")
|
|
sfi = spec_props.get("first_line_indent")
|
|
if sfi is not None and fi is not None and abs(fi - sfi) > 0.01:
|
|
issues.append(
|
|
f" first_line_indent={fi}in (expected {sfi}in)"
|
|
)
|
|
ls = info.get("line_spacing")
|
|
sls = spec_props.get("line_spacing")
|
|
if sls is not None and ls is not None:
|
|
ls_pt = emu_to_pt(ls) if ls and ls > 100 else ls
|
|
if abs(ls_pt - sls) > 0.1:
|
|
issues.append(f" line_spacing={ls_pt}pt (expected {sls}pt)")
|
|
|
|
results["styles_found"][actual_name] = {
|
|
"properties": info,
|
|
"issues": issues,
|
|
"count": style_counts.get(actual_name, 0),
|
|
}
|
|
if issues:
|
|
results["warnings"].append(
|
|
f"Style '{actual_name}': {'; '.join(issues)}"
|
|
)
|
|
|
|
# Check character styles
|
|
for spec_name, spec_props in SPEC["character_styles"].items():
|
|
found = False
|
|
for st in doc.styles:
|
|
if st.name == spec_name and st.type == 2:
|
|
found = True
|
|
font = st.font
|
|
c_issues = []
|
|
if (font.name or "").lower() != spec_props["font"].lower():
|
|
c_issues.append(f"font={font.name} (expected {spec_props['font']})")
|
|
if font.italic != spec_props.get("italic"):
|
|
c_issues.append(f"italic={font.italic} (expected {spec_props.get('italic')})")
|
|
if c_issues:
|
|
results["warnings"].append(f"Char style '{spec_name}': {'; '.join(c_issues)}")
|
|
break
|
|
if not found:
|
|
results["styles_missing"].append(f"{spec_name} (character)")
|
|
results["issues"].append(f"Character style '{spec_name}': NOT FOUND")
|
|
|
|
# --- Headers ---
|
|
for i, s in enumerate(doc.sections[:1]):
|
|
sp = s._sectPr
|
|
for hr in sp.findall(qn("w:headerReference")):
|
|
htype = hr.get(qn("w:type"))
|
|
rid = hr.get(qn("r:id"))
|
|
try:
|
|
part = doc.part.related_parts[rid]
|
|
texts = []
|
|
for p in part.element.findall(qn("w:p")):
|
|
for t in p.findall(".//" + qn("w:t")):
|
|
if t.text:
|
|
texts.append(t.text)
|
|
content = " ".join(texts).strip()
|
|
if htype == "default" and not content:
|
|
results["warnings"].append("Default (odd) header is empty")
|
|
elif htype == "even" and not content:
|
|
results["warnings"].append("Even header is empty")
|
|
except (KeyError, AttributeError):
|
|
pass
|
|
|
|
# --- Footers ---
|
|
for i, s in enumerate(doc.sections[:1]):
|
|
has_page_field = False
|
|
for hr in s._sectPr.findall(qn("w:footerReference")):
|
|
rid = hr.get(qn("r:id"))
|
|
try:
|
|
part = doc.part.related_parts[rid]
|
|
body_xml = part.element.xml if hasattr(part.element, 'xml') else str(part.element)
|
|
if "PAGE" in body_xml:
|
|
has_page_field = True
|
|
except (KeyError, AttributeError):
|
|
pass
|
|
if not has_page_field:
|
|
results["warnings"].append("No PAGE field found in footer")
|
|
|
|
return results
|