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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user