Files
Umbraformat/tests/test_formatter.py
bm 0c32168231 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).
2026-09-22 19:06:03 -07:00

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