diff --git a/docs/umbraformat_specification_document.md b/docs/umbraformat_specification_document.md index d05102d..18554fb 100644 --- a/docs/umbraformat_specification_document.md +++ b/docs/umbraformat_specification_document.md @@ -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. diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 59b07b2..1f8cc0a 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -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) diff --git a/umbraformat/formatter.py b/umbraformat/formatter.py index c575786..1ea6445 100644 --- a/umbraformat/formatter.py +++ b/umbraformat/formatter.py @@ -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 diff --git a/umbraformat/static/js/main.js b/umbraformat/static/js/main.js index c9ccd6b..52d6462 100644 --- a/umbraformat/static/js/main.js +++ b/umbraformat/static/js/main.js @@ -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) {
What was applied
diff --git a/umbraformat/templates/format.html b/umbraformat/templates/format.html index 2d6751f..f8615f4 100644 --- a/umbraformat/templates/format.html +++ b/umbraformat/templates/format.html @@ -60,14 +60,10 @@
- - + +
+ Computed automatically from the final page count +
@@ -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'); - }); {% endblock %}