commit 0c3216823110e22993dba691294e0b33747fa8fe Author: bm Date: Tue Sep 22 19:06:03 2026 -0700 Apply updated spec compliance and establish git/docker baseline - Spec §4.1: running headers carry titles only (recto: chapter/section title; verso: author name + book title); page-number fields removed from headers. - Spec §4.2: page numbers centered in the footer on both verso and recto pages; front-matter display pages (copyright, dedication, epigraph, TOC) show their Roman numeral on the opening page too. - Spec §2.2: lowercase Roman numbering visible from the copyright page through the front matter; Chapter 1 restarts at Arabic 1. - Chapter/back-matter sections keep running matter on body pages; only the first page of each chapter is suppressed (spec §4.3). - Drop titlePg on single-page front-matter display sections so footer page numbers render in LibreOffice and Word alike. - Preserve <<<>>> scene-break cues (centered, 12pt before/after) and first-word-only capitalization of chapter openers. - UI: remove the obsolete "Page Numbers" selector; footer_mode is accepted-but-ignored for backward compatibility. - Tests updated to the new spec behavior (51 pass). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97a02e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +env/ + +# Runtime data (uploaded manuscripts + generated output) +umbraformat/uploads/ +umbraformat/formatted/ + +# Documents / user scratch files (not part of the app source) +*.docx +summary.txt +Sections_Tags +Word_to_PDF_print_formatting +NewBook_5.5x8.5g.docx + +# Editors / OS +.vscode/ +.idea/ +*.swp +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5b1a75d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends libreoffice-core libreoffice-writer && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY umbraformat/ umbraformat/ + +RUN mkdir -p umbraformat/uploads umbraformat/formatted + +EXPOSE 5001 + +CMD ["python", "-c", "from umbraformat.app import app; app.run(host='0.0.0.0', port=5001)"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d690560 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + umbraformat: + build: . + container_name: umbraformat + ports: + - "5001:5001" + volumes: + - ./umbraformat/uploads:/app/umbraformat/uploads + - ./umbraformat/formatted:/app/umbraformat/formatted + restart: unless-stopped diff --git a/docs/OLD_Book_formatting_spec.md.old b/docs/OLD_Book_formatting_spec.md.old new file mode 100644 index 0000000..74ea335 --- /dev/null +++ b/docs/OLD_Book_formatting_spec.md.old @@ -0,0 +1,404 @@ +# UmbraFormat — Book Formatting Specification + +## 1. Overview + +UmbraFormat is a manuscript formatting tool that converts tagged `.docx` manuscripts into print-ready trade paperback documents. It implements the **B.D. Murphy General Book Formatting Specification**, enforcing consistent page layout, styles, headers, footers, and page numbering across all output. + +**Version:** 1.1.0 +**Tech Stack:** Python 3.12, Flask, python-docx, LibreHeadless (PDF conversion) + +--- + +## 2. System Architecture + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Web UI │────▶│ Analyzer │────▶│ Formatter │ +│ (Flask) │ │ (analyzer) │ │ (formatter) │ +└──────────────┘ └──────────────┘ └──────┬───────┘ + │ + ┌──────▼───────┐ + │ LibreOffice │ + │ (PDF conv.) │ + └──────────────┘ +``` + +### Modules + +| Module | File | Lines | Responsibility | +|--------|------|-------|----------------| +| `app.py` | `umbraformat/app.py` | 187 | Flask routes, file upload, API endpoints | +| `analyzer.py` | `umbraformat/analyzer.py` | 332 | DOCX analysis against spec | +| `formatter.py` | `umbraformat/formatter.py` | 1062 | Two-pass document reconstruction engine | + +--- + +## 3. Input Specification + +### 3.1 File Format + +- **Type:** `.docx` (Office Open XML) +- **Max size:** 50 MB +- **Encoding:** UTF-8 (implicit in OOXML) + +### 3.2 Structural Tags + +All tags must appear on their own line, with no surrounding content. + +| Tag | Purpose | +|-----|---------| +| `[book start]` | Beginning of the manuscript (title page) | +| `[chapter]` | Marks the start of each new chapter | +| `[font]` / `[/font]` | Passthrough block — original formatting preserved verbatim | +| `[copyright]` | Copyright page content follows | +| `[TOC]` | Table of contents placeholder | +| `[back matter]` | Back matter section (verbatim copy) | +| `[Req review]` | Review request page (verbatim copy) | +| `[about author]` | About the author page (verbatim copy) | +| `<<<>>>` | Scene break marker within chapters | + +### 3.3 Manuscript Structure + +``` +[book start] +Title: The Book Title +Subtitle: A Subtitle +Author: Author Name +Imprint: Publisher Name +[optional images] +[chapter] +Chapter 1: Chapter Title +Paragraph text... +Paragraph text... +<<<>>> +Scene break... +[font] +[verbatim block] +[/font] +[chapter] +Chapter 2: Chapter Title +... +[copyright] +Copyright page text... +[TOC] +Table of contents... +[back matter] +Back matter content... +[Req review] +Review request text... +[about author] +Author bio text... +``` + +--- + +## 4. Output Specification + +### 4.1 Page Layout + +| Property | Value | +|----------|-------| +| Page size | 5.5 × 8.5 inches (trade paperback) | +| Orientation | Portrait | +| Mirror margins | Yes | +| Different first page | Yes | + +### 4.2 Margins + +| Margin | Value | +|--------|-------| +| Top | 0.35 inches | +| Bottom | 0.25 inches | +| Inside (configurable) | 0.375 – 0.875 inches (default: 0.55) | +| Outside | 0.40 inches | + +**Inner margin reference by page count:** + +| Page Count | Inner Margin | +|------------|--------------| +| < 100 | 0.375 inches | +| 100–200 | 0.50 inches | +| 200–400 | 0.625 inches | +| 400–600 | 0.75 inches | +| > 600 | 0.875 inches | + +### 4.3 Headers + +| Section Type | Header Behavior | +|--------------|-----------------| +| Title page (Section 0) | No header | +| Front matter | No header | +| Chapter sections | Alternating: odd = book title, even = author name | +| Back matter / verbatim | No header | + +- Header distance: 0.50 inches from page edge +- Enabled via `evenAndOddHeaders` in document settings + +### 4.4 Footers + +| Section Type | Footer Behavior | +|--------------|-----------------| +| Title page | No footer | +| Front matter | PAGE field (Roman numerals, centered) | +| Chapter sections | PAGE field (Arabic numerals, centered) | +| Back matter / verbatim | PAGE field (Arabic, centered) | + +- Footer distance: 0.50 inches from page edge + +### 4.5 Page Numbering + +| Section | Numbering | +|---------|-----------| +| Front matter (copyright, TOC) | Lower Roman (i, ii, iii...) | +| Chapters onward | Decimal (1, 2, 3...), starting at 1 | + +--- + +## 5. Typography & Styles + +### 5.1 Named Styles + +#### `WA Chapter` — Chapter Title + +| Property | Value | +|----------|-------| +| Font | Caladea | +| Size | 18 pt | +| Alignment | Center | +| Spacing before | 24 pt | +| Spacing after | 0 pt | +| Line spacing | Single | + +#### `WA Nrml Pgr` — Normal Paragraph + +| Property | Value | +|----------|-------| +| Font | Caladea | +| Size | 12 pt | +| First-line indent | 0.30 inches | +| Line spacing | 15 pt (exactly) | +| Spacing after | 0 pt | + +#### `WA NChp Pgr` — First Paragraph of Chapter (No Indent) + +| Property | Value | +|----------|-------| +| Font | Caladea | +| Size | 12 pt | +| First-line indent | 0 inches | +| Line spacing | 14 pt (exactly) | +| Spacing after | 0 pt | +| First letter handling | Uppercase, drop-cap ready | + +#### `WA Section` — Section Heading + +| Property | Value | +|----------|-------| +| Font | Caladea | +| Size | 16 pt | +| Alignment | Left | +| Spacing before | 18 pt | +| Spacing after | 6 pt | + +#### `WA Scene Break` — Scene Break + +| Property | Value | +|----------|-------| +| Font | Caladea | +| Size | 14 pt | +| Alignment | Center | +| Content | `<<<>>>` rendered as decorative break | + +#### `WA Thkng` — Internal Thought (Character Style) + +| Property | Value | +|----------|-------| +| Font | Caladea | +| Size | 12 pt | +| Italic | Yes | + +### 5.2 Title Page Typography + +| Element | Font | Size | Alignment | +|---------|------|------|-----------| +| Book title | Caladea | 48 pt | Center | +| Subtitle | Caladea | 18 pt | Center | +| Author name | Caladea | 36 pt | Center | +| Imprint / Publisher | Caladea | 18 pt | Center | + +Title page includes 24 empty spacer paragraphs to push content into vertical center. + +### 5.3 Images + +- Title page images are placed side-by-side using tab stops +- Image relationships (`r:embed`) are remapped during document reconstruction +- Deep-copies drawing XML to preserve original image data + +--- + +## 6. Formatting Pipeline + +### 6.1 Pass 1 — Content Assembly + +1. **Tag scanning:** `_find_tags()` locates all structural markers +2. **Title extraction:** Parses title, subtitle, author, imprint, images from `[book start]` block +3. **Chapter extraction:** `_extract_chapters()` groups paragraphs by `[chapter]` tags, tracks `[font]`/`[/font]` passthrough blocks, detects `<<<>>>` scene breaks +4. **Verbatim extraction:** Back matter, review request, about author sections copied with raw XML deep-copy (preserves hyperlinks, images, formatting) +5. **Document creation:** New `.docx` built from scratch with: + - Section 0: Title page (spacer layout) + - Section 1: Copyright + TOC (with PAGEREF fields) + - Sections 2+: Chapters (each in its own section) + - Final sections: Verbatim content + +### 6.2 Pass 2 — Section Properties + +1. **Settings update:** Enable `evenAndOddHeaders` +2. **Per-section configuration:** + - Page size (5.5 × 8.5") + - Margins (mirror, configurable inner) + - Header/footer content (PAGE fields, alternating headers) + - Page numbering format (Roman vs. Arabic) +3. **OOXML schema reordering:** `w:sectPr` children reordered for Word compatibility + +### 6.3 PDF Conversion + +- Uses LibreOffice headless: `libreoffice --headless --convert-to pdf` +- Timeout: 120 seconds +- Output: same filename with `.pdf` extension + +--- + +## 7. API Specification + +### 7.1 Endpoints + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/` | Home page | +| `GET` | `/format` | Upload/format page | +| `POST` | `/api/analyze` | Upload and analyze a `.docx` | +| `POST` | `/api/format` | Format an analyzed document | +| `GET` | `/api/download/` | Download formatted `.docx` | +| `GET` | `/api/download//pdf` | Download formatted `.pdf` | +| `GET` | `/api/diag/` | Download diagnostic output | +| `GET` | `/api/version` | Return version JSON | + +### 7.2 Analysis Response (`/api/analyze`) + +```json +{ + "page_setup": { + "width": 5.5, + "height": 8.5, + "orientation": "PORTRAIT" + }, + "margins": { + "top": 0.35, + "bottom": 0.25, + "inside": 0.55, + "outside": 0.40, + "mirror_margins": true + }, + "sections": [], + "styles_found": { + "WA Nrml Pgr": { + "properties": {}, + "issues": [], + "count": 150 + } + }, + "styles_missing": [], + "paragraph_count": 300, + "paragraphs_by_style": {}, + "issues": [], + "warnings": [], + "chapter_word_count": 45000, + "upload_id": "uuid-string", + "original_filename": "manuscript.docx" +} +``` + +### 7.3 Format Request (`/api/format`) + +```json +{ + "upload_id": "uuid-string", + "font": "Caladea", + "fontSize": 12, + "innerMargin": 0.55 +} +``` + +### 7.4 File Storage + +- **Uploads:** `umbraformat/uploads/.docx` + `_analysis.json` +- **Formatted output:** `umbraformat/formatted/_formatted.docx` + `_formatted.pdf` +- **Name mapping:** `umbraformat/formatted/_formatted.name` (maps UUID to original filename) + +--- + +## 8. Configuration + +### 8.1 Application Limits + +| Setting | Value | +|---------|-------| +| Max upload size | 50 MB | +| Allowed extensions | `.docx` only | +| Server host | `0.0.0.0` | +| Server port | `5001` | + +### 8.2 Docker + +- Base image: `python:3.12-slim` +- LibreOffice installed for PDF conversion +- Volumes: `uploads/`, `formatted/` + +--- + +## 9. Testing + +### 9.1 Current State + +No automated test suite exists. Manual testing performed through the web UI with output artifacts in `formatted/`: + +| Artifact | Purpose | +|----------|---------| +| `test_stage_a.docx` – `test_stage_e.docx` | Incremental feature tests | +| `test_feat_full.docx` | Full feature integration | +| `test_feat_toc.docx` | Table of contents | +| `test_feat_pageref.docx` | Page reference fields | +| `test_feat_bookmarks.docx` | Bookmarks | +| `diag_minimal.docx` | Diagnostic output | + +### 9.2 Recommended Test Coverage + +- **Unit tests:** Tag parsing, style generation, margin calculations +- **Integration tests:** Full pipeline with sample manuscripts +- **Regression tests:** Document comparison against known-good outputs +- **Edge cases:** Empty chapters, missing tags, oversized images, 34+ chapters + +--- + +## 10. Known Constraints & Limitations + +1. **No undo/history:** Formatting is destructive — original file is not modified, but formatted output replaces previous version +2. **Single-user:** No concurrent session management; stateless file-based storage +3. **Tag-dependent:** Manuscript must use exact tag syntax; malformed tags cause silent failures +4. **Fixed page size:** 5.5 × 8.5 inches hardcoded; not configurable for other trim sizes +5. **Font-limited:** Only Caladea (body) and Georgia (headings) defined in spec +6. **No database:** All state stored on filesystem; no persistence across container restarts unless volumes mounted +7. **LibreOffice dependency:** PDF conversion requires LibreOffice headless, adding ~300 MB to Docker image + +--- + +## 11. Future Considerations + +- Automated test suite with pytest +- Support for additional trim sizes (6×9, 5×8) +- Custom font configuration +- Batch processing (multiple manuscripts) +- Version history / rollback +- Database-backed project tracking +- Webhook notifications on format completion +- Pre-flight checks for common manuscript issues (missing tags, inconsistent styles) diff --git a/docs/umbraformat_specification_document.md b/docs/umbraformat_specification_document.md new file mode 100644 index 0000000..d05102d --- /dev/null +++ b/docs/umbraformat_specification_document.md @@ -0,0 +1,175 @@ +# Specification: UmbraFormat (Print-Ready Book Document Processor) + +## 1. Executive Summary +**UmbraFormat** is an automated document transformation engine designed to convert Microsoft Word (`.docx`) manuscript drafts into print-ready PDF files suitable for physical publication (e.g., KDP, IngramSpark, traditional print runs). + +The primary objective of this specification is to establish deterministic rules for handling **Front Matter**, **Body Text**, and **Back Matter**, while ensuring precision in page setup, running headers/footers, pagination, typographic hierarchy, multilingual support, and embedded graphical icon placement. + +--- + +## 2. Document Layout & Global Specifications + +### 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: + * $< 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. + +### 2.2 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. +2. **Page Numbering Schemes:** + * **Front Matter:** Lowercase Roman numerals (`i, ii, iii, iv...`). Numbering begins implicitly on the Title Page but is **suppressed/hidden** on display pages (Title, Copyright, Dedication). + * **Body Matter:** Arabic numerals (`1, 2, 3...`). Restarts at `1` on Page 1 of Chapter 1. + * **Back Matter:** Continues Arabic numerals from the Body Matter sequentially. + +--- + +## 3. Structural Breakdown & Processing Rules + +### 3.1 Front Matter Sequence +Front Matter sections must be recognized, categorized, and formatted according to the following canonical order: + +| Order | Section Name | Pagination Style | Display Number? | Page Placement | Header/Footer | +| :--- | :--- | :--- | :--- | :--- | :--- | +| 1 | Half Title Page | Roman (`i`) | Hidden | Recto | Suppressed | +| 2 | Blank / Ad Card | Roman (`ii`) | Hidden | Verso | Suppressed | +| 3 | Title Page | Roman (`iii`) | Hidden | Recto | Suppressed | +| 4 | Copyright Page | Roman (`iv`) | Hidden | Verso | Suppressed | +| 5 | Dedication | Roman (`v`) | Hidden | Recto | Suppressed | +| 6 | Epigraph | Roman (`vi`) | Hidden | Recto / Verso | Suppressed | +| 7 | Table of Contents | Roman | Visible | Recto | Standard Front | +| 8 | Foreword / Preface | Roman | Visible | Recto | Standard Front | +| 9 | Acknowledgments | Roman | Visible | Recto | Standard Front | + +#### Section Specific Rules: +* **Copyright Page:** Formatted in small point size ($8\text{pt} - 9.5\text{pt}$), aligned flush-left or centered at the bottom of the Verso page preceding Chapter 1 or TOC. +* **Table of Contents (TOC):** Auto-generated or normalized from `.docx` Heading styles. Dot leaders ($\dots$) right-aligning to page numbers. Suppresses page numbers for unnumbered front/back matter items if required. + +--- + +### 3.2 Body Matter Engine +* **Chapter Opening Pages:** + * 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. + +--- + +### 3.3 Back Matter Sequence +Back Matter elements immediately follow the final chapter: + +| Order | Section Name | Pagination Style | Display Number? | Page Placement | Header/Footer | +| :--- | :--- | :--- | :--- | :--- | :--- | +| 1 | Appendix / Appendices | Arabic (Continued) | Visible | Recto | Standard Body | +| 2 | Endnotes / Notes | Arabic (Continued) | Visible | Recto | Standard Body | +| 3 | Glossary | Arabic (Continued) | Visible | Recto | Standard Body | +| 4 | Bibliography / Ref | Arabic (Continued) | Visible | Recto | Standard Body | +| 5 | Index | Arabic (Continued) | Visible | Recto | Two-Column Grid | +| 6 | About the Author | Arabic (Continued) | Visible | Recto | Suppressed/Standard| + +#### Section Specific Rules: +* **Index:** Automatically parsed into 2-column layout with hanging indents ($0.15\text{ in}$) and smaller font size ($8.5\text{pt} - 9.5\text{pt}$). +* **About the Author:** May include an embedded headshot or icon image subject to section 5 rules. + +--- + +## 4. Header & Footer Specifications + +### 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). + +### 4.2 Running Footers +* Bottom pagination is preferred: + * **Verso Page Number:** Bottom-center. + * **Recto Page Number:** Bottom-center. + +### 4.3 Suppression Matrix +Headers and footers **must be completely hidden** on: +1. All Front Matter pages before TOC. +2. The first page of any major Section/Chapter. +3. Completely blank pages inserted to maintain Recto orientation. + +--- + +## 5. Graphic Icons & Inline Image Rules + +Embedded icons and decorative elements (e.g., scene break ornaments, section header icons) are processed via strict anchoring rules: + +### 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. +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`. + +### 5.2 Graphic Fallback Processing +If a embedded image/icon in the Word file is corrupt, incorrectly formatted, or exceeds printable margin bounds: +* **Option A (Default):** Re-anchor to the nearest text flow line as a inline block. +* **Option B (`--strip-graphics` flag):** Automatically strip non-essential decorative images while preserving structural content and replacing scene breaks with standard asterisks (`***`). + +--- + +## 6. Internationalization & Multilingual Support (i18n) + +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`). +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. + * Align page numbers and running header positions to RTL standards. +3. **Localized Front/Back Matter Titles:** + * Dynamic translation keys for auto-generated headings (e.g., "Table of Contents" $\rightarrow$ "Índice" / "Inhaltsverzeichnis"). + +--- + +## 7. Implementation Checklist & Engine Architecture + +``` +[ .DOCX Source Manuscript ] + │ + ▼ +[ Structure Parsing & Tagging Engine ] + ├── Identify Front / Body / Back Matter + ├── Map Style Headers (H1, H2, H3, Body) + └── Extract Images & Anchor Points + │ + ▼ +[ Layout Transformer & Pagination Builder ] + ├── Enforce Recto / Verso Page Assignments + ├── Apply Margins, Gutters, and Bleed Settings + ├── Build Running Headers & Suppressions + └── Resolve Graphic Positions / Stripping Logic + │ + ▼ +[ PDF/X Render Pipeline ] + ├── Font Embedding (Subsetted TrueType/OpenType) + ├── Output Intent: PDF/X-1a:2001 or PDF/X-4 (CMYK) + └── [ Print-Ready Output PDF File ] +``` + +### Key Validation Checks Prior to Output Build: +- [ ] No orphan headings (`keep_with_next` enforced). +- [ ] No single-line widows/orphans in body text. +- [ ] All front matter pages properly use Roman numerals; body restarts at Arabic `1`. +- [ ] Page count is a multiple of 4 (or padded with clean blank pages at the back). +- [ ] Color profile converted to CMYK / Grayscale (300 DPI for graphics). diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..95eff95 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-docx==1.2.0 +flask==3.1.3 +pypdf==6.19.0 diff --git a/start_umbraformat.py b/start_umbraformat.py new file mode 100644 index 0000000..68d3a3e --- /dev/null +++ b/start_umbraformat.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Start the UmbraFormat web server.""" + +import os, sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from umbraformat.app import app + +if __name__ == "__main__": + print("=" * 60) + print(" UmbraFormat - B.D. Murphy Book Formatting Specification") + print("=" * 60) + print(f" Open: http://localhost:5001") + print(f" API: http://localhost:5001/api/analyze") + print("=" * 60) + app.run(debug=True, host="0.0.0.0", port=5001) diff --git a/tests/test_formatter.py b/tests/test_formatter.py new file mode 100644 index 0000000..1a933b4 --- /dev/null +++ b/tests/test_formatter.py @@ -0,0 +1,914 @@ +"""Unit + integration tests for P1 (geometry/config), P2 (recto pagination), +and P3 (auto-generated front matter). + +Run with: python -m unittest tests.test_formatter (or python -m unittest discover) +""" + +import os +import shutil +import struct +import tempfile +import unittest +import zlib + +from docx import Document +from docx.shared import Inches +from docx.enum.section import WD_SECTION_START +from docx.enum.text import WD_LINE_SPACING, WD_ALIGN_PARAGRAPH +from docx.oxml.ns import qn, nsdecls +from docx.oxml import parse_xml + +from umbraformat.formatter import ( + TRIM_SIZES, + MARGIN_TABLES, + compute_gutter, + format_document, + verify_recto_starts, + pdf_page_count, + pad_pdf_to_multiple, + convert_to_pdf, + _iter_drawings, + _drawing_extent, + _drawing_is_floating, + _floating_to_inline, + _language_code, + _language_strings, + _is_rtl, +) + + +def _make_png(w=10, h=10, rgb=(180, 40, 40)): + """Build a valid RGB PNG of the given pixel size, in pure Python.""" + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)) + ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0) + raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h)) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) + + +def _has_page_field(paragraph): + """True when the paragraph contains a PAGE field instruction.""" + for r in paragraph.runs: + for it in r._r.findall(qn("w:instrText")): + if it.text and "PAGE" in it.text: + return True + return False + + +def _section_paragraphs(doc, idx): + """Paragraphs belonging to section ``idx`` (python-docx 1.2 has no + Section.paragraphs). A paragraph holding a w:sectPr ends its section.""" + from docx.text.paragraph import Paragraph + + result = [] + current = 0 + for child in doc.element.body.iterchildren(): + if child.tag == qn("w:p"): + p = Paragraph(child, doc) + if current == idx: + result.append(p) + pPr = child.find(qn("w:pPr")) + if pPr is not None and pPr.find(qn("w:sectPr")) is not None: + current += 1 + elif child.tag == qn("w:sectPr"): + if current == idx: + current += 1 + return result + + +def _build_manuscript(): + """Build a minimal tagged manuscript DOCX exercising front + body + back matter.""" + tmp = tempfile.mkdtemp(prefix="umbraformat_src_") + path = os.path.join(tmp, "manuscript.docx") + d = Document() + d.add_paragraph("[book start]") + d.add_paragraph("The Test Book") + d.add_paragraph("A Subtitle") + d.add_paragraph("[copyright]") + d.add_paragraph("Copyright 2026 The Test Book. All rights reserved.") + d.add_paragraph("[TOC]") + d.add_paragraph("[chapter]") + d.add_paragraph("Chapter One: The Beginning") + d.add_paragraph( + "It was the best of times in the kingdom of formatting. " + "Every page was justified, every heading kept with next." + ) + d.add_paragraph( + "The second paragraph about bleed, gutters, and mirrored margins must " + "indent on its first line while the paragraph above did not." + ) + d.add_paragraph("[chapter]") + d.add_paragraph("Chapter Two: The End") + d.add_paragraph("And thus the manuscript reached its final chapter at last.") + d.add_paragraph("[back matter]") + d.add_paragraph("Appendix: A lengthy appendix of reference material follows.") + d.add_paragraph("[about author]") + d.add_paragraph("The author lives in a small town and writes at night.") + d.save(path) + return tmp, path + + +class ComputeGutterTests(unittest.TestCase): + def test_umbraformat_table(self): + self.assertEqual(compute_gutter(0, "umbraformat"), 0.50) + self.assertEqual(compute_gutter(149, "umbraformat"), 0.50) + self.assertEqual(compute_gutter(150, "umbraformat"), 0.625) + self.assertEqual(compute_gutter(300, "umbraformat"), 0.625) + self.assertEqual(compute_gutter(301, "umbraformat"), 0.75) + self.assertEqual(compute_gutter(5000, "umbraformat"), 0.75) + + def test_kdp_table(self): + self.assertEqual(compute_gutter(10, "kdp"), 0.375) + self.assertEqual(compute_gutter(150, "kdp"), 0.50) + self.assertEqual(compute_gutter(300, "kdp"), 0.625) + self.assertEqual(compute_gutter(500, "kdp"), 0.75) + self.assertEqual(compute_gutter(700, "kdp"), 0.875) + self.assertEqual(compute_gutter(2000, "kdp"), 0.875) + + def test_unknown_table_falls_back(self): + self.assertEqual(compute_gutter(100, "bogus"), compute_gutter(100, "umbraformat")) + + def test_trim_sizes(self): + self.assertEqual(TRIM_SIZES["5.5x8.5"], (5.5, 8.5)) + self.assertEqual(TRIM_SIZES["6x9"], (6.0, 9.0)) + + +class GeometryTests(unittest.TestCase): + def setUp(self): + self.src_dir, self.src = _build_manuscript() + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_out_") + + def tearDown(self): + for p in (self.src_dir, self.out_dir): + try: + shutil.rmtree(p) + except OSError: + pass + + def _format(self, **kwargs): + out = os.path.join(self.out_dir, "out.docx") + format_document(self.src, out, **kwargs) + return Document(out) + + def test_default_trim_and_margins(self): + d = self._format(inner_margin=0.50) + 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) + + def test_6x9_trim(self): + d = self._format(trim_size="6x9", inner_margin=0.625) + s = d.sections[0] + self.assertAlmostEqual(s.page_width.inches, 6.0, places=3) + self.assertAlmostEqual(s.page_height.inches, 9.0, places=3) + + 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") + + def test_bleed_expands_page_and_margins(self): + d = self._format(inner_margin=0.50, 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.left_margin.inches, 0.625, places=3) + self.assertAlmostEqual(s.right_margin.inches, 0.625, places=3) + + +class RectoPaginationTests(unittest.TestCase): + def setUp(self): + self.src_dir, self.src = _build_manuscript() + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_out_") + + def tearDown(self): + for p in (self.src_dir, self.out_dir): + try: + shutil.rmtree(p) + except OSError: + pass + + def _format(self): + out = os.path.join(self.out_dir, "out.docx") + format_document(self.src, out, inner_margin=0.50) + return out + + def test_major_sections_use_odd_page_breaks(self): + out = self._format() + d = Document(out) + # Section order: 0 half title, 1 blank, 2 title, 3 copyright (nextPage), + # 4 TOC, 5 ch1, 6 ch2, 7 back, 8 about (oddPage) + types = [] + for s in d.sections: + sp = s._sectPr + t = sp.find(qn("w:type")) + # Missing w:type == the OOXML default, "nextPage" + types.append(t.get(qn("w:val")) if t is not None else "nextPage") + # Half title (default), blank, title, copyright alternate single pages + self.assertEqual(types[0], "nextPage") + for sec_type in types[1:4]: + self.assertEqual(sec_type, "nextPage") + # TOC + every chapter + back matter must be oddPage (recto) + for sec_type in types[4:]: + self.assertEqual(sec_type, "oddPage", + f"section must be oddPage, got {sec_type}") + + @unittest.skipUnless(shutil.which("libreoffice") and shutil.which("pdftotext"), + "LibreOffice / poppler required") + def test_rendered_pdf_smoke(self): + """Render the formatted DOCX and sanity-check page placement. + + LibreOffice does not honor oddPage section breaks, so chapter/back-matter + recto starts are validated structurally (oddPage sectPr) and by the + authoritative renderer (Word). This test verifies every marker renders + and that front-matter pages (title=1, TOC) keep deterministic parity. + """ + out = self._format() + markers = [ + "The Test Book", + "Table of Contents", + "Chapter One: The Beginning", + "Chapter Two: The End", + "Appendix: A lengthy appendix", + ] + found = {r["marker"]: r for r in verify_recto_starts(out, markers)} + for marker in markers: + self.assertGreater(found[marker]["page"], 0, f"marker not found: {marker}") + # Title page is deterministic: page 1, recto. + self.assertEqual(found["The Test Book"]["page"], 1) + self.assertTrue(found["The Test Book"]["recto"]) + # Optional: every oddPage-governed marker SHOULD be recto in Word; LO + # ignores oddPage so we only assert presence there (see structural test). + for marker in ("Table of Contents", "Chapter One: The Beginning", + "Chapter Two: The End", "Appendix: A lengthy appendix"): + if not found[marker]["recto"]: + print(f"NOTE: {marker!r} renders on page " + f"{found[marker]['page']} under LibreOffice (oddPage breaks " + "are Word-authoritative)") + + +class FrontMatterTests(unittest.TestCase): + def setUp(self): + self.src_dir, self.src = _build_manuscript() + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_out_") + + def tearDown(self): + for p in (self.src_dir, self.out_dir): + try: + shutil.rmtree(p) + except OSError: + pass + + def _format(self, **kw): + out = os.path.join(self.out_dir, "out.docx") + format_document(self.src, out, inner_margin=0.50, **kw) + self._out = out + return Document(out) + + def test_front_matter_auto_generated(self): + d = self._format(author="B.D. Murphy", imprint="WorstAuthor LLC") + texts = [p.text.strip() for p in d.paragraphs] + self.assertIn("The Test Book", texts) # half title + self.assertIn("A Subtitle", texts) # title page + self.assertIn("B.D. Murphy", texts) # title page author + self.assertIn("WorstAuthor LLC", texts) + self.assertIn("Table of Contents", texts) + self.assertTrue(any(t.startswith("Copyright 2026") for t in texts)) + # No structural tags may leak into the output + self.assertFalse(any(t.startswith("[") and t.endswith("]") for t in texts if t)) + + def test_author_imprint_parameters_used(self): + d = self._format(author="Jane Doe", imprint="Mystic Press") + texts = [p.text for p in d.paragraphs] + self.assertIn("Jane Doe", texts) + self.assertIn("Mystic Press", texts) + + def test_copyright_missing_generates_boilerplate(self): + tmp = tempfile.mkdtemp(prefix="umbraformat_nocop_") + try: + d = Document() + d.add_paragraph("[book start]") + d.add_paragraph("No Copyright Book") + d.add_paragraph("[chapter]") + d.add_paragraph("Only One Chapter") + d.add_paragraph("Body text only here, nothing else.") + 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") + out_d = Document(out) + texts = [p.text for p in out_d.paragraphs] + self.assertTrue(any(t.startswith("Copyright") for t in texts)) + self.assertIn("Jane Doe", texts) + self.assertIn("Mystic Press", texts) + self.assertIn("Table of Contents", texts) + self.assertFalse(any(t.startswith("[") and t.endswith("]") for t in texts if t)) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def test_optional_front_matter_included_when_tagged(self): + tmp = tempfile.mkdtemp(prefix="umbraformat_opt_") + try: + d = Document() + d.add_paragraph("[book start]") + d.add_paragraph("Optional Book") + d.add_paragraph("[dedication]") + d.add_paragraph("To the printers.") + d.add_paragraph("[epigraph]") + d.add_paragraph("It was a dark and stormy night.") + d.add_paragraph("[foreword]") + d.add_paragraph("Foreword by a friend.") + d.add_paragraph("[acknowledgments]") + d.add_paragraph("Thanks to everyone.") + d.add_paragraph("[chapter]") + d.add_paragraph("Chapter One") + d.add_paragraph("The actual story begins here.") + 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") + out_d = Document(out) + texts = [p.text.strip() for p in out_d.paragraphs] + for expected in ("Optional Book", "To the printers.", + "It was a dark and stormy night.", + "Foreword by a friend.", "Thanks to everyone.", + "The actual story begins here."): + self.assertIn(expected, texts) + self.assertFalse(any(t.startswith("[") and t.endswith("]") for t in texts if t)) + # 0 half, 1 blank, 2 title, 3 copyright, 4 dedication, 5 epigraph, + # 6 TOC, 7 foreword, 8 acknowledgments, 9 chapter + self.assertEqual(len(out_d.sections), 10) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +class ChapterOpenerTests(unittest.TestCase): + def setUp(self): + self.src_dir, self.src = _build_manuscript() + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_out_") + + def tearDown(self): + for p in (self.src_dir, self.out_dir): + try: + shutil.rmtree(p) + except OSError: + pass + + def _format(self, **kw): + out = os.path.join(self.out_dir, "out.docx") + format_document(self.src, out, inner_margin=0.50, **kw) + return Document(out) + + def test_chapter_top_drop_sinkage(self): + d = self._format() + # Section 5 == first chapter (after half/blank/title/copyright/toc) + paras = _section_paragraphs(d, 5) + spacer = paras[0] + self.assertEqual(spacer.paragraph_format.line_spacing_rule, + WD_LINE_SPACING.EXACTLY) + self.assertAlmostEqual(spacer.paragraph_format.line_spacing.pt, 126.0, places=0) + # Next paragraph is the WA Chapter heading + self.assertEqual(paras[1].style.name, "WA Chapter") + + def test_first_paragraph_capitalizes_only_first_word(self): + d = self._format() + body = [p for p in _section_paragraphs(d, 5) if p.style.name == "WA NChp Pgr"] + 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) + + 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) + self.assertEqual(p.style.paragraph_format.alignment, + WD_ALIGN_PARAGRAPH.JUSTIFY) + + def test_scene_break_device(self): + tmp = tempfile.mkdtemp(prefix="umbraformat_scene_") + try: + doc = Document() + doc.add_paragraph("[book start]") + doc.add_paragraph("Scene Book") + doc.add_paragraph("[chapter]") + doc.add_paragraph("Chapter One") + doc.add_paragraph("The first words of the tale begin the journey.") + doc.add_paragraph("[section]") + doc.add_paragraph("After the break the story continues onward.") + 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) + 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) + # No literal tag leaks + self.assertFalse(any(p.text.strip() == "[section]" for p in d.paragraphs)) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +class RunningHeaderTests(unittest.TestCase): + def setUp(self): + self.src_dir, self.src = _build_manuscript() + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_out_") + + def tearDown(self): + for p in (self.src_dir, self.out_dir): + try: + shutil.rmtree(p) + except OSError: + pass + + def _format(self, **kw): + out = os.path.join(self.out_dir, "out.docx") + format_document(self.src, out, inner_margin=0.50, + author="B.D. Murphy", imprint="WorstAuthor LLC", **kw) + return Document(out) + + def test_chapter_running_headers(self): + d = self._format() + ch_sec = d.sections[5] # Chapter One + # Recto (odd) header: chapter title only (spec 4.1, no page field) + odd = ch_sec.header + self.assertFalse(odd.is_linked_to_previous) + odd_text = odd.paragraphs[0].text + self.assertIn("Chapter One: The Beginning", odd_text) + self.assertFalse(_has_page_field(odd.paragraphs[0])) + # Verso (even) header: author name + book title (spec 4.1) + even = ch_sec.even_page_header + self.assertFalse(even.is_linked_to_previous) + self.assertFalse(_has_page_field(even.paragraphs[0])) + self.assertIn("B.D. Murphy", even.paragraphs[0].text) + self.assertIn("The Test Book", even.paragraphs[0].text) + # Page numbers live in the centered footers (spec 4.2) + self.assertTrue(_has_page_field(ch_sec.footer.paragraphs[0])) + self.assertEqual(ch_sec.footer.paragraphs[0].alignment, + WD_ALIGN_PARAGRAPH.CENTER) + self.assertTrue(_has_page_field(ch_sec.even_page_footer.paragraphs[0])) + self.assertEqual(ch_sec.even_page_footer.paragraphs[0].alignment, + WD_ALIGN_PARAGRAPH.CENTER) + # First-page suppression still holds + self.assertEqual(ch_sec.first_page_header.paragraphs[0].text.strip(), "") + self.assertEqual(ch_sec.first_page_footer.paragraphs[0].text.strip(), "") + + def test_front_matter_visible_running_header(self): + d = self._format() + toc_sec = d.sections[4] + # Headers carry titles only (spec 4.1) + self.assertIn("Contents", toc_sec.header.paragraphs[0].text) + self.assertFalse(_has_page_field(toc_sec.header.paragraphs[0])) + # Page number is centered in the footer (spec 4.2) + self.assertTrue(_has_page_field(toc_sec.footer.paragraphs[0])) + self.assertEqual(toc_sec.footer.paragraphs[0].alignment, + WD_ALIGN_PARAGRAPH.CENTER) + + def test_first_page_headers_suppressed(self): + d = self._format() + # First-page header/footer suppressed on major sections (toc, chapters) + for idx in (4, 5, 6): # toc, ch1, ch2 + first = d.sections[idx].first_page_header + self.assertFalse(first.is_linked_to_previous) + self.assertEqual(first.paragraphs[0].text.strip(), "") + # Chapters suppress the first-page footer (spec 4.3)... + for idx in (5, 6): + first_f = d.sections[idx].first_page_footer + self.assertFalse(first_f.is_linked_to_previous) + self.assertEqual(first_f.paragraphs[0].text.strip(), "") + # ...but the TOC opening page shows its (Roman) page number. + self.assertTrue(_has_page_field(d.sections[4].first_page_footer.paragraphs[0])) + + def test_suppressed_sections_have_no_page_numbers(self): + d = self._format() + # Half title, blank, title: fully suppressed (no page fields anywhere) + for idx in (0, 1, 2): + sec = d.sections[idx] + for footer in (sec.footer, sec.even_page_footer, + sec.first_page_footer): + self.assertFalse(_has_page_field(footer.paragraphs[0])) + for header in (sec.header, sec.even_page_header, + sec.first_page_header): + self.assertFalse(_has_page_field(header.paragraphs[0])) + + def test_roman_numbering_visible_from_copyright(self): + d = self._format() + for idx in (3, 4): # copyright, toc + pr = d.sections[idx]._sectPr + self.assertEqual(pr.find(qn("w:pgNumType")).get(qn("w:fmt")), + "lowerRoman") + # Copyright + TOC show their Roman page number on the opening page + # too (centered footer, all header/footer variants). + for idx in (3, 4): + sec = d.sections[idx] + for footer in (sec.footer, sec.even_page_footer, + sec.first_page_footer): + self.assertTrue(_has_page_field(footer.paragraphs[0])) + self.assertEqual(footer.paragraphs[0].alignment, + WD_ALIGN_PARAGRAPH.CENTER) + # Copyright keeps suppressed running heads (display page before TOC) + self.assertEqual(d.sections[3].header.paragraphs[0].text.strip(), "") + self.assertEqual(d.sections[3].even_page_header.paragraphs[0].text.strip(), "") + + def test_pagenum_schemes(self): + d = self._format() + toc_pr = d.sections[4]._sectPr + fmt = toc_pr.find(qn("w:pgNumType")).get(qn("w:fmt")) + self.assertEqual(fmt, "lowerRoman") + ch_pr = d.sections[5]._sectPr + pg = ch_pr.find(qn("w:pgNumType")) + self.assertEqual(pg.get(qn("w:fmt")), "decimal") + self.assertEqual(pg.get(qn("w:start")), "1") + # 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): + d = self._format() + self.assertIsNotNone(d.settings.element.find(qn("w:autoHyphenation"))) + + +class WidowOrphanTests(unittest.TestCase): + def setUp(self): + self.src_dir, self.src = _build_manuscript() + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_out_") + + def tearDown(self): + for p in (self.src_dir, self.out_dir): + try: + shutil.rmtree(p) + except OSError: + pass + + def _format(self, **kw): + out = os.path.join(self.out_dir, "out.docx") + format_document(self.src, out, inner_margin=0.50, **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") + + def test_body_styles_widow_control(self): + d = self._format() + for name in ("WA Nrml Pgr", "WA NChp Pgr"): + 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): + 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) + + def test_styles_xml_contains_controls(self): + d = self._format() + for name, tags in ( + ("WA Chapter", ("w:keepNext", "w:keepLines")), + ("WA Nrml Pgr", ("w:widowControl",)), + ("WA NChp Pgr", ("w:widowControl",)), + ): + xml = d.styles[name].element.xml + for tag in tags: + self.assertIn(tag, xml, f"{name} missing {tag}") + + +class FooterModeTests(unittest.TestCase): + """The selectable header/footer mode is obsolete per the updated spec: + page numbers always render centered in the footers (spec 4.2) while + running heads carry titles only (spec 4.1).""" + + def setUp(self): + self.src_dir, self.src = _build_manuscript() + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_out_") + + def tearDown(self): + for p in (self.src_dir, self.out_dir): + try: + shutil.rmtree(p) + except OSError: + pass + + def _format(self, mode=None): + kw = {"inner_margin": 0.50, "author": "B.D. Murphy", + "imprint": "WorstAuthor LLC"} + if mode is not None: + kw["footer_mode"] = mode + out = os.path.join(self.out_dir, "out.docx") + format_document(self.src, out, **kw) + return Document(out) + + def test_page_numbers_always_centered_in_footer(self): + for mode in (None, "headers", "footer"): + d = self._format(mode) + ch = d.sections[5] + # Footers carry the page number (recto + verso), centered + self.assertTrue(_has_page_field(ch.footer.paragraphs[0])) + self.assertEqual(ch.footer.paragraphs[0].alignment, + WD_ALIGN_PARAGRAPH.CENTER) + self.assertTrue(_has_page_field(ch.even_page_footer.paragraphs[0])) + self.assertEqual(ch.even_page_footer.paragraphs[0].alignment, + WD_ALIGN_PARAGRAPH.CENTER) + # Headers keep titles but no page fields + self.assertFalse(_has_page_field(ch.header.paragraphs[0])) + self.assertIn("Chapter One: The Beginning", + ch.header.paragraphs[0].text) + self.assertFalse(_has_page_field(ch.even_page_header.paragraphs[0])) + # First-page suppression still holds + self.assertEqual(ch.first_page_footer.paragraphs[0].text.strip(), "") + self.assertEqual(ch.first_page_header.paragraphs[0].text.strip(), "") + + def test_legacy_footer_mode_values_still_accepted(self): + # 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) + d = Document(out) + self.assertTrue(_has_page_field(d.sections[5].footer.paragraphs[0])) + + +class PdfPaddingTests(unittest.TestCase): + def setUp(self): + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_pdf_") + + def tearDown(self): + shutil.rmtree(self.out_dir, ignore_errors=True) + + def _make_pdf(self, pages, path): + from pypdf import PdfWriter + w = PdfWriter() + for _ in range(pages): + w.add_blank_page(width=396.0, height=612.0) # 5.5 x 8.5 in + with open(path, "wb") as fh: + w.write(fh) + return path + + def test_page_count(self): + p = self._make_pdf(7, os.path.join(self.out_dir, "seven.pdf")) + self.assertEqual(pdf_page_count(p), 7) + + def test_pad_adds_blank_pages(self): + p = self._make_pdf(5, os.path.join(self.out_dir, "five.pdf")) + added = pad_pdf_to_multiple(p, 4) + self.assertEqual(added, 3) + self.assertEqual(pdf_page_count(p), 8) + self.assertEqual(pdf_page_count(p) % 4, 0) + + def test_pad_noop_when_already_multiple(self): + p = self._make_pdf(8, os.path.join(self.out_dir, "eight.pdf")) + self.assertEqual(pad_pdf_to_multiple(p, 4), 0) + self.assertEqual(pdf_page_count(p), 8) + + def test_padded_pages_match_trim_size(self): + from pypdf import PdfReader + p = self._make_pdf(6, os.path.join(self.out_dir, "six.pdf")) + pad_pdf_to_multiple(p, 4) + reader = PdfReader(p) + self.assertEqual(len(reader.pages), 8) + last = reader.pages[-1] + self.assertAlmostEqual(float(last.mediabox.width), 396.0, places=0) + self.assertAlmostEqual(float(last.mediabox.height), 612.0, places=0) + + @unittest.skipUnless(convert_to_pdf.__module__, "renderer required") + def test_convert_to_pdf_pads(self): + src_dir, src = _build_manuscript() + try: + out = os.path.join(self.out_dir, "pad.docx") + format_document(src, out, inner_margin=0.50) + pdf = convert_to_pdf(out, os.path.join(self.out_dir, "pad.pdf"), + pad_multiple_of_4=True) + if pdf is None: + self.skipTest("LibreOffice unavailable") + count = pdf_page_count(pdf) + self.assertGreater(count, 0) + self.assertEqual(count % 4, 0) + finally: + shutil.rmtree(src_dir, ignore_errors=True) + + +class GraphicsTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="umbraformat_gfx_") + self.png = os.path.join(self.tmp, "img.png") + with open(self.png, "wb") as fh: + fh.write(_make_png()) + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_gfxout_") + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + shutil.rmtree(self.out_dir, ignore_errors=True) + + def _manuscript(self, image_before_title=False, width_in=9.0): + path = os.path.join(self.tmp, "gfx.docx") + d = Document() + d.add_paragraph("[book start]") + d.add_paragraph("Graphic Book") + d.add_paragraph("[chapter]") + if image_before_title: + d.add_paragraph().add_run().add_picture( + self.png, width=Inches(1), height=Inches(1)) + d.add_paragraph("Chapter One") + d.add_paragraph("First words of the chapter begin the narrative here.") + img_p = d.add_paragraph() + img_p.add_run().add_picture(self.png, width=Inches(width_in), + height=Inches(width_in / 3.0)) + d.add_paragraph("After the image the story resumes with more text.") + d.add_paragraph("[section]") + d.add_paragraph("A second scene follows the break.") + d.save(path) + return path + + def _format(self, src, **kw): + out = os.path.join(self.out_dir, "gfx_out.docx") + format_document(src, out, inner_margin=0.50, **kw) + return Document(out) + + def _drawings(self, doc): + return [dr for p in doc.paragraphs for dr in _iter_drawings(p)] + + def test_body_image_preserved_and_inline(self): + d = self._format(self._manuscript()) + drawings = self._drawings(d) + self.assertEqual(len(drawings), 1) + self.assertFalse(_drawing_is_floating(drawings[0])) + self.assertIsNotNone(drawings[0].find(qn("wp:inline"))) + + def test_oversized_image_scaled_to_text_box(self): + 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))) + # aspect ratio preserved (3:1) + self.assertAlmostEqual(cx / cy, 3.0, places=2) + + def test_strip_problematic_removes_overflow(self): + d = self._format(self._manuscript(width_in=9.0), + strip_problematic_graphics=True) + self.assertEqual(self._drawings(d), []) + + def test_strip_graphics_drops_images_and_uses_asterisks(self): + d = self._format(self._manuscript(), strip_graphics=True) + self.assertEqual(self._drawings(d), []) + texts = [p.text.strip() for p in d.paragraphs] + self.assertIn("***", texts) + self.assertNotIn("\u2022 \u2022 \u2022", texts) + + def test_chapter_header_icon_rendered_above_heading(self): + d = self._format(self._manuscript(image_before_title=True)) + paras = _section_paragraphs(d, 5) # first chapter section + kinds = [] + for p in paras: + if p.style.name == "WA Chapter": + kinds.append("heading") + elif list(_iter_drawings(p)): + kinds.append("image") + self.assertIn("image", kinds) + self.assertIn("heading", kinds) + self.assertLess(kinds.index("image"), kinds.index("heading")) + + def test_floating_anchor_converted_to_inline(self): + xml = ( + '' + '' + '' + '0' + '0' + '' + '' + '' + '' + '' + '' + '' + ) % nsdecls("w", "wp", "a") + drawing = parse_xml(xml) + self.assertTrue(_drawing_is_floating(drawing)) + self.assertTrue(_floating_to_inline(drawing)) + self.assertFalse(_drawing_is_floating(drawing)) + self.assertIsNotNone(drawing.find(qn("wp:inline"))) + # positioning-only elements are dropped, content is kept + self.assertIsNone(drawing.find(".//" + qn("wp:positionH"))) + self.assertIsNotNone(drawing.find(".//" + qn("wp:extent"))) + + +class LocalizationTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="umbraformat_i18n_") + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _manuscript(self): + path = os.path.join(self.tmp, "i18n.docx") + d = Document() + d.add_paragraph("[book start]") + d.add_paragraph("Libro de Prueba") + d.add_paragraph("[foreword]") + d.add_paragraph("Una nota preliminar del autor.") + d.add_paragraph("[chapter]") + d.add_paragraph("Capítulo Uno") + d.add_paragraph("El primer párrafo del capítulo comienza aquí.") + d.add_paragraph("[back matter]") + d.add_paragraph("Apéndice con material de referencia.") + d.save(path) + return path + + def _format(self, language, name="out.docx"): + out = os.path.join(self.tmp, name) + format_document(self._manuscript(), out, inner_margin=0.50, + author="Ana Ruiz", imprint="Editorial Sol", + language=language) + return Document(out) + + def test_language_code_normalization(self): + self.assertEqual(_language_code("es-MX"), "es") + self.assertEqual(_language_code("en_US"), "en") + self.assertEqual(_language_code(""), "en") + self.assertEqual(_language_code(None), "en") + + def test_unknown_language_falls_back_to_english(self): + self.assertEqual(_language_strings("xx"), _language_strings("en")) + + def test_rtl_detection(self): + self.assertTrue(_is_rtl("ar")) + self.assertTrue(_is_rtl("he-IL")) + self.assertFalse(_is_rtl("es")) + self.assertFalse(_is_rtl("en")) + + def test_spanish_headings_localized(self): + d = self._format("es") + texts = [p.text.strip() for p in d.paragraphs] + self.assertIn("Índice", texts) # TOC title + self.assertIn("Prólogo", texts) # foreword heading + self.assertNotIn("Table of Contents", texts) + + def test_language_tag_set_in_styles(self): + d = self._format("es") + xml = d.styles.element.xml + self.assertIn("w:lang", xml) + self.assertIn('w:val="es"', xml) + + def test_rtl_document_defaults_and_gutter(self): + d = self._format("ar") + styles_xml = d.styles.element.xml + self.assertIn("w:bidi", styles_xml) + self.assertIn("w:rtl", styles_xml) + for sec in d.sections: + self.assertIsNotNone(sec._sectPr.find(qn("w:rtlGutter"))) + + def test_non_rtl_has_no_rtl_gutter(self): + d = self._format("de") + for sec in d.sections: + self.assertIsNone(sec._sectPr.find(qn("w:rtlGutter"))) + + def test_localized_back_matter_running_header(self): + d = self._format("es") + # Sections: half/blank/title/copyright/toc/foreword/chapter/back matter + self.assertIn("Apéndice", d.sections[7].header.paragraphs[0].text) + + +class EndToEndTests(unittest.TestCase): + def setUp(self): + self.src_dir, self.src = _build_manuscript() + self.out_dir = tempfile.mkdtemp(prefix="umbraformat_out_") + + def tearDown(self): + for p in (self.src_dir, self.out_dir): + try: + shutil.rmtree(p) + except OSError: + pass + + 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) + self.assertTrue(os.path.exists(path)) + self.assertTrue(os.path.getsize(path) > 0) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/umbraformat/__init__.py b/umbraformat/__init__.py new file mode 100644 index 0000000..6849410 --- /dev/null +++ b/umbraformat/__init__.py @@ -0,0 +1 @@ +__version__ = "1.1.0" diff --git a/umbraformat/analyzer.py b/umbraformat/analyzer.py new file mode 100644 index 0000000..28657cf --- /dev/null +++ b/umbraformat/analyzer.py @@ -0,0 +1,339 @@ +"""Analyze a DOCX file and compare its formatting against the B.D. Murphy spec.""" + +from docx import Document +from docx.shared import Inches, Pt, Emu +from docx.oxml.ns import qn +from collections import Counter + + +SPEC = { + "page": {"width": 5.5, "height": 8.5, "orientation": "PORTRAIT"}, + "margins": {"top": 0.35, "bottom": 0.25, "inside": 0.55, "outside": 0.40}, + "mirror_margins": True, + "different_first_page": True, + "header_distance": 0.50, + "footer_distance": 0.50, + "styles": { + "WA Nrml Pgr": { + "font": "Caladea", + "size": 12, + "line_spacing": 15, + "line_spacing_rule": "EXACTLY", + "first_line_indent": 0.30, + }, + "WA NChp Pgr": { + "font": "Caladea", + "size": 12, + "line_spacing": 14, + "line_spacing_rule": "EXACTLY", + }, + "WA Chapter": { + "font": "Caladea", + "size": 18, + "alignment": "CENTER", + "line_spacing": 4.0, + "line_spacing_rule": "MULTIPLE", + }, + "WA Section": { + "font": "Georgia", + "size": 16, + "alignment": "CENTER", + "space_before": 6, + "space_after": 12, + "line_spacing": 1.5, + "line_spacing_rule": "ONE_POINT_FIVE", + }, + "WA Scene Break": { + "font": "Georgia", + "size": 14, + "alignment": "CENTER", + "space_before": 12, + "space_after": 14, + "line_spacing": 15, + "line_spacing_rule": "EXACTLY", + }, + }, + "character_styles": { + "WA Thkng": { + "font": "Caladea", + "size": 12, + "italic": True, + }, + }, +} + + +def emu_to_in(emu): + if emu is None: + return None + return round(emu / 914400, 4) + + +def emu_to_pt(emu): + if emu is None: + return None + return round(emu / 12700, 1) + + +def analyze_document(filepath): + doc = Document(filepath) + results = { + "page_setup": {}, + "margins": {}, + "sections": [], + "styles_found": {}, + "styles_missing": [], + "paragraph_count": 0, + "paragraphs_by_style": {}, + "issues": [], + "warnings": [], + } + + # --- Page Setup --- + s0 = doc.sections[0] + results["page_setup"] = { + "width": round(s0.page_width / 914400, 2), + "height": round(s0.page_height / 914400, 2), + "orientation": "LANDSCAPE" if s0.page_width > s0.page_height else "PORTRAIT", + } + + if abs(results["page_setup"]["width"] - SPEC["page"]["width"]) > 0.05: + results["issues"].append( + f"Page width: {results['page_setup']['width']}in (expected {SPEC['page']['width']}in)" + ) + if abs(results["page_setup"]["height"] - SPEC["page"]["height"]) > 0.05: + results["issues"].append( + f"Page height: {results['page_setup']['height']}in (expected {SPEC['page']['height']}in)" + ) + + # --- Sections & Margins --- + for i, s in enumerate(doc.sections): + sp = s._sectPr + mirror = sp.find(qn("w:mirrorMargins")) is not None + title_pg = sp.find(qn("w:titlePg")) is not None + sec_info = { + "index": i, + "top": emu_to_in(s.top_margin), + "bottom": emu_to_in(s.bottom_margin), + "left": emu_to_in(s.left_margin), + "right": emu_to_in(s.right_margin), + "mirror_margins": mirror, + "different_first_page": title_pg, + "header_distance": emu_to_in( + s.header_distance if s.header_distance else 0 + ), + "footer_distance": emu_to_in( + s.footer_distance if s.footer_distance else 0 + ), + } + # Check pgNumType + pgNumType = sp.find(qn("w:pgNumType")) + if pgNumType is not None: + sec_info["page_number_fmt"] = pgNumType.get(qn("w:fmt")) + sec_info["page_number_start"] = pgNumType.get(qn("w:start")) + results["sections"].append(sec_info) + + if i == 0: + results["margins"] = { + "top": sec_info["top"], + "bottom": sec_info["bottom"], + "inside": sec_info["left"], + "outside": sec_info["right"], + "mirror_margins": mirror, + } + + # Check margin compliance on section 0 + for k, expected in SPEC["margins"].items(): + actual = results["margins"].get(k) + if k == "mirror_margins": + if actual != expected: + results["issues"].append(f"Mirror margins: {actual} (expected {expected})") + elif actual is not None and abs(actual - expected) > 0.02: + results["issues"].append( + f"Margin '{k}': {actual}in (expected {expected}in)" + ) + + # Check all sections have consistent margins + first_margins = ( + results["sections"][0]["top"], + results["sections"][0]["bottom"], + ) + for sec in results["sections"][1:]: + if (sec["top"], sec["bottom"]) != first_margins: + results["warnings"].append( + f"Section {sec['index']}: top/bottom margins " + f"({sec['top']}/{sec['bottom']}) differ from section 0 " + f"({first_margins[0]}/{first_margins[1]})" + ) + + # --- Styles Analysis --- + style_names_seen = set() + style_counts = Counter() + for p in doc.paragraphs: + if p.style: + style_counts[p.style.name] += 1 + style_names_seen.add(p.style.name) + results["paragraph_count"] = sum(style_counts.values()) + results["paragraphs_by_style"] = dict(style_counts.most_common()) + + # --- Word Count for Page Estimation --- + in_chapter_area = False + chapter_word_count = 0 + has_book_start = False + has_chapter = False + for p in doc.paragraphs: + text = p.text.strip() + if text == "[book start]": + has_book_start = True + if text == "[chapter]": + has_chapter = True + in_chapter_area = True + continue + if text in ["[back matter]", "[Req review]", "[about author]"]: + in_chapter_area = False + continue + if in_chapter_area and text: + chapter_word_count += len(text.split()) + results["chapter_word_count"] = chapter_word_count + + if not has_book_start: + results["issues"].append("Missing [book start] tag — document cannot be formatted") + if not has_chapter: + results["issues"].append("Missing [chapter] tag — document cannot be formatted") + + # Name mapping: spec names -> expected actual names in documents + STYLE_NAME_MAP = { + "WA Nrml Pgr": ["WA Nrml Pgr", "WA Normal", "Wrst Normal - Body Text", "#Wrst Normal - Body Text", "CSP - Front Matter Body Text"], + "WA NChp Pgr": ["WA NChp Pgr", "WA Npr", "Wrst Normal - Chapter Body Text", "CSP - Chapter Body Text - First Paragraph"], + "WA Chapter": ["WA Chapter", "WA Chap", "Worst Chapter", "Wrst Chapter - Chapter Title + (Latin) Garamond"], + "WA Section": ["WA Section", "WA Sectn", "Wst Sectn"], + "WA Scene Break": ["WA Scene Break", "Worst Scene Break", "Wxrst Scene Break"], + } + + # Check each spec style + for spec_name, spec_props in SPEC["styles"].items(): + aliases = STYLE_NAME_MAP.get(spec_name, [spec_name]) + matching_styles = [sn for sn in style_names_seen if sn in aliases] + if not matching_styles: + for alias in aliases: + try: + doc.styles[alias] + matching_styles.append(alias) + break + except KeyError: + pass + if not matching_styles: + results["styles_missing"].append(spec_name) + results["issues"].append(f"Style '{spec_name}': NOT FOUND") + continue + + for actual_name in matching_styles: + try: + st = doc.styles[actual_name] + except KeyError: + continue + pf = st.paragraph_format + font = st.font + info = { + "name": actual_name, + "font": font.name, + "size_pt": emu_to_pt(font.size) if font.size else None, + "bold": font.bold, + "italic": font.italic, + "alignment": str(pf.alignment) if pf.alignment else None, + "first_line_indent": emu_to_in(pf.first_line_indent), + "line_spacing": pf.line_spacing, + "space_before": emu_to_pt(pf.space_before), + "space_after": emu_to_pt(pf.space_after), + "left_indent": emu_to_in(pf.left_indent), + } + + # Compare + issues = [] + if spec_props.get("font") and (info["font"] or "").lower() != spec_props["font"].lower(): + issues.append( + f" font={info['font']} (expected {spec_props['font']})" + ) + if spec_props.get("size") and info["size_pt"] and abs(info["size_pt"] - spec_props["size"]) > 0.5: + issues.append( + f" size={info['size_pt']}pt (expected {spec_props['size']}pt)" + ) + fi = info.get("first_line_indent") + sfi = spec_props.get("first_line_indent") + if sfi is not None and fi is not None and abs(fi - sfi) > 0.01: + issues.append( + f" first_line_indent={fi}in (expected {sfi}in)" + ) + ls = info.get("line_spacing") + sls = spec_props.get("line_spacing") + if sls is not None and ls is not None: + ls_pt = emu_to_pt(ls) if ls and ls > 100 else ls + if abs(ls_pt - sls) > 0.1: + issues.append(f" line_spacing={ls_pt}pt (expected {sls}pt)") + + results["styles_found"][actual_name] = { + "properties": info, + "issues": issues, + "count": style_counts.get(actual_name, 0), + } + if issues: + results["warnings"].append( + f"Style '{actual_name}': {'; '.join(issues)}" + ) + + # Check character styles + for spec_name, spec_props in SPEC["character_styles"].items(): + found = False + for st in doc.styles: + if st.name == spec_name and st.type == 2: + found = True + font = st.font + c_issues = [] + if (font.name or "").lower() != spec_props["font"].lower(): + c_issues.append(f"font={font.name} (expected {spec_props['font']})") + if font.italic != spec_props.get("italic"): + c_issues.append(f"italic={font.italic} (expected {spec_props.get('italic')})") + if c_issues: + results["warnings"].append(f"Char style '{spec_name}': {'; '.join(c_issues)}") + break + if not found: + results["styles_missing"].append(f"{spec_name} (character)") + results["issues"].append(f"Character style '{spec_name}': NOT FOUND") + + # --- Headers --- + for i, s in enumerate(doc.sections[:1]): + sp = s._sectPr + for hr in sp.findall(qn("w:headerReference")): + htype = hr.get(qn("w:type")) + rid = hr.get(qn("r:id")) + try: + part = doc.part.related_parts[rid] + texts = [] + for p in part.element.findall(qn("w:p")): + for t in p.findall(".//" + qn("w:t")): + if t.text: + texts.append(t.text) + content = " ".join(texts).strip() + if htype == "default" and not content: + results["warnings"].append("Default (odd) header is empty") + elif htype == "even" and not content: + results["warnings"].append("Even header is empty") + except (KeyError, AttributeError): + pass + + # --- Footers --- + for i, s in enumerate(doc.sections[:1]): + has_page_field = False + for hr in s._sectPr.findall(qn("w:footerReference")): + rid = hr.get(qn("r:id")) + try: + part = doc.part.related_parts[rid] + body_xml = part.element.xml if hasattr(part.element, 'xml') else str(part.element) + if "PAGE" in body_xml: + has_page_field = True + except (KeyError, AttributeError): + pass + if not has_page_field: + results["warnings"].append("No PAGE field found in footer") + + return results diff --git a/umbraformat/app.py b/umbraformat/app.py new file mode 100644 index 0000000..7742cd2 --- /dev/null +++ b/umbraformat/app.py @@ -0,0 +1,220 @@ +"""UmbraFormat Flask web application.""" + +import os, re, uuid, json +from flask import ( + Flask, + render_template, + request, + jsonify, + send_file, + url_for, + abort, +) + +from umbraformat import __version__ +from umbraformat.analyzer import analyze_document +from umbraformat.formatter import format_document, convert_to_pdf + +app = Flask(__name__) +app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 +app.config["UPLOAD_FOLDER"] = os.path.join(os.path.dirname(__file__), "uploads") +app.config["FORMATTED_FOLDER"] = os.path.join(os.path.dirname(__file__), "formatted") +app.config["VERSION"] = __version__ + +os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) +os.makedirs(app.config["FORMATTED_FOLDER"], exist_ok=True) + +ALLOWED_EXTENSIONS = {".docx"} + + +def allowed_file(filename): + return os.path.splitext(filename.lower())[1] in ALLOWED_EXTENSIONS + + +def _analyze(upload_id): + """Run analysis on an uploaded file, return results dict.""" + docx_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}.docx") + analysis_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}_analysis.json") + + if os.path.exists(analysis_path): + with open(analysis_path) as f: + return json.load(f) + + results = analyze_document(docx_path) + results["upload_id"] = upload_id + with open(analysis_path, "w") as f: + json.dump(results, f, indent=2, default=str) + return results + + +# --- Routes --- + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/format") +def format_page(): + return render_template("format.html") + + +@app.route("/api/analyze", methods=["POST"]) +def api_analyze(): + if "file" not in request.files: + return jsonify({"error": "No file provided"}), 400 + + f = request.files["file"] + if not f.filename: + return jsonify({"error": "Empty filename"}), 400 + + if not allowed_file(f.filename): + return jsonify({"error": "Only .docx files are supported"}), 400 + + upload_id = str(uuid.uuid4()) + ext = os.path.splitext(f.filename)[1] + save_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}{ext}") + f.save(save_path) + + try: + results = _analyze(upload_id) + results["original_filename"] = f.filename + analysis_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}_analysis.json") + with open(analysis_path, "w") as af: + json.dump(results, af, indent=2, default=str) + return jsonify(results) + except Exception as e: + return jsonify({"error": f"Analysis failed: {str(e)}"}), 500 + + +@app.route("/api/format", methods=["POST"]) +def api_format(): + data = request.get_json() + if not data or "upload_id" not in data: + return jsonify({"error": "No upload_id provided"}), 400 + + upload_id = data["upload_id"] + docx_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}.docx") + + if not os.path.exists(docx_path): + return jsonify({"error": "File not found"}), 404 + + output_filename = f"{upload_id}_formatted.docx" + output_path = os.path.join(app.config["FORMATTED_FOLDER"], output_filename) + + # Extract formatting options + font_name = data.get("font", "Caladea") + font_size = int(data.get("fontSize", 12)) + inner_margin = float(data.get("innerMargin", 0.500)) + trim_size = data.get("trimSize", "5.5x8.5") + bleed = float(data.get("bleed", 0.0)) + author = data.get("author", "") + imprint = data.get("imprint", "") + pad_multiple_of_4 = bool(data.get("padMultipleOf4", True)) + strip_graphics = bool(data.get("stripGraphics", False)) + strip_problematic_graphics = bool(data.get("stripProblematicGraphics", False)) + language = data.get("language", "en") + + try: + format_document( + docx_path, output_path, + font_name=font_name, font_size=font_size, inner_margin=inner_margin, + trim_size=trim_size, bleed=bleed, author=author, imprint=imprint, + strip_graphics=strip_graphics, + strip_problematic_graphics=strip_problematic_graphics, + language=language, + ) + # Read original filename from analysis cache and record it + analysis_path = os.path.join(app.config["UPLOAD_FOLDER"], f"{upload_id}_analysis.json") + orig_filename = "formatted_manuscript.docx" + if os.path.exists(analysis_path): + with open(analysis_path) as af: + ad = json.load(af) + orig_filename = ad.get("original_filename", orig_filename) + orig_stem = os.path.splitext(orig_filename)[0] + name_path = os.path.splitext(output_path)[0] + ".name" + with open(name_path, "w") as nf: + nf.write(f"{orig_stem}_formatted.docx") + opts_path = os.path.splitext(output_path)[0] + ".opts.json" + with open(opts_path, "w") as of: + json.dump({"padMultipleOf4": pad_multiple_of_4}, of) + return jsonify( + { + "success": True, + "download_docx": url_for("api_download", filename=output_filename), + "download_pdf": url_for("api_download_pdf", filename=output_filename), + "formatted_filename": output_filename, + } + ) + except Exception as e: + return jsonify({"error": f"Formatting failed: {str(e)}"}), 500 + + +def _get_original_name(filename): + """Read the original filename stored alongside a formatted file.""" + name_path = os.path.join(app.config["FORMATTED_FOLDER"], os.path.splitext(filename)[0] + ".name") + if os.path.exists(name_path): + with open(name_path) as f: + return f.read().strip() + return None + + +@app.route("/api/download/") +def api_download(filename): + filepath = os.path.join(app.config["FORMATTED_FOLDER"], filename) + if not os.path.exists(filepath): + abort(404) + orig = _get_original_name(filename) or "formatted_manuscript.docx" + return send_file(filepath, as_attachment=True, download_name=orig) + + +@app.route("/api/download//pdf") +def api_download_pdf(filename): + docx_path = os.path.join(app.config["FORMATTED_FOLDER"], filename) + if not os.path.exists(docx_path): + abort(404) + + pdf_filename = os.path.splitext(filename)[0] + ".pdf" + pdf_path = os.path.join(app.config["FORMATTED_FOLDER"], pdf_filename) + + # Respect the print-prep option chosen when the document was formatted. + opts_path = os.path.join(app.config["FORMATTED_FOLDER"], + os.path.splitext(filename)[0] + ".opts.json") + pad_multiple_of_4 = True + if os.path.exists(opts_path): + try: + with open(opts_path) as of: + pad_multiple_of_4 = bool(json.load(of).get("padMultipleOf4", True)) + except (OSError, ValueError): + pass + + if not os.path.exists(pdf_path): + result = convert_to_pdf(docx_path, pdf_path, + pad_multiple_of_4=pad_multiple_of_4) + if result is None: + return jsonify({"error": "PDF conversion failed. LibreOffice may not be available."}), 500 + + orig = _get_original_name(filename) + dl_name = os.path.splitext(orig)[0] + ".pdf" if orig else "formatted_manuscript.pdf" + return send_file(pdf_path, as_attachment=True, download_name=dl_name) + + +@app.route("/api/diag/") +def api_diag(name): + # Sanitize: only allow alphanumeric, hyphens, and underscores to prevent path traversal + if not re.match(r'^[A-Za-z0-9_-]+$', name): + abort(400) + filepath = os.path.join(app.config["UPLOAD_FOLDER"], f"diag_{name}.docx") + if not os.path.exists(filepath): + abort(404) + return send_file(filepath, as_attachment=True, download_name=f"{name}.docx") + + +@app.route("/api/version") +def api_version(): + return jsonify({"version": __version__}) + + +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0", port=5001) diff --git a/umbraformat/formatter.py b/umbraformat/formatter.py new file mode 100644 index 0000000..cc4a2b8 --- /dev/null +++ b/umbraformat/formatter.py @@ -0,0 +1,2059 @@ +"""Generate formatted PDF from tagged DOCX input.""" + +import io, os, shutil, subprocess, tempfile +from copy import deepcopy + +from docx import Document +from docx.shared import Inches, Pt, Emu, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING +from docx.enum.section import WD_SECTION_START +from docx.oxml.ns import qn, nsdecls +from docx.oxml import parse_xml, OxmlElement +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.text.run import Run + +TAGS = { + "book_start": "[book start]", + "chapter": "[chapter]", + "font_start": "[font]", + "font_end": "[/font]", + "back_matter": "[back matter]", + "copyright": "[copyright]", + "toc": "[TOC]", + "req_review": "[Req review]", + "about_author": "[about author]", + "section": "[section]", + "dedication": "[dedication]", + "epigraph": "[epigraph]", + "foreword": "[foreword]", + "acknowledgments": "[acknowledgments]", +} + +VERBATIM_TAGS = {"back_matter", "req_review", "about_author"} + +# P4 chapter-opener constants +SCENE_BREAK_ORNAMENT = "\u2022 \u2022 \u2022" # "• • •", present in every Caladea glyph set +CHAPTER_TOP_DROP_PT = 126.0 # ~1.75" sinkage below the top margin +SMALL_CAPS_LEADIN_WORDS = 4 # first N words of a chapter set in small caps +BODY_FIRST_LINE_INDENT = 0.22 # inches, per spec 0.18"-0.25" + +# P9 graphic rules (spec section 5) +SCENE_BREAK_FALLBACK = "***" # spec 5.2 Option B: asterisks replace ornaments +GRAPHIC_OVERFLOW_MARGIN_IN = 0.5 # spec 5.1.3: boundary tolerance (informational) + +# P10 internationalization (spec section 6) +DEFAULT_LANGUAGE = "en" +RTL_LANGUAGES = {"ar", "he", "fa", "ur", "yi"} +LANGUAGE_STRINGS = { + "en": { + "toc": "Table of Contents", "contents": "Contents", "foreword": "Foreword", + "acknowledgments": "Acknowledgments", "appendix": "Appendix", + "review": "Review", "about_author": "About the Author", + "rights": ("All rights reserved. No part of this publication may be " + "reproduced, distributed, or transmitted in any form or by any " + "means without the prior written permission of the publisher."), + "fiction": ("This is a work of fiction. Names, characters, places, and " + "incidents are products of the author's imagination or are used " + "fictitiously."), + "published_by": "Published by {imprint}.", + }, + "es": { + "toc": "Índice", "contents": "Índice", "foreword": "Prólogo", + "acknowledgments": "Agradecimientos", "appendix": "Apéndice", + "review": "Reseña", "about_author": "Sobre el autor", + "rights": ("Todos los derechos reservados. Ninguna parte de esta publicación " + "puede reproducirse, distribuirse o transmitirse por ningún medio " + "sin la autorización previa por escrito del editor."), + "fiction": ("Esta es una obra de ficción. Los nombres, personajes, lugares e " + "incidentes son producto de la imaginación del autor o se usan de " + "forma ficticia."), + "published_by": "Publicado por {imprint}.", + }, + "de": { + "toc": "Inhaltsverzeichnis", "contents": "Inhalt", "foreword": "Vorwort", + "acknowledgments": "Danksagung", "appendix": "Anhang", + "review": "Rezension", "about_author": "Über den Autor", + "rights": ("Alle Rechte vorbehalten. Kein Teil dieser Veröffentlichung darf " + "ohne vorherige schriftliche Genehmigung des Verlags reproduziert, " + "verbreitet oder übertragen werden."), + "fiction": ("Dies ist ein fiktionales Werk. Namen, Figuren, Orte und " + "Ereignisse sind Produkte der Fantasie des Autors oder werden " + "fiktiv verwendet."), + "published_by": "Veröffentlicht von {imprint}.", + }, + "fr": { + "toc": "Table des matières", "contents": "Sommaire", "foreword": "Préface", + "acknowledgments": "Remerciements", "appendix": "Annexe", + "review": "Critique", "about_author": "À propos de l'auteur", + "rights": ("Tous droits réservés. Aucune partie de cette publication ne peut " + "être reproduite, distribuée ou transmise sous quelque forme que ce " + "soit sans l'autorisation écrite préalable de l'éditeur."), + "fiction": ("Ceci est une œuvre de fiction. Les noms, personnages, lieux et " + "événements sont le produit de l'imagination de l'auteur ou sont " + "utilisés de manière fictive."), + "published_by": "Publié par {imprint}.", + }, + "ar": { + "toc": "جدول المحتويات", "contents": "المحتويات", "foreword": "تقديم", + "acknowledgments": "شكر وتقدير", "appendix": "الملحق", + "review": "مراجعة", "about_author": "عن المؤلف", + "rights": ("جميع الحقوق محفوظة. لا يجوز إعادة إنتاج أي جزء من هذا المنشور أو " + "توزيعه أو نقله بأي شكل دون إذن كتابي مسبق من الناشر."), + "fiction": ("هذا عمل خيالي. الأسماء والشخصيات والأماكن والأحداث من نسج خيال " + "المؤلف أو تُستخدم بشكل خيالي."), + "published_by": "نشر بواسطة {imprint}.", + }, + "he": { + "toc": "תוכן העניינים", "contents": "תוכן", "foreword": "הקדמה", + "acknowledgments": "תודות", "appendix": "נספח", + "review": "סקירה", "about_author": "אודות המחבר", + "rights": ("כל הזכויות שמורות. אין לשכפל, להפיץ או להעביר חלק כלשהו מפרסום זה " + "בכל צורה שהיא ללא אישור בכתב מראש מהמוציא לאור."), + "fiction": ("זהו יצירה בדיונית. השמות, הדמויות, המקומות והאירועים הם פרי " + "דמיונו של המחבר או משמשים באופן בדיוני."), + "published_by": "הוצאה לאור: {imprint}.", + }, +} + + +def _language_code(language): + """Normalize a BCP-47/ISO language tag to its base code (e.g. 'es-MX'→'es').""" + return (language or DEFAULT_LANGUAGE).replace("_", "-").split("-")[0].lower() + + +def _language_strings(language): + """Return the localized string table for a language (English fallback).""" + return LANGUAGE_STRINGS.get(_language_code(language), LANGUAGE_STRINGS[DEFAULT_LANGUAGE]) + + +def _is_rtl(language): + """True for right-to-left languages (spec section 6.2).""" + return _language_code(language) in RTL_LANGUAGES + + +def _apply_language_defaults(doc, language, rtl=False): + """Tag the document default language so Word picks the right proofing and + hyphenation dictionary (spec section 6.1). For RTL languages also set the + bidirectional paragraph/run defaults (spec section 6.2).""" + styles_el = doc.styles.element + doc_defaults = styles_el.find(qn("w:docDefaults")) + if doc_defaults is None: + doc_defaults = OxmlElement("w:docDefaults") + styles_el.insert(0, doc_defaults) + + rpr_default = doc_defaults.find(qn("w:rPrDefault")) + if rpr_default is None: + rpr_default = OxmlElement("w:rPrDefault") + doc_defaults.insert(0, rpr_default) + rpr = rpr_default.find(qn("w:rPr")) + if rpr is None: + rpr = OxmlElement("w:rPr") + rpr_default.append(rpr) + lang = rpr.find(qn("w:lang")) + if lang is None: + lang = OxmlElement("w:lang") + rpr.append(lang) + lang.set(qn("w:val"), language) + if rtl and rpr.find(qn("w:rtl")) is None: + rtl_el = OxmlElement("w:rtl") + # w:rtl precedes w:lang in the rPr schema sequence. + rpr.insert(rpr.index(lang), rtl_el) + + if rtl: + ppr_default = doc_defaults.find(qn("w:pPrDefault")) + if ppr_default is None: + ppr_default = OxmlElement("w:pPrDefault") + doc_defaults.append(ppr_default) + ppr = ppr_default.find(qn("w:pPr")) + if ppr is None: + ppr = OxmlElement("w:pPr") + ppr_default.append(ppr) + if ppr.find(qn("w:bidi")) is None: + ppr.append(OxmlElement("w:bidi")) + + + +TRIM_SIZES = { + "5.5x8.5": (5.5, 8.5), + "6x9": (6.0, 9.0), +} + +MARGIN_TABLES = { + "umbraformat": ( + (0, 150, 0.50), + (150, 301, 0.625), + (301, float("inf"), 0.75), + ), + "kdp": ( + (0, 24, 0.375), + (24, 150, 0.375), + (150, 300, 0.50), + (300, 500, 0.625), + (500, 700, 0.75), + (700, float("inf"), 0.875), + ), +} + + +def compute_gutter(page_count, margin_table="umbraformat"): + """Return the recommended inside/gutter margin (inches) for a page count. + + ``margin_table`` selects which published recommendation table applies: + "umbraformat" (the format spec: 0.50 / 0.625 / 0.75) or "kdp" + (KDP print guidance: 0.375 .. 0.875). Falls back to the top bucket of + the selected table for out-of-range counts. + """ + rows = MARGIN_TABLES.get(margin_table, MARGIN_TABLES["umbraformat"]) + for lo, hi, margin in rows: + if lo <= page_count < hi: + return margin + return rows[-1][2] + +WA_STYLES = { + "WA Chapter": { + "type": "paragraph", + "base": "Heading 1", + "font": "Caladea", + "size": Pt(18), + "alignment": WD_ALIGN_PARAGRAPH.CENTER, + "line_spacing": 4.0, + "line_spacing_rule": WD_LINE_SPACING.MULTIPLE, + "space_before": Pt(0), + "space_after": Pt(0), + "keep_with_next": True, + "keep_together": True, + }, + "WA Nrml Pgr": { + "type": "paragraph", + "base": "Normal", + "font": "Caladea", + "size": Pt(12), + "alignment": WD_ALIGN_PARAGRAPH.JUSTIFY, + "first_line_indent": Inches(BODY_FIRST_LINE_INDENT), + "line_spacing": Emu(190500), + "line_spacing_rule": WD_LINE_SPACING.EXACTLY, + "space_before": Pt(0), + "space_after": Pt(0), + "widow_control": True, + }, + "WA NChp Pgr": { + "type": "paragraph", + "base": "Normal", + "font": "Caladea", + "size": Pt(12), + "alignment": WD_ALIGN_PARAGRAPH.JUSTIFY, + "first_line_indent": Inches(0), + "line_spacing": Emu(177800), + "line_spacing_rule": WD_LINE_SPACING.EXACTLY, + "space_before": Pt(0), + "space_after": Pt(0), + "widow_control": True, + }, + "WA Section": { + "type": "paragraph", + "base": "Normal", + "font": "Georgia", + "size": Pt(16), + "alignment": WD_ALIGN_PARAGRAPH.CENTER, + "line_spacing": 1.5, + "line_spacing_rule": WD_LINE_SPACING.ONE_POINT_FIVE, + "space_before": Pt(6), + "space_after": Pt(12), + "keep_with_next": True, + "keep_together": True, + "widow_control": True, + }, + "WA Thkng": { + "type": "character", + "font": "Caladea", + "size": Pt(12), + "italic": True, + }, +} + + +def _create_wa_styles(doc): + """Create WA styles in the output document.""" + from docx.enum.style import WD_STYLE_TYPE + + for name, props in WA_STYLES.items(): + try: + style = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH if props["type"] == "paragraph" else WD_STYLE_TYPE.CHARACTER) + except ValueError: + style = doc.styles[name] + + # Set base style if specified (e.g., Heading 1 for TOC support) + if "base" in props: + try: + style.base_style = doc.styles[props["base"]] + except KeyError: + pass + + if "font" in props and style.font: + style.font.name = props["font"] + if "size" in props and style.font: + style.font.size = props["size"] + if "italic" in props and style.font: + style.font.italic = props["italic"] + if "bold" in props and style.font: + style.font.bold = props["bold"] + + if props["type"] == "paragraph" and style.paragraph_format: + pf = style.paragraph_format + if "alignment" in props: + pf.alignment = props["alignment"] + if "first_line_indent" in props: + pf.first_line_indent = props["first_line_indent"] + if "line_spacing" in props: + pf.line_spacing = props["line_spacing"] + if "line_spacing_rule" in props: + pf.line_spacing_rule = props["line_spacing_rule"] + if "space_before" in props: + pf.space_before = props["space_before"] + if "space_after" in props: + pf.space_after = props["space_after"] + if "keep_with_next" in props: + pf.keep_with_next = props["keep_with_next"] + if "keep_together" in props: + pf.keep_together = props["keep_together"] + if "widow_control" in props: + pf.widow_control = props["widow_control"] + + +def _find_tags(doc): + """Walk paragraphs and locate structural tags. + Returns dict with all found tag positions.""" + tags = {"chapters": []} + for i, p in enumerate(doc.paragraphs): + text = p.text.strip() + if text == TAGS["book_start"]: + tags["book_start"] = i + elif text == TAGS["chapter"]: + tags["chapters"].append(i) + elif text == TAGS["back_matter"]: + tags["back_matter"] = i + elif text == TAGS["copyright"]: + tags["copyright"] = i + elif text == TAGS["toc"]: + tags["toc"] = i + elif text == TAGS["req_review"]: + tags["req_review"] = i + elif text == TAGS["about_author"]: + tags["about_author"] = i + elif text == TAGS["dedication"]: + tags["dedication"] = i + elif text == TAGS["epigraph"]: + tags["epigraph"] = i + elif text == TAGS["foreword"]: + tags["foreword"] = i + elif text == TAGS["acknowledgments"]: + tags["acknowledgments"] = i + return tags + + +def _extract_title_page(doc, start_idx, end_idx, default_author="", default_imprint=""): + """Extract title, subtitle, author, imprint from title page paragraphs. + Returns (title, subtitle, author, imprint, image_paras) where image_paras + is a list of source paragraphs containing drawings. + Author/imprint are taken from explicit "Author: ..." / "Imprint: ..." or + "By ..." lines when present, otherwise the caller-provided defaults are used.""" + import re as _re + + title = subtitle = author = imprint = "" + image_paras = [] + paras = [] + for i in range(start_idx, end_idx): + p = doc.paragraphs[i] + text = p.text.strip() + # Collect paragraphs that contain drawings + has_drawing = any( + r._r.find(qn('w:drawing')) is not None + for r in p.runs + ) + if has_drawing: + image_paras.append(p) + if not text: + continue + # Skip structural tag lines entirely (e.g., [copyright], [TOC]) + if text.startswith("[") and text.endswith("]"): + continue + paras.append((i, text, p)) + + structured = [] + for i, text, _p in paras: + lm = _re.match(r"^author\s*:\s*(.+)$", text, _re.I) + pm = _re.match(r"^(?:imprint|publisher)\s*:\s*(.+)$", text, _re.I) + if lm and not author: + author = lm.group(1).strip() + elif pm and not imprint: + imprint = pm.group(1).strip() + else: + structured.append((i, text, _p)) + + if not author: + for i, text, _p in structured: + bm = _re.match(r"^by\s+(.+)$", text, _re.I) + if bm: + author = bm.group(1).strip() + structured.remove((i, text, _p)) + break + + if len(structured) >= 1: + title = structured[0][1] + if len(structured) >= 2: + subtitle = structured[1][1] + author = author or default_author + imprint = imprint or default_imprint + return title, subtitle, author, imprint, image_paras + + + +def _extract_chapters(doc, chapter_indices): + """Extract chapter content using [chapter] tags and section breaks. + Returns list of dicts with 'title' and 'paras'.""" + chapters = [] + cur_title = "" + cur_paras = [] + font_mode = False + chapter_tag_set = set(chapter_indices) + + def _flush(): + nonlocal cur_title, cur_paras + icon = None + if cur_paras and cur_paras[0]["type"] == "icon": + icon = cur_paras.pop(0) + if cur_title or cur_paras: + chapters.append({"title": cur_title, "paras": cur_paras, + "icon": icon}) + cur_title = "" + cur_paras = [] + + start_idx = chapter_indices[0] if chapter_indices else 0 + for i in range(start_idx + 1, len(doc.paragraphs)): + p = doc.paragraphs[i] + text = p.text.strip() + + # Check for verbatim section tags → stop chapter extraction + if text in (TAGS[t] for t in VERBATIM_TAGS): + _flush() + break + + # Check for explicit [chapter] tag — flush current chapter, do NOT + # append the tag itself (structural marker, not content) + if text == TAGS["chapter"]: + _flush() + continue + + # Check for [font] blocks — tags are structural, not content + if text == TAGS["font_start"]: + font_mode = True + continue + if text == TAGS["font_end"]: + font_mode = False + continue + if font_mode: + if not cur_title and not cur_paras: + cur_paras.append({"type": "font", "para": p}) + else: + cur_paras.append({"type": "font", "para": p}) + continue + + # Check for section break → implicit chapter boundary + pPr = p._p.find(qn("w:pPr")) + has_sect_break = pPr is not None and pPr.find(qn("w:sectPr")) is not None + if has_sect_break: + if cur_title: + # Content on the break paragraph belongs to current chapter + if text: + cur_paras.append({"type": "text", "para": p, "text": text}) + _flush() + continue + + # Skip [book start] tag if encountered in body + if text == TAGS["book_start"]: + continue + + # Image-bearing paragraphs are content even when they carry no text. + # An image before any chapter text is treated as a chapter header icon + # (spec section 5.1.1) and rendered above the heading. + if _paragraph_has_image(p): + if not cur_title and not cur_paras: + cur_paras.append({"type": "icon", "para": p, "text": text}) + else: + cur_paras.append({"type": "image", "para": p, "text": text}) + continue + + if not cur_title: + if text: + cur_title = text + elif text: + cur_paras.append({"type": "text", "para": p, "text": text}) + + _flush() + return chapters + + +def _extract_between_tags(doc, start_idx, end_idx=None): + """Extract paragraphs from start_idx to end_idx (exclusive).""" + blocks = [] + for i in range(start_idx, end_idx if end_idx is not None else len(doc.paragraphs)): + p = doc.paragraphs[i] + blocks.append(p) + return blocks + + +def _tag_positions(doc, tags): + """Flatten every structural-tag paragraph index into one sorted list.""" + positions = [] + for name, idx in tags.items(): + if name == "chapters": + positions.extend(idx) + elif isinstance(idx, int): + positions.append(idx) + return sorted(positions) + + +def _content_after_tag(doc, tags, tagname): + """Return the paragraphs following ``tagname`` up to the next structural tag.""" + if tagname not in tags: + return [] + start = tags[tagname] + after = [p for p in _tag_positions(doc, tags) if p > start] + end = after[0] if after else len(doc.paragraphs) + return _extract_between_tags(doc, start + 1, end) + + +def _add_centered_spacer(out, pt_size, count=1): + """Consecutive empty lines of a fixed exact line height (vertical spacing).""" + for _ in range(count): + p = out.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf = p.paragraph_format + pf.line_spacing = Pt(pt_size) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + + +def _build_half_title_page(out, title): + """Half title — book title only, centered, small caps-like modest size.""" + _add_centered_spacer(out, 36, 6) + p = out.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf = p.paragraph_format + pf.line_spacing = Pt(24) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + run = p.add_run(title) + run.font.name = "Caladea" + run.font.size = Pt(24) + + +def _build_title_page(out, title, subtitle, author, imprint): + """Full title page — vertically centered title block, author below.""" + # Fit the block within the 5.5x8.5 text area (504pt usable), centered-ish. + _add_centered_spacer(out, 42, 3) + p = out.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf = p.paragraph_format + pf.line_spacing = Pt(42) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + run = p.add_run(title) + run.font.name = "Caladea" + run.font.size = Pt(42) + if subtitle: + _add_centered_spacer(out, 12, 1) + p = out.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf = p.paragraph_format + pf.line_spacing = Pt(18) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + run = p.add_run(subtitle) + run.font.name = "Caladea" + run.font.size = Pt(18) + _add_centered_spacer(out, 14, 7) + if author: + p = out.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf = p.paragraph_format + pf.line_spacing = Pt(32) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + run = p.add_run(author) + run.font.name = "Caladea" + run.font.size = Pt(30) + if imprint: + p = out.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf = p.paragraph_format + pf.line_spacing = Pt(16) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(8) + pf.space_after = Pt(0) + run = p.add_run(imprint) + run.font.name = "Caladea" + run.font.size = Pt(13) + + +def _default_copyright_text(author, imprint, year=None, strings=None): + """Standard copyright-page boilerplate used when [copyright] is absent.""" + import datetime + year = year or datetime.date.today().year + s = strings or LANGUAGE_STRINGS[DEFAULT_LANGUAGE] + return [ + f"Copyright \u00a9 {year} by {author}.", + s["rights"], + "", + s["fiction"], + "", + s["published_by"].format(imprint=imprint), + ] + + +def _copy_copyright_paragraphs(out, src_paras, src_doc): + """Render copyright text small (9pt), flush-left, no indentation.""" + for src_p in src_paras: + new_p = out.add_paragraph() + pf = new_p.paragraph_format + pf.alignment = WD_ALIGN_PARAGRAPH.LEFT + pf.line_spacing = Pt(12) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + for r in src_p.runs: + if not r.text: + continue + nr = new_p.add_run(r.text) + nr.font.name = "Caladea" + nr.font.size = Pt(9) + nr.font.bold = r.font.bold + nr.font.italic = r.font.italic + return new_p + + +def _build_copyright_page(out, src_paras, src_doc, author, imprint, strings=None): + """Copyright page at the base of the verso facing the title page. + The section's vAlign=bottom (applied in Pass 2) grounds this block.""" + if src_paras: + for p in src_paras: + _copy_copyright_paragraphs(out, [p], src_doc) + else: + text = _default_copyright_text(author, imprint, strings=strings) + for line in text: + p = out.add_paragraph() + pf = p.paragraph_format + pf.alignment = WD_ALIGN_PARAGRAPH.LEFT + pf.line_spacing = Pt(12) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + if not line: + continue + run = p.add_run(line) + run.font.name = "Caladea" + run.font.size = Pt(9) + + +def _build_centered_quote_section(out, paras): + """Centered, restrained front matter (dedication / epigraph) lines.""" + for src_p in paras: + text = src_p.text.strip() + if not text: + _add_centered_spacer(out, 12, 1) + continue + p = out.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf = p.paragraph_format + pf.line_spacing = Pt(18) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + run = p.add_run(text) + run.font.name = "Caladea" + run.font.size = Pt(18) + run.font.italic = True + + +def _build_toc(out, chapters, tab_pos_twips, heading="Table of Contents"): + """Emit the TOC heading and one dot-leader + PAGEREF entry per titled chapter. + Returns {chapter_index: paragraph} for bookmark back-fill.""" + toc_entry_paras = {} + add_p = out.add_paragraph() + add_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf_toc = add_p.paragraph_format + pf_toc.space_before = Pt(0) + pf_toc.space_after = Pt(18) + pf_toc.line_spacing = Pt(18) + pf_toc.line_spacing_rule = WD_LINE_SPACING.EXACTLY + run = add_p.add_run(heading) + run.font.name = "Caladea" + run.font.size = Pt(20) + + for ci, ch in enumerate(chapters): + if not ch["title"]: + continue + bm_name = f"_UF_ch_{ci}" + new_p = out.add_paragraph() + pf = new_p.paragraph_format + pf.alignment = WD_ALIGN_PARAGRAPH.LEFT + pf.line_spacing = Pt(15) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + + # Right tab stop with dot leader at the inner text edge + pPr = new_p._p.find(qn('w:pPr')) + if pPr is None: + pPr = OxmlElement('w:pPr') + new_p._p.insert(0, pPr) + tabs_el = OxmlElement('w:tabs') + tab_stop = OxmlElement('w:tab') + tab_stop.set(qn('w:val'), 'right') + tab_stop.set(qn('w:pos'), str(tab_pos_twips)) + tab_stop.set(qn('w:leader'), 'dot') + tabs_el.append(tab_stop) + pPr.append(tabs_el) + + # Chapter label (left-aligned) + r_title = new_p.add_run(f"{ci + 1}. {ch['title']}") + r_title.font.name = "Caladea" + r_title.font.size = Pt(11) + + # Tab character to jump to the dot-leader tab stop + r_tab = new_p.add_run() + r_tab.font.name = "Caladea" + r_tab.font.size = Pt(11) + tab_el = OxmlElement('w:tab') + r_tab._r.append(tab_el) + + # PAGEREF field pointing to the chapter bookmark + _add_pageref_field(new_p, bm_name, font_name="Caladea", font_size=Pt(11)) + + toc_entry_paras[ci] = new_p + return toc_entry_paras + + +def _build_prose_section(out, paras, font_name, font_size): + """Body-styled prose for front matter sections (foreword / acknowledgments).""" + for src_p in paras: + text = src_p.text.strip() + if not text: + continue + new_p = out.add_paragraph(style="WA Nrml Pgr") + run = new_p.add_run(text) + run.font.name = font_name + run.font.size = Pt(font_size) + + +def _is_scene_break_marker(item, text): + """True when the paragraph is an explicit scene-break device (literal + '<<<>>>', or a paragraph styled as a scene break) rather than prose.""" + if text and text in ("<<<>>>", "[section]"): + return True + style = getattr(item.get("para"), "style", None) + name = getattr(style, "name", "") or "" + return bool(name) and "Scene Break" in name + + +def _copy_paragraph_verbatim(out, src_p, src_doc): + """Copy a paragraph from input to output. + Enforces Caladea 12pt, 15pt exact line spacing. + Preserves alignment, indentation, empty paragraphs, and images (via blob copy).""" + new_p = out.add_paragraph() + has_content = False + for r in src_p.runs: + # Copy images via run.add_picture (creates proper relationships) + for inline in r._r.findall(qn('w:drawing')): + blips = inline.findall('.//' + qn('a:blip')) + for blip in blips: + embed = blip.get(qn('r:embed')) + if embed: + try: + img_part = src_doc.part.related_parts[embed] + extents = inline.findall('.//' + qn('wp:extent')) + cx = int(extents[0].get('cx')) if extents else None + cy = int(extents[0].get('cy')) if extents else None + stream = io.BytesIO(img_part.blob) + r2 = new_p.add_run() + r2.add_picture(stream, width=Emu(cx) if cx else None, + height=Emu(cy) if cy else None) + has_content = True + except Exception: + pass + # Copy text runs with enforced formatting + if r.text: + new_r = new_p.add_run(r.text) + new_r.font.name = "Caladea" + new_r.font.size = Pt(12) + new_r.font.bold = r.font.bold + new_r.font.italic = r.font.italic + if r.font.color.rgb: + new_r.font.color.rgb = r.font.color.rgb + has_content = True + # Copy paragraph formatting: enforce line spacing, preserve everything else + pf = new_p.paragraph_format + spf = src_p.paragraph_format + pf.alignment = spf.alignment + pf.line_spacing = Pt(15) + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + if spf.first_line_indent is not None: + pf.first_line_indent = spf.first_line_indent + return new_p + + +def _add_bookmark(para, bookmark_id, bookmark_name): + """Add a bookmark spanning the entire paragraph.""" + pPr = para._p.find(qn('w:pPr')) + if pPr is not None: + idx = list(para._p).index(pPr) + 1 + else: + idx = 0 + bm_start = OxmlElement('w:bookmarkStart') + bm_start.set(qn('w:id'), str(bookmark_id)) + bm_start.set(qn('w:name'), bookmark_name) + para._p.insert(idx, bm_start) + bm_end = OxmlElement('w:bookmarkEnd') + bm_end.set(qn('w:id'), str(bookmark_id)) + para._p.append(bm_end) + + +def _add_pageref_field(para, bookmark_name, font_name="Caladea", font_size=Pt(11)): + """Add a PAGEREF field (page number of bookmark) to the paragraph. + Each field char is in its own run per OOXML spec (Word requires this). + The begin run carries w:dirty so Word knows to recalculate the field.""" + r1 = para.add_run() + r1.font.name = font_name + r1.font.size = font_size + fld1 = OxmlElement("w:fldChar") + fld1.set(qn("w:fldCharType"), "begin") + r1._r.append(fld1) + # Mark field as dirty so Word recalculates it on open + rpr = r1._r.find(qn('w:rPr')) + if rpr is None: + rpr = OxmlElement('w:rPr') + r1._r.insert(0, rpr) + dirty = OxmlElement('w:dirty') + dirty.set(qn('w:val'), '1') + rpr.append(dirty) + + r2 = para.add_run() + r2.font.name = font_name + r2.font.size = font_size + instr = OxmlElement("w:instrText") + instr.set(qn("xml:space"), "preserve") + instr.text = f" PAGEREF {bookmark_name} " + r2._r.append(instr) + + r3 = para.add_run() + r3.font.name = font_name + r3.font.size = font_size + sep = OxmlElement("w:fldChar") + sep.set(qn("w:fldCharType"), "separate") + r3._r.append(sep) + + r4 = para.add_run() + r4.font.name = font_name + r4.font.size = font_size + placeholder = OxmlElement("w:t") + placeholder.text = "1" + r4._r.append(placeholder) + + r5 = para.add_run() + r5.font.name = font_name + r5.font.size = font_size + fld2 = OxmlElement("w:fldChar") + fld2.set(qn("w:fldCharType"), "end") + r5._r.append(fld2) + + +def _transfer_image_paragraph(out, src_p, src_doc): + """Copy a paragraph with images, preserving fixed (anchored) positioning. + Deep-copies the drawing XML and remaps r:embed relationship IDs.""" + new_p = out.add_paragraph() + pf = new_p.paragraph_format + spf = src_p.paragraph_format + pf.alignment = spf.alignment + pf.space_before = Pt(0) + pf.space_after = Pt(0) + + old_to_new = {} + + for r in src_p.runs: + text = r.text + drawing = r._r.find(qn('w:drawing')) + + if text: + new_r = new_p.add_run(text) + _copy_run_format(r, new_r) + + if drawing is not None: + for blip in drawing.findall('.//' + qn('a:blip')): + embed = blip.get(qn('r:embed')) + if embed and embed not in old_to_new: + try: + img_part = src_doc.part.related_parts[embed] + stream = io.BytesIO(img_part.blob) + image_part = out.part.package.get_or_add_image_part(stream) + new_rId = out.part.relate_to(image_part, RT.IMAGE) + old_to_new[embed] = new_rId + except Exception: + pass + + drawing_copy = deepcopy(drawing) + for blip in drawing_copy.findall('.//' + qn('a:blip')): + embed = blip.get(qn('r:embed')) + if embed in old_to_new: + blip.set(qn('r:embed'), old_to_new[embed]) + + new_r = new_p.add_run() + new_r._r.append(drawing_copy) + + return new_p + + +def _iter_drawings(paragraph): + """Yield the w:drawing elements found in a paragraph's runs.""" + for r in paragraph.runs: + drawing = r._r.find(qn("w:drawing")) + if drawing is not None: + yield drawing + + +def _paragraph_has_image(paragraph): + """True when the paragraph contains at least one drawing.""" + for _ in _iter_drawings(paragraph): + return True + return False + + +def _drawing_extent(drawing): + """Return (cx, cy) EMU dimensions of a drawing, or (None, None).""" + ext = drawing.find(".//" + qn("wp:extent")) + if ext is None: + return None, None + try: + return int(ext.get("cx")), int(ext.get("cy")) + except (TypeError, ValueError): + return None, None + + +def _set_drawing_extent(drawing, cx, cy): + ext = drawing.find(".//" + qn("wp:extent")) + if ext is not None: + ext.set("cx", str(int(cx))) + ext.set("cy", str(int(cy))) + + +def _drawing_is_floating(drawing): + """True when the drawing is anchored (floating) rather than inline.""" + return drawing.find(qn("wp:anchor")) is not None + + +def _drawing_overflows(drawing, max_width_emu, max_height_emu=None): + """True when the drawing is larger than the printable text box.""" + cx, cy = _drawing_extent(drawing) + if not cx or not cy: + return False + if max_width_emu and cx > max_width_emu: + return True + if max_height_emu and cy > max_height_emu: + return True + return False + + +def _floating_to_inline(drawing): + """Re-anchor a floating (wp:anchor) drawing as an inline (wp:inline) block + in the text flow (spec section 5.2, Option A default). Returns True when a + conversion was performed.""" + anchor = drawing.find(qn("wp:anchor")) + if anchor is None: + return False + inline = OxmlElement("wp:inline") + for attr in ("distT", "distB", "distL", "distR"): + inline.set(attr, anchor.get(attr, "0")) + positioning_only = { + qn("wp:simplePos"), qn("wp:positionH"), qn("wp:positionV"), + qn("wp:wrapNone"), qn("wp:wrapSquare"), qn("wp:wrapTight"), + qn("wp:wrapThrough"), qn("wp:wrapTopAndBottom"), + qn("wp:relativeHeight"), + } + for child in list(anchor): + if child.tag in positioning_only: + continue + inline.append(child) + drawing.replace(anchor, inline) + return True + + +def _clamp_drawing_to_box(drawing, max_width_emu, max_height_emu=None): + """Scale a drawing down so it fits the text box, keeping aspect ratio + (the "100% scale lock" of spec section 5.1.1). Returns True if scaled.""" + cx, cy = _drawing_extent(drawing) + if not cx or not cy: + return False + scale = 1.0 + if max_width_emu and cx > max_width_emu: + scale = min(scale, max_width_emu / cx) + if max_height_emu and cy > max_height_emu: + scale = min(scale, max_height_emu / cy) + if scale < 1.0: + _set_drawing_extent(drawing, cx * scale, cy * scale) + return True + return False + + +def _add_body_image(out, src_p, src_doc, text_width_emu, text_height_emu, + strip_graphics=False, strip_problematic=False, + font_name="Caladea", font_size=Pt(12)): + """Transfer a source paragraph's drawing into the output as a centered, + inline body image (spec section 5). + + * Floating anchors are re-anchored inline (Option A default). + * Images wider/taller than the text box are scaled to fit (aspect locked). + * With ``strip_problematic`` set, overflowing images are removed instead. + * With ``strip_graphics`` set, decorative images are dropped entirely. + Returns the created paragraph, or None if nothing was emitted. + """ + if strip_graphics: + return None + new_p = _transfer_image_paragraph(out, src_p, src_doc) + if new_p is None: + return None + pf = new_p.paragraph_format + pf.space_before = Pt(6) + pf.space_after = Pt(6) + pf.alignment = WD_ALIGN_PARAGRAPH.CENTER + for run in new_p.runs: + if run.font.name is None: + run.font.name = font_name + if run.font.size is None: + run.font.size = font_size + + for drawing in list(_iter_drawings(new_p)): + if strip_problematic and _drawing_overflows(drawing, text_width_emu, + text_height_emu): + drawing.getparent().remove(drawing) + continue + _floating_to_inline(drawing) + _clamp_drawing_to_box(drawing, text_width_emu, text_height_emu) + return new_p + + +def _add_side_by_side_images(out, image_paras, doc, trim_width=5.5, outside_margin=0.50): + """Extract images from title page paragraphs and add them side by side + in a single paragraph - left image at left, right image at right.""" + blobs = [] + for src_p in image_paras: + for r in src_p.runs: + drawing = r._r.find(qn('w:drawing')) + if drawing is None: + continue + for blip in drawing.findall('.//' + qn('a:blip')): + embed = blip.get(qn('r:embed')) + if embed: + try: + img_part = doc.part.related_parts[embed] + extents = drawing.findall('.//' + qn('wp:extent')) + cx = int(extents[0].get('cx')) if extents else None + cy = int(extents[0].get('cy')) if extents else None + blobs.append({ + 'blob': img_part.blob, + 'width': Emu(cx) if cx else None, + 'height': Emu(cy) if cy else None, + }) + except Exception: + pass + + if len(blobs) < 2: + for img in blobs: + p = out.add_paragraph() + pf = p.paragraph_format + pf.space_before = Pt(0) + pf.space_after = Pt(0) + pf.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run() + stream = io.BytesIO(img['blob']) + run.add_picture(stream, width=img['width'], height=img['height']) + return + + p = out.add_paragraph() + pf = p.paragraph_format + pf.space_before = Pt(0) + pf.space_after = Pt(0) + pf.alignment = WD_ALIGN_PARAGRAPH.LEFT + + tab_pos_twips = int((trim_width - 0.55 - outside_margin) * 1440) + pPr = p._p.find(qn('w:pPr')) + if pPr is None: + pPr = OxmlElement('w:pPr') + p._p.insert(0, pPr) + tabs_elem = OxmlElement('w:tabs') + tab_stop = OxmlElement('w:tab') + tab_stop.set(qn('w:val'), 'right') + tab_stop.set(qn('w:pos'), str(tab_pos_twips)) + tabs_elem.append(tab_stop) + pPr.append(tabs_elem) + + run = p.add_run() + stream = io.BytesIO(blobs[0]['blob']) + run.add_picture(stream, width=blobs[0]['width'], height=blobs[0]['height']) + run.add_tab() + run = p.add_run() + stream = io.BytesIO(blobs[1]['blob']) + run.add_picture(stream, width=blobs[1]['width'], height=blobs[1]['height']) + + +def _ensure_pgNumType(sect_pr, fmt, start=None): + existing = sect_pr.find(qn("w:pgNumType")) + if existing is not None: + sect_pr.remove(existing) + pgNumType = OxmlElement("w:pgNumType") + pgNumType.set(qn("w:fmt"), fmt) + if start is not None: + pgNumType.set(qn("w:start"), str(start)) + sect_pr.append(pgNumType) + + +def _set_section_v_align_bottom(sect_pr): + """Vertically align a section's text block at the bottom of the page + (used so the copyright block sits at the base of the verso page).""" + vAlign = sect_pr.find(qn("w:vAlign")) + if vAlign is None: + vAlign = OxmlElement("w:vAlign") + sect_pr.append(vAlign) + vAlign.set(qn("w:val"), "bottom") + + + + +def _insert_page_field(para, font_name, font_size, letter_spacing_twips=0): + """Insert a PAGE field into the given paragraph (no alignment change). + Runs must follow: begin -> instrText -> separate -> placeholder -> end, + each field char in its own run (Word requires this).""" + def _field_run(char_type): + r = para.add_run() + r.font.name = font_name + r.font.size = font_size + _set_run_spacing(r, letter_spacing_twips) + fld = OxmlElement("w:fldChar") + fld.set(qn("w:fldCharType"), char_type) + r._r.append(fld) + + _field_run("begin") + + instr = para.add_run() + instr.font.name = font_name + instr.font.size = font_size + _set_run_spacing(instr, letter_spacing_twips) + instrText = OxmlElement("w:instrText") + instrText.set(qn("xml:space"), "preserve") + instrText.text = " PAGE " + instr._r.append(instrText) + + _field_run("separate") + + placeholder = para.add_run() + placeholder.font.name = font_name + placeholder.font.size = font_size + _set_run_spacing(placeholder, letter_spacing_twips) + t = OxmlElement("w:t") + t.text = "1" + placeholder._r.append(t) + + _field_run("end") + + +def _set_run_spacing(run, twips): + """Apply letter-spacing (in twentieths of a point) to a run's rPr.""" + if not twips: + return + rpr = run._r.get_or_add_rPr() + spacing = rpr.find(qn("w:spacing")) + if spacing is None: + spacing = OxmlElement("w:spacing") + rpr.append(spacing) + spacing.set(qn("w:val"), str(twips)) + + + + +def _suppress_section_footers(section): + """Unlink a section's footers and guarantee they are blank + (bottom page numbers migrated to running headers per spec section 4.1).""" + section.footer.is_linked_to_previous = False + _clear_paragraph(section.footer.paragraphs[0]) + section.even_page_footer.is_linked_to_previous = False + _clear_paragraph(section.even_page_footer.paragraphs[0]) + section.first_page_footer.is_linked_to_previous = False + _clear_paragraph(section.first_page_footer.paragraphs[0]) + if section._sectPr.find(qn("w:titlePg")) is None: + tp = OxmlElement("w:titlePg") + section._sectPr.append(tp) + + +def _clear_paragraph(paragraph): + """Remove all runs and page fields from a paragraph.""" + for r in list(paragraph.runs): + r._r.getparent().remove(r._r) + + +def _set_right_tab(paragraph, pos_twips): + """Attach a right-aligned tab stop to a paragraph.""" + pPr = paragraph._p.get_or_add_pPr() + tabs_el = pPr.find(qn("w:tabs")) + if tabs_el is None: + tabs_el = OxmlElement("w:tabs") + pPr.append(tabs_el) + else: + for tab in tabs_el.findall(qn("w:tab")): + if tab.get(qn("w:val")) == "right": + tabs_el.remove(tab) + tab_stop = OxmlElement("w:tab") + tab_stop.set(qn("w:val"), "right") + tab_stop.set(qn("w:pos"), str(pos_twips)) + tabs_el.append(tab_stop) + + +def _add_running_header(doc, section, recto_text, author, title, tab_pos_twips, + font_name="Caladea", font_size=Pt(9)): + """Install per-spec running headers (section 4.1): + verso (even/left): [ Author Name ] [ Book Title ] + recto (odd/right): [ Chapter Title or Section Title ] + + Headers carry titles only; page numbers live in the centered running + footers (spec section 4.2). The section's first-page header stays empty + so the opening page carries no running head (suppression, section 4.3).""" + sect_pr = section._sectPr + for hr in sect_pr.findall(qn("w:headerReference")): + sect_pr.remove(hr) + + # Recto (odd, default) header: chapter/section title + odd_header = section.header + odd_header.is_linked_to_previous = False + p = odd_header.paragraphs[0] + _clear_paragraph(p) + run = p.add_run(recto_text or "") + run.font.name = font_name + run.font.size = font_size + + # Verso (even) header: "[Author Name]\t[Book Title]" + even_header = section.even_page_header + even_header.is_linked_to_previous = False + p = even_header.paragraphs[0] + _clear_paragraph(p) + _set_right_tab(p, tab_pos_twips) + run = p.add_run(author or "") + run.font.name = font_name + run.font.size = font_size + tab_run = p.add_run() + tab_run.font.name = font_name + tab_run.font.size = font_size + tab_run.add_tab() + run = p.add_run(title or "") + run.font.name = font_name + run.font.size = font_size + + # First page header: empty (suppressed) + first_header = section.first_page_header + first_header.is_linked_to_previous = False + _clear_paragraph(first_header.paragraphs[0]) + + if sect_pr.find(qn("w:titlePg")) is None: + tp = OxmlElement("w:titlePg") + sect_pr.append(tp) + + +def _add_running_footer(section, font_name="Caladea", font_size=Pt(9), + suppress_first=True): + """Bottom pagination (spec section 4.2): the page number is centered at + the bottom of both verso (even/left) and recto (odd/right) pages. + + With ``suppress_first=True`` the section's first-page footer stays empty + (suppression, spec section 4.3). Front-matter display pages (copyright, + dedication, epigraph, TOC) pass ``suppress_first=False`` so their Roman + page number is visible starting on the page that opens the section.""" + sect_pr = section._sectPr + for fr in sect_pr.findall(qn("w:footerReference")): + sect_pr.remove(fr) + + # Recto (odd): centered page number + odd_footer = section.footer + odd_footer.is_linked_to_previous = False + p = odd_footer.paragraphs[0] + _clear_paragraph(p) + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + _insert_page_field(p, font_name, font_size) + + # Verso (even): centered page number + even_footer = section.even_page_footer + even_footer.is_linked_to_previous = False + p = even_footer.paragraphs[0] + _clear_paragraph(p) + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + _insert_page_field(p, font_name, font_size) + + # First page footer: empty (suppressed) unless front-matter display page + first_footer = section.first_page_footer + first_footer.is_linked_to_previous = False + p = first_footer.paragraphs[0] + _clear_paragraph(p) + if not suppress_first: + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + _insert_page_field(p, font_name, font_size) + + if sect_pr.find(qn("w:titlePg")) is None: + tp = OxmlElement("w:titlePg") + sect_pr.append(tp) + + +def _suppress_section_headers(section): + """Unlink a section's headers and guarantee they are blank.""" + section.header.is_linked_to_previous = False + _clear_paragraph(section.header.paragraphs[0]) + section.even_page_header.is_linked_to_previous = False + _clear_paragraph(section.even_page_header.paragraphs[0]) + section.first_page_header.is_linked_to_previous = False + _clear_paragraph(section.first_page_header.paragraphs[0]) + + +def _apply_running_matter(out, sec, recto_text, author, title, tab_pos_twips, + suppress_first=True): + """Install per-spec running matter: + - running headers (section 4.1): recto title, verso author + book title; + - centered footer page numbers on every body page (section 4.2). + + First-page headers stay empty (suppression, section 4.3). The first-page + footer is also suppressed unless ``suppress_first`` is False (front-matter + display pages such as the TOC show their page number on the opening page).""" + _add_running_header(out, sec, recto_text, author, title, tab_pos_twips) + _add_running_footer(sec, suppress_first=suppress_first) + + +def _apply_paragraph_format(p, font_name, font_size, alignment, line_spacing=None, + line_spacing_rule=None, first_line_indent=None, + space_before=None, space_after=None): + """Apply formatting to a paragraph and return the first run.""" + pf = p.paragraph_format + if alignment is not None: + pf.alignment = alignment + if line_spacing is not None: + pf.line_spacing = line_spacing + if line_spacing_rule is not None: + pf.line_spacing_rule = line_spacing_rule + if first_line_indent is not None: + pf.first_line_indent = first_line_indent + if space_before is not None: + pf.space_before = space_before + if space_after is not None: + pf.space_after = space_after + run = p.runs[0] if p.runs else p.add_run() + run.font.name = font_name + run.font.size = font_size + return run + + +def _add_empty_para(doc, count=1, font_size=Pt(12)): + """Add empty paragraphs as spacers with exact font_size line spacing.""" + for _ in range(count): + p = doc.add_paragraph() + run = p.add_run("") + run.font.size = font_size + pf = p.paragraph_format + pf.line_spacing = font_size + pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + pf.space_before = Pt(0) + pf.space_after = Pt(0) + + +def _copy_paragraph_raw(out, src_p, src_doc=None): + """Copy a paragraph preserving ALL original formatting (no overrides). + Handles w:hyperlink elements by remapping relationship IDs.""" + new_p = out.add_paragraph() + pf = new_p.paragraph_format + spf = src_p.paragraph_format + pf.alignment = spf.alignment + if spf.first_line_indent is not None: + pf.first_line_indent = spf.first_line_indent + if spf.space_before is not None: + pf.space_before = spf.space_before + if spf.space_after is not None: + pf.space_after = spf.space_after + if spf.line_spacing is not None: + pf.line_spacing = spf.line_spacing + if spf.line_spacing_rule is not None: + pf.line_spacing_rule = spf.line_spacing_rule + for child in list(src_p._p): + tag = child.tag + if tag == qn('w:r'): + src_run = Run(child, src_p) + new_r = new_p.add_run(src_run.text) + _copy_run_format(src_run, new_r) + elif tag == qn('w:hyperlink') and src_doc is not None: + hl_copy = deepcopy(child) + r_id = hl_copy.get(qn('r:id')) + if r_id: + try: + src_rel = src_doc.part.rels[r_id] + new_rId = out.part.relate_to( + src_rel.target_ref, + src_rel.reltype, + is_external=True + ) + hl_copy.set(qn('r:id'), new_rId) + except Exception: + pass + new_p._p.append(hl_copy) + return new_p + + +def _copy_paragraph_raw_deep(out, src_p, src_doc=None): + """Preserve ALL formatting by deep-copying paragraph XML element. + Remaps hyperlinks, strips w:sectPr and source bookmarks to avoid orphan ranges.""" + p_copy = deepcopy(src_p._p) + pPr = p_copy.find(qn('w:pPr')) + if pPr is not None: + sectPr = pPr.find(qn('w:sectPr')) + if sectPr is not None: + pPr.remove(sectPr) + for tag in (qn('w:bookmarkStart'), qn('w:bookmarkEnd')): + for el in p_copy.findall('.//' + tag): + el.getparent().remove(el) + new_p = out.add_paragraph() + parent = new_p._p.getparent() + idx = list(parent).index(new_p._p) + parent.remove(new_p._p) + if src_doc is not None: + for hl in p_copy.findall('.//' + qn('w:hyperlink')): + r_id = hl.get(qn('r:id')) + if r_id: + try: + src_rel = src_doc.part.rels[r_id] + new_rId = out.part.relate_to( + src_rel.target_ref, + src_rel.reltype, + is_external=True + ) + hl.set(qn('r:id'), new_rId) + except Exception: + pass + parent.insert(idx, p_copy) + return out.paragraphs[-1] + + +def _copy_run_format(src_run, dst_run): + """Copy font formatting from one run to another.""" + dst_run.font.name = src_run.font.name + dst_run.font.size = src_run.font.size + dst_run.font.bold = src_run.font.bold + dst_run.font.italic = src_run.font.italic + dst_run.font.underline = src_run.font.underline + dst_run.font.strike = src_run.font.strike + if src_run.font.color.rgb: + dst_run.font.color.rgb = src_run.font.color.rgb + + +def _copy_para_with_font(doc, src_p): + """Copy a paragraph preserving original font formatting ([font] block).""" + new_p = doc.add_paragraph() + pf = new_p.paragraph_format + pf.space_before = Pt(0) + pf.space_after = Pt(0) + for r in src_p.runs: + new_r = new_p.add_run(r.text) + _copy_run_format(r, new_r) + return new_p + + +def _add_page_break(doc): + """Add a page break paragraph with zero spacing.""" + p = doc.add_paragraph() + pf = p.paragraph_format + pf.space_before = Pt(0) + pf.space_after = Pt(0) + run = p.add_run() + br = OxmlElement("w:br") + br.set(qn("w:type"), "page") + run._r.append(br) + + +def _add_section_break(doc): + """Add a section break using python-docx API.""" + return doc.add_section() + + +SECTPR_ORDER = [ + qn('w:footnotePr'), + qn('w:endnotePr'), + qn('w:type'), + qn('w:pgSz'), + qn('w:pgMar'), + qn('w:paperSrc'), + qn('w:pgBorders'), + qn('w:lnNumType'), + qn('w:pgNumType'), + qn('w:cols'), + qn('w:formProt'), + qn('w:vAlign'), + qn('w:noEndnote'), + qn('w:titlePg'), + qn('w:mirrorMargins'), + qn('w:textDirection'), + qn('w:bidi'), + qn('w:rtlGutter'), + qn('w:headerReference'), + qn('w:footerReference'), + qn('w:docGrid'), + qn('w:printerSettings'), + qn('w:sectPrChange'), +] + + +def _reorder_sect_pr(sect_pr): + """Reorder w:sectPr children to match OOXML schema sequence. + Word rejects documents with sectPr elements in wrong order.""" + children = list(sect_pr) + ordered = [] + for tag in SECTPR_ORDER: + for child in children[:]: + if child.tag == tag: + ordered.append(child) + children.remove(child) + ordered.extend(children) + for child in ordered: + sect_pr.append(child) + + +def format_document(filepath, output_path=None, font_name="Caladea", font_size=11, + inner_margin=0.500, trim_size="5.5x8.5", bleed=0.0, + author="", imprint="", footer_mode="headers", + strip_graphics=False, strip_problematic_graphics=False, + language="en"): + """Parse tagged DOCX and generate formatted DOCX. + + ``trim_size`` is one of :data:`TRIM_SIZES` (default "5.5x8.5"). + ``inner_margin`` is the inside/gutter margin in inches (mirror margins). + ``bleed`` adds the given amount (inches) of full-bleed around the trim + size and shifts margins outward so the text block stays on the trim area. + ``author`` / ``imprint`` override the title page and (auto-generated) + copyright boilerplate. Manuscript "Author: ..." / "Imprint: ..." lines + take precedence over these defaults. + ``footer_mode`` is accepted for backward compatibility but ignored: page + numbers always render centered in the running footers (spec section 4.2) + while headers carry titles only (spec section 4.1). + ``strip_graphics`` removes decorative body images and renders scene breaks + as plain asterisks (spec section 5.2, Option B). + ``strip_problematic_graphics`` drops images that overflow the printable + text box; otherwise such images are re-anchored inline and scaled to fit + (spec section 5.1.3). + ``language`` is a BCP-47/ISO code (default "en"); it localizes generated + headings/copyright text, sets the proofing/hyphenation language, and turns + on bidirectional layout for RTL languages (spec section 6). + """ + if output_path is None: + base, ext = os.path.splitext(filepath) + output_path = f"{base}_formatted{ext}" + + try: + width, height = TRIM_SIZES[trim_size] + except KeyError: + raise ValueError( + f"Unsupported trim_size '{trim_size}'. " + f"Choose from {list(TRIM_SIZES)}" + ) + if bleed < 0: + raise ValueError("bleed must be >= 0") + page_w = width + 2 * bleed + page_h = height + 2 * bleed + # Printable text box (EMU) used to clamp/anchor body images (spec section 5). + text_width_emu = int(Inches(width - inner_margin - 0.50)) + text_height_emu = int(Inches(height - 1.50)) + + # Localization (spec section 6) + strings = _language_strings(language) + rtl = _is_rtl(language) + front_headings = { + "toc": strings["contents"], + "foreword": strings["foreword"], + "acknowledgments": strings["acknowledgments"], + } + back_headings = { + "back_matter": strings["appendix"], + "req_review": strings["review"], + "about_author": strings["about_author"], + } + + doc = Document(filepath) + tags = _find_tags(doc) + + if "book_start" not in tags: + raise ValueError("Input document must contain [book start] tag on the title page.") + + book_start = tags["book_start"] + chapter_indices = tags["chapters"] + if not chapter_indices: + raise ValueError("Input document must contain at least one [chapter] tag.") + first_chapter_idx = chapter_indices[0] + + # --- Extract content by section --- + # Title extraction stops at the first structural/optional tag after + # [book start] so it no longer swallows copyright or front-matter text. + all_tag_positions = _tag_positions(doc, tags) + title_end = next((p for p in all_tag_positions if p > book_start), first_chapter_idx) + title, subtitle, author, imprint, logos = _extract_title_page( + doc, book_start, title_end, default_author=author, default_imprint=imprint, + ) + # Copyright text lives after [copyright], up to the next structural tag. + copyright_paras = _content_after_tag(doc, tags, "copyright") + chapters = _extract_chapters(doc, chapter_indices) + # Gather verbatim section tags in document order + verbatim_tag_positions = [] + for tagname in ("back_matter", "req_review", "about_author"): + if tagname in tags: + verbatim_tag_positions.append((tags[tagname], tagname)) + verbatim_tag_positions.sort() + # Extract content for each verbatim section (skip the tag paragraph itself) + verbatim_sections = [] + for vi, (tag_pos, tagname) in enumerate(verbatim_tag_positions): + next_pos = verbatim_tag_positions[vi + 1][0] if vi + 1 < len(verbatim_tag_positions) else None + paras = _extract_between_tags(doc, tag_pos + 1, next_pos) + verbatim_sections.append({"name": tagname, "paras": paras}) + + if not title: + raise ValueError("No title found after [book start].") + + # === Pass 1: Build content with alternating section breaks === + out = Document() + if out.paragraphs: + out.paragraphs[0]._p.getparent().remove(out.paragraphs[0]._p) + + # Create WA styles in output document + _create_wa_styles(out) + # Tag document language (proofing/hyphenation) and RTL defaults (spec 6). + _apply_language_defaults(out, language, rtl) + + # Section roles recorded in document order; Pass 2 applies per-role props. + section_roles = [] + + # Section 0 — Half Title (recto as page 1; page number suppressed) + section_roles.append("half_title") + _build_half_title_page(out, title) + + # Section 1 — Blank / advert verso (facing the half title) + out.add_section(WD_SECTION_START.NEW_PAGE) + section_roles.append("blank") + _add_centered_spacer(out, 12, 1) + + # Section 2 — Title page (recto) + out.add_section(WD_SECTION_START.NEW_PAGE) + section_roles.append("title") + _build_title_page(out, title, subtitle, author, imprint) + # Title-page logos/images (spec section 5): centered, inline, bounded. + for logo_p in logos or []: + _add_body_image(out, logo_p, doc, text_width_emu, text_height_emu, + strip_graphics=strip_graphics, + strip_problematic=strip_problematic_graphics, + font_name=font_name, font_size=Pt(font_size)) + + # Section 3 — Copyright / edition page (verso facing the title, base-aligned) + out.add_section(WD_SECTION_START.NEW_PAGE) + section_roles.append("copyright") + _build_copyright_page(out, copyright_paras, doc, author, imprint, + strings=strings) + + # Optional front matter — dedication, epigraph (single centered pages) + for tagname in ("dedication", "epigraph"): + paras = _content_after_tag(doc, tags, tagname) + if not paras: + continue + out.add_section(WD_SECTION_START.NEW_PAGE) + section_roles.append(tagname) + _build_centered_quote_section(out, paras) + + # Section N — TOC (recto start; always auto-generated) + out.add_section(WD_SECTION_START.ODD_PAGE) + section_roles.append("toc") + tab_pos_twips = int((width - inner_margin - 0.50) * 1440) + toc_entry_paras = _build_toc(out, chapters, tab_pos_twips, + heading=strings["toc"]) + + # Optional front matter — foreword, acknowledgments (recto starts) + for tagname, heading in ( + ("foreword", strings["foreword"]), + ("acknowledgments", strings["acknowledgments"]), + ): + paras = _content_after_tag(doc, tags, tagname) + if not paras: + continue + out.add_section(WD_SECTION_START.ODD_PAGE) + section_roles.append(tagname) + add_p = out.add_paragraph(style="WA Chapter") + add_p.alignment = WD_ALIGN_PARAGRAPH.LEFT + run = add_p.add_run(heading) + run.font.name = font_name + run.font.size = Pt(font_size + 2) + _build_prose_section(out, paras, font_name, font_size) + + # Sections — Chapters (odd-page section break before each so chapters + # always begin on a recto / right-hand / odd-numbered page) + first_chapter_section_idx = len(section_roles) + for ci, ch in enumerate(chapters): + out.add_section(WD_SECTION_START.ODD_PAGE) + section_roles.append("chapter") + # Top drop (sinkage): heading starts ~1.75" below the top margin. + drop_p = out.add_paragraph() + drop_pf = drop_p.paragraph_format + drop_pf.line_spacing = Pt(CHAPTER_TOP_DROP_PT) + drop_pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY + drop_pf.space_before = Pt(0) + drop_pf.space_after = Pt(0) + # Chapter header icon (spec 5.1.1): image directly above the heading. + if ch.get("icon") and not strip_graphics: + _add_body_image(out, ch["icon"]["para"], doc, text_width_emu, + text_height_emu, strip_graphics=strip_graphics, + strip_problematic=strip_problematic_graphics, + font_name=font_name, font_size=Pt(font_size)) + if ch["title"]: + add_p = out.add_paragraph(style="WA Chapter") + add_p.paragraph_format.keep_with_next = True + add_p.paragraph_format.keep_together = True + run = add_p.add_run(ch["title"]) + # Add a bookmark at the chapter title so PAGEREF fields in the TOC + # can reference the correct page number. + _add_bookmark(add_p, ci, f"_UF_ch_{ci}") + first_para = True + for item in ch["paras"]: + if item["type"] == "font": + _copy_para_with_font(out, item["para"]) + continue + if item["type"] in ("icon", "image"): + _add_body_image(out, item["para"], doc, text_width_emu, + text_height_emu, strip_graphics=strip_graphics, + strip_problematic=strip_problematic_graphics, + font_name=font_name, font_size=Pt(font_size)) + continue + text = item["text"] + if (_is_scene_break_marker(item, text) or text == TAGS["section"]): + add_p = out.add_paragraph() + add_p.alignment = WD_ALIGN_PARAGRAPH.CENTER + pf = add_p.paragraph_format + pf.space_before = Pt(12) + pf.space_after = Pt(12) + pf.keep_with_next = False + # Preserve <<<>>> as the visual scene-break cue; otherwise use ornament. + if text == "<<<>>>": + run = add_p.add_run("<<<>>>") + run.font.name = "Caladea" + run.font.size = Pt(11) + elif strip_graphics: + run = add_p.add_run(SCENE_BREAK_FALLBACK) + run.font.name = "Caladea" + run.font.size = Pt(11) + else: + ornament = SCENE_BREAK_ORNAMENT + run = add_p.add_run(ornament) + run.font.name = "Caladea" + run.font.size = Pt(11) + first_para = True + continue + + if not text: + continue + if first_para: + # First paragraph of a chapter: no first-line indent, with only + # the first word capitalized (spec section 3.2). + new_p = out.add_paragraph(style="WA NChp Pgr") + pf = new_p.paragraph_format + pf.first_line_indent = Inches(0) + words = text.split(" ") + # Only capitalize the first word; rest are normal case + if words: + # Capitalize first letter of first word only + first_word = words[0] + idx = next((i for i, ch_ in enumerate(first_word) if ch_.isalpha()), None) + if idx is not None: + first_word = first_word[:idx] + first_word[idx].upper() + first_word[idx+1:] + run1 = new_p.add_run(first_word) + run1.font.name = font_name + run1.font.size = Pt(font_size) + # Rest of paragraph text (not forced uppercase/small-caps) + rest_text = " ".join(words[1:]) if len(words) > 1 else "" + if rest_text: + run2 = new_p.add_run(" " + rest_text) + run2.font.name = font_name + run2.font.size = Pt(font_size) + else: + run1 = new_p.add_run("") + run1.font.name = font_name + run1.font.size = Pt(font_size) + first_para = False + else: + new_p = out.add_paragraph(style="WA Nrml Pgr") + pf = new_p.paragraph_format + pf.first_line_indent = Inches(BODY_FIRST_LINE_INDENT) + run = new_p.add_run(text) + run.font.name = font_name + run.font.size = Pt(font_size) + + # Verbatim sections (back matter, req review, about author) — raw copy, user handles formatting + for vs in verbatim_sections: + out.add_section(WD_SECTION_START.ODD_PAGE) + section_roles.append(vs["name"]) + for src_p in vs["paras"]: + _copy_paragraph_raw_deep(out, src_p, doc) + + out.save(output_path) + + # === Pass 2: Re-open and apply section properties === + out2 = Document(output_path) + secs = list(out2.sections) + + settings = out2.settings.element + if settings.find(qn('w:evenAndOddHeaders')) is None: + eo = OxmlElement('w:evenAndOddHeaders') + settings.append(eo) + # Hyphenation for justified text (spec section 3.2). Stored directly under + # w:settings per OOXML; Word honors it in modern layout compat mode. + if settings.find(qn('w:autoHyphenation')) is None: + settings.append(OxmlElement('w:autoHyphenation')) + + for idx, sec in enumerate(secs): + sec.page_width = Inches(page_w) + sec.page_height = Inches(page_h) + sec.top_margin = Inches(0.75 + bleed) + sec.bottom_margin = Inches(0.75 + bleed) + sec.left_margin = Inches(inner_margin + bleed) + sec.right_margin = Inches(0.50 + bleed) + sec.header_distance = Inches(0.5) + sec.footer_distance = Inches(0.5) + sp = sec._sectPr + if sp.find(qn('w:mirrorMargins')) is None: + mm = OxmlElement("w:mirrorMargins") + cols = sp.find(qn('w:cols')) + if cols is not None: + sp.insert(sp.index(cols), mm) + else: + sp.append(mm) + if sp.find(qn('w:titlePg')) is None: + tp = OxmlElement("w:titlePg") + sp.append(tp) + if rtl and sp.find(qn("w:rtlGutter")) is None: + # RTL bindings place the gutter on the right (spec section 6.2). + sp.append(OxmlElement("w:rtlGutter")) + + role = section_roles[idx] if idx < len(section_roles) else "back_matter" + + # Outer-margin right tab stop for running headers (text-region width, + # independent of bleed since bleed shifts margins and page equally). + header_tab = int((width - inner_margin - 0.50) * 1440) + + if role in ("half_title", "blank", "title"): + # Numbered lowerRoman but suppressed — empty headers/footers. + _ensure_pgNumType(sp, "lowerRoman") + _suppress_section_footers(sec) + _suppress_section_headers(sec) + elif role in ("copyright", "dedication", "epigraph"): + # lowerRoman with the page number visible in the centered footer: + # numbering starts on the copyright page and runs through the + # front matter until Chapter 1. No running heads on these + # display pages. + _ensure_pgNumType(sp, "lowerRoman") + _suppress_section_headers(sec) + _add_running_footer(sec, suppress_first=False) + # These are single display pages: drop titlePg so the default + # (numbered) footer renders on their opening page in every + # renderer (LibreOffice otherwise suppresses the footer here). + tp = sp.find(qn("w:titlePg")) + if tp is not None: + sp.remove(tp) + if role == "copyright": + # Ground the copyright block at the base of the verso page + _set_section_v_align_bottom(sp) + elif role in ("toc", "foreword", "acknowledgments"): + # Visible lowerRoman page numbers + running heads. The Roman + # number shows on the section's opening page too. + _ensure_pgNumType(sp, "lowerRoman") + _apply_running_matter(out2, sec, + front_headings.get(role, role), + author, title, header_tab, + suppress_first=False) + elif role == "chapter": + # Chapters — decimal (restart 1) + running heads. + # Per spec §4.3 headers/footers are hidden only on the first page + # of each chapter (empty first-page header/footer + titlePg); + # body pages carry centered footer page numbers and running heads. + pg_start = 1 if idx == first_chapter_section_idx else None + _ensure_pgNumType(sp, "decimal", pg_start) + ch_idx = idx - first_chapter_section_idx + ch_title = chapters[ch_idx]["title"] if 0 <= ch_idx < len(chapters) else title + _apply_running_matter(out2, sec, ch_title, author, title, + header_tab) + else: + # Back matter — decimal continued + running heads by section. + _ensure_pgNumType(sp, "decimal") + _apply_running_matter(out2, sec, + back_headings.get(role, role), + author, title, header_tab) + + # Reorder sectPr children per OOXML spec (Word requires strict ordering) + for sec in secs: + _reorder_sect_pr(sec._sectPr) + + out2.save(output_path) + return output_path + + +def convert_to_pdf(docx_path, pdf_path=None, pad_multiple_of_4=False): + """Convert a DOCX to PDF using LibreOffice headless. + + When ``pad_multiple_of_4`` is set, clean blank pages are appended so the + final page count is a multiple of four (spec section 7 print-prep rule). + """ + if pdf_path is None: + pdf_path = os.path.splitext(docx_path)[0] + ".pdf" + try: + subprocess.run( + ["libreoffice", "--headless", "--convert-to", "pdf", + "--outdir", os.path.dirname(pdf_path), docx_path], + check=True, capture_output=True, timeout=120, + ) + lo_pdf = os.path.splitext(docx_path)[0] + ".pdf" + if lo_pdf != pdf_path and os.path.exists(lo_pdf): + shutil.move(lo_pdf, pdf_path) + if not os.path.exists(pdf_path): + return None + if pad_multiple_of_4: + pad_pdf_to_multiple(pdf_path, 4) + return pdf_path + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + return None + + +def pdf_page_count(pdf_path): + """Return the number of pages in a PDF, or 0 if it cannot be read.""" + try: + from pypdf import PdfReader + except ImportError: + return 0 + try: + return len(PdfReader(pdf_path).pages) + except Exception: + return 0 + + +def pad_pdf_to_multiple(pdf_path, multiple=4): + """Append clean blank pages so the page count is a multiple of ``multiple`` + (spec section 7). Blank pages match the last page's trim dimensions. + Returns the number of blank pages added (0 when already a multiple).""" + try: + from pypdf import PdfReader, PdfWriter + except ImportError: + return 0 + reader = PdfReader(pdf_path) + count = len(reader.pages) + if count == 0: + return 0 + remainder = count % multiple + if remainder == 0: + return 0 + add = multiple - remainder + + writer = PdfWriter() + for page in reader.pages: + writer.add_page(page) + last = reader.pages[-1] + blank_w = float(last.mediabox.width) + blank_h = float(last.mediabox.height) + for _ in range(add): + writer.add_blank_page(width=blank_w, height=blank_h) + + # Write to a temp file first: the reader may still hold the source open. + fd, tmp_path = tempfile.mkstemp(suffix=".pdf", + dir=os.path.dirname(pdf_path) or ".") + os.close(fd) + try: + with open(tmp_path, "wb") as fh: + writer.write(fh) + shutil.move(tmp_path, pdf_path) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + return add + + +def _page_texts(pdf_path): + """Return a list of normalized single-line strings, one per PDF page. + + Uses poppler's pdftotext (page-by-page). Returns [] if unavailable.""" + import re as _re + texts = [] + try: + subprocess.run(["pdftotext", "-v"], capture_output=True) + except (FileNotFoundError, subprocess.SubprocessError): + return texts + try: + info = subprocess.run( + ["pdfinfo", pdf_path], capture_output=True, text=True, timeout=60, + ) + pages = 0 + for line in info.stdout.splitlines(): + if line.lower().startswith("pages:"): + pages = int(line.split()[1]) + break + except (subprocess.SubprocessError, ValueError): + return texts + for p in range(1, pages + 1): + out = subprocess.run( + ["pdftotext", "-f", str(p), "-l", str(p), pdf_path, "-"], + capture_output=True, text=True, timeout=60, + ) + norm = _re.sub(r"\s+", " ", out.stdout).strip() + texts.append(norm) + return texts + + +def verify_recto_starts(docx_path, markers, pdf_path=None): + """Detect on which page each marker first appears in the rendered PDF and + report whether that page is recto (odd). + + ``markers`` should be ordered by document position; marker[0] establishes + the leading page so later markers are searched only after it (TOC page + numbers are otherwise misread as chapter starts). LibreOffice is used for + rendering, so parity is an approximation of Word's authoritative layout. + + Returns a list of dicts: {marker, page, recto}. Page 0 means "not found". + """ + import tempfile + + own_pdf = pdf_path is None + if own_pdf: + tmp_dir = tempfile.mkdtemp(prefix="umbraformat_parity_") + base = os.path.join(tmp_dir, os.path.basename(docx_path)) + shutil.copy(docx_path, base) + pdf_path = convert_to_pdf(base) + texts = _page_texts(pdf_path) if pdf_path else [] + results = [] + search_from = 0 + for i, marker in enumerate(markers): + needle = " ".join(marker.split()) + page = 0 + if texts: + for p in range(search_from, len(texts)): + if needle and needle in texts[p]: + page = p + 1 + search_from = p + 1 + break + results.append({"marker": marker, "page": page, "recto": (page % 2 == 1)}) + if own_pdf and pdf_path: + try: + shutil.rmtree(os.path.dirname(pdf_path)) + except OSError: + pass + return results diff --git a/umbraformat/static/css/style.css b/umbraformat/static/css/style.css new file mode 100644 index 0000000..f41519a --- /dev/null +++ b/umbraformat/static/css/style.css @@ -0,0 +1,85 @@ +:root { + --bs-body-bg: #1a1d23; + --bs-body-color: #e0e0e0; +} + +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.footer { + margin-top: auto; +} + +.upload-zone { + position: relative; + border: 2px dashed #495057; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + overflow: hidden; +} +.upload-zone:hover, +.upload-zone.drag-over { + border-color: var(--bs-primary); + background: rgba(var(--bs-primary-rgb), 0.05); +} + +.issue-item { + padding: 8px 12px; + border-left: 3px solid transparent; + margin-bottom: 4px; + border-radius: 4px; +} +.issue-item.error { + border-left-color: var(--bs-danger); + background: rgba(var(--bs-danger-rgb), 0.08); +} +.issue-item.warning { + border-left-color: var(--bs-warning); + background: rgba(var(--bs-warning-rgb), 0.08); +} +.issue-item.success { + border-left-color: var(--bs-success); + background: rgba(var(--bs-success-rgb), 0.08); +} + +.spec-badge { + font-size: 0.75rem; + padding: 2px 8px; +} + +#loadingOverlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.loading-spinner { + text-align: center; +} + +.format-summary { + background: rgba(var(--bs-success-rgb), 0.05); + border: 1px solid rgba(var(--bs-success-rgb), 0.2); + border-radius: 8px; + padding: 16px; +} + +.style-check { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; +} +.style-check .bi-check-circle-fill { color: var(--bs-success); } +.style-check .bi-x-circle-fill { color: var(--bs-danger); } diff --git a/umbraformat/static/js/main.js b/umbraformat/static/js/main.js new file mode 100644 index 0000000..c9ccd6b --- /dev/null +++ b/umbraformat/static/js/main.js @@ -0,0 +1,408 @@ +let currentUploadId = null; +let analysisData = null; + +const WORDS_PER_PAGE = { + "Caladea": { 10: 360, 11: 300, 12: 260 }, + "Garamond": { 10: 340, 11: 285, 12: 245 }, + "Baskerville": { 10: 350, 11: 295, 12: 255 }, + "Palatino": { 10: 355, 11: 298, 12: 258 } +}; + +const MARGIN_TABLES = { + umbraformat: [ + { min: 0, max: 149, margin: 0.500 }, + { min: 150, max: 299, margin: 0.625 }, + { min: 300, max: Infinity, margin: 0.750 } + ], + kdp: [ + { min: 0, max: 149, margin: 0.375 }, + { min: 150, max: 299, margin: 0.500 }, + { min: 300, max: 499, margin: 0.625 }, + { min: 500, max: 699, margin: 0.750 }, + { min: 700, max: Infinity, margin: 0.875 } + ] +}; + +function getRecommendedMargin(pageCount, table) { + const rows = MARGIN_TABLES[table] || MARGIN_TABLES.umbraformat; + for (const row of rows) { + if (pageCount >= row.min && pageCount <= row.max) { + return row.margin; + } + } + return rows[rows.length - 1].margin; +} + +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`; + recDiv.classList.remove('d-none'); + marginSelect.value = rec; + } +} + +document.addEventListener("DOMContentLoaded", function () { + const dropZone = document.getElementById("dropZone"); + const fileInput = document.getElementById("fileInput"); + + // Drag and drop + dropZone.addEventListener("dragover", (e) => { + e.preventDefault(); + dropZone.classList.add("drag-over"); + }); + dropZone.addEventListener("dragleave", () => { + dropZone.classList.remove("drag-over"); + }); + dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + dropZone.classList.remove("drag-over"); + if (e.dataTransfer.files.length > 0) { + handleFile(e.dataTransfer.files[0]); + } + }); + + // File input change + fileInput.addEventListener("change", () => { + if (fileInput.files.length > 0) { + handleFile(fileInput.files[0]); + } + }); + + // Format button + document.getElementById("formatBtn").addEventListener("click", doFormat); +}); + +function handleFile(file) { + if (!file.name.toLowerCase().endsWith(".docx")) { + showToast("Only .docx files are supported", "danger"); + return; + } + if (file.size > 50 * 1024 * 1024) { + showToast("File exceeds 50 MB limit", "danger"); + return; + } + + showProgress(file.name, 0); + uploadFile(file); +} + +function uploadFile(file) { + const formData = new FormData(); + formData.append("file", file); + + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + updateProgress(pct); + } + }); + + xhr.addEventListener("load", () => { + hideProgress(); + if (xhr.status === 200) { + const data = JSON.parse(xhr.responseText); + if (data.error) { + showToast(data.error, "danger"); + return; + } + currentUploadId = data.upload_id; + analysisData = data; + showResults(data); + } else { + let msg = "Upload failed"; + try { + const err = JSON.parse(xhr.responseText); + msg = err.error || msg; + } catch (e) {} + showToast(msg, "danger"); + } + }); + + xhr.addEventListener("error", () => { + hideProgress(); + showToast("Network error during upload", "danger"); + }); + + xhr.open("POST", "/api/analyze", true); + xhr.send(formData); +} + +function showProgress(name, pct) { + document.getElementById("uploadFileName").textContent = name; + document.getElementById("uploadPercent").textContent = pct + "%"; + document.getElementById("uploadProgressBar").style.width = pct + "%"; + document.getElementById("uploadProgress").classList.remove("d-none"); +} + +function updateProgress(pct) { + document.getElementById("uploadPercent").textContent = pct + "%"; + document.getElementById("uploadProgressBar").style.width = pct + "%"; +} + +function hideProgress() { + document.getElementById("uploadProgress").classList.add("d-none"); +} + +function showResults(data) { + document.getElementById("resultsArea").classList.remove("d-none"); + document.getElementById("optionsArea").classList.remove("d-none"); + document.getElementById("formatBtn").disabled = false; + + analysisData = data; + updatePageEstimate(); + + renderAnalysis(data); +} + +function updatePageEstimate() { + if (!analysisData) return; + + const font = document.getElementById("fontSelect").value; + const fontSize = parseInt(document.getElementById("fontSizeSelect").value); + const trimSize = document.getElementById("trimSelect").value; + const wordCount = analysisData.chapter_word_count || 0; + + const wpp = (WORDS_PER_PAGE[font] && WORDS_PER_PAGE[font][fontSize]) || 300; + const trimFactor = trimSize === "6x9" ? 1.35 : 1.0; + const bodyPages = Math.ceil(wordCount / (wpp * trimFactor)); + const totalPages = bodyPages + 10; // Add 10 for front/back matter + + window._pageCount = Math.max(24, totalPages); + + const pageInfo = document.getElementById("pageCountInfo"); + pageInfo.innerHTML = `${wordCount} words · ~${totalPages} pages`; + + updateMarginRecommendation(); +} + +function renderAnalysis(data) { + const panel = document.getElementById("analysisPanel"); + const issueCount = (data.issues ? data.issues.length : 0) + (data.warnings ? data.warnings.length : 0); + document.getElementById("issueCount").textContent = issueCount + " items"; + + const s0 = data.sections && data.sections.length > 0 ? data.sections[0] : null; + + let html = `
+
${escapeHtml(data.original_filename || "Untitled")}
+
`; + + // Page setup summary + html += `
+
Page Setup
+ + + `; + + if (s0) { + html += ``; + html += ``; + html += ``; + } + html += `
Size${data.page_setup.width} x ${data.page_setup.height} in
Orientation${data.page_setup.orientation}
MarginsT:${s0.top}" B:${s0.bottom}" I:${s0.left}" O:${s0.right}"
Mirror margins${s0.mirror_margins ? "Yes" : 'No'}
Different first page${s0.different_first_page ? "Yes" : 'No'}
`; + + // Sections count + html += `
+
Sections
+ ${data.sections ? data.sections.length : 0} total + Paragraphs: ${data.paragraph_count} +
`; + + // Styles + html += `
+
Styles
`; + + const styleNames = Object.keys(data.styles_found || {}); + const missingStyles = data.styles_missing || []; + + if (styleNames.length > 0) { + html += `
`; + for (const [name, info] of Object.entries(data.styles_found)) { + const hasIssues = info.issues && info.issues.length > 0; + html += `
+ + ${name} + ${info.count || 0} ¶ +
`; + } + html += `
`; + } + + if (missingStyles.length > 0) { + html += `
`; + for (const name of missingStyles) { + html += `
+ + ${name} + MISSING +
`; + } + html += `
`; + } + + html += `
`; + + // Issues and warnings + const allIssues = [ + ...(data.issues || []).map((i) => ({ text: i, type: "error" })), + ...(data.warnings || []).map((w) => ({ text: w, type: "warning" })), + ]; + + if (allIssues.length > 0) { + html += `
+
Issues
`; + for (const issue of allIssues) { + const icon = issue.type === "error" ? "bi-x-circle" : "bi-exclamation-circle"; + html += `
+ ${escapeHtml(issue.text)} +
`; + } + html += `
`; + } + + panel.innerHTML = html; +} + +function doFormat() { + if (!currentUploadId) return; + + document.getElementById("formatBtn").disabled = true; + showLoading("Formatting document..."); + + 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(); + const imprint = document.getElementById("imprintInput").value.trim(); + const padMultipleOf4 = document.getElementById("padMultipleCheck").checked; + const stripGraphics = document.getElementById("stripGraphicsCheck").checked; + const stripProblematicGraphics = document.getElementById("stripProblematicCheck").checked; + const language = document.getElementById("languageSelect").value; + + fetch("/api/format", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + upload_id: currentUploadId, + font: font, + fontSize: fontSize, + innerMargin: innerMargin, + trimSize: trimSize, + bleed: bleed, + author: author, + imprint: imprint, + padMultipleOf4: padMultipleOf4, + stripGraphics: stripGraphics, + stripProblematicGraphics: stripProblematicGraphics, + language: language + }), + }) + .then((r) => r.json()) + .then((data) => { + hideLoading(); + document.getElementById("formatBtn").disabled = false; + if (data.error) { + showToast(data.error, "danger"); + return; + } + showFormatted(data); + }) + .catch((err) => { + hideLoading(); + document.getElementById("formatBtn").disabled = false; + showToast("Formatting failed: " + err.message, "danger"); + }); +} + +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"; + + // Enable downloads + dlDiv.classList.remove("d-none"); + document.getElementById("downloadDocx").href = data.download_docx; + document.getElementById("downloadPdf").href = data.download_pdf; + + panel.innerHTML = ` +
+ +
Formatting Complete
+
+
+
What was applied
+
    +
  • Trim size: ${trimLabel}, portrait (bleed: ${bleed})
  • +
  • Mirrored margins: Inside ${innerMargin} in / Outside 0.50 in
  • +
  • Top/Bottom margins: 0.75 in / 0.75 in
  • +
  • Font: ${font} ${fontSize}pt (body paragraphs)
  • +
  • Chapter titles: Caladea 18pt
  • +
  • Different first page enabled on all sections
  • +
  • Major sections (TOC, chapters, back matter) start on a right-hand (recto) page
  • +
  • Page numbers: centered in the footer (running heads carry titles only)
  • +
  • Language: ${document.getElementById("languageSelect").value}
  • +
  • Front matter: lowercase Roman numerals
  • +
  • Chapter 1: restarts at page 1 (Arabic)
  • +
  • Styles applied: WA Chapter, WA Nrml Pgr, WA NChp Pgr
  • +
  • Section break markers standardized to <<<>>>
  • +
+
+
+

Download the formatted file using the buttons above.

+
+ `; +} + +function showLoading(text) { + document.getElementById("loadingText").textContent = text || "Processing..."; + document.getElementById("loadingOverlay").classList.remove("d-none"); +} + +function hideLoading() { + document.getElementById("loadingOverlay").classList.add("d-none"); +} + +function showToast(msg, type) { + const container = document.getElementById("toastContainer") || (() => { + const c = document.createElement("div"); + c.id = "toastContainer"; + c.style.cssText = "position:fixed;top:20px;right:20px;z-index:99999"; + document.body.appendChild(c); + return c; + })(); + + const colors = { danger: "bg-danger", warning: "bg-warning text-dark", success: "bg-success", info: "bg-info" }; + const cls = colors[type] || "bg-secondary"; + + const el = document.createElement("div"); + el.className = `toast align-items-center text-white border-0 ${cls} show`; + el.role = "alert"; + el.innerHTML = `
+
${escapeHtml(msg)}
+ +
`; + container.appendChild(el); + + setTimeout(() => { el.remove(); }, 5000); +} + +function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} diff --git a/umbraformat/templates/base.html b/umbraformat/templates/base.html new file mode 100644 index 0000000..793dc40 --- /dev/null +++ b/umbraformat/templates/base.html @@ -0,0 +1,39 @@ + + + + + + {% block title %}UmbraFormat{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ +
+
+ UmbraFormat v{{ config.get('VERSION', '1.0.5') }} — B.D. Murphy Book Formatting Specification +
+
+ + + + {% block scripts %}{% endblock %} + + diff --git a/umbraformat/templates/format.html b/umbraformat/templates/format.html new file mode 100644 index 0000000..2d6751f --- /dev/null +++ b/umbraformat/templates/format.html @@ -0,0 +1,267 @@ +{% extends "base.html" %} +{% block title %}UmbraFormat — Format a Manuscript{% endblock %} +{% block content %} + +
+
+

Format a Manuscript

+

Upload a .docx file to analyze and apply the B.D. Murphy formatting spec.

+
+
+ +
+
+
+
+
+ +
Drop a .docx file here or click to browse
+

Maximum file size: 50 MB

+ +
+
+
+ + 0% +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
Formatting Options
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Upload a file to see page count +
+
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
+ + +
+
+
+
+
+
+ + Recommended inner margin: +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+
Analysis Results
+ 0 issues +
+
+
+
+
+
+
+
+
Formatted Output
+ +
+
+
+ +

Upload and analyze a file, then click Format to see results here.

+
+
+
+
+
+
+ +
+
+
+
+
Inner Margin Reference
+
+
+
+
+
UmbraFormat spec
+ + + + + + + + + + + + +
Total Page CountGutter / Inside Margin
under 150 pages0.500 in (12.7 mm)
150 to 300 pages0.625 in (15.8 mm)
over 300 pages0.750 in (19.0 mm)
+
+
+
KDP guidance
+ + + + + + + + + + + + + + +
Total Page CountMinimum Inner Margin
24 to 150 pages0.375 in (9.6 mm)
151 to 300 pages0.500 in (12.7 mm)
301 to 500 pages0.625 in (15.9 mm)
501 to 700 pages0.750 in (19.1 mm)
701 to 828 pages0.875 in (22.3 mm)
+
+
+
+
+
+
+ +
+
+
+ Processing... +
+

Processing...

+
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/umbraformat/templates/index.html b/umbraformat/templates/index.html new file mode 100644 index 0000000..bbb72d6 --- /dev/null +++ b/umbraformat/templates/index.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}UmbraFormat — Home{% endblock %} +{% block content %} +
+
+ +
+ +

UmbraFormat

+

Format a manuscript for publishing and printing

+
+ +
+
+
About
+

+ UmbraFormat applies the B.D. Murphy General Book Formatting Specification to any manuscript. + Upload a .docx file with any formatting, and the system will analyze it against the spec, + then apply corrections to produce a print-ready document. +

+
+
+ + + +
+
+
Specification Summary
+
+
+
+ Page + 5.5 × 8.5 in, portrait, mirrored margins +
+
+ Margins + Inside (Configurable) / Outside 0.40" / Top 0.35" / Bottom 0.25" +
+
+ Body + Caladea 12pt, justified, exactly 15pt leading +
+
+
+
+ Chapter + Caladea 18pt, centered, 4× line spacing +
+
+ Header + Odd: book title / Even: B.D. Murphy +
+
+ Pages + Roman (front matter) / Arabic from Chapter 1 +
+
+
+
+
+ +
+
+{% endblock %}