spec section 2.2 WA style family: authoritative styles + dynamic gutter

- spec: new section 2.2 style table (WA Chapter/Section/NChp Pgr/Nrml Pgr/
  Thkng + TOC mapping), margins 0.40/0.40/0.375 + 0.25 header-footer
  distance, dynamic gutter resolution procedure, header Caladea 10pt caps,
  footer 11pt, justified body without auto-hyphenation, WA Section reserved
  for scene breaks.
- formatter: WA_STYLES rebuilt to the spec values (next-style chain, outline
  level on WA Chapter, automatic color, widow control); content assigned by
  style reference with direct paragraph/run formatting removed; scene breaks
  use WA Section; TOC entries use WA TOC; auto-hyphenation removed; new page
  setup constants; header ALL CAPS 10pt / footer 11pt.
- format_document: inside/gutter margin now computed from the final page
  count (build -> render -> recompute fixed point, cap 3, settle on larger
  gutter on oscillation); inner_margin input ignored (UI selector removed).
- tests: 68 pass, incl. style-family value tests and mocked gutter-loop tests.
This commit is contained in:
bm
2026-09-24 07:53:19 -07:00
parent a080505f61
commit 166a4849da
5 changed files with 616 additions and 224 deletions

View File

@@ -11,16 +11,59 @@ The primary objective of this specification is to establish deterministic rules
### 2.1 Target Geometry & Margins
* **Trim Size:** Standard $6" \times 9"$ (152.4 mm $\times$ 228.6 mm) or $5.5" \times 8.5"$ (139.7 mm $\times$ 215.9 mm) configurable.
* **Gutter / Inside Margin:** Dynamic based on total page count:
* **Gutter / Inside Margin:** Dynamic based on the **final** total page count:
* $< 150$ pages: 0.50 in (12.7 mm)
* $150 - 300$ pages: 0.625 in (15.8 mm)
* $> 300$ pages: 0.75 in (19.0 mm)
* **Outside Margin:** 0.50 in (12.7 mm)
* **Top Margin:** 0.75 in (19.0 mm)
* **Bottom Margin:** 0.75 in (19.0 mm)
* **Bleed:** 0.125 in (3.175 mm) where full-bleed graphics are present; 0.00 in for standard text pages.
* Resolution procedure: the engine builds the document with the lowest-bucket value, renders it to determine the page count, recomputes the gutter, and rebuilds until the page count and gutter agree (fixed point, maximum 3 builds; if the count oscillates across a bucket boundary, the larger gutter is used).
* **Outside Margin:** 0.375 in (9.5 mm)
* **Top Margin:** 0.40 in (10.2 mm)
* **Bottom Margin:** 0.40 in (10.2 mm)
* **Header / Footer Distance:** 0.25 in (6.35 mm) from the page edge, so header/footer content sits inside the 0.40 in text margin.
* **Bleed:** 0.125 in (3.175 mm) where full-bleed graphics are present; 0.00 in for standard text pages. Bleed is **added** to the margins above (the text block stays on the trim area).
### 2.2 Pagination & Section Rules
### 2.2 Style System (WA Family)
The output document **defines and applies** the following named styles. All values below are the authoritative definition; content is assigned by style reference, and paragraph-level direct formatting must not override these properties (the only exception is the Table of Contents tab stop, whose position depends on the final text width, section 2.1).
| Property | WA Chapter | WA Section | WA NChp Pgr | WA Nrml Pgr | WA Thkng |
|---|---|---|---|---|---|
| Style type | Paragraph | Paragraph | Paragraph | Paragraph | Character |
| Based on | Heading 1 | Normal | Normal | Normal | WA Nrml Pgr |
| Followed by (next style) | WA NChp Pgr | WA NChp Pgr | WA Nrml Pgr | WA Nrml Pgr | n/a (character style) |
| Outline level | 1 (Heading 1) | Body | Body | Body | n/a |
| Font | Caladea | Caladea | Caladea | Caladea | Caladea |
| Font size | 18 pt | 16 pt | 11 pt | 11 pt | 11 pt |
| Bold | No | No | No | No | No |
| Italic | No | No | No | No | Yes |
| Underline | No | No | No | No | No |
| Font color | Automatic (black) | Automatic | Automatic | Automatic | Automatic |
| Alignment | Centered | Centered | Justified | Justified | n/a |
| Special indentation | None | None | None | First line 0.2 in | n/a |
| Paragraph indent (left/right) | 0 | 0 | 0 | 0 | n/a |
| Spacing before | 0 pt | 6 pt | 0 pt | 0 pt | n/a |
| Spacing after | 0 pt | 12 pt | 0 pt | 0 pt | n/a |
| Line spacing | Multiple 4 | Multiple 1.15 | Exactly 15 pt | Exactly 15 pt | n/a |
| Keep with next | Yes | No | No | No | n/a |
| Keep lines together | Yes | Yes | Yes | Yes | n/a |
| Widow/orphan control | Yes | Yes | Yes | Yes | n/a |
| Language | en-US | en-US | en-US | en-US | en-US |
**WA Section is reserved for the scene-break marker `<<<>>>`** (section 5.1.2); it is not used for section titles.
**Style-to-content mapping:**
| Content | Style |
|---|---|
| Chapter title | WA Chapter |
| Scene break `<<<>>>` (and its `***` fallback / ornament) | WA Section |
| First paragraph of a chapter (no first-line indent) | WA NChp Pgr |
| All subsequent body paragraphs | WA Nrml Pgr |
| Inline italic emphasis | WA Thkng |
| Front matter prose (Foreword, Acknowledgments) | WA Nrml Pgr |
| Table of Contents entries | WA TOC: Caladea 11 pt, left, exactly 15 pt, 0/0 spacing, right dot-leader tab at the text edge (position applied per paragraph, final text width) |
### 2.3 Pagination & Section Rules
1. **Recto / Verso Enforcements:**
* **Recto (Right Hand Page):** Always odd-numbered. Major sections (Title Page, Chapter 1, Key Back Matter sections) **must** begin on a Recto page. If necessary, insert an explicitly blank page before them.
* **Verso (Left Hand Page):** Always even-numbered.
@@ -59,10 +102,12 @@ Front Matter sections must be recognized, categorized, and formatted according t
* Must start on a **Recto** page.
* Top drop (sinkage): Heading starts $1.5" - 2.0"$ below the top margin.
* Running headers and footers are **suppressed** on the first page of every chapter.
* **Paragraph Formatting:**
* **First Paragraph of Chapter/Section:** No first-line indent (`0.0 in`). Normal capitalization of first word only.
* **Subsequent Paragraphs:** First-line indent of $0.18" - 0.25"$ ($13\text{pt} - 18\text{pt}$). No extra space between paragraphs (`space_after = 0pt`, `space_before = 0pt`).
* **Text Alignment:** Fully justified with micro-typography hyphenation enabled.
* **Paragraph Formatting** (styles per section 2.2 — no direct paragraph formatting):
* **First Paragraph of Chapter/Section:** Style `WA NChp Pgr` — no first-line indent (`0.0 in`). Normal capitalization of first word only.
* **Subsequent Paragraphs:** Style `WA Nrml Pgr` — first-line indent of $0.2"$ ($14.4\text{pt}$). No extra space between paragraphs (`space_after = 0pt`, `space_before = 0pt`).
* **Typography:** Caladea $11\text{pt}$, line spacing exactly $15\text{pt}$.
* **Text Alignment:** Fully justified.
* **Automatic hyphenation is disabled** document-wide.
---
@@ -89,12 +134,13 @@ Back Matter elements immediately follow the final chapter:
### 4.1 Running Headers (Body Text)
* **Left Page (Verso):** `[ Author Name ]` $\quad$ `[ Book Title ]`
* **Right Page (Recto):** `[ Chapter Title or Section Title ]` $\quad$
* **Font:** Set in matching body font or clean sans-serif (e.g., $8.5\text{pt} - 9.5\text{pt}$ Small Caps or Regular with $1.5\text{pt}$ letter spacing).
* **Font:** Caladea, $10\text{pt}$, ALL CAPS.
### 4.2 Running Footers
* Bottom pagination is preferred:
* **Verso Page Number:** Bottom-center.
* **Recto Page Number:** Bottom-center.
* **Font:** Caladea, $11\text{pt}$.
### 4.3 Suppression Matrix
Headers and footers **must be completely hidden** on:
@@ -110,10 +156,7 @@ Embedded icons and decorative elements (e.g., scene break ornaments, section hea
### 5.1 Inline Graphic Categories
1. **Chapter Header Icons:** Positioned directly above or alongside `Heading 1` text. Must be anchored relative to the heading block with fixed aspect ratio ($100\%$ scale lock).
2. **Scene Break Ornaments ''<<<>>>'':**
* Centered horizontally between paragraphs.
* Top/Bottom space: $12\text{pt}$ before, $12\text{pt}$ after.
* `Keep with next` set to `False` to avoid orphaned breaks, but tied to surrounding block spacing.
2. **Scene Break Ornaments ''<<<>>>'':** Style `WA Section` (section 2.2) — Caladea $16\text{pt}$, centered, line spacing Multiple $1.15$, $6\text{pt}$ before, $12\text{pt}$ after, `keep lines together` on, `keep with next` off, so the break shows clear separation without being dragged to the next page.
3. **Margin / Decorative Icons:**
* Absolute positioning relative to column or page margin edge.
* Fallback rule: If a margin icon falls within $0.5\text{ in}$ of a page boundary overflow, auto-align it inline or remove it via configuration flag `--strip-problematic-graphics`.
@@ -132,7 +175,7 @@ To support non-English target languages (e.g., Spanish, German, CJK, Arabic):
1. **Font Fallback & Glyph Coverage:**
* Dynamic font embedding for localized glyph sets (e.g., Noto Serif / Sans families).
* Primary font is Caladea, fallback is Cambria
* Automatic hyphenation dictionary selection based on document language tags (`xml:lang`).
* Automatic hyphenation is disabled document-wide (section 3.2); where a language build re-enables it, dictionary selection follows document language tags (`xml:lang`).
2. **Right-to-Left (RTL) Layout Engines (e.g., Arabic, Hebrew):**
* Reverse Recto/Verso bindings (Recto becomes left page for opening books in RTL bindings).
* Mirror gutter and outside margins accordingly.

View File

@@ -10,6 +10,7 @@ import struct
import tempfile
import unittest
import zlib
from unittest import mock
from docx import Document
from docx.shared import Inches
@@ -149,21 +150,27 @@ class GeometryTests(unittest.TestCase):
def _format(self, **kwargs):
out = os.path.join(self.out_dir, "out.docx")
# Tests skip the LibreOffice gutter-resolution loop by default; the
# loop itself is covered by the GutterResolutionTests below.
kwargs.setdefault("resolve_gutter", False)
format_document(self.src, out, **kwargs)
return Document(out)
def test_default_trim_and_margins(self):
d = self._format(inner_margin=0.50)
d = self._format(resolve_gutter=False)
s = d.sections[0]
self.assertAlmostEqual(s.page_width.inches, 5.5, places=3)
self.assertAlmostEqual(s.page_height.inches, 8.5, places=3)
self.assertAlmostEqual(s.top_margin.inches, 0.75, places=3)
self.assertAlmostEqual(s.bottom_margin.inches, 0.75, places=3)
self.assertAlmostEqual(s.left_margin.inches, 0.50, places=3)
self.assertAlmostEqual(s.right_margin.inches, 0.50, places=3)
# spec section 2.1
self.assertAlmostEqual(s.top_margin.inches, 0.40, places=3)
self.assertAlmostEqual(s.bottom_margin.inches, 0.40, places=3)
self.assertAlmostEqual(s.left_margin.inches, 0.50, places=3) # lowest gutter bucket
self.assertAlmostEqual(s.right_margin.inches, 0.375, places=3)
self.assertAlmostEqual(s.header_distance.inches, 0.25, places=3)
self.assertAlmostEqual(s.footer_distance.inches, 0.25, places=3)
def test_6x9_trim(self):
d = self._format(trim_size="6x9", inner_margin=0.625)
d = self._format(trim_size="6x9", resolve_gutter=False)
s = d.sections[0]
self.assertAlmostEqual(s.page_width.inches, 6.0, places=3)
self.assertAlmostEqual(s.page_height.inches, 9.0, places=3)
@@ -171,17 +178,17 @@ class GeometryTests(unittest.TestCase):
def test_invalid_trim_raises(self):
out = os.path.join(self.out_dir, "out.docx")
with self.assertRaises(ValueError):
format_document(self.src, out, trim_size="4x7")
format_document(self.src, out, trim_size="4x7", resolve_gutter=False)
def test_bleed_expands_page_and_margins(self):
d = self._format(inner_margin=0.50, bleed=0.125)
d = self._format(resolve_gutter=False, bleed=0.125)
s = d.sections[0]
# 5.5 + 2*0.125 = 5.75 across; margins shift outward by bleed
self.assertAlmostEqual(s.page_width.inches, 5.75, places=3)
self.assertAlmostEqual(s.page_height.inches, 8.75, places=3)
self.assertAlmostEqual(s.top_margin.inches, 0.875, places=3)
self.assertAlmostEqual(s.top_margin.inches, 0.525, places=3)
self.assertAlmostEqual(s.left_margin.inches, 0.625, places=3)
self.assertAlmostEqual(s.right_margin.inches, 0.625, places=3)
self.assertAlmostEqual(s.right_margin.inches, 0.5, places=3)
class RectoPaginationTests(unittest.TestCase):
@@ -198,7 +205,7 @@ class RectoPaginationTests(unittest.TestCase):
def _format(self):
out = os.path.join(self.out_dir, "out.docx")
format_document(self.src, out, inner_margin=0.50)
format_document(self.src, out, resolve_gutter=False)
return out
def test_major_sections_use_odd_page_breaks(self):
@@ -269,7 +276,7 @@ class FrontMatterTests(unittest.TestCase):
def _format(self, **kw):
out = os.path.join(self.out_dir, "out.docx")
format_document(self.src, out, inner_margin=0.50, **kw)
format_document(self.src, out, resolve_gutter=False, **kw)
self._out = out
return Document(out)
@@ -303,7 +310,8 @@ class FrontMatterTests(unittest.TestCase):
src = os.path.join(tmp, "nocopy.docx")
d.save(src)
out = os.path.join(self.out_dir, "out2.docx")
format_document(src, out, author="Jane Doe", imprint="Mystic Press")
format_document(src, out, author="Jane Doe", imprint="Mystic Press",
resolve_gutter=False)
out_d = Document(out)
texts = [p.text for p in out_d.paragraphs]
self.assertTrue(any(t.startswith("Copyright") for t in texts))
@@ -334,7 +342,7 @@ class FrontMatterTests(unittest.TestCase):
src = os.path.join(tmp, "opt.docx")
d.save(src)
out = os.path.join(self.out_dir, "out3.docx")
format_document(src, out, author="A. Author")
format_document(src, out, author="A. Author", resolve_gutter=False)
out_d = Document(out)
texts = [p.text.strip() for p in out_d.paragraphs]
for expected in ("Optional Book", "To the printers.",
@@ -364,7 +372,7 @@ class ChapterOpenerTests(unittest.TestCase):
def _format(self, **kw):
out = os.path.join(self.out_dir, "out.docx")
format_document(self.src, out, inner_margin=0.50, **kw)
format_document(self.src, out, resolve_gutter=False, **kw)
return Document(out)
def test_chapter_top_drop_sinkage(self):
@@ -384,16 +392,22 @@ class ChapterOpenerTests(unittest.TestCase):
self.assertTrue(body, "chapter must have an opener paragraph")
# No small-caps lead-in: first word only, regular case (spec 3.2)
self.assertIsNone(body[0].runs[0].font.small_caps)
self.assertEqual(body[0].paragraph_format.first_line_indent.inches, 0.0)
# WA NChp Pgr: no first-line indent at the style level, and the
# paragraph carries no direct override (spec 2.2).
self.assertAlmostEqual(body[0].style.paragraph_format.first_line_indent.inches,
0.0, places=3)
self.assertIsNone(body[0].paragraph_format.first_line_indent)
def test_subsequent_paragraph_indent_and_justify(self):
d = self._format()
body = [p for p in _section_paragraphs(d, 5) if p.style.name == "WA Nrml Pgr"]
self.assertTrue(body)
for p in body:
self.assertAlmostEqual(p.paragraph_format.first_line_indent.inches, 0.22,
places=2)
# Alignment is set on the WA Nrml Pgr style (direct format is None)
# Typography comes from the style (no direct paragraph formatting)
self.assertAlmostEqual(p.style.paragraph_format.first_line_indent.inches,
0.20, places=2)
self.assertIsNone(p.paragraph_format.first_line_indent)
self.assertIsNone(p.paragraph_format.alignment)
self.assertEqual(p.style.paragraph_format.alignment,
WD_ALIGN_PARAGRAPH.JUSTIFY)
@@ -411,16 +425,25 @@ class ChapterOpenerTests(unittest.TestCase):
src = os.path.join(tmp, "scene.docx")
doc.save(src)
out = os.path.join(self.out_dir, "scene_out.docx")
format_document(src, out, inner_margin=0.50)
format_document(src, out, resolve_gutter=False)
d = Document(out)
breaks = [p for p in d.paragraphs
if p.text.strip() == "\u2022 \u2022 \u2022"]
self.assertEqual(len(breaks), 1)
b = breaks[0]
self.assertEqual(b.alignment, WD_ALIGN_PARAGRAPH.CENTER)
self.assertEqual(b.paragraph_format.space_before.pt, 12)
self.assertEqual(b.paragraph_format.space_after.pt, 12)
self.assertFalse(b.paragraph_format.keep_with_next)
# WA Section carries all scene-break typography (spec 2.2 / 5.1.2)
self.assertEqual(b.style.name, "WA Section")
self.assertIsNone(b.paragraph_format.alignment)
self.assertIsNone(b.paragraph_format.space_before)
self.assertIsNone(b.paragraph_format.space_after)
self.assertIsNone(b.paragraph_format.keep_with_next)
st = b.style
self.assertEqual(st.paragraph_format.alignment,
WD_ALIGN_PARAGRAPH.CENTER)
self.assertEqual(st.paragraph_format.space_before.pt, 6)
self.assertEqual(st.paragraph_format.space_after.pt, 12)
self.assertFalse(st.paragraph_format.keep_with_next)
self.assertTrue(st.paragraph_format.keep_together)
# No literal tag leaks
self.assertFalse(any(p.text.strip() == "[section]" for p in d.paragraphs))
finally:
@@ -441,7 +464,7 @@ class RunningHeaderTests(unittest.TestCase):
def _format(self, **kw):
out = os.path.join(self.out_dir, "out.docx")
format_document(self.src, out, inner_margin=0.50,
format_document(self.src, out, resolve_gutter=False,
author="B.D. Murphy", imprint="WorstAuthor LLC", **kw)
return Document(out)
@@ -471,6 +494,25 @@ class RunningHeaderTests(unittest.TestCase):
self.assertEqual(ch_sec.first_page_header.paragraphs[0].text.strip(), "")
self.assertEqual(ch_sec.first_page_footer.paragraphs[0].text.strip(), "")
def test_header_all_caps_10pt_and_footer_11pt(self):
# Spec 4.1: headers Caladea 10 pt ALL CAPS. Spec 4.2: footers 11 pt.
d = self._format()
ch_sec = d.sections[5]
for para in (ch_sec.header.paragraphs[0],
ch_sec.even_page_header.paragraphs[0]):
text_runs = [r for r in para.runs if r.text.strip()]
self.assertTrue(text_runs)
for r in text_runs:
self.assertTrue(r.font.all_caps)
self.assertEqual(r.font.size.pt, 10)
self.assertEqual(r.font.name, "Caladea")
for para in (ch_sec.footer.paragraphs[0],
ch_sec.even_page_footer.paragraphs[0]):
sizes = {r.font.size.pt for r in para.runs if r.font.size is not None}
self.assertEqual(sizes, {11.0})
names = {r.font.name for r in para.runs if r.font.name is not None}
self.assertEqual(names, {"Caladea"})
def test_front_matter_visible_running_header(self):
d = self._format()
toc_sec = d.sections[4]
@@ -540,9 +582,10 @@ class RunningHeaderTests(unittest.TestCase):
# Second chapter restarts nothing (continues decimal)
self.assertIsNone(d.sections[6]._sectPr.find(qn("w:pgNumType")).get(qn("w:start")))
def test_autohyphenation_enabled(self):
def test_autohyphenation_disabled(self):
# Justified body without automatic hyphenation (spec section 3.2).
d = self._format()
self.assertIsNotNone(d.settings.element.find(qn("w:autoHyphenation")))
self.assertIsNone(d.settings.element.find(qn("w:autoHyphenation")))
def test_sectpr_child_order_is_ooxml_valid(self):
# Word validates CT_SectPr child order strictly (ECMA-376); LibreOffice
@@ -711,28 +754,38 @@ class WidowOrphanTests(unittest.TestCase):
def _format(self, **kw):
out = os.path.join(self.out_dir, "out.docx")
format_document(self.src, out, inner_margin=0.50, **kw)
format_document(self.src, out, resolve_gutter=False, **kw)
return Document(out)
def test_heading_styles_keep_with_next(self):
d = self._format()
for name in ("WA Chapter", "WA Section"):
pf = d.styles[name].paragraph_format
self.assertTrue(pf.keep_with_next, f"{name} must keep with next")
self.assertTrue(pf.keep_together, f"{name} must keep lines together")
# WA Chapter: keep with next + keep lines together (spec 2.2)
ch_pf = d.styles["WA Chapter"].paragraph_format
self.assertTrue(ch_pf.keep_with_next)
self.assertTrue(ch_pf.keep_together)
# WA Section is the scene-break style: keep lines together, but keep
# with next OFF so the break is not dragged to the next page (5.1.2).
sec_pf = d.styles["WA Section"].paragraph_format
self.assertFalse(sec_pf.keep_with_next)
self.assertTrue(sec_pf.keep_together)
def test_body_styles_widow_control(self):
d = self._format()
for name in ("WA Nrml Pgr", "WA NChp Pgr"):
for name in ("WA Nrml Pgr", "WA NChp Pgr", "WA TOC"):
self.assertTrue(d.styles[name].paragraph_format.widow_control,
f"{name} must enable widow/orphan control")
def test_chapter_title_para_keep_with_next(self):
def test_chapter_title_para_uses_style_not_direct_flags(self):
d = self._format()
paras = _section_paragraphs(d, 5)
title = paras[1]
self.assertEqual(title.style.name, "WA Chapter")
self.assertTrue(title.paragraph_format.keep_with_next)
# No direct paragraph overrides: the style governs (spec 2.2)
self.assertIsNone(title.paragraph_format.keep_with_next)
self.assertIsNone(title.paragraph_format.keep_together)
self.assertIsNone(title.paragraph_format.alignment)
self.assertIsNone(title.paragraph_format.line_spacing)
self.assertTrue(d.styles["WA Chapter"].paragraph_format.keep_with_next)
def test_styles_xml_contains_controls(self):
d = self._format()
@@ -763,7 +816,7 @@ class FooterModeTests(unittest.TestCase):
pass
def _format(self, mode=None):
kw = {"inner_margin": 0.50, "author": "B.D. Murphy",
kw = {"resolve_gutter": False, "author": "B.D. Murphy",
"imprint": "WorstAuthor LLC"}
if mode is not None:
kw["footer_mode"] = mode
@@ -795,11 +848,157 @@ class FooterModeTests(unittest.TestCase):
# Backward compatibility: legacy mode strings are accepted and ignored.
for mode in ("headers", "footer", "sideways"):
out = os.path.join(self.out_dir, f"out_{mode}.docx")
format_document(self.src, out, footer_mode=mode)
format_document(self.src, out, footer_mode=mode,
resolve_gutter=False)
d = Document(out)
self.assertTrue(_has_page_field(d.sections[5].footer.paragraphs[0]))
class DynamicGutterTests(unittest.TestCase):
"""Spec section 2.1: the inside/gutter margin is derived from the final
page count through a fixed-point build/render loop. The render is mocked
so the loop is exercised deterministically and quickly."""
def setUp(self):
self.src_dir, self.src = _build_manuscript()
self.out_dir = tempfile.mkdtemp(prefix="umbraformat_gutter_")
def tearDown(self):
for p in (self.src_dir, self.out_dir):
shutil.rmtree(p, ignore_errors=True)
def _run(self, page_counts):
out = os.path.join(self.out_dir, "out.docx")
with mock.patch("umbraformat.formatter._rendered_page_count",
side_effect=list(page_counts)) as counter:
format_document(self.src, out)
return Document(out), counter.call_count
def test_low_page_count_keeps_lowest_bucket(self):
d, calls = self._run([120])
self.assertEqual(calls, 1)
self.assertAlmostEqual(d.sections[0].left_margin.inches, 0.50, places=3)
def test_long_book_rebuilds_with_wider_gutter(self):
d, calls = self._run([400, 400])
self.assertEqual(calls, 2)
self.assertAlmostEqual(d.sections[0].left_margin.inches, 0.75, places=3)
def test_oscillation_settles_on_larger_gutter(self):
d, calls = self._run([151, 149])
self.assertAlmostEqual(d.sections[0].left_margin.inches, 0.625,
places=3)
self.assertEqual(calls, 2)
def test_render_unavailable_keeps_provisional_gutter(self):
d, calls = self._run([0])
self.assertEqual(calls, 1)
self.assertAlmostEqual(d.sections[0].left_margin.inches, 0.50, places=3)
def test_every_section_shares_the_resolved_gutter(self):
d, _ = self._run([400, 400])
for sec in d.sections:
self.assertAlmostEqual(sec.left_margin.inches, 0.75, places=3)
self.assertAlmostEqual(sec.right_margin.inches, 0.375, places=3)
class WAStyleFamilyTests(unittest.TestCase):
"""Spec section 2.2: the WA style family is defined with the spec's exact
values and is applied to content (no direct paragraph overrides)."""
def setUp(self):
self.src_dir, self.src = _build_manuscript()
self.out_dir = tempfile.mkdtemp(prefix="umbraformat_styles_")
def tearDown(self):
for p in (self.src_dir, self.out_dir):
shutil.rmtree(p, ignore_errors=True)
def _format(self, **kw):
kw.setdefault("resolve_gutter", False)
out = os.path.join(self.out_dir, "out.docx")
format_document(self.src, out, **kw)
return Document(out)
def test_wa_style_values_match_spec(self):
st = self._format().styles
for name in ("WA Chapter", "WA Section", "WA NChp Pgr", "WA Nrml Pgr",
"WA Thkng"):
self.assertEqual(st[name].font.name, "Caladea", name)
self.assertEqual(st["WA Chapter"].font.size.pt, 18)
self.assertEqual(st["WA Section"].font.size.pt, 16)
self.assertEqual(st["WA NChp Pgr"].font.size.pt, 11)
self.assertEqual(st["WA Nrml Pgr"].font.size.pt, 11)
self.assertEqual(st["WA Thkng"].font.size.pt, 11)
self.assertTrue(st["WA Thkng"].font.italic)
for name in ("WA Chapter", "WA Section"):
self.assertEqual(st[name].paragraph_format.alignment,
WD_ALIGN_PARAGRAPH.CENTER, name)
for name in ("WA NChp Pgr", "WA Nrml Pgr", "WA TOC"):
self.assertEqual(st[name].paragraph_format.alignment,
WD_ALIGN_PARAGRAPH.JUSTIFY
if name != "WA TOC" else WD_ALIGN_PARAGRAPH.LEFT,
name)
pf = st[name].paragraph_format
self.assertEqual(pf.line_spacing_rule, WD_LINE_SPACING.EXACTLY, name)
self.assertAlmostEqual(pf.line_spacing.pt, 15.0, places=1, msg=name)
self.assertEqual(pf.space_before.pt, 0, name)
self.assertEqual(pf.space_after.pt, 0, name)
self.assertEqual(st["WA Section"].paragraph_format.space_before.pt, 6)
self.assertEqual(st["WA Section"].paragraph_format.space_after.pt, 12)
self.assertAlmostEqual(st["WA Section"].paragraph_format.line_spacing,
1.15, places=2)
self.assertAlmostEqual(st["WA Chapter"].paragraph_format.line_spacing,
4.0, places=2)
self.assertAlmostEqual(
st["WA Nrml Pgr"].paragraph_format.first_line_indent.inches,
0.20, places=2)
self.assertAlmostEqual(
st["WA NChp Pgr"].paragraph_format.first_line_indent.inches,
0.0, places=3)
def test_wa_style_bases_and_next_styles(self):
st = self._format().styles
self.assertEqual(st["WA Chapter"].base_style.name, "Heading 1")
self.assertEqual(st["WA Section"].base_style.name, "Normal")
self.assertEqual(st["WA Thkng"].base_style.name, "WA Nrml Pgr")
self.assertEqual(st["WA Chapter"].next_paragraph_style.name, "WA NChp Pgr")
self.assertEqual(st["WA Section"].next_paragraph_style.name, "WA NChp Pgr")
self.assertEqual(st["WA NChp Pgr"].next_paragraph_style.name, "WA Nrml Pgr")
self.assertEqual(st["WA Nrml Pgr"].next_paragraph_style.name, "WA Nrml Pgr")
def test_wa_chapter_outline_level_and_automatic_color(self):
el = self._format().styles["WA Chapter"].element
outline = el.find(qn("w:pPr")).find(qn("w:outlineLvl"))
self.assertEqual(outline.get(qn("w:val")), "0")
color = el.find(qn("w:rPr")).find(qn("w:color"))
self.assertEqual(color.get(qn("w:val")), "auto")
def test_toc_entries_use_wa_toc_style(self):
d = self._format()
entries = [p for p in _section_paragraphs(d, 4)
if p._p.find(".//" + qn("w:instrText")) is not None]
self.assertTrue(entries, "TOC should contain PAGEREF field entries")
for p in entries:
self.assertEqual(p.style.name, "WA TOC")
# The right dot-leader tab stop is the one allowed per-paragraph
# override (position depends on the final text width).
self.assertEqual(len(p.paragraph_format.tab_stops), 1)
def test_body_paragraphs_carry_no_direct_typography(self):
d = self._format()
for p in d.paragraphs:
if p.style.name in ("WA Nrml Pgr", "WA NChp Pgr", "WA Chapter"):
self.assertIsNone(p.paragraph_format.alignment)
self.assertIsNone(p.paragraph_format.line_spacing)
self.assertIsNone(p.paragraph_format.space_before)
self.assertIsNone(p.paragraph_format.space_after)
for r in p.runs:
# Font/size come from the style, not the run
self.assertIsNone(r.font.size)
self.assertIsNone(r.font.name)
class PdfPaddingTests(unittest.TestCase):
def setUp(self):
self.out_dir = tempfile.mkdtemp(prefix="umbraformat_pdf_")
@@ -847,7 +1046,7 @@ class PdfPaddingTests(unittest.TestCase):
src_dir, src = _build_manuscript()
try:
out = os.path.join(self.out_dir, "pad.docx")
format_document(src, out, inner_margin=0.50)
format_document(src, out, resolve_gutter=False)
pdf = convert_to_pdf(out, os.path.join(self.out_dir, "pad.pdf"),
pad_multiple_of_4=True)
if pdf is None:
@@ -893,7 +1092,7 @@ class GraphicsTests(unittest.TestCase):
def _format(self, src, **kw):
out = os.path.join(self.out_dir, "gfx_out.docx")
format_document(src, out, inner_margin=0.50, **kw)
format_document(src, out, resolve_gutter=False, **kw)
return Document(out)
def _drawings(self, doc):
@@ -910,8 +1109,8 @@ class GraphicsTests(unittest.TestCase):
d = self._format(self._manuscript(width_in=9.0), trim_size="5.5x8.5")
drawing = self._drawings(d)[0]
cx, cy = _drawing_extent(drawing)
# text box = 5.5 - 0.50 (inner) - 0.50 (outer) = 4.5 in
self.assertLessEqual(cx, int(Inches(4.5)))
# text box = 5.5 - 0.50 (gutter bucket) - 0.375 (outside, spec 2.1)
self.assertLessEqual(cx, int(Inches(4.625)))
# aspect ratio preserved (3:1)
self.assertAlmostEqual(cx / cy, 3.0, places=2)
@@ -991,7 +1190,7 @@ class LocalizationTests(unittest.TestCase):
def _format(self, language, name="out.docx"):
out = os.path.join(self.tmp, name)
format_document(self._manuscript(), out, inner_margin=0.50,
format_document(self._manuscript(), out, resolve_gutter=False,
author="Ana Ruiz", imprint="Editorial Sol",
language=language)
return Document(out)
@@ -1057,7 +1256,7 @@ class EndToEndTests(unittest.TestCase):
def test_format_round_trip(self):
out = os.path.join(self.out_dir, "out.docx")
path = format_document(self.src, out, inner_margin=0.50)
path = format_document(self.src, out, resolve_gutter=False)
self.assertTrue(os.path.exists(path))
self.assertTrue(os.path.getsize(path) > 0)

View File

@@ -38,7 +38,19 @@ VERBATIM_TAGS = {"back_matter", "req_review", "about_author"}
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"
BODY_FIRST_LINE_INDENT = 0.20 # inches, per spec section 2.2 (WA Nrml Pgr)
# Page setup constants (spec section 2.1). The inside/gutter margin is dynamic
# (compute_gutter) and resolved from the final page count; these are the fixed
# values. Bleed is added on top of every margin at use time.
TOP_MARGIN_IN = 0.40
BOTTOM_MARGIN_IN = 0.40
OUTSIDE_MARGIN_IN = 0.375
HEADER_FOOTER_DISTANCE_IN = 0.25
HEADER_FONT_PT = 10 # spec 4.1, ALL CAPS
FOOTER_FONT_PT = 11 # spec 4.2
GUTTER_TABLE = "umbraformat" # dynamic inside margin buckets (spec 2.1)
MAX_GUTTER_ITERATIONS = 3 # fixed-point builds before settling on the larger gutter
# P9 graphic rules (spec section 5)
SCENE_BREAK_FALLBACK = "***" # spec 5.2 Option B: asterisks replace ornaments
@@ -212,12 +224,18 @@ def compute_gutter(page_count, margin_table="umbraformat"):
return margin
return rows[-1][2]
# WA style family — the authoritative definitions from spec section 2.2.
# Every value below mirrors the spec table exactly; content is assigned by
# pStyle reference and must not carry conflicting direct formatting.
WA_STYLES = {
"WA Chapter": {
"type": "paragraph",
"base": "Heading 1",
"next_style": "WA NChp Pgr",
"outline_level": 0, # Heading 1 (w:outlineLvl is 0-based)
"font": "Caladea",
"size": Pt(18),
"color": "auto", # neutralize Heading 1's theme color
"alignment": WD_ALIGN_PARAGRAPH.CENTER,
"line_spacing": 4.0,
"line_spacing_rule": WD_LINE_SPACING.MULTIPLE,
@@ -225,65 +243,104 @@ WA_STYLES = {
"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": {
# Scene-break marker only (spec 5.1.2).
"type": "paragraph",
"base": "Normal",
"font": "Georgia",
"next_style": "WA NChp Pgr",
"font": "Caladea",
"size": Pt(16),
"alignment": WD_ALIGN_PARAGRAPH.CENTER,
"line_spacing": 1.5,
"line_spacing_rule": WD_LINE_SPACING.ONE_POINT_FIVE,
"line_spacing": 1.15,
"line_spacing_rule": WD_LINE_SPACING.MULTIPLE,
"space_before": Pt(6),
"space_after": Pt(12),
"keep_with_next": True,
"keep_with_next": False,
"keep_together": True,
"widow_control": True,
},
"WA Thkng": {
"type": "character",
"WA NChp Pgr": {
# Body prose, no first-line indent (chapter first paragraph, spec 3.2).
"type": "paragraph",
"base": "Normal",
"next_style": "WA Nrml Pgr",
"font": "Caladea",
"size": Pt(12),
"size": Pt(11),
"alignment": WD_ALIGN_PARAGRAPH.JUSTIFY,
"first_line_indent": Inches(0),
"line_spacing": Emu(190500), # 15 pt
"line_spacing_rule": WD_LINE_SPACING.EXACTLY,
"space_before": Pt(0),
"space_after": Pt(0),
"widow_control": True,
},
"WA Nrml Pgr": {
# Body prose with first-line indent (all subsequent paragraphs, spec 3.2).
"type": "paragraph",
"base": "Normal",
"next_style": "WA Nrml Pgr",
"font": "Caladea",
"size": Pt(11),
"alignment": WD_ALIGN_PARAGRAPH.JUSTIFY,
"first_line_indent": Inches(BODY_FIRST_LINE_INDENT),
"line_spacing": Emu(190500), # 15 pt
"line_spacing_rule": WD_LINE_SPACING.EXACTLY,
"space_before": Pt(0),
"space_after": Pt(0),
"widow_control": True,
},
"WA Thkng": {
# Character style: inline italic emphasis.
"type": "character",
"base": "WA Nrml Pgr",
"font": "Caladea",
"size": Pt(11),
"italic": True,
},
"WA TOC": {
# TOC entry lines (spec 2.2 mapping). The right dot-leader tab stop is
# applied per paragraph because its position depends on the final
# text width (dynamic gutter).
"type": "paragraph",
"base": "Normal",
"next_style": "WA TOC",
"font": "Caladea",
"size": Pt(11),
"alignment": WD_ALIGN_PARAGRAPH.LEFT,
"first_line_indent": Inches(0),
"line_spacing": Emu(190500), # 15 pt
"line_spacing_rule": WD_LINE_SPACING.EXACTLY,
"space_before": Pt(0),
"space_after": Pt(0),
"widow_control": True,
},
}
def _create_wa_styles(doc):
"""Create WA styles in the output document."""
"""Create and configure the WA style family in the output document.
Two passes: first create/resolve every style object, then apply the
properties — based-on and next-style references may point at styles that
are defined later in :data:`WA_STYLES` (e.g. WA Chapter -> WA NChp Pgr).
"""
from docx.enum.style import WD_STYLE_TYPE
styles = {}
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)
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]
styles[name] = style
for name, props in WA_STYLES.items():
style = styles[name]
# Set base style if specified (e.g., Heading 1 for TOC support)
if "base" in props:
@@ -300,8 +357,38 @@ def _create_wa_styles(doc):
style.font.italic = props["italic"]
if "bold" in props and style.font:
style.font.bold = props["bold"]
if "color" in props and style.font:
# w:color val="auto" neutralizes inherited theme colors
# (e.g. WA Chapter based on Heading 1). Insert in CT_RPr order:
# w:color precedes w:spacing/w:sz/... (Word validates the order).
rpr = style.element.get_or_add_rPr()
color_el = rpr.find(qn('w:color'))
if color_el is None:
color_el = OxmlElement('w:color')
rpr.insert_element_before(
color_el, 'w:spacing', 'w:w', 'w:kern', 'w:position',
'w:sz', 'w:szCs', 'w:highlight', 'w:u', 'w:effect',
'w:bdr', 'w:shd', 'w:fitText', 'w:vertAlign', 'w:rtl',
'w:cs', 'w:em', 'w:lang', 'w:eastAsianLayout',
'w:specVanish', 'w:oMath', 'w:rPrChange')
color_el.set(qn('w:val'), props["color"])
if "next_style" in props:
try:
style.next_paragraph_style = doc.styles[props["next_style"]]
except KeyError:
pass
if props["type"] == "paragraph" and style.paragraph_format:
if "outline_level" in props:
ppr = style.element.get_or_add_pPr()
outline = ppr.find(qn('w:outlineLvl'))
if outline is None:
outline = OxmlElement('w:outlineLvl')
# CT_PPr order: ... jc, textDirection, textAlignment,
# textboxTightWrap, outlineLvl, divId, cnfStyle
ppr.insert_element_before(outline, 'w:divId', 'w:cnfStyle')
outline.set(qn('w:val'), str(props["outline_level"]))
pf = style.paragraph_format
if "alignment" in props:
pf.alignment = props["alignment"]
@@ -702,49 +789,35 @@ def _build_toc(out, chapters, tab_pos_twips, heading="Table of Contents"):
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. Using the
# python-docx API keeps w:tabs in the schema-correct CT_PPr position.
# WA TOC carries font/size/alignment/spacing (spec 2.2); only the
# right dot-leader tab stop is per-paragraph because its position
# depends on the final text width (dynamic gutter).
new_p = out.add_paragraph(style="WA TOC")
new_p.paragraph_format.tab_stops.add_tab_stop(
Emu(int(tab_pos_twips) * 635),
WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS)
# 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
# Chapter label (left-aligned) + tab jump to the dot-leader stop
new_p.add_run(f"{ci + 1}. {ch['title']}")
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))
_add_pageref_field(new_p, bm_name)
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)."""
def _build_prose_section(out, paras, font_name=None, font_size=None):
"""Front/back matter prose in the WA Nrml Pgr style (typography comes from
the style; no direct run formatting)."""
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)
out.add_paragraph(style="WA Nrml Pgr").add_run(text)
def _is_scene_break_marker(item, text):
@@ -821,9 +894,11 @@ def _add_bookmark(para, bookmark_id, bookmark_name):
para._p.append(bm_end)
def _add_pageref_field(para, bookmark_name, font_name="Caladea", font_size=Pt(11)):
def _add_pageref_field(para, bookmark_name, font_name=None, font_size=None):
"""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).
Run fonts are left to the paragraph style (WA TOC) unless explicitly
passed.
The begin run deliberately carries NO w:dirty. Word's per-field dirty
update path crashes with the hard "Word experienced an error" dialog on
@@ -834,37 +909,47 @@ def _add_pageref_field(para, bookmark_name, font_name="Caladea", font_size=Pt(11
batch path that survives the same document.
"""
r1 = para.add_run()
r1.font.name = font_name
r1.font.size = font_size
if font_name:
r1.font.name = font_name
if font_size:
r1.font.size = font_size
fld1 = OxmlElement("w:fldChar")
fld1.set(qn("w:fldCharType"), "begin")
r1._r.append(fld1)
r2 = para.add_run()
r2.font.name = font_name
r2.font.size = font_size
if font_name:
r2.font.name = font_name
if font_size:
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
if font_name:
r3.font.name = font_name
if font_size:
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
if font_name:
r4.font.name = font_name
if font_size:
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
if font_name:
r5.font.name = font_name
if font_size:
r5.font.size = font_size
fld2 = OxmlElement("w:fldChar")
fld2.set(qn("w:fldCharType"), "end")
r5._r.append(fld2)
@@ -1219,14 +1304,17 @@ def _set_right_tab(paragraph, pos_twips):
def _add_running_header(doc, section, recto_text, author, title, tab_pos_twips,
font_name="Caladea", font_size=Pt(9)):
font_name="Caladea", font_size=None):
"""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)."""
Caladea 10 pt ALL CAPS (spec section 4.1). 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)."""
if font_size is None:
font_size = Pt(HEADER_FONT_PT)
sect_pr = section._sectPr
for hr in sect_pr.findall(qn("w:headerReference")):
sect_pr.remove(hr)
@@ -1239,6 +1327,7 @@ def _add_running_header(doc, section, recto_text, author, title, tab_pos_twips,
run = p.add_run(recto_text or "")
run.font.name = font_name
run.font.size = font_size
run.font.all_caps = True
# Verso (even) header: "[Author Name]\t[Book Title]"
even_header = section.even_page_header
@@ -1249,6 +1338,7 @@ def _add_running_header(doc, section, recto_text, author, title, tab_pos_twips,
run = p.add_run(author or "")
run.font.name = font_name
run.font.size = font_size
run.font.all_caps = True
tab_run = p.add_run()
tab_run.font.name = font_name
tab_run.font.size = font_size
@@ -1256,6 +1346,7 @@ def _add_running_header(doc, section, recto_text, author, title, tab_pos_twips,
run = p.add_run(title or "")
run.font.name = font_name
run.font.size = font_size
run.font.all_caps = True
# First page header: empty (suppressed)
first_header = section.first_page_header
@@ -1267,15 +1358,18 @@ def _add_running_header(doc, section, recto_text, author, title, tab_pos_twips,
sect_pr.append(tp)
def _add_running_footer(section, font_name="Caladea", font_size=Pt(9),
def _add_running_footer(section, font_name="Caladea", font_size=None,
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.
the bottom of both verso (even/left) and recto (odd/right) pages, in
Caladea 11 pt.
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."""
if font_size is None:
font_size = Pt(FOOTER_FONT_PT)
sect_pr = section._sectPr
for fr in sect_pr.findall(qn("w:footerReference")):
sect_pr.remove(fr)
@@ -1596,31 +1690,33 @@ def _reorder_settings(settings):
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.
def _format_document_once(filepath, output_path=None, font_name="Caladea",
font_size=11, gutter=0.50, trim_size="5.5x8.5",
bleed=0.0, author="", imprint="",
strip_graphics=False, strip_problematic_graphics=False,
language="en"):
"""Build the formatted DOCX once for a given inside/gutter ``gutter``.
The dynamic-gutter fixed point (spec section 2.1) lives in
:func:`format_document`, which calls this repeatedly with the gutter
derived from each render's page count.
``trim_size`` is one of :data:`TRIM_SIZES` (default "5.5x8.5").
``inner_margin`` is the inside/gutter margin in inches (mirror margins).
``gutter`` is the inside/gutter margin in inches (mirror margins); it is
computed by the caller from the final page count.
``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).
headings/copyright text, sets the proofing language, and turns on
bidirectional layout for RTL languages (spec section 6).
"""
if output_path is None:
base, ext = os.path.splitext(filepath)
@@ -1638,8 +1734,8 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
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))
text_width_emu = int(Inches(width - gutter - OUTSIDE_MARGIN_IN))
text_height_emu = int(Inches(height - TOP_MARGIN_IN - BOTTOM_MARGIN_IN))
# Localization (spec section 6)
strings = _language_strings(language)
@@ -1745,7 +1841,7 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
# 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)
tab_pos_twips = int((width - gutter - OUTSIDE_MARGIN_IN) * 1440)
toc_entry_paras = _build_toc(out, chapters, tab_pos_twips,
heading=strings["toc"])
@@ -1786,12 +1882,10 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
strip_problematic=strip_problematic_graphics,
font_name=font_name, font_size=Pt(font_size))
if ch["title"]:
# WA Chapter carries all typography (spec 2.2); the bookmark lets
# TOC PAGEREF fields resolve the title's page.
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_p.add_run(ch["title"])
_add_bookmark(add_p, ci, f"_UF_ch_{ci}")
first_para = True
for item in ch["paras"]:
@@ -1806,37 +1900,24 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
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
# Scene break: WA Section carries all typography (spec 5.1.2).
add_p = out.add_paragraph(style="WA Section")
# 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)
add_p.add_run("<<<>>>")
elif strip_graphics:
run = add_p.add_run(SCENE_BREAK_FALLBACK)
run.font.name = "Caladea"
run.font.size = Pt(11)
add_p.add_run(SCENE_BREAK_FALLBACK)
else:
ornament = SCENE_BREAK_ORNAMENT
run = add_p.add_run(ornament)
run.font.name = "Caladea"
run.font.size = Pt(11)
add_p.add_run(SCENE_BREAK_ORNAMENT)
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).
# First paragraph of a chapter: WA NChp Pgr (no first-line
# indent), with only the first word capitalized (spec 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:
@@ -1845,27 +1926,18 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
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)
new_p.add_run(first_word)
# 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)
new_p.add_run(" " + rest_text)
else:
run1 = new_p.add_run("")
run1.font.name = font_name
run1.font.size = Pt(font_size)
new_p.add_run("")
first_para = False
else:
# Subsequent paragraphs: WA Nrml Pgr (0.2" first-line indent).
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)
new_p.add_run(text)
# Verbatim sections (back matter, req review, about author) — raw copy, user handles formatting
for vs in verbatim_sections:
@@ -1884,10 +1956,11 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
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'))
# Automatic hyphenation is disabled document-wide (spec section 3.2):
# remove it if a template or source document enabled it.
ah = settings.find(qn('w:autoHyphenation'))
if ah is not None:
settings.remove(ah)
# Fields update on open through the settings-level batch path. Per-field
# w:dirty cannot be used: Word's dirty-driven field update crashes this
# book with a hard open error (empirically isolated over 24 bisections).
@@ -1902,12 +1975,15 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
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)
sec.top_margin = Inches(TOP_MARGIN_IN + bleed)
sec.bottom_margin = Inches(BOTTOM_MARGIN_IN + bleed)
# Inside/gutter margin: dynamic per final page count (spec 2.1);
# bleed is added on top. Mirror margins are set below, making this
# the inside edge on verso pages.
sec.left_margin = Inches(gutter + bleed)
sec.right_margin = Inches(OUTSIDE_MARGIN_IN + bleed)
sec.header_distance = Inches(HEADER_FOOTER_DISTANCE_IN)
sec.footer_distance = Inches(HEADER_FOOTER_DISTANCE_IN)
sp = sec._sectPr
if sp.find(qn('w:mirrorMargins')) is None:
mm = OxmlElement("w:mirrorMargins")
@@ -1927,7 +2003,7 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
# 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)
header_tab = int((width - gutter - OUTSIDE_MARGIN_IN) * 1440)
if role in ("half_title", "blank", "title"):
# Numbered lowerRoman but suppressed — empty headers/footers.
@@ -1990,6 +2066,94 @@ def format_document(filepath, output_path=None, font_name="Caladea", font_size=1
return output_path
def _rendered_page_count(docx_path, timeout=600):
"""Page count from a throwaway LibreOffice render of ``docx_path``.
Returns 0 when rendering is unavailable (no LibreOffice, timeout, or an
unreadable PDF) so callers can fall back to the provisional gutter.
"""
tmpdir = tempfile.mkdtemp(prefix="umbraformat_pages_")
try:
staged = os.path.join(tmpdir, "book.docx")
shutil.copy2(docx_path, staged)
subprocess.run(
["libreoffice", "--headless", "--convert-to", "pdf",
"--outdir", tmpdir, staged],
check=True, capture_output=True, timeout=timeout,
)
return pdf_page_count(os.path.join(tmpdir, "book.pdf"))
except (subprocess.CalledProcessError, FileNotFoundError,
subprocess.TimeoutExpired, OSError):
return 0
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def format_document(filepath, output_path=None, font_name="Caladea", font_size=11,
inner_margin=None, trim_size="5.5x8.5", bleed=0.0,
author="", imprint="", footer_mode="headers",
strip_graphics=False, strip_problematic_graphics=False,
language="en", resolve_gutter=True,
max_gutter_iterations=MAX_GUTTER_ITERATIONS):
"""Parse tagged DOCX and generate the formatted DOCX (spec section 2.1).
The inside/gutter margin is **dynamic**: the document is built with the
lowest gutter bucket, rendered to count pages, and rebuilt with the
gutter that count implies until page count and gutter agree (fixed
point, ``max_gutter_iterations`` builds; on oscillation across a bucket
boundary the larger gutter is used). When rendering is unavailable the
lowest bucket is kept. ``resolve_gutter=False`` skips the render loop and
builds once with the lowest bucket (used by unit tests; the app always
resolves).
``inner_margin`` is accepted for backward compatibility and **ignored** —
the engine computes the gutter (spec section 2.1).
``footer_mode`` is accepted for backward compatibility and ignored: page
numbers always render centered in the running footers (spec section 4.2)
while headers carry titles only (spec section 4.1).
"""
if output_path is None:
base, ext = os.path.splitext(filepath)
output_path = f"{base}_formatted{ext}"
def build(gutter):
_format_document_once(
filepath, output_path, font_name=font_name, font_size=font_size,
gutter=gutter, trim_size=trim_size, bleed=bleed, author=author,
imprint=imprint, strip_graphics=strip_graphics,
strip_problematic_graphics=strip_problematic_graphics,
language=language)
lowest_gutter = MARGIN_TABLES[GUTTER_TABLE][0][2]
if not resolve_gutter:
build(lowest_gutter)
return output_path
gutter = lowest_gutter
seen = []
for _ in range(max(1, max_gutter_iterations)):
build(gutter)
pages = _rendered_page_count(output_path)
if not pages:
return output_path # render unavailable: keep the provisional gutter
target = compute_gutter(pages, GUTTER_TABLE)
if target == gutter:
return output_path
if target in seen:
# Oscillation across a bucket boundary: settle on the larger
# gutter (safer binding) — rebuild only if it isn't current.
final = max(seen + [gutter, target])
if final != gutter:
build(final)
return output_path
seen.append(gutter)
gutter = target
# Iteration cap reached: settle on the largest gutter observed.
final = max(seen + [gutter])
if final != gutter:
build(final)
return output_path
def _merge_missing_styles(out2, src_doc):
"""Copy w:style definitions referenced by the output but missing from its
styles.xml, taken from the source manuscript's styles part. Word requires

View File

@@ -36,14 +36,12 @@ function getRecommendedMargin(pageCount, table) {
function updateMarginRecommendation() {
const recDiv = document.getElementById('marginRecommendation');
const recSpan = document.getElementById('recommendedMargin');
const marginSelect = document.getElementById('innerMarginSelect');
const marginTable = document.getElementById('marginTableSelect');
if (window._pageCount) {
const rec = getRecommendedMargin(window._pageCount, marginTable.value);
recSpan.textContent = `${rec} in for ${window._pageCount} pages`;
recSpan.textContent = `${rec} in for ${window._pageCount} pages (applied automatically)`;
recDiv.classList.remove('d-none');
marginSelect.value = rec;
}
}
@@ -278,7 +276,6 @@ function doFormat() {
const font = document.getElementById("fontSelect").value;
const fontSize = document.getElementById("fontSizeSelect").value;
const innerMargin = document.getElementById("innerMarginSelect").value;
const trimSize = document.getElementById("trimSelect").value;
const bleed = document.getElementById("bleedCheck").checked ? 0.125 : 0;
const author = document.getElementById("authorInput").value.trim();
@@ -295,7 +292,6 @@ function doFormat() {
upload_id: currentUploadId,
font: font,
fontSize: fontSize,
innerMargin: innerMargin,
trimSize: trimSize,
bleed: bleed,
author: author,
@@ -327,9 +323,6 @@ function showFormatted(data) {
const panel = document.getElementById("formatPanel");
const dlDiv = document.getElementById("downloadButtons");
const font = document.getElementById("fontSelect").value;
const fontSize = document.getElementById("fontSizeSelect").value;
const innerMargin = document.getElementById("innerMarginSelect").value;
const trimSize = document.getElementById("trimSelect").value;
const bleed = document.getElementById("bleedCheck").checked ? "0.125 in" : "none";
const trimLabel = trimSize === "6x9" ? "6 × 9 in" : "5.5 × 8.5 in";
@@ -348,9 +341,9 @@ function showFormatted(data) {
<h6 class="mb-3"><i class="bi bi-list-check me-2"></i>What was applied</h6>
<ul class="small text-secondary mb-0">
<li>Trim size: ${trimLabel}, portrait (bleed: ${bleed})</li>
<li>Mirrored margins: Inside ${innerMargin} in / Outside 0.50 in</li>
<li>Top/Bottom margins: 0.75 in / 0.75 in</li>
<li>Font: ${font} ${fontSize}pt (body paragraphs)</li>
<li>Mirrored margins: Inside computed from final page count / Outside 0.375 in</li>
<li>Top/Bottom margins: 0.40 in / 0.40 in</li>
<li>Body: WA styles (Caladea 11pt, justified, exactly 15pt line)</li>
<li>Chapter titles: Caladea 18pt</li>
<li>Different first page enabled on all sections</li>
<li>Major sections (TOC, chapters, back matter) start on a right-hand (recto) page</li>
@@ -358,7 +351,7 @@ function showFormatted(data) {
<li>Language: ${document.getElementById("languageSelect").value}</li>
<li>Front matter: lowercase Roman numerals</li>
<li>Chapter 1: restarts at page 1 (Arabic)</li>
<li>Styles applied: WA Chapter, WA Nrml Pgr, WA NChp Pgr</li>
<li>Styles applied: WA Chapter, WA Section, WA NChp Pgr, WA Nrml Pgr, WA Thkng, WA TOC</li>
<li>Section break markers standardized to <<<>>></li>
</ul>
</div>

View File

@@ -60,14 +60,10 @@
</select>
</div>
<div class="col-md-3">
<label for="innerMarginSelect" class="form-label">Inner Margin</label>
<select id="innerMarginSelect" class="form-select">
<option value="0.375">0.375 in (24-150 pages)</option>
<option value="0.500">0.500 in (151-300 pages)</option>
<option value="0.625">0.625 in (301-500 pages)</option>
<option value="0.750">0.750 in (501-700 pages)</option>
<option value="0.875">0.875 in (701-828 pages)</option>
</select>
<label class="form-label">Inside / Gutter Margin</label>
<div class="form-control bg-light text-secondary" aria-readonly="true">
Computed automatically from the final page count
</div>
</div>
<div class="col-md-4 d-flex align-items-end">
<div id="pageCountInfo" class="text-secondary small">
@@ -260,8 +256,5 @@
document.getElementById('fontSizeSelect').addEventListener('change', updatePageEstimate);
document.getElementById('trimSelect').addEventListener('change', updatePageEstimate);
document.getElementById('marginTableSelect').addEventListener('change', updateMarginRecommendation);
document.getElementById('innerMarginSelect').addEventListener('change', function() {
document.getElementById('marginRecommendation').classList.add('d-none');
});
</script>
{% endblock %}