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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
18
Dockerfile
Normal 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
10
docker-compose.yml
Normal 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
|
||||
404
docs/OLD_Book_formatting_spec.md.old
Normal file
404
docs/OLD_Book_formatting_spec.md.old
Normal 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)
|
||||
175
docs/umbraformat_specification_document.md
Normal file
175
docs/umbraformat_specification_document.md
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
python-docx==1.2.0
|
||||
flask==3.1.3
|
||||
pypdf==6.19.0
|
||||
17
start_umbraformat.py
Normal file
17
start_umbraformat.py
Normal 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
914
tests/test_formatter.py
Normal 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
1
umbraformat/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "1.1.0"
|
||||
339
umbraformat/analyzer.py
Normal file
339
umbraformat/analyzer.py
Normal 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
220
umbraformat/app.py
Normal 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
2059
umbraformat/formatter.py
Normal file
File diff suppressed because it is too large
Load Diff
85
umbraformat/static/css/style.css
Normal file
85
umbraformat/static/css/style.css
Normal 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); }
|
||||
408
umbraformat/static/js/main.js
Normal file
408
umbraformat/static/js/main.js
Normal 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 · ~${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;
|
||||
}
|
||||
39
umbraformat/templates/base.html
Normal file
39
umbraformat/templates/base.html
Normal 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') }} — 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>
|
||||
267
umbraformat/templates/format.html
Normal file
267
umbraformat/templates/format.html
Normal 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"> </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 %}
|
||||
78
umbraformat/templates/index.html
Normal file
78
umbraformat/templates/index.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user