Files
Umbraformat/umbraformat/analyzer.py
bm 0c32168231 Apply updated spec compliance and establish git/docker baseline
- 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).
2026-09-22 19:06:03 -07:00

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