- 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).
914 lines
38 KiB
Python
914 lines
38 KiB
Python
"""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() |