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).
This commit is contained in:
bm
2026-09-22 19:06:03 -07:00
commit 0c32168231
17 changed files with 5061 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -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

18
Dockerfile Normal file
View File

@@ -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)"]

10
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -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/<filename>` | Download formatted `.docx` |
| `GET` | `/api/download/<filename>/pdf` | Download formatted `.pdf` |
| `GET` | `/api/diag/<name>` | 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/<uuid>.docx` + `<uuid>_analysis.json`
- **Formatted output:** `umbraformat/formatted/<uuid>_formatted.docx` + `<uuid>_formatted.pdf`
- **Name mapping:** `umbraformat/formatted/<uuid>_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)

View File

@@ -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).

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
python-docx==1.2.0
flask==3.1.3
pypdf==6.19.0

17
start_umbraformat.py Normal file
View File

@@ -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)

914
tests/test_formatter.py Normal file
View File

@@ -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 = (
'<w:drawing %s>'
'<wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" '
'relativeHeight="1" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1">'
'<wp:simplePos x="0" y="0"/>'
'<wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH>'
'<wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV>'
'<wp:extent cx="914400" cy="457200"/>'
'<wp:wrapNone/>'
'<wp:docPr id="1" name="pic1"/>'
'<a:graphic><a:graphicData '
'uri="http://schemas.openxmlformats.org/drawingml/2006/picture"/>'
'</a:graphic>'
'</wp:anchor>'
'</w:drawing>'
) % 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()

1
umbraformat/__init__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "1.1.0"

339
umbraformat/analyzer.py Normal file
View File

@@ -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

220
umbraformat/app.py Normal file
View File

@@ -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/<filename>")
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/<filename>/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/<name>")
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)

2059
umbraformat/formatter.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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); }

View File

@@ -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 = `<i class="bi bi-file-text me-1"></i>${wordCount} words &middot; ~${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 = `<div class="mb-3 pb-2 border-bottom border-secondary">
<h6 class="text-info"><i class="bi bi-file-earmark-text me-2"></i>${escapeHtml(data.original_filename || "Untitled")}</h6>
</div>`;
// Page setup summary
html += `<div class="mb-3">
<h6 class="text-secondary mb-2"><i class="bi bi-layout-text-window-reverse"></i> Page Setup</h6>
<table class="table table-sm table-borderless text-secondary small mb-0">
<tr><td>Size</td><td class="text-end">${data.page_setup.width} x ${data.page_setup.height} in</td></tr>
<tr><td>Orientation</td><td class="text-end">${data.page_setup.orientation}</td></tr>`;
if (s0) {
html += `<tr><td>Margins</td><td class="text-end">T:${s0.top}" B:${s0.bottom}" I:${s0.left}" O:${s0.right}"</td></tr>`;
html += `<tr><td>Mirror margins</td><td class="text-end">${s0.mirror_margins ? "Yes" : '<span class="text-danger">No</span>'}</td></tr>`;
html += `<tr><td>Different first page</td><td class="text-end">${s0.different_first_page ? "Yes" : '<span class="text-danger">No</span>'}</td></tr>`;
}
html += `</table></div>`;
// Sections count
html += `<div class="mb-3">
<h6 class="text-secondary mb-2"><i class="bi bi-columns-gap"></i> Sections</h6>
<span class="text-secondary small">${data.sections ? data.sections.length : 0} total</span>
<span class="text-secondary small ms-3">Paragraphs: ${data.paragraph_count}</span>
</div>`;
// Styles
html += `<div class="mb-3">
<h6 class="text-secondary mb-2"><i class="bi bi-palette"></i> Styles</h6>`;
const styleNames = Object.keys(data.styles_found || {});
const missingStyles = data.styles_missing || [];
if (styleNames.length > 0) {
html += `<div class="small">`;
for (const [name, info] of Object.entries(data.styles_found)) {
const hasIssues = info.issues && info.issues.length > 0;
html += `<div class="style-check">
<i class="bi ${hasIssues ? 'bi-x-circle-fill text-danger' : 'bi-check-circle-fill'}"></i>
<span class="${hasIssues ? 'text-danger' : ''}">${name}</span>
<span class="text-secondary ms-auto">${info.count || 0} ¶</span>
</div>`;
}
html += `</div>`;
}
if (missingStyles.length > 0) {
html += `<div class="mt-2 small">`;
for (const name of missingStyles) {
html += `<div class="style-check">
<i class="bi bi-x-circle-fill text-danger"></i>
<span class="text-danger">${name}</span>
<span class="text-secondary ms-auto">MISSING</span>
</div>`;
}
html += `</div>`;
}
html += `</div>`;
// 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 += `<div>
<h6 class="text-secondary mb-2"><i class="bi bi-exclamation-triangle"></i> Issues</h6>`;
for (const issue of allIssues) {
const icon = issue.type === "error" ? "bi-x-circle" : "bi-exclamation-circle";
html += `<div class="issue-item ${issue.type} small">
<i class="bi ${icon} me-1"></i> ${escapeHtml(issue.text)}
</div>`;
}
html += `</div>`;
}
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 = `
<div class="text-center mb-3">
<i class="bi bi-check-circle-fill text-success" style="font-size: 2.5rem;"></i>
<h5 class="mt-2 text-success">Formatting Complete</h5>
</div>
<div class="format-summary">
<h6 class="mb-3"><i class="bi bi-list-check me-2"></i>What was applied</h6>
<ul class="small text-secondary mb-0">
<li>Trim size: ${trimLabel}, portrait (bleed: ${bleed})</li>
<li>Mirrored margins: Inside ${innerMargin} in / Outside 0.50 in</li>
<li>Top/Bottom margins: 0.75 in / 0.75 in</li>
<li>Font: ${font} ${fontSize}pt (body paragraphs)</li>
<li>Chapter titles: Caladea 18pt</li>
<li>Different first page enabled on all sections</li>
<li>Major sections (TOC, chapters, back matter) start on a right-hand (recto) page</li>
<li>Page numbers: centered in the footer (running heads carry titles only)</li>
<li>Language: ${document.getElementById("languageSelect").value}</li>
<li>Front matter: lowercase Roman numerals</li>
<li>Chapter 1: restarts at page 1 (Arabic)</li>
<li>Styles applied: WA Chapter, WA Nrml Pgr, WA NChp Pgr</li>
<li>Section break markers standardized to <<<>>></li>
</ul>
</div>
<div class="text-center mt-3">
<p class="small text-secondary">Download the formatted file using the buttons above.</p>
</div>
`;
}
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 = `<div class="d-flex">
<div class="toast-body">${escapeHtml(msg)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>`;
container.appendChild(el);
setTimeout(() => { el.remove(); }, 5000);
}
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}UmbraFormat{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('index') }}">
<i class="bi bi-file-earmark-text me-2 text-primary"></i>
<strong>UmbraFormat</strong>
</a>
<div class="navbar-nav ms-auto">
<a class="nav-link" href="{{ url_for('index') }}">Home</a>
<a class="nav-link" href="{{ url_for('format_page') }}">Format</a>
</div>
</div>
</nav>
<main class="container py-4">
{% block content %}{% endblock %}
</main>
<footer class="footer mt-auto py-3 bg-dark border-top border-secondary">
<div class="container text-center text-secondary">
<small>UmbraFormat v{{ config.get('VERSION', '1.0.5') }} &mdash; B.D. Murphy Book Formatting Specification</small>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,267 @@
{% extends "base.html" %}
{% block title %}UmbraFormat — Format a Manuscript{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2><i class="bi bi-upload me-2"></i>Format a Manuscript</h2>
<p class="text-secondary">Upload a <code>.docx</code> file to analyze and apply the B.D. Murphy formatting spec.</p>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card bg-dark border-secondary">
<div class="card-body">
<div id="dropZone" class="upload-zone text-center p-5" style="cursor: pointer;">
<i class="bi bi-cloud-upload" style="font-size: 3rem;"></i>
<h5 class="mt-3">Drop a .docx file here or click to browse</h5>
<p class="text-secondary small mb-0">Maximum file size: 50 MB</p>
<input type="file" id="fileInput" accept=".docx" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer;">
</div>
<div id="uploadProgress" class="d-none mt-3">
<div class="d-flex justify-content-between mb-1">
<span id="uploadFileName" class="small"></span>
<span id="uploadPercent" class="small">0%</span>
</div>
<div class="progress" style="height: 6px;">
<div id="uploadProgressBar" class="progress-bar" style="width: 0%"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="optionsArea" class="d-none mb-4">
<div class="row">
<div class="col-12">
<div class="card bg-dark border-secondary">
<div class="card-header bg-dark border-secondary">
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Formatting Options</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<label for="fontSelect" class="form-label">Paragraph Font</label>
<select id="fontSelect" class="form-select">
<option value="Caladea" selected>Caladea</option>
<option value="Garamond">Garamond</option>
<option value="Baskerville">Baskerville</option>
<option value="Palatino">Palatino</option>
</select>
</div>
<div class="col-md-2">
<label for="fontSizeSelect" class="form-label">Font Size</label>
<select id="fontSizeSelect" class="form-select">
<option value="10">10 pt</option>
<option value="11">11 pt</option>
<option value="12" selected>12 pt</option>
</select>
</div>
<div class="col-md-3">
<label for="innerMarginSelect" class="form-label">Inner Margin</label>
<select id="innerMarginSelect" class="form-select">
<option value="0.375">0.375 in (24-150 pages)</option>
<option value="0.500">0.500 in (151-300 pages)</option>
<option value="0.625">0.625 in (301-500 pages)</option>
<option value="0.750">0.750 in (501-700 pages)</option>
<option value="0.875">0.875 in (701-828 pages)</option>
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div id="pageCountInfo" class="text-secondary small">
<i class="bi bi-info-circle me-1"></i>Upload a file to see page count
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-3">
<label for="trimSelect" class="form-label">Trim Size</label>
<select id="trimSelect" class="form-select">
<option value="5.5x8.5" selected>5.5 × 8.5 in (trade paperback)</option>
<option value="6x9">6 × 9 in</option>
</select>
</div>
<div class="col-md-3">
<label for="marginTableSelect" class="form-label">Gutter Table</label>
<select id="marginTableSelect" class="form-select">
<option value="umbraformat" selected>UmbraFormat spec (0.50 – 0.75 in)</option>
<option value="kdp">KDP guidance (0.375 – 0.875 in)</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label d-block">&nbsp;</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="bleedCheck">
<label class="form-check-label" for="bleedCheck">0.125 in bleed (full-bleed graphics)</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="padMultipleCheck" checked>
<label class="form-check-label" for="padMultipleCheck">Pad PDF to a multiple of 4 pages</label>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-4">
<label for="authorInput" class="form-label">Author (title page)</label>
<input type="text" id="authorInput" class="form-control"
placeholder="e.g. B.D. Murphy">
</div>
<div class="col-md-4">
<label for="imprintInput" class="form-label">Imprint / Publisher</label>
<input type="text" id="imprintInput" class="form-control"
placeholder="e.g. WorstAuthor LLC">
</div>
<div class="col-md-4">
<label for="languageSelect" class="form-label">Language</label>
<select id="languageSelect" class="form-select">
<option value="en" selected>English</option>
<option value="es">Español (Spanish)</option>
<option value="de">Deutsch (German)</option>
<option value="fr">Français (French)</option>
<option value="ar">العربية (Arabic, RTL)</option>
<option value="he">עברית (Hebrew, RTL)</option>
</select>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<label class="form-label d-block">Graphics handling</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="stripProblematicCheck">
<label class="form-check-label" for="stripProblematicCheck">Remove images that overflow the text box</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="stripGraphicsCheck">
<label class="form-check-label" for="stripGraphicsCheck">Strip decorative images (scene breaks become ***)</label>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div id="marginRecommendation" class="alert alert-info d-none mb-0">
<i class="bi bi-lightbulb me-2"></i>
<strong>Recommended inner margin:</strong> <span id="recommendedMargin"></span>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 text-center">
<button id="formatBtn" class="btn btn-lg btn-primary px-5" disabled>
<i class="bi bi-magic me-2"></i>Format Document
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="resultsArea" class="d-none">
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card bg-dark border-secondary h-100">
<div class="card-header bg-dark border-secondary d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-search me-2"></i>Analysis Results</h5>
<span id="issueCount" class="badge bg-warning">0 issues</span>
</div>
<div class="card-body" id="analysisPanel" style="max-height: 600px; overflow-y: auto;">
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card bg-dark border-secondary h-100">
<div class="card-header bg-dark border-secondary d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-check-circle me-2"></i>Formatted Output</h5>
<div id="downloadButtons" class="d-none">
<a id="downloadDocx" class="btn btn-sm btn-outline-primary me-1" href="#">
<i class="bi bi-file-earmark-word"></i> DOCX
</a>
<a id="downloadPdf" class="btn btn-sm btn-outline-danger" href="#">
<i class="bi bi-file-earmark-pdf"></i> PDF
</a>
</div>
</div>
<div class="card-body" id="formatPanel" style="max-height: 600px; overflow-y: auto;">
<div class="text-center text-secondary py-5">
<i class="bi bi-arrow-left" style="font-size: 2rem;"></i>
<p class="mt-2">Upload and analyze a file, then click <strong>Format</strong> to see results here.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card bg-dark border-secondary">
<div class="card-header bg-dark border-secondary">
<h5 class="mb-0"><i class="bi bi-table me-2"></i>Inner Margin Reference</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-secondary small text-uppercase">UmbraFormat spec</h6>
<table class="table table-sm table-bordered mb-3" style="max-width: 420px;">
<thead>
<tr>
<th>Total Page Count</th>
<th>Gutter / Inside Margin</th>
</tr>
</thead>
<tbody>
<tr><td>under 150 pages</td><td>0.500 in (12.7 mm)</td></tr>
<tr><td>150 to 300 pages</td><td>0.625 in (15.8 mm)</td></tr>
<tr><td>over 300 pages</td><td>0.750 in (19.0 mm)</td></tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<h6 class="text-secondary small text-uppercase">KDP guidance</h6>
<table class="table table-sm table-bordered mb-0" style="max-width: 420px;">
<thead>
<tr>
<th>Total Page Count</th>
<th>Minimum Inner Margin</th>
</tr>
</thead>
<tbody>
<tr><td>24 to 150 pages</td><td>0.375 in (9.6 mm)</td></tr>
<tr><td>151 to 300 pages</td><td>0.500 in (12.7 mm)</td></tr>
<tr><td>301 to 500 pages</td><td>0.625 in (15.9 mm)</td></tr>
<tr><td>501 to 700 pages</td><td>0.750 in (19.1 mm)</td></tr>
<tr><td>701 to 828 pages</td><td>0.875 in (22.3 mm)</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="loadingOverlay" class="d-none">
<div class="loading-spinner">
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Processing...</span>
</div>
<p class="mt-3" id="loadingText">Processing...</p>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('fontSelect').addEventListener('change', updatePageEstimate);
document.getElementById('fontSizeSelect').addEventListener('change', updatePageEstimate);
document.getElementById('trimSelect').addEventListener('change', updatePageEstimate);
document.getElementById('marginTableSelect').addEventListener('change', updateMarginRecommendation);
document.getElementById('innerMarginSelect').addEventListener('change', function() {
document.getElementById('marginRecommendation').classList.add('d-none');
});
</script>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}UmbraFormat — Home{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-10">
<div class="text-center my-5">
<i class="bi bi-file-earmark-text" style="font-size: 4rem; color: var(--bs-primary);"></i>
<h1 class="display-4 fw-bold mt-3">UmbraFormat</h1>
<p class="lead text-secondary">Format a manuscript for publishing and printing</p>
</div>
<div class="card bg-dark border-secondary mb-4">
<div class="card-body p-4">
<h5 class="card-title"><i class="bi bi-info-circle me-2"></i>About</h5>
<p class="card-text text-secondary">
UmbraFormat applies the <strong>B.D. Murphy General Book Formatting Specification</strong> to any manuscript.
Upload a <code>.docx</code> file with any formatting, and the system will analyze it against the spec,
then apply corrections to produce a print-ready document.
</p>
</div>
</div>
<div class="card bg-dark border-secondary mb-4">
<div class="card-body p-4">
<h5 class="card-title"><i class="bi bi-tools me-2"></i>Tools</h5>
<div class="list-group list-group-flush bg-transparent">
<a href="{{ url_for('format_page') }}" class="list-group-item list-group-item-action bg-transparent border-secondary d-flex align-items-center">
<i class="bi bi-upload me-3 text-primary fs-4"></i>
<div>
<strong>Format a Manuscript</strong>
<div class="text-secondary small">Upload a DOCX file, analyze formatting, and apply the B.D. Murphy spec</div>
</div>
<i class="bi bi-chevron-right ms-auto text-secondary"></i>
</a>
</div>
</div>
</div>
<div class="card bg-dark border-secondary">
<div class="card-body p-4">
<h5 class="card-title"><i class="bi bi-book me-2"></i>Specification Summary</h5>
<div class="row g-3 mt-2">
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">Page</span>
<span class="text-secondary">5.5 × 8.5 in, portrait, mirrored margins</span>
</div>
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">Margins</span>
<span class="text-secondary">Inside (Configurable) / Outside 0.40" / Top 0.35" / Bottom 0.25"</span>
</div>
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">Body</span>
<span class="text-secondary">Caladea 12pt, justified, exactly 15pt leading</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">Chapter</span>
<span class="text-secondary">Caladea 18pt, centered, 4× line spacing</span>
</div>
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">Header</span>
<span class="text-secondary">Odd: book title / Even: B.D. Murphy</span>
</div>
<div class="d-flex align-items-center mb-2">
<span class="badge bg-primary me-2">Pages</span>
<span class="text-secondary">Roman (front matter) / Arabic from Chapter 1</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}