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.
2132 lines
81 KiB
Python
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
|