Files
Umbraformat/umbraformat/formatter.py
bm dcd0716813 Fix Word-open failure: strict OOXML element ordering in sectPr and settings
sectPr children were emitted with headerReference/footerReference after
pgSz..mirrorMargins (SECTPR_ORDER), and w:settings had evenAndOddHeaders /
autoHyphenation appended after decimalSymbol/listSeparator. LibreOffice
tolerates out-of-order XML; Word validates CT_SectPr and CT_Settings
strictly and refuses the file.

- Move headerReference/footerReference to the head of SECTPR_ORDER per
  ECMA-376 CT_SectPr.
- Add SETTINGS_ORDER + _reorder_settings() using the full CT_Settings
  sequence (mathPr in the m: namespace) and apply it in Pass 2.
- Add regression tests asserting monotonic CT_SectPr and CT_Settings
  order for every section. 53 tests pass.
2026-09-23 06:27:21 -07:00

2132 lines
81 KiB
Python

"""Generate formatted PDF from tagged DOCX input."""
import io, os, shutil, subprocess, tempfile
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.section import WD_SECTION_START
from docx.oxml.ns import qn, nsdecls
from docx.oxml import parse_xml, OxmlElement
from docx.opc.constants import RELATIONSHIP_TYPE as RT
from docx.text.run import Run
TAGS = {
"book_start": "[book start]",
"chapter": "[chapter]",
"font_start": "[font]",
"font_end": "[/font]",
"back_matter": "[back matter]",
"copyright": "[copyright]",
"toc": "[TOC]",
"req_review": "[Req review]",
"about_author": "[about author]",
"section": "[section]",
"dedication": "[dedication]",
"epigraph": "[epigraph]",
"foreword": "[foreword]",
"acknowledgments": "[acknowledgments]",
}
VERBATIM_TAGS = {"back_matter", "req_review", "about_author"}
# P4 chapter-opener constants
SCENE_BREAK_ORNAMENT = "\u2022 \u2022 \u2022" # "• • •", present in every Caladea glyph set
CHAPTER_TOP_DROP_PT = 126.0 # ~1.75" sinkage below the top margin
SMALL_CAPS_LEADIN_WORDS = 4 # first N words of a chapter set in small caps
BODY_FIRST_LINE_INDENT = 0.22 # inches, per spec 0.18"-0.25"
# P9 graphic rules (spec section 5)
SCENE_BREAK_FALLBACK = "***" # spec 5.2 Option B: asterisks replace ornaments
GRAPHIC_OVERFLOW_MARGIN_IN = 0.5 # spec 5.1.3: boundary tolerance (informational)
# P10 internationalization (spec section 6)
DEFAULT_LANGUAGE = "en"
RTL_LANGUAGES = {"ar", "he", "fa", "ur", "yi"}
LANGUAGE_STRINGS = {
"en": {
"toc": "Table of Contents", "contents": "Contents", "foreword": "Foreword",
"acknowledgments": "Acknowledgments", "appendix": "Appendix",
"review": "Review", "about_author": "About the Author",
"rights": ("All rights reserved. No part of this publication may be "
"reproduced, distributed, or transmitted in any form or by any "
"means without the prior written permission of the publisher."),
"fiction": ("This is a work of fiction. Names, characters, places, and "
"incidents are products of the author's imagination or are used "
"fictitiously."),
"published_by": "Published by {imprint}.",
},
"es": {
"toc": "Índice", "contents": "Índice", "foreword": "Prólogo",
"acknowledgments": "Agradecimientos", "appendix": "Apéndice",
"review": "Reseña", "about_author": "Sobre el autor",
"rights": ("Todos los derechos reservados. Ninguna parte de esta publicación "
"puede reproducirse, distribuirse o transmitirse por ningún medio "
"sin la autorización previa por escrito del editor."),
"fiction": ("Esta es una obra de ficción. Los nombres, personajes, lugares e "
"incidentes son producto de la imaginación del autor o se usan de "
"forma ficticia."),
"published_by": "Publicado por {imprint}.",
},
"de": {
"toc": "Inhaltsverzeichnis", "contents": "Inhalt", "foreword": "Vorwort",
"acknowledgments": "Danksagung", "appendix": "Anhang",
"review": "Rezension", "about_author": "Über den Autor",
"rights": ("Alle Rechte vorbehalten. Kein Teil dieser Veröffentlichung darf "
"ohne vorherige schriftliche Genehmigung des Verlags reproduziert, "
"verbreitet oder übertragen werden."),
"fiction": ("Dies ist ein fiktionales Werk. Namen, Figuren, Orte und "
"Ereignisse sind Produkte der Fantasie des Autors oder werden "
"fiktiv verwendet."),
"published_by": "Veröffentlicht von {imprint}.",
},
"fr": {
"toc": "Table des matières", "contents": "Sommaire", "foreword": "Préface",
"acknowledgments": "Remerciements", "appendix": "Annexe",
"review": "Critique", "about_author": "À propos de l'auteur",
"rights": ("Tous droits réservés. Aucune partie de cette publication ne peut "
"être reproduite, distribuée ou transmise sous quelque forme que ce "
"soit sans l'autorisation écrite préalable de l'éditeur."),
"fiction": ("Ceci est une œuvre de fiction. Les noms, personnages, lieux et "
"événements sont le produit de l'imagination de l'auteur ou sont "
"utilisés de manière fictive."),
"published_by": "Publié par {imprint}.",
},
"ar": {
"toc": "جدول المحتويات", "contents": "المحتويات", "foreword": "تقديم",
"acknowledgments": "شكر وتقدير", "appendix": "الملحق",
"review": "مراجعة", "about_author": "عن المؤلف",
"rights": ("جميع الحقوق محفوظة. لا يجوز إعادة إنتاج أي جزء من هذا المنشور أو "
"توزيعه أو نقله بأي شكل دون إذن كتابي مسبق من الناشر."),
"fiction": ("هذا عمل خيالي. الأسماء والشخصيات والأماكن والأحداث من نسج خيال "
"المؤلف أو تُستخدم بشكل خيالي."),
"published_by": "نشر بواسطة {imprint}.",
},
"he": {
"toc": "תוכן העניינים", "contents": "תוכן", "foreword": "הקדמה",
"acknowledgments": "תודות", "appendix": "נספח",
"review": "סקירה", "about_author": "אודות המחבר",
"rights": ("כל הזכויות שמורות. אין לשכפל, להפיץ או להעביר חלק כלשהו מפרסום זה "
"בכל צורה שהיא ללא אישור בכתב מראש מהמוציא לאור."),
"fiction": ("זהו יצירה בדיונית. השמות, הדמויות, המקומות והאירועים הם פרי "
"דמיונו של המחבר או משמשים באופן בדיוני."),
"published_by": "הוצאה לאור: {imprint}.",
},
}
def _language_code(language):
"""Normalize a BCP-47/ISO language tag to its base code (e.g. 'es-MX'→'es')."""
return (language or DEFAULT_LANGUAGE).replace("_", "-").split("-")[0].lower()
def _language_strings(language):
"""Return the localized string table for a language (English fallback)."""
return LANGUAGE_STRINGS.get(_language_code(language), LANGUAGE_STRINGS[DEFAULT_LANGUAGE])
def _is_rtl(language):
"""True for right-to-left languages (spec section 6.2)."""
return _language_code(language) in RTL_LANGUAGES
def _apply_language_defaults(doc, language, rtl=False):
"""Tag the document default language so Word picks the right proofing and
hyphenation dictionary (spec section 6.1). For RTL languages also set the
bidirectional paragraph/run defaults (spec section 6.2)."""
styles_el = doc.styles.element
doc_defaults = styles_el.find(qn("w:docDefaults"))
if doc_defaults is None:
doc_defaults = OxmlElement("w:docDefaults")
styles_el.insert(0, doc_defaults)
rpr_default = doc_defaults.find(qn("w:rPrDefault"))
if rpr_default is None:
rpr_default = OxmlElement("w:rPrDefault")
doc_defaults.insert(0, rpr_default)
rpr = rpr_default.find(qn("w:rPr"))
if rpr is None:
rpr = OxmlElement("w:rPr")
rpr_default.append(rpr)
lang = rpr.find(qn("w:lang"))
if lang is None:
lang = OxmlElement("w:lang")
rpr.append(lang)
lang.set(qn("w:val"), language)
if rtl and rpr.find(qn("w:rtl")) is None:
rtl_el = OxmlElement("w:rtl")
# w:rtl precedes w:lang in the rPr schema sequence.
rpr.insert(rpr.index(lang), rtl_el)
if rtl:
ppr_default = doc_defaults.find(qn("w:pPrDefault"))
if ppr_default is None:
ppr_default = OxmlElement("w:pPrDefault")
doc_defaults.append(ppr_default)
ppr = ppr_default.find(qn("w:pPr"))
if ppr is None:
ppr = OxmlElement("w:pPr")
ppr_default.append(ppr)
if ppr.find(qn("w:bidi")) is None:
ppr.append(OxmlElement("w:bidi"))
TRIM_SIZES = {
"5.5x8.5": (5.5, 8.5),
"6x9": (6.0, 9.0),
}
MARGIN_TABLES = {
"umbraformat": (
(0, 150, 0.50),
(150, 301, 0.625),
(301, float("inf"), 0.75),
),
"kdp": (
(0, 24, 0.375),
(24, 150, 0.375),
(150, 300, 0.50),
(300, 500, 0.625),
(500, 700, 0.75),
(700, float("inf"), 0.875),
),
}
def compute_gutter(page_count, margin_table="umbraformat"):
"""Return the recommended inside/gutter margin (inches) for a page count.
``margin_table`` selects which published recommendation table applies:
"umbraformat" (the format spec: 0.50 / 0.625 / 0.75) or "kdp"
(KDP print guidance: 0.375 .. 0.875). Falls back to the top bucket of
the selected table for out-of-range counts.
"""
rows = MARGIN_TABLES.get(margin_table, MARGIN_TABLES["umbraformat"])
for lo, hi, margin in rows:
if lo <= page_count < hi:
return margin
return rows[-1][2]
WA_STYLES = {
"WA Chapter": {
"type": "paragraph",
"base": "Heading 1",
"font": "Caladea",
"size": Pt(18),
"alignment": WD_ALIGN_PARAGRAPH.CENTER,
"line_spacing": 4.0,
"line_spacing_rule": WD_LINE_SPACING.MULTIPLE,
"space_before": Pt(0),
"space_after": Pt(0),
"keep_with_next": True,
"keep_together": True,
},
"WA Nrml Pgr": {
"type": "paragraph",
"base": "Normal",
"font": "Caladea",
"size": Pt(12),
"alignment": WD_ALIGN_PARAGRAPH.JUSTIFY,
"first_line_indent": Inches(BODY_FIRST_LINE_INDENT),
"line_spacing": Emu(190500),
"line_spacing_rule": WD_LINE_SPACING.EXACTLY,
"space_before": Pt(0),
"space_after": Pt(0),
"widow_control": True,
},
"WA NChp Pgr": {
"type": "paragraph",
"base": "Normal",
"font": "Caladea",
"size": Pt(12),
"alignment": WD_ALIGN_PARAGRAPH.JUSTIFY,
"first_line_indent": Inches(0),
"line_spacing": Emu(177800),
"line_spacing_rule": WD_LINE_SPACING.EXACTLY,
"space_before": Pt(0),
"space_after": Pt(0),
"widow_control": True,
},
"WA Section": {
"type": "paragraph",
"base": "Normal",
"font": "Georgia",
"size": Pt(16),
"alignment": WD_ALIGN_PARAGRAPH.CENTER,
"line_spacing": 1.5,
"line_spacing_rule": WD_LINE_SPACING.ONE_POINT_FIVE,
"space_before": Pt(6),
"space_after": Pt(12),
"keep_with_next": True,
"keep_together": True,
"widow_control": True,
},
"WA Thkng": {
"type": "character",
"font": "Caladea",
"size": Pt(12),
"italic": True,
},
}
def _create_wa_styles(doc):
"""Create WA styles in the output document."""
from docx.enum.style import WD_STYLE_TYPE
for name, props in WA_STYLES.items():
try:
style = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH if props["type"] == "paragraph" else WD_STYLE_TYPE.CHARACTER)
except ValueError:
style = doc.styles[name]
# Set base style if specified (e.g., Heading 1 for TOC support)
if "base" in props:
try:
style.base_style = doc.styles[props["base"]]
except KeyError:
pass
if "font" in props and style.font:
style.font.name = props["font"]
if "size" in props and style.font:
style.font.size = props["size"]
if "italic" in props and style.font:
style.font.italic = props["italic"]
if "bold" in props and style.font:
style.font.bold = props["bold"]
if props["type"] == "paragraph" and style.paragraph_format:
pf = style.paragraph_format
if "alignment" in props:
pf.alignment = props["alignment"]
if "first_line_indent" in props:
pf.first_line_indent = props["first_line_indent"]
if "line_spacing" in props:
pf.line_spacing = props["line_spacing"]
if "line_spacing_rule" in props:
pf.line_spacing_rule = props["line_spacing_rule"]
if "space_before" in props:
pf.space_before = props["space_before"]
if "space_after" in props:
pf.space_after = props["space_after"]
if "keep_with_next" in props:
pf.keep_with_next = props["keep_with_next"]
if "keep_together" in props:
pf.keep_together = props["keep_together"]
if "widow_control" in props:
pf.widow_control = props["widow_control"]
def _find_tags(doc):
"""Walk paragraphs and locate structural tags.
Returns dict with all found tag positions."""
tags = {"chapters": []}
for i, p in enumerate(doc.paragraphs):
text = p.text.strip()
if text == TAGS["book_start"]:
tags["book_start"] = i
elif text == TAGS["chapter"]:
tags["chapters"].append(i)
elif text == TAGS["back_matter"]:
tags["back_matter"] = i
elif text == TAGS["copyright"]:
tags["copyright"] = i
elif text == TAGS["toc"]:
tags["toc"] = i
elif text == TAGS["req_review"]:
tags["req_review"] = i
elif text == TAGS["about_author"]:
tags["about_author"] = i
elif text == TAGS["dedication"]:
tags["dedication"] = i
elif text == TAGS["epigraph"]:
tags["epigraph"] = i
elif text == TAGS["foreword"]:
tags["foreword"] = i
elif text == TAGS["acknowledgments"]:
tags["acknowledgments"] = i
return tags
def _extract_title_page(doc, start_idx, end_idx, default_author="", default_imprint=""):
"""Extract title, subtitle, author, imprint from title page paragraphs.
Returns (title, subtitle, author, imprint, image_paras) where image_paras
is a list of source paragraphs containing drawings.
Author/imprint are taken from explicit "Author: ..." / "Imprint: ..." or
"By ..." lines when present, otherwise the caller-provided defaults are used."""
import re as _re
title = subtitle = author = imprint = ""
image_paras = []
paras = []
for i in range(start_idx, end_idx):
p = doc.paragraphs[i]
text = p.text.strip()
# Collect paragraphs that contain drawings
has_drawing = any(
r._r.find(qn('w:drawing')) is not None
for r in p.runs
)
if has_drawing:
image_paras.append(p)
if not text:
continue
# Skip structural tag lines entirely (e.g., [copyright], [TOC])
if text.startswith("[") and text.endswith("]"):
continue
paras.append((i, text, p))
structured = []
for i, text, _p in paras:
lm = _re.match(r"^author\s*:\s*(.+)$", text, _re.I)
pm = _re.match(r"^(?:imprint|publisher)\s*:\s*(.+)$", text, _re.I)
if lm and not author:
author = lm.group(1).strip()
elif pm and not imprint:
imprint = pm.group(1).strip()
else:
structured.append((i, text, _p))
if not author:
for i, text, _p in structured:
bm = _re.match(r"^by\s+(.+)$", text, _re.I)
if bm:
author = bm.group(1).strip()
structured.remove((i, text, _p))
break
if len(structured) >= 1:
title = structured[0][1]
if len(structured) >= 2:
subtitle = structured[1][1]
author = author or default_author
imprint = imprint or default_imprint
return title, subtitle, author, imprint, image_paras
def _extract_chapters(doc, chapter_indices):
"""Extract chapter content using [chapter] tags and section breaks.
Returns list of dicts with 'title' and 'paras'."""
chapters = []
cur_title = ""
cur_paras = []
font_mode = False
chapter_tag_set = set(chapter_indices)
def _flush():
nonlocal cur_title, cur_paras
icon = None
if cur_paras and cur_paras[0]["type"] == "icon":
icon = cur_paras.pop(0)
if cur_title or cur_paras:
chapters.append({"title": cur_title, "paras": cur_paras,
"icon": icon})
cur_title = ""
cur_paras = []
start_idx = chapter_indices[0] if chapter_indices else 0
for i in range(start_idx + 1, len(doc.paragraphs)):
p = doc.paragraphs[i]
text = p.text.strip()
# Check for verbatim section tags → stop chapter extraction
if text in (TAGS[t] for t in VERBATIM_TAGS):
_flush()
break
# Check for explicit [chapter] tag — flush current chapter, do NOT
# append the tag itself (structural marker, not content)
if text == TAGS["chapter"]:
_flush()
continue
# Check for [font] blocks — tags are structural, not content
if text == TAGS["font_start"]:
font_mode = True
continue
if text == TAGS["font_end"]:
font_mode = False
continue
if font_mode:
if not cur_title and not cur_paras:
cur_paras.append({"type": "font", "para": p})
else:
cur_paras.append({"type": "font", "para": p})
continue
# Check for section break → implicit chapter boundary
pPr = p._p.find(qn("w:pPr"))
has_sect_break = pPr is not None and pPr.find(qn("w:sectPr")) is not None
if has_sect_break:
if cur_title:
# Content on the break paragraph belongs to current chapter
if text:
cur_paras.append({"type": "text", "para": p, "text": text})
_flush()
continue
# Skip [book start] tag if encountered in body
if text == TAGS["book_start"]:
continue
# Image-bearing paragraphs are content even when they carry no text.
# An image before any chapter text is treated as a chapter header icon
# (spec section 5.1.1) and rendered above the heading.
if _paragraph_has_image(p):
if not cur_title and not cur_paras:
cur_paras.append({"type": "icon", "para": p, "text": text})
else:
cur_paras.append({"type": "image", "para": p, "text": text})
continue
if not cur_title:
if text:
cur_title = text
elif text:
cur_paras.append({"type": "text", "para": p, "text": text})
_flush()
return chapters
def _extract_between_tags(doc, start_idx, end_idx=None):
"""Extract paragraphs from start_idx to end_idx (exclusive)."""
blocks = []
for i in range(start_idx, end_idx if end_idx is not None else len(doc.paragraphs)):
p = doc.paragraphs[i]
blocks.append(p)
return blocks
def _tag_positions(doc, tags):
"""Flatten every structural-tag paragraph index into one sorted list."""
positions = []
for name, idx in tags.items():
if name == "chapters":
positions.extend(idx)
elif isinstance(idx, int):
positions.append(idx)
return sorted(positions)
def _content_after_tag(doc, tags, tagname):
"""Return the paragraphs following ``tagname`` up to the next structural tag."""
if tagname not in tags:
return []
start = tags[tagname]
after = [p for p in _tag_positions(doc, tags) if p > start]
end = after[0] if after else len(doc.paragraphs)
return _extract_between_tags(doc, start + 1, end)
def _add_centered_spacer(out, pt_size, count=1):
"""Consecutive empty lines of a fixed exact line height (vertical spacing)."""
for _ in range(count):
p = out.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf = p.paragraph_format
pf.line_spacing = Pt(pt_size)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
def _build_half_title_page(out, title):
"""Half title — book title only, centered, small caps-like modest size."""
_add_centered_spacer(out, 36, 6)
p = out.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf = p.paragraph_format
pf.line_spacing = Pt(24)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
run = p.add_run(title)
run.font.name = "Caladea"
run.font.size = Pt(24)
def _build_title_page(out, title, subtitle, author, imprint):
"""Full title page — vertically centered title block, author below."""
# Fit the block within the 5.5x8.5 text area (504pt usable), centered-ish.
_add_centered_spacer(out, 42, 3)
p = out.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf = p.paragraph_format
pf.line_spacing = Pt(42)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
run = p.add_run(title)
run.font.name = "Caladea"
run.font.size = Pt(42)
if subtitle:
_add_centered_spacer(out, 12, 1)
p = out.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf = p.paragraph_format
pf.line_spacing = Pt(18)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
run = p.add_run(subtitle)
run.font.name = "Caladea"
run.font.size = Pt(18)
_add_centered_spacer(out, 14, 7)
if author:
p = out.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf = p.paragraph_format
pf.line_spacing = Pt(32)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
run = p.add_run(author)
run.font.name = "Caladea"
run.font.size = Pt(30)
if imprint:
p = out.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf = p.paragraph_format
pf.line_spacing = Pt(16)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(8)
pf.space_after = Pt(0)
run = p.add_run(imprint)
run.font.name = "Caladea"
run.font.size = Pt(13)
def _default_copyright_text(author, imprint, year=None, strings=None):
"""Standard copyright-page boilerplate used when [copyright] is absent."""
import datetime
year = year or datetime.date.today().year
s = strings or LANGUAGE_STRINGS[DEFAULT_LANGUAGE]
return [
f"Copyright \u00a9 {year} by {author}.",
s["rights"],
"",
s["fiction"],
"",
s["published_by"].format(imprint=imprint),
]
def _copy_copyright_paragraphs(out, src_paras, src_doc):
"""Render copyright text small (9pt), flush-left, no indentation."""
for src_p in src_paras:
new_p = out.add_paragraph()
pf = new_p.paragraph_format
pf.alignment = WD_ALIGN_PARAGRAPH.LEFT
pf.line_spacing = Pt(12)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
for r in src_p.runs:
if not r.text:
continue
nr = new_p.add_run(r.text)
nr.font.name = "Caladea"
nr.font.size = Pt(9)
nr.font.bold = r.font.bold
nr.font.italic = r.font.italic
return new_p
def _build_copyright_page(out, src_paras, src_doc, author, imprint, strings=None):
"""Copyright page at the base of the verso facing the title page.
The section's vAlign=bottom (applied in Pass 2) grounds this block."""
if src_paras:
for p in src_paras:
_copy_copyright_paragraphs(out, [p], src_doc)
else:
text = _default_copyright_text(author, imprint, strings=strings)
for line in text:
p = out.add_paragraph()
pf = p.paragraph_format
pf.alignment = WD_ALIGN_PARAGRAPH.LEFT
pf.line_spacing = Pt(12)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
if not line:
continue
run = p.add_run(line)
run.font.name = "Caladea"
run.font.size = Pt(9)
def _build_centered_quote_section(out, paras):
"""Centered, restrained front matter (dedication / epigraph) lines."""
for src_p in paras:
text = src_p.text.strip()
if not text:
_add_centered_spacer(out, 12, 1)
continue
p = out.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf = p.paragraph_format
pf.line_spacing = Pt(18)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
run = p.add_run(text)
run.font.name = "Caladea"
run.font.size = Pt(18)
run.font.italic = True
def _build_toc(out, chapters, tab_pos_twips, heading="Table of Contents"):
"""Emit the TOC heading and one dot-leader + PAGEREF entry per titled chapter.
Returns {chapter_index: paragraph} for bookmark back-fill."""
toc_entry_paras = {}
add_p = out.add_paragraph()
add_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf_toc = add_p.paragraph_format
pf_toc.space_before = Pt(0)
pf_toc.space_after = Pt(18)
pf_toc.line_spacing = Pt(18)
pf_toc.line_spacing_rule = WD_LINE_SPACING.EXACTLY
run = add_p.add_run(heading)
run.font.name = "Caladea"
run.font.size = Pt(20)
for ci, ch in enumerate(chapters):
if not ch["title"]:
continue
bm_name = f"_UF_ch_{ci}"
new_p = out.add_paragraph()
pf = new_p.paragraph_format
pf.alignment = WD_ALIGN_PARAGRAPH.LEFT
pf.line_spacing = Pt(15)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
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)
# Chapter label (left-aligned)
r_title = new_p.add_run(f"{ci + 1}. {ch['title']}")
r_title.font.name = "Caladea"
r_title.font.size = Pt(11)
# Tab character to jump to the dot-leader tab stop
r_tab = new_p.add_run()
r_tab.font.name = "Caladea"
r_tab.font.size = Pt(11)
tab_el = OxmlElement('w:tab')
r_tab._r.append(tab_el)
# PAGEREF field pointing to the chapter bookmark
_add_pageref_field(new_p, bm_name, font_name="Caladea", font_size=Pt(11))
toc_entry_paras[ci] = new_p
return toc_entry_paras
def _build_prose_section(out, paras, font_name, font_size):
"""Body-styled prose for front matter sections (foreword / acknowledgments)."""
for src_p in paras:
text = src_p.text.strip()
if not text:
continue
new_p = out.add_paragraph(style="WA Nrml Pgr")
run = new_p.add_run(text)
run.font.name = font_name
run.font.size = Pt(font_size)
def _is_scene_break_marker(item, text):
"""True when the paragraph is an explicit scene-break device (literal
'<<<>>>', or a paragraph styled as a scene break) rather than prose."""
if text and text in ("<<<>>>", "[section]"):
return True
style = getattr(item.get("para"), "style", None)
name = getattr(style, "name", "") or ""
return bool(name) and "Scene Break" in name
def _copy_paragraph_verbatim(out, src_p, src_doc):
"""Copy a paragraph from input to output.
Enforces Caladea 12pt, 15pt exact line spacing.
Preserves alignment, indentation, empty paragraphs, and images (via blob copy)."""
new_p = out.add_paragraph()
has_content = False
for r in src_p.runs:
# Copy images via run.add_picture (creates proper relationships)
for inline in r._r.findall(qn('w:drawing')):
blips = inline.findall('.//' + qn('a:blip'))
for blip in blips:
embed = blip.get(qn('r:embed'))
if embed:
try:
img_part = src_doc.part.related_parts[embed]
extents = inline.findall('.//' + qn('wp:extent'))
cx = int(extents[0].get('cx')) if extents else None
cy = int(extents[0].get('cy')) if extents else None
stream = io.BytesIO(img_part.blob)
r2 = new_p.add_run()
r2.add_picture(stream, width=Emu(cx) if cx else None,
height=Emu(cy) if cy else None)
has_content = True
except Exception:
pass
# Copy text runs with enforced formatting
if r.text:
new_r = new_p.add_run(r.text)
new_r.font.name = "Caladea"
new_r.font.size = Pt(12)
new_r.font.bold = r.font.bold
new_r.font.italic = r.font.italic
if r.font.color.rgb:
new_r.font.color.rgb = r.font.color.rgb
has_content = True
# Copy paragraph formatting: enforce line spacing, preserve everything else
pf = new_p.paragraph_format
spf = src_p.paragraph_format
pf.alignment = spf.alignment
pf.line_spacing = Pt(15)
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
if spf.first_line_indent is not None:
pf.first_line_indent = spf.first_line_indent
return new_p
def _add_bookmark(para, bookmark_id, bookmark_name):
"""Add a bookmark spanning the entire paragraph."""
pPr = para._p.find(qn('w:pPr'))
if pPr is not None:
idx = list(para._p).index(pPr) + 1
else:
idx = 0
bm_start = OxmlElement('w:bookmarkStart')
bm_start.set(qn('w:id'), str(bookmark_id))
bm_start.set(qn('w:name'), bookmark_name)
para._p.insert(idx, bm_start)
bm_end = OxmlElement('w:bookmarkEnd')
bm_end.set(qn('w:id'), str(bookmark_id))
para._p.append(bm_end)
def _add_pageref_field(para, bookmark_name, font_name="Caladea", font_size=Pt(11)):
"""Add a PAGEREF field (page number of bookmark) to the paragraph.
Each field char is in its own run per OOXML spec (Word requires this).
The begin run carries w:dirty so Word knows to recalculate the field."""
r1 = para.add_run()
r1.font.name = font_name
r1.font.size = font_size
fld1 = OxmlElement("w:fldChar")
fld1.set(qn("w:fldCharType"), "begin")
r1._r.append(fld1)
# Mark field as dirty so Word recalculates it on open
rpr = r1._r.find(qn('w:rPr'))
if rpr is None:
rpr = OxmlElement('w:rPr')
r1._r.insert(0, rpr)
dirty = OxmlElement('w:dirty')
dirty.set(qn('w:val'), '1')
rpr.append(dirty)
r2 = para.add_run()
r2.font.name = font_name
r2.font.size = font_size
instr = OxmlElement("w:instrText")
instr.set(qn("xml:space"), "preserve")
instr.text = f" PAGEREF {bookmark_name} "
r2._r.append(instr)
r3 = para.add_run()
r3.font.name = font_name
r3.font.size = font_size
sep = OxmlElement("w:fldChar")
sep.set(qn("w:fldCharType"), "separate")
r3._r.append(sep)
r4 = para.add_run()
r4.font.name = font_name
r4.font.size = font_size
placeholder = OxmlElement("w:t")
placeholder.text = "1"
r4._r.append(placeholder)
r5 = para.add_run()
r5.font.name = font_name
r5.font.size = font_size
fld2 = OxmlElement("w:fldChar")
fld2.set(qn("w:fldCharType"), "end")
r5._r.append(fld2)
def _transfer_image_paragraph(out, src_p, src_doc):
"""Copy a paragraph with images, preserving fixed (anchored) positioning.
Deep-copies the drawing XML and remaps r:embed relationship IDs."""
new_p = out.add_paragraph()
pf = new_p.paragraph_format
spf = src_p.paragraph_format
pf.alignment = spf.alignment
pf.space_before = Pt(0)
pf.space_after = Pt(0)
old_to_new = {}
for r in src_p.runs:
text = r.text
drawing = r._r.find(qn('w:drawing'))
if text:
new_r = new_p.add_run(text)
_copy_run_format(r, new_r)
if drawing is not None:
for blip in drawing.findall('.//' + qn('a:blip')):
embed = blip.get(qn('r:embed'))
if embed and embed not in old_to_new:
try:
img_part = src_doc.part.related_parts[embed]
stream = io.BytesIO(img_part.blob)
image_part = out.part.package.get_or_add_image_part(stream)
new_rId = out.part.relate_to(image_part, RT.IMAGE)
old_to_new[embed] = new_rId
except Exception:
pass
drawing_copy = deepcopy(drawing)
for blip in drawing_copy.findall('.//' + qn('a:blip')):
embed = blip.get(qn('r:embed'))
if embed in old_to_new:
blip.set(qn('r:embed'), old_to_new[embed])
new_r = new_p.add_run()
new_r._r.append(drawing_copy)
return new_p
def _iter_drawings(paragraph):
"""Yield the w:drawing elements found in a paragraph's runs."""
for r in paragraph.runs:
drawing = r._r.find(qn("w:drawing"))
if drawing is not None:
yield drawing
def _paragraph_has_image(paragraph):
"""True when the paragraph contains at least one drawing."""
for _ in _iter_drawings(paragraph):
return True
return False
def _drawing_extent(drawing):
"""Return (cx, cy) EMU dimensions of a drawing, or (None, None)."""
ext = drawing.find(".//" + qn("wp:extent"))
if ext is None:
return None, None
try:
return int(ext.get("cx")), int(ext.get("cy"))
except (TypeError, ValueError):
return None, None
def _set_drawing_extent(drawing, cx, cy):
ext = drawing.find(".//" + qn("wp:extent"))
if ext is not None:
ext.set("cx", str(int(cx)))
ext.set("cy", str(int(cy)))
def _drawing_is_floating(drawing):
"""True when the drawing is anchored (floating) rather than inline."""
return drawing.find(qn("wp:anchor")) is not None
def _drawing_overflows(drawing, max_width_emu, max_height_emu=None):
"""True when the drawing is larger than the printable text box."""
cx, cy = _drawing_extent(drawing)
if not cx or not cy:
return False
if max_width_emu and cx > max_width_emu:
return True
if max_height_emu and cy > max_height_emu:
return True
return False
def _floating_to_inline(drawing):
"""Re-anchor a floating (wp:anchor) drawing as an inline (wp:inline) block
in the text flow (spec section 5.2, Option A default). Returns True when a
conversion was performed."""
anchor = drawing.find(qn("wp:anchor"))
if anchor is None:
return False
inline = OxmlElement("wp:inline")
for attr in ("distT", "distB", "distL", "distR"):
inline.set(attr, anchor.get(attr, "0"))
positioning_only = {
qn("wp:simplePos"), qn("wp:positionH"), qn("wp:positionV"),
qn("wp:wrapNone"), qn("wp:wrapSquare"), qn("wp:wrapTight"),
qn("wp:wrapThrough"), qn("wp:wrapTopAndBottom"),
qn("wp:relativeHeight"),
}
for child in list(anchor):
if child.tag in positioning_only:
continue
inline.append(child)
drawing.replace(anchor, inline)
return True
def _clamp_drawing_to_box(drawing, max_width_emu, max_height_emu=None):
"""Scale a drawing down so it fits the text box, keeping aspect ratio
(the "100% scale lock" of spec section 5.1.1). Returns True if scaled."""
cx, cy = _drawing_extent(drawing)
if not cx or not cy:
return False
scale = 1.0
if max_width_emu and cx > max_width_emu:
scale = min(scale, max_width_emu / cx)
if max_height_emu and cy > max_height_emu:
scale = min(scale, max_height_emu / cy)
if scale < 1.0:
_set_drawing_extent(drawing, cx * scale, cy * scale)
return True
return False
def _add_body_image(out, src_p, src_doc, text_width_emu, text_height_emu,
strip_graphics=False, strip_problematic=False,
font_name="Caladea", font_size=Pt(12)):
"""Transfer a source paragraph's drawing into the output as a centered,
inline body image (spec section 5).
* Floating anchors are re-anchored inline (Option A default).
* Images wider/taller than the text box are scaled to fit (aspect locked).
* With ``strip_problematic`` set, overflowing images are removed instead.
* With ``strip_graphics`` set, decorative images are dropped entirely.
Returns the created paragraph, or None if nothing was emitted.
"""
if strip_graphics:
return None
new_p = _transfer_image_paragraph(out, src_p, src_doc)
if new_p is None:
return None
pf = new_p.paragraph_format
pf.space_before = Pt(6)
pf.space_after = Pt(6)
pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in new_p.runs:
if run.font.name is None:
run.font.name = font_name
if run.font.size is None:
run.font.size = font_size
for drawing in list(_iter_drawings(new_p)):
if strip_problematic and _drawing_overflows(drawing, text_width_emu,
text_height_emu):
drawing.getparent().remove(drawing)
continue
_floating_to_inline(drawing)
_clamp_drawing_to_box(drawing, text_width_emu, text_height_emu)
return new_p
def _add_side_by_side_images(out, image_paras, doc, trim_width=5.5, outside_margin=0.50):
"""Extract images from title page paragraphs and add them side by side
in a single paragraph - left image at left, right image at right."""
blobs = []
for src_p in image_paras:
for r in src_p.runs:
drawing = r._r.find(qn('w:drawing'))
if drawing is None:
continue
for blip in drawing.findall('.//' + qn('a:blip')):
embed = blip.get(qn('r:embed'))
if embed:
try:
img_part = doc.part.related_parts[embed]
extents = drawing.findall('.//' + qn('wp:extent'))
cx = int(extents[0].get('cx')) if extents else None
cy = int(extents[0].get('cy')) if extents else None
blobs.append({
'blob': img_part.blob,
'width': Emu(cx) if cx else None,
'height': Emu(cy) if cy else None,
})
except Exception:
pass
if len(blobs) < 2:
for img in blobs:
p = out.add_paragraph()
pf = p.paragraph_format
pf.space_before = Pt(0)
pf.space_after = Pt(0)
pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run()
stream = io.BytesIO(img['blob'])
run.add_picture(stream, width=img['width'], height=img['height'])
return
p = out.add_paragraph()
pf = p.paragraph_format
pf.space_before = Pt(0)
pf.space_after = Pt(0)
pf.alignment = WD_ALIGN_PARAGRAPH.LEFT
tab_pos_twips = int((trim_width - 0.55 - outside_margin) * 1440)
pPr = p._p.find(qn('w:pPr'))
if pPr is None:
pPr = OxmlElement('w:pPr')
p._p.insert(0, pPr)
tabs_elem = 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))
tabs_elem.append(tab_stop)
pPr.append(tabs_elem)
run = p.add_run()
stream = io.BytesIO(blobs[0]['blob'])
run.add_picture(stream, width=blobs[0]['width'], height=blobs[0]['height'])
run.add_tab()
run = p.add_run()
stream = io.BytesIO(blobs[1]['blob'])
run.add_picture(stream, width=blobs[1]['width'], height=blobs[1]['height'])
def _ensure_pgNumType(sect_pr, fmt, start=None):
existing = sect_pr.find(qn("w:pgNumType"))
if existing is not None:
sect_pr.remove(existing)
pgNumType = OxmlElement("w:pgNumType")
pgNumType.set(qn("w:fmt"), fmt)
if start is not None:
pgNumType.set(qn("w:start"), str(start))
sect_pr.append(pgNumType)
def _set_section_v_align_bottom(sect_pr):
"""Vertically align a section's text block at the bottom of the page
(used so the copyright block sits at the base of the verso page)."""
vAlign = sect_pr.find(qn("w:vAlign"))
if vAlign is None:
vAlign = OxmlElement("w:vAlign")
sect_pr.append(vAlign)
vAlign.set(qn("w:val"), "bottom")
def _insert_page_field(para, font_name, font_size, letter_spacing_twips=0):
"""Insert a PAGE field into the given paragraph (no alignment change).
Runs must follow: begin -> instrText -> separate -> placeholder -> end,
each field char in its own run (Word requires this)."""
def _field_run(char_type):
r = para.add_run()
r.font.name = font_name
r.font.size = font_size
_set_run_spacing(r, letter_spacing_twips)
fld = OxmlElement("w:fldChar")
fld.set(qn("w:fldCharType"), char_type)
r._r.append(fld)
_field_run("begin")
instr = para.add_run()
instr.font.name = font_name
instr.font.size = font_size
_set_run_spacing(instr, letter_spacing_twips)
instrText = OxmlElement("w:instrText")
instrText.set(qn("xml:space"), "preserve")
instrText.text = " PAGE "
instr._r.append(instrText)
_field_run("separate")
placeholder = para.add_run()
placeholder.font.name = font_name
placeholder.font.size = font_size
_set_run_spacing(placeholder, letter_spacing_twips)
t = OxmlElement("w:t")
t.text = "1"
placeholder._r.append(t)
_field_run("end")
def _set_run_spacing(run, twips):
"""Apply letter-spacing (in twentieths of a point) to a run's rPr."""
if not twips:
return
rpr = run._r.get_or_add_rPr()
spacing = rpr.find(qn("w:spacing"))
if spacing is None:
spacing = OxmlElement("w:spacing")
rpr.append(spacing)
spacing.set(qn("w:val"), str(twips))
def _suppress_section_footers(section):
"""Unlink a section's footers and guarantee they are blank
(bottom page numbers migrated to running headers per spec section 4.1)."""
section.footer.is_linked_to_previous = False
_clear_paragraph(section.footer.paragraphs[0])
section.even_page_footer.is_linked_to_previous = False
_clear_paragraph(section.even_page_footer.paragraphs[0])
section.first_page_footer.is_linked_to_previous = False
_clear_paragraph(section.first_page_footer.paragraphs[0])
if section._sectPr.find(qn("w:titlePg")) is None:
tp = OxmlElement("w:titlePg")
section._sectPr.append(tp)
def _clear_paragraph(paragraph):
"""Remove all runs and page fields from a paragraph."""
for r in list(paragraph.runs):
r._r.getparent().remove(r._r)
def _set_right_tab(paragraph, pos_twips):
"""Attach a right-aligned tab stop to a paragraph."""
pPr = paragraph._p.get_or_add_pPr()
tabs_el = pPr.find(qn("w:tabs"))
if tabs_el is None:
tabs_el = OxmlElement("w:tabs")
pPr.append(tabs_el)
else:
for tab in tabs_el.findall(qn("w:tab")):
if tab.get(qn("w:val")) == "right":
tabs_el.remove(tab)
tab_stop = OxmlElement("w:tab")
tab_stop.set(qn("w:val"), "right")
tab_stop.set(qn("w:pos"), str(pos_twips))
tabs_el.append(tab_stop)
def _add_running_header(doc, section, recto_text, author, title, tab_pos_twips,
font_name="Caladea", font_size=Pt(9)):
"""Install per-spec running headers (section 4.1):
verso (even/left): [ Author Name ] [ Book Title ]
recto (odd/right): [ Chapter Title or Section Title ]
Headers carry titles only; page numbers live in the centered running
footers (spec section 4.2). The section's first-page header stays empty
so the opening page carries no running head (suppression, section 4.3)."""
sect_pr = section._sectPr
for hr in sect_pr.findall(qn("w:headerReference")):
sect_pr.remove(hr)
# Recto (odd, default) header: chapter/section title
odd_header = section.header
odd_header.is_linked_to_previous = False
p = odd_header.paragraphs[0]
_clear_paragraph(p)
run = p.add_run(recto_text or "")
run.font.name = font_name
run.font.size = font_size
# Verso (even) header: "[Author Name]\t[Book Title]"
even_header = section.even_page_header
even_header.is_linked_to_previous = False
p = even_header.paragraphs[0]
_clear_paragraph(p)
_set_right_tab(p, tab_pos_twips)
run = p.add_run(author or "")
run.font.name = font_name
run.font.size = font_size
tab_run = p.add_run()
tab_run.font.name = font_name
tab_run.font.size = font_size
tab_run.add_tab()
run = p.add_run(title or "")
run.font.name = font_name
run.font.size = font_size
# First page header: empty (suppressed)
first_header = section.first_page_header
first_header.is_linked_to_previous = False
_clear_paragraph(first_header.paragraphs[0])
if sect_pr.find(qn("w:titlePg")) is None:
tp = OxmlElement("w:titlePg")
sect_pr.append(tp)
def _add_running_footer(section, font_name="Caladea", font_size=Pt(9),
suppress_first=True):
"""Bottom pagination (spec section 4.2): the page number is centered at
the bottom of both verso (even/left) and recto (odd/right) pages.
With ``suppress_first=True`` the section's first-page footer stays empty
(suppression, spec section 4.3). Front-matter display pages (copyright,
dedication, epigraph, TOC) pass ``suppress_first=False`` so their Roman
page number is visible starting on the page that opens the section."""
sect_pr = section._sectPr
for fr in sect_pr.findall(qn("w:footerReference")):
sect_pr.remove(fr)
# Recto (odd): centered page number
odd_footer = section.footer
odd_footer.is_linked_to_previous = False
p = odd_footer.paragraphs[0]
_clear_paragraph(p)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
_insert_page_field(p, font_name, font_size)
# Verso (even): centered page number
even_footer = section.even_page_footer
even_footer.is_linked_to_previous = False
p = even_footer.paragraphs[0]
_clear_paragraph(p)
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
_insert_page_field(p, font_name, font_size)
# First page footer: empty (suppressed) unless front-matter display page
first_footer = section.first_page_footer
first_footer.is_linked_to_previous = False
p = first_footer.paragraphs[0]
_clear_paragraph(p)
if not suppress_first:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
_insert_page_field(p, font_name, font_size)
if sect_pr.find(qn("w:titlePg")) is None:
tp = OxmlElement("w:titlePg")
sect_pr.append(tp)
def _suppress_section_headers(section):
"""Unlink a section's headers and guarantee they are blank."""
section.header.is_linked_to_previous = False
_clear_paragraph(section.header.paragraphs[0])
section.even_page_header.is_linked_to_previous = False
_clear_paragraph(section.even_page_header.paragraphs[0])
section.first_page_header.is_linked_to_previous = False
_clear_paragraph(section.first_page_header.paragraphs[0])
def _apply_running_matter(out, sec, recto_text, author, title, tab_pos_twips,
suppress_first=True):
"""Install per-spec running matter:
- running headers (section 4.1): recto title, verso author + book title;
- centered footer page numbers on every body page (section 4.2).
First-page headers stay empty (suppression, section 4.3). The first-page
footer is also suppressed unless ``suppress_first`` is False (front-matter
display pages such as the TOC show their page number on the opening page)."""
_add_running_header(out, sec, recto_text, author, title, tab_pos_twips)
_add_running_footer(sec, suppress_first=suppress_first)
def _apply_paragraph_format(p, font_name, font_size, alignment, line_spacing=None,
line_spacing_rule=None, first_line_indent=None,
space_before=None, space_after=None):
"""Apply formatting to a paragraph and return the first run."""
pf = p.paragraph_format
if alignment is not None:
pf.alignment = alignment
if line_spacing is not None:
pf.line_spacing = line_spacing
if line_spacing_rule is not None:
pf.line_spacing_rule = line_spacing_rule
if first_line_indent is not None:
pf.first_line_indent = first_line_indent
if space_before is not None:
pf.space_before = space_before
if space_after is not None:
pf.space_after = space_after
run = p.runs[0] if p.runs else p.add_run()
run.font.name = font_name
run.font.size = font_size
return run
def _add_empty_para(doc, count=1, font_size=Pt(12)):
"""Add empty paragraphs as spacers with exact font_size line spacing."""
for _ in range(count):
p = doc.add_paragraph()
run = p.add_run("")
run.font.size = font_size
pf = p.paragraph_format
pf.line_spacing = font_size
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
pf.space_before = Pt(0)
pf.space_after = Pt(0)
def _copy_paragraph_raw(out, src_p, src_doc=None):
"""Copy a paragraph preserving ALL original formatting (no overrides).
Handles w:hyperlink elements by remapping relationship IDs."""
new_p = out.add_paragraph()
pf = new_p.paragraph_format
spf = src_p.paragraph_format
pf.alignment = spf.alignment
if spf.first_line_indent is not None:
pf.first_line_indent = spf.first_line_indent
if spf.space_before is not None:
pf.space_before = spf.space_before
if spf.space_after is not None:
pf.space_after = spf.space_after
if spf.line_spacing is not None:
pf.line_spacing = spf.line_spacing
if spf.line_spacing_rule is not None:
pf.line_spacing_rule = spf.line_spacing_rule
for child in list(src_p._p):
tag = child.tag
if tag == qn('w:r'):
src_run = Run(child, src_p)
new_r = new_p.add_run(src_run.text)
_copy_run_format(src_run, new_r)
elif tag == qn('w:hyperlink') and src_doc is not None:
hl_copy = deepcopy(child)
r_id = hl_copy.get(qn('r:id'))
if r_id:
try:
src_rel = src_doc.part.rels[r_id]
new_rId = out.part.relate_to(
src_rel.target_ref,
src_rel.reltype,
is_external=True
)
hl_copy.set(qn('r:id'), new_rId)
except Exception:
pass
new_p._p.append(hl_copy)
return new_p
def _copy_paragraph_raw_deep(out, src_p, src_doc=None):
"""Preserve ALL formatting by deep-copying paragraph XML element.
Remaps hyperlinks, strips w:sectPr and source bookmarks to avoid orphan ranges."""
p_copy = deepcopy(src_p._p)
pPr = p_copy.find(qn('w:pPr'))
if pPr is not None:
sectPr = pPr.find(qn('w:sectPr'))
if sectPr is not None:
pPr.remove(sectPr)
for tag in (qn('w:bookmarkStart'), qn('w:bookmarkEnd')):
for el in p_copy.findall('.//' + tag):
el.getparent().remove(el)
new_p = out.add_paragraph()
parent = new_p._p.getparent()
idx = list(parent).index(new_p._p)
parent.remove(new_p._p)
if src_doc is not None:
for hl in p_copy.findall('.//' + qn('w:hyperlink')):
r_id = hl.get(qn('r:id'))
if r_id:
try:
src_rel = src_doc.part.rels[r_id]
new_rId = out.part.relate_to(
src_rel.target_ref,
src_rel.reltype,
is_external=True
)
hl.set(qn('r:id'), new_rId)
except Exception:
pass
parent.insert(idx, p_copy)
return out.paragraphs[-1]
def _copy_run_format(src_run, dst_run):
"""Copy font formatting from one run to another."""
dst_run.font.name = src_run.font.name
dst_run.font.size = src_run.font.size
dst_run.font.bold = src_run.font.bold
dst_run.font.italic = src_run.font.italic
dst_run.font.underline = src_run.font.underline
dst_run.font.strike = src_run.font.strike
if src_run.font.color.rgb:
dst_run.font.color.rgb = src_run.font.color.rgb
def _copy_para_with_font(doc, src_p):
"""Copy a paragraph preserving original font formatting ([font] block)."""
new_p = doc.add_paragraph()
pf = new_p.paragraph_format
pf.space_before = Pt(0)
pf.space_after = Pt(0)
for r in src_p.runs:
new_r = new_p.add_run(r.text)
_copy_run_format(r, new_r)
return new_p
def _add_page_break(doc):
"""Add a page break paragraph with zero spacing."""
p = doc.add_paragraph()
pf = p.paragraph_format
pf.space_before = Pt(0)
pf.space_after = Pt(0)
run = p.add_run()
br = OxmlElement("w:br")
br.set(qn("w:type"), "page")
run._r.append(br)
def _add_section_break(doc):
"""Add a section break using python-docx API."""
return doc.add_section()
# Child order of w:sectPr per ECMA-376 CT_SectPr. Word requires this exact
# sequence (header/footer references FIRST); LibreOffice tolerates any order.
SECTPR_ORDER = [
qn('w:headerReference'),
qn('w:footerReference'),
qn('w:footnotePr'),
qn('w:endnotePr'),
qn('w:type'),
qn('w:pgSz'),
qn('w:pgMar'),
qn('w:paperSrc'),
qn('w:pgBorders'),
qn('w:lnNumType'),
qn('w:pgNumType'),
qn('w:cols'),
qn('w:formProt'),
qn('w:vAlign'),
qn('w:noEndnote'),
qn('w:titlePg'),
qn('w:mirrorMargins'),
qn('w:textDirection'),
qn('w:bidi'),
qn('w:rtlGutter'),
qn('w:docGrid'),
qn('w:printerSettings'),
qn('w:sectPrChange'),
]
def _reorder_sect_pr(sect_pr):
"""Reorder w:sectPr children to match OOXML schema sequence.
Word rejects documents with sectPr elements in wrong order."""
children = list(sect_pr)
ordered = []
for tag in SECTPR_ORDER:
for child in children[:]:
if child.tag == tag:
ordered.append(child)
children.remove(child)
ordered.extend(children)
for child in ordered:
sect_pr.append(child)
# Child order of w:settings per ECMA-376 CT_Settings. Same strictness rule:
# Word rejects out-of-order children, LibreOffice ignores the order.
SETTINGS_ORDER = [
qn('w:writeProtection'), qn('w:view'), qn('w:zoom'),
qn('w:removePersonalInformation'), qn('w:doNotDisplayPageBoundaries'),
qn('w:displayBackgroundShape'), qn('w:printPostScriptOverText'),
qn('w:printFractionalCharacterWidth'), qn('w:printFormsData'),
qn('w:embedTrueTypeFonts'), qn('w:embedSystemFonts'),
qn('w:saveSubsetFonts'), qn('w:saveFormsData'), qn('w:mirrorMargins'),
qn('w:alignBordersAndEdges'), qn('w:bordersDoNotSurroundHeader'),
qn('w:bordersDoNotSurroundFooter'), qn('w:gutterAtTop'),
qn('w:hideSpellingErrors'), qn('w:hideGrammaticalErrors'),
qn('w:activeWritingStyle'), qn('w:proofState'), qn('w:formsDesign'),
qn('w:attachedTemplate'), qn('w:linkStyles'),
qn('w:stylePaneFormatFilter'), qn('w:stylePaneSortMethod'),
qn('w:documentType'), qn('w:mailMerge'), qn('w:revisionView'),
qn('w:trackChanges'), qn('w:doNotTrackMoves'),
qn('w:doNotTrackFormatting'), qn('w:documentProtection'),
qn('w:autoFormatOverride'), qn('w:styleLockTheme'),
qn('w:styleLockQFSet'), qn('w:defaultTabStop'), qn('w:autoHyphenation'),
qn('w:consecutiveHyphenLimit'), qn('w:hyphenationZone'),
qn('w:doNotHyphenateCaps'), qn('w:showEnvelope'),
qn('w:summaryLength'), qn('w:clickAndTypeStyle'),
qn('w:defaultTableStyle'), qn('w:evenAndOddHeaders'),
qn('w:bookFoldRevPrinting'), qn('w:bookFoldPrinting'),
qn('w:bookFoldPrintingSheets'), qn('w:drawingGridHorizontalSpacing'),
qn('w:drawingGridVerticalSpacing'),
qn('w:displayHorizontalDrawingGridEvery'),
qn('w:displayVerticalDrawingGridEvery'),
qn('w:doNotUseMarginsForDrawingGridOrigin'),
qn('w:drawingGridHorizontalOrigin'),
qn('w:drawingGridVerticalOrigin'), qn('w:doNotShadeFormData'),
qn('w:noPunctuationKerning'), qn('w:characterSpacingControl'),
qn('w:printTwoOnOne'), qn('w:strictFirstAndLastChars'),
qn('w:noLineBreaksAfter'), qn('w:noLineBreaksBefore'),
qn('w:savePreviewPicture'), qn('w:doNotValidateAgainstSchema'),
qn('w:saveInvalidXml'), qn('w:ignoreMixedContent'),
qn('w:alwaysShowPlaceholderText'), qn('w:doNotDemarcateInvalidXml'),
qn('w:saveXmlDataOnly'), qn('w:useXSLTWhenSaving'),
qn('w:saveThroughXslt'), qn('w:showXMLTags'),
qn('w:alwaysMergeEmptyNamespace'), qn('w:updateFields'),
qn('w:hdrShapeDefaults'), qn('w:footnotePr'), qn('w:endnotePr'),
qn('w:compat'), qn('w:rsids'),
'{http://schemas.openxmlformats.org/officeDocument/2006/math}mathPr',
qn('w:themeFontLang'), qn('w:clrSchemeMapping'), qn('w:doNotIncludeSubdocsInStats'),
qn('w:doNotAutoCompressPictures'), qn('w:forceUpgrade'),
qn('w:captions'), qn('w:readModeInkLockDown'), qn('w:smartTagType'),
qn('w:shapeDefaults'), qn('w:doNotEmbedSmartTags'),
qn('w:decimalSymbol'), qn('w:listSeparator'),
]
def _reorder_settings(settings):
"""Reorder w:settings children to match the CT_Settings schema sequence.
Word rejects documents with settings elements in wrong order."""
children = list(settings)
ordered = []
for tag in SETTINGS_ORDER:
for child in children[:]:
if child.tag == tag:
ordered.append(child)
children.remove(child)
ordered.extend(children)
for child in ordered:
settings.append(child)
def format_document(filepath, output_path=None, font_name="Caladea", font_size=11,
inner_margin=0.500, trim_size="5.5x8.5", bleed=0.0,
author="", imprint="", footer_mode="headers",
strip_graphics=False, strip_problematic_graphics=False,
language="en"):
"""Parse tagged DOCX and generate formatted DOCX.
``trim_size`` is one of :data:`TRIM_SIZES` (default "5.5x8.5").
``inner_margin`` is the inside/gutter margin in inches (mirror margins).
``bleed`` adds the given amount (inches) of full-bleed around the trim
size and shifts margins outward so the text block stays on the trim area.
``author`` / ``imprint`` override the title page and (auto-generated)
copyright boilerplate. Manuscript "Author: ..." / "Imprint: ..." lines
take precedence over these defaults.
``footer_mode`` is accepted for backward compatibility but ignored: page
numbers always render centered in the running footers (spec section 4.2)
while headers carry titles only (spec section 4.1).
``strip_graphics`` removes decorative body images and renders scene breaks
as plain asterisks (spec section 5.2, Option B).
``strip_problematic_graphics`` drops images that overflow the printable
text box; otherwise such images are re-anchored inline and scaled to fit
(spec section 5.1.3).
``language`` is a BCP-47/ISO code (default "en"); it localizes generated
headings/copyright text, sets the proofing/hyphenation language, and turns
on bidirectional layout for RTL languages (spec section 6).
"""
if output_path is None:
base, ext = os.path.splitext(filepath)
output_path = f"{base}_formatted{ext}"
try:
width, height = TRIM_SIZES[trim_size]
except KeyError:
raise ValueError(
f"Unsupported trim_size '{trim_size}'. "
f"Choose from {list(TRIM_SIZES)}"
)
if bleed < 0:
raise ValueError("bleed must be >= 0")
page_w = width + 2 * bleed
page_h = height + 2 * bleed
# Printable text box (EMU) used to clamp/anchor body images (spec section 5).
text_width_emu = int(Inches(width - inner_margin - 0.50))
text_height_emu = int(Inches(height - 1.50))
# Localization (spec section 6)
strings = _language_strings(language)
rtl = _is_rtl(language)
front_headings = {
"toc": strings["contents"],
"foreword": strings["foreword"],
"acknowledgments": strings["acknowledgments"],
}
back_headings = {
"back_matter": strings["appendix"],
"req_review": strings["review"],
"about_author": strings["about_author"],
}
doc = Document(filepath)
tags = _find_tags(doc)
if "book_start" not in tags:
raise ValueError("Input document must contain [book start] tag on the title page.")
book_start = tags["book_start"]
chapter_indices = tags["chapters"]
if not chapter_indices:
raise ValueError("Input document must contain at least one [chapter] tag.")
first_chapter_idx = chapter_indices[0]
# --- Extract content by section ---
# Title extraction stops at the first structural/optional tag after
# [book start] so it no longer swallows copyright or front-matter text.
all_tag_positions = _tag_positions(doc, tags)
title_end = next((p for p in all_tag_positions if p > book_start), first_chapter_idx)
title, subtitle, author, imprint, logos = _extract_title_page(
doc, book_start, title_end, default_author=author, default_imprint=imprint,
)
# Copyright text lives after [copyright], up to the next structural tag.
copyright_paras = _content_after_tag(doc, tags, "copyright")
chapters = _extract_chapters(doc, chapter_indices)
# Gather verbatim section tags in document order
verbatim_tag_positions = []
for tagname in ("back_matter", "req_review", "about_author"):
if tagname in tags:
verbatim_tag_positions.append((tags[tagname], tagname))
verbatim_tag_positions.sort()
# Extract content for each verbatim section (skip the tag paragraph itself)
verbatim_sections = []
for vi, (tag_pos, tagname) in enumerate(verbatim_tag_positions):
next_pos = verbatim_tag_positions[vi + 1][0] if vi + 1 < len(verbatim_tag_positions) else None
paras = _extract_between_tags(doc, tag_pos + 1, next_pos)
verbatim_sections.append({"name": tagname, "paras": paras})
if not title:
raise ValueError("No title found after [book start].")
# === Pass 1: Build content with alternating section breaks ===
out = Document()
if out.paragraphs:
out.paragraphs[0]._p.getparent().remove(out.paragraphs[0]._p)
# Create WA styles in output document
_create_wa_styles(out)
# Tag document language (proofing/hyphenation) and RTL defaults (spec 6).
_apply_language_defaults(out, language, rtl)
# Section roles recorded in document order; Pass 2 applies per-role props.
section_roles = []
# Section 0 — Half Title (recto as page 1; page number suppressed)
section_roles.append("half_title")
_build_half_title_page(out, title)
# Section 1 — Blank / advert verso (facing the half title)
out.add_section(WD_SECTION_START.NEW_PAGE)
section_roles.append("blank")
_add_centered_spacer(out, 12, 1)
# Section 2 — Title page (recto)
out.add_section(WD_SECTION_START.NEW_PAGE)
section_roles.append("title")
_build_title_page(out, title, subtitle, author, imprint)
# Title-page logos/images (spec section 5): centered, inline, bounded.
for logo_p in logos or []:
_add_body_image(out, logo_p, doc, text_width_emu, text_height_emu,
strip_graphics=strip_graphics,
strip_problematic=strip_problematic_graphics,
font_name=font_name, font_size=Pt(font_size))
# Section 3 — Copyright / edition page (verso facing the title, base-aligned)
out.add_section(WD_SECTION_START.NEW_PAGE)
section_roles.append("copyright")
_build_copyright_page(out, copyright_paras, doc, author, imprint,
strings=strings)
# Optional front matter — dedication, epigraph (single centered pages)
for tagname in ("dedication", "epigraph"):
paras = _content_after_tag(doc, tags, tagname)
if not paras:
continue
out.add_section(WD_SECTION_START.NEW_PAGE)
section_roles.append(tagname)
_build_centered_quote_section(out, paras)
# Section N — TOC (recto start; always auto-generated)
out.add_section(WD_SECTION_START.ODD_PAGE)
section_roles.append("toc")
tab_pos_twips = int((width - inner_margin - 0.50) * 1440)
toc_entry_paras = _build_toc(out, chapters, tab_pos_twips,
heading=strings["toc"])
# Optional front matter — foreword, acknowledgments (recto starts)
for tagname, heading in (
("foreword", strings["foreword"]),
("acknowledgments", strings["acknowledgments"]),
):
paras = _content_after_tag(doc, tags, tagname)
if not paras:
continue
out.add_section(WD_SECTION_START.ODD_PAGE)
section_roles.append(tagname)
add_p = out.add_paragraph(style="WA Chapter")
add_p.alignment = WD_ALIGN_PARAGRAPH.LEFT
run = add_p.add_run(heading)
run.font.name = font_name
run.font.size = Pt(font_size + 2)
_build_prose_section(out, paras, font_name, font_size)
# Sections — Chapters (odd-page section break before each so chapters
# always begin on a recto / right-hand / odd-numbered page)
first_chapter_section_idx = len(section_roles)
for ci, ch in enumerate(chapters):
out.add_section(WD_SECTION_START.ODD_PAGE)
section_roles.append("chapter")
# Top drop (sinkage): heading starts ~1.75" below the top margin.
drop_p = out.add_paragraph()
drop_pf = drop_p.paragraph_format
drop_pf.line_spacing = Pt(CHAPTER_TOP_DROP_PT)
drop_pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
drop_pf.space_before = Pt(0)
drop_pf.space_after = Pt(0)
# Chapter header icon (spec 5.1.1): image directly above the heading.
if ch.get("icon") and not strip_graphics:
_add_body_image(out, ch["icon"]["para"], doc, text_width_emu,
text_height_emu, strip_graphics=strip_graphics,
strip_problematic=strip_problematic_graphics,
font_name=font_name, font_size=Pt(font_size))
if ch["title"]:
add_p = out.add_paragraph(style="WA Chapter")
add_p.paragraph_format.keep_with_next = True
add_p.paragraph_format.keep_together = True
run = add_p.add_run(ch["title"])
# Add a bookmark at the chapter title so PAGEREF fields in the TOC
# can reference the correct page number.
_add_bookmark(add_p, ci, f"_UF_ch_{ci}")
first_para = True
for item in ch["paras"]:
if item["type"] == "font":
_copy_para_with_font(out, item["para"])
continue
if item["type"] in ("icon", "image"):
_add_body_image(out, item["para"], doc, text_width_emu,
text_height_emu, strip_graphics=strip_graphics,
strip_problematic=strip_problematic_graphics,
font_name=font_name, font_size=Pt(font_size))
continue
text = item["text"]
if (_is_scene_break_marker(item, text) or text == TAGS["section"]):
add_p = out.add_paragraph()
add_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
pf = add_p.paragraph_format
pf.space_before = Pt(12)
pf.space_after = Pt(12)
pf.keep_with_next = False
# Preserve <<<>>> as the visual scene-break cue; otherwise use ornament.
if text == "<<<>>>":
run = add_p.add_run("<<<>>>")
run.font.name = "Caladea"
run.font.size = Pt(11)
elif strip_graphics:
run = add_p.add_run(SCENE_BREAK_FALLBACK)
run.font.name = "Caladea"
run.font.size = Pt(11)
else:
ornament = SCENE_BREAK_ORNAMENT
run = add_p.add_run(ornament)
run.font.name = "Caladea"
run.font.size = Pt(11)
first_para = True
continue
if not text:
continue
if first_para:
# First paragraph of a chapter: no first-line indent, with only
# the first word capitalized (spec section 3.2).
new_p = out.add_paragraph(style="WA NChp Pgr")
pf = new_p.paragraph_format
pf.first_line_indent = Inches(0)
words = text.split(" ")
# Only capitalize the first word; rest are normal case
if words:
# Capitalize first letter of first word only
first_word = words[0]
idx = next((i for i, ch_ in enumerate(first_word) if ch_.isalpha()), None)
if idx is not None:
first_word = first_word[:idx] + first_word[idx].upper() + first_word[idx+1:]
run1 = new_p.add_run(first_word)
run1.font.name = font_name
run1.font.size = Pt(font_size)
# Rest of paragraph text (not forced uppercase/small-caps)
rest_text = " ".join(words[1:]) if len(words) > 1 else ""
if rest_text:
run2 = new_p.add_run(" " + rest_text)
run2.font.name = font_name
run2.font.size = Pt(font_size)
else:
run1 = new_p.add_run("")
run1.font.name = font_name
run1.font.size = Pt(font_size)
first_para = False
else:
new_p = out.add_paragraph(style="WA Nrml Pgr")
pf = new_p.paragraph_format
pf.first_line_indent = Inches(BODY_FIRST_LINE_INDENT)
run = new_p.add_run(text)
run.font.name = font_name
run.font.size = Pt(font_size)
# Verbatim sections (back matter, req review, about author) — raw copy, user handles formatting
for vs in verbatim_sections:
out.add_section(WD_SECTION_START.ODD_PAGE)
section_roles.append(vs["name"])
for src_p in vs["paras"]:
_copy_paragraph_raw_deep(out, src_p, doc)
out.save(output_path)
# === Pass 2: Re-open and apply section properties ===
out2 = Document(output_path)
secs = list(out2.sections)
settings = out2.settings.element
if settings.find(qn('w:evenAndOddHeaders')) is None:
eo = OxmlElement('w:evenAndOddHeaders')
settings.append(eo)
# Hyphenation for justified text (spec section 3.2). Stored directly under
# w:settings per OOXML; Word honors it in modern layout compat mode.
if settings.find(qn('w:autoHyphenation')) is None:
settings.append(OxmlElement('w:autoHyphenation'))
# Word requires w:settings children in strict CT_Settings order, so fix up
# the position of the two elements just added (LibreOffice would not care).
_reorder_settings(settings)
for idx, sec in enumerate(secs):
sec.page_width = Inches(page_w)
sec.page_height = Inches(page_h)
sec.top_margin = Inches(0.75 + bleed)
sec.bottom_margin = Inches(0.75 + bleed)
sec.left_margin = Inches(inner_margin + bleed)
sec.right_margin = Inches(0.50 + bleed)
sec.header_distance = Inches(0.5)
sec.footer_distance = Inches(0.5)
sp = sec._sectPr
if sp.find(qn('w:mirrorMargins')) is None:
mm = OxmlElement("w:mirrorMargins")
cols = sp.find(qn('w:cols'))
if cols is not None:
sp.insert(sp.index(cols), mm)
else:
sp.append(mm)
if sp.find(qn('w:titlePg')) is None:
tp = OxmlElement("w:titlePg")
sp.append(tp)
if rtl and sp.find(qn("w:rtlGutter")) is None:
# RTL bindings place the gutter on the right (spec section 6.2).
sp.append(OxmlElement("w:rtlGutter"))
role = section_roles[idx] if idx < len(section_roles) else "back_matter"
# Outer-margin right tab stop for running headers (text-region width,
# independent of bleed since bleed shifts margins and page equally).
header_tab = int((width - inner_margin - 0.50) * 1440)
if role in ("half_title", "blank", "title"):
# Numbered lowerRoman but suppressed — empty headers/footers.
_ensure_pgNumType(sp, "lowerRoman")
_suppress_section_footers(sec)
_suppress_section_headers(sec)
elif role in ("copyright", "dedication", "epigraph"):
# lowerRoman with the page number visible in the centered footer:
# numbering starts on the copyright page and runs through the
# front matter until Chapter 1. No running heads on these
# display pages.
_ensure_pgNumType(sp, "lowerRoman")
_suppress_section_headers(sec)
_add_running_footer(sec, suppress_first=False)
# These are single display pages: drop titlePg so the default
# (numbered) footer renders on their opening page in every
# renderer (LibreOffice otherwise suppresses the footer here).
tp = sp.find(qn("w:titlePg"))
if tp is not None:
sp.remove(tp)
if role == "copyright":
# Ground the copyright block at the base of the verso page
_set_section_v_align_bottom(sp)
elif role in ("toc", "foreword", "acknowledgments"):
# Visible lowerRoman page numbers + running heads. The Roman
# number shows on the section's opening page too.
_ensure_pgNumType(sp, "lowerRoman")
_apply_running_matter(out2, sec,
front_headings.get(role, role),
author, title, header_tab,
suppress_first=False)
elif role == "chapter":
# Chapters — decimal (restart 1) + running heads.
# Per spec §4.3 headers/footers are hidden only on the first page
# of each chapter (empty first-page header/footer + titlePg);
# body pages carry centered footer page numbers and running heads.
pg_start = 1 if idx == first_chapter_section_idx else None
_ensure_pgNumType(sp, "decimal", pg_start)
ch_idx = idx - first_chapter_section_idx
ch_title = chapters[ch_idx]["title"] if 0 <= ch_idx < len(chapters) else title
_apply_running_matter(out2, sec, ch_title, author, title,
header_tab)
else:
# Back matter — decimal continued + running heads by section.
_ensure_pgNumType(sp, "decimal")
_apply_running_matter(out2, sec,
back_headings.get(role, role),
author, title, header_tab)
# Reorder sectPr children per OOXML spec (Word requires strict ordering)
for sec in secs:
_reorder_sect_pr(sec._sectPr)
out2.save(output_path)
return output_path
def convert_to_pdf(docx_path, pdf_path=None, pad_multiple_of_4=False):
"""Convert a DOCX to PDF using LibreOffice headless.
When ``pad_multiple_of_4`` is set, clean blank pages are appended so the
final page count is a multiple of four (spec section 7 print-prep rule).
"""
if pdf_path is None:
pdf_path = os.path.splitext(docx_path)[0] + ".pdf"
try:
subprocess.run(
["libreoffice", "--headless", "--convert-to", "pdf",
"--outdir", os.path.dirname(pdf_path), docx_path],
check=True, capture_output=True, timeout=120,
)
lo_pdf = os.path.splitext(docx_path)[0] + ".pdf"
if lo_pdf != pdf_path and os.path.exists(lo_pdf):
shutil.move(lo_pdf, pdf_path)
if not os.path.exists(pdf_path):
return None
if pad_multiple_of_4:
pad_pdf_to_multiple(pdf_path, 4)
return pdf_path
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return None
def pdf_page_count(pdf_path):
"""Return the number of pages in a PDF, or 0 if it cannot be read."""
try:
from pypdf import PdfReader
except ImportError:
return 0
try:
return len(PdfReader(pdf_path).pages)
except Exception:
return 0
def pad_pdf_to_multiple(pdf_path, multiple=4):
"""Append clean blank pages so the page count is a multiple of ``multiple``
(spec section 7). Blank pages match the last page's trim dimensions.
Returns the number of blank pages added (0 when already a multiple)."""
try:
from pypdf import PdfReader, PdfWriter
except ImportError:
return 0
reader = PdfReader(pdf_path)
count = len(reader.pages)
if count == 0:
return 0
remainder = count % multiple
if remainder == 0:
return 0
add = multiple - remainder
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
last = reader.pages[-1]
blank_w = float(last.mediabox.width)
blank_h = float(last.mediabox.height)
for _ in range(add):
writer.add_blank_page(width=blank_w, height=blank_h)
# Write to a temp file first: the reader may still hold the source open.
fd, tmp_path = tempfile.mkstemp(suffix=".pdf",
dir=os.path.dirname(pdf_path) or ".")
os.close(fd)
try:
with open(tmp_path, "wb") as fh:
writer.write(fh)
shutil.move(tmp_path, pdf_path)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
return add
def _page_texts(pdf_path):
"""Return a list of normalized single-line strings, one per PDF page.
Uses poppler's pdftotext (page-by-page). Returns [] if unavailable."""
import re as _re
texts = []
try:
subprocess.run(["pdftotext", "-v"], capture_output=True)
except (FileNotFoundError, subprocess.SubprocessError):
return texts
try:
info = subprocess.run(
["pdfinfo", pdf_path], capture_output=True, text=True, timeout=60,
)
pages = 0
for line in info.stdout.splitlines():
if line.lower().startswith("pages:"):
pages = int(line.split()[1])
break
except (subprocess.SubprocessError, ValueError):
return texts
for p in range(1, pages + 1):
out = subprocess.run(
["pdftotext", "-f", str(p), "-l", str(p), pdf_path, "-"],
capture_output=True, text=True, timeout=60,
)
norm = _re.sub(r"\s+", " ", out.stdout).strip()
texts.append(norm)
return texts
def verify_recto_starts(docx_path, markers, pdf_path=None):
"""Detect on which page each marker first appears in the rendered PDF and
report whether that page is recto (odd).
``markers`` should be ordered by document position; marker[0] establishes
the leading page so later markers are searched only after it (TOC page
numbers are otherwise misread as chapter starts). LibreOffice is used for
rendering, so parity is an approximation of Word's authoritative layout.
Returns a list of dicts: {marker, page, recto}. Page 0 means "not found".
"""
import tempfile
own_pdf = pdf_path is None
if own_pdf:
tmp_dir = tempfile.mkdtemp(prefix="umbraformat_parity_")
base = os.path.join(tmp_dir, os.path.basename(docx_path))
shutil.copy(docx_path, base)
pdf_path = convert_to_pdf(base)
texts = _page_texts(pdf_path) if pdf_path else []
results = []
search_from = 0
for i, marker in enumerate(markers):
needle = " ".join(marker.split())
page = 0
if texts:
for p in range(search_from, len(texts)):
if needle and needle in texts[p]:
page = p + 1
search_from = p + 1
break
results.append({"marker": marker, "page": page, "recto": (page % 2 == 1)})
if own_pdf and pdf_path:
try:
shutil.rmtree(os.path.dirname(pdf_path))
except OSError:
pass
return results