# Copyright 2026 Umbra Tools (umbratools.com) # SPDX-License-Identifier: Apache-2.0 """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