Files
Umbraformat/tests/test_formatter.py
bm 166a4849da spec section 2.2 WA style family: authoritative styles + dynamic gutter
- spec: new section 2.2 style table (WA Chapter/Section/NChp Pgr/Nrml Pgr/
  Thkng + TOC mapping), margins 0.40/0.40/0.375 + 0.25 header-footer
  distance, dynamic gutter resolution procedure, header Caladea 10pt caps,
  footer 11pt, justified body without auto-hyphenation, WA Section reserved
  for scene breaks.
- formatter: WA_STYLES rebuilt to the spec values (next-style chain, outline
  level on WA Chapter, automatic color, widow control); content assigned by
  style reference with direct paragraph/run formatting removed; scene breaks
  use WA Section; TOC entries use WA TOC; auto-hyphenation removed; new page
  setup constants; header ALL CAPS 10pt / footer 11pt.
- format_document: inside/gutter margin now computed from the final page
  count (build -> render -> recompute fixed point, cap 3, settle on larger
  gutter on oscillation); inner_margin input ignored (UI selector removed).
- tests: 68 pass, incl. style-family value tests and mocked gutter-loop tests.
2026-09-24 07:53:19 -07:00

1265 lines
56 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 unittest import mock
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")
# Tests skip the LibreOffice gutter-resolution loop by default; the
# loop itself is covered by the GutterResolutionTests below.
kwargs.setdefault("resolve_gutter", False)
format_document(self.src, out, **kwargs)
return Document(out)
def test_default_trim_and_margins(self):
d = self._format(resolve_gutter=False)
s = d.sections[0]
self.assertAlmostEqual(s.page_width.inches, 5.5, places=3)
self.assertAlmostEqual(s.page_height.inches, 8.5, places=3)
# spec section 2.1
self.assertAlmostEqual(s.top_margin.inches, 0.40, places=3)
self.assertAlmostEqual(s.bottom_margin.inches, 0.40, places=3)
self.assertAlmostEqual(s.left_margin.inches, 0.50, places=3) # lowest gutter bucket
self.assertAlmostEqual(s.right_margin.inches, 0.375, places=3)
self.assertAlmostEqual(s.header_distance.inches, 0.25, places=3)
self.assertAlmostEqual(s.footer_distance.inches, 0.25, places=3)
def test_6x9_trim(self):
d = self._format(trim_size="6x9", resolve_gutter=False)
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", resolve_gutter=False)
def test_bleed_expands_page_and_margins(self):
d = self._format(resolve_gutter=False, 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.525, places=3)
self.assertAlmostEqual(s.left_margin.inches, 0.625, places=3)
self.assertAlmostEqual(s.right_margin.inches, 0.5, 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, resolve_gutter=False)
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, resolve_gutter=False, **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",
resolve_gutter=False)
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", resolve_gutter=False)
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, resolve_gutter=False, **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)
# WA NChp Pgr: no first-line indent at the style level, and the
# paragraph carries no direct override (spec 2.2).
self.assertAlmostEqual(body[0].style.paragraph_format.first_line_indent.inches,
0.0, places=3)
self.assertIsNone(body[0].paragraph_format.first_line_indent)
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:
# Typography comes from the style (no direct paragraph formatting)
self.assertAlmostEqual(p.style.paragraph_format.first_line_indent.inches,
0.20, places=2)
self.assertIsNone(p.paragraph_format.first_line_indent)
self.assertIsNone(p.paragraph_format.alignment)
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, resolve_gutter=False)
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]
# WA Section carries all scene-break typography (spec 2.2 / 5.1.2)
self.assertEqual(b.style.name, "WA Section")
self.assertIsNone(b.paragraph_format.alignment)
self.assertIsNone(b.paragraph_format.space_before)
self.assertIsNone(b.paragraph_format.space_after)
self.assertIsNone(b.paragraph_format.keep_with_next)
st = b.style
self.assertEqual(st.paragraph_format.alignment,
WD_ALIGN_PARAGRAPH.CENTER)
self.assertEqual(st.paragraph_format.space_before.pt, 6)
self.assertEqual(st.paragraph_format.space_after.pt, 12)
self.assertFalse(st.paragraph_format.keep_with_next)
self.assertTrue(st.paragraph_format.keep_together)
# 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, resolve_gutter=False,
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_header_all_caps_10pt_and_footer_11pt(self):
# Spec 4.1: headers Caladea 10 pt ALL CAPS. Spec 4.2: footers 11 pt.
d = self._format()
ch_sec = d.sections[5]
for para in (ch_sec.header.paragraphs[0],
ch_sec.even_page_header.paragraphs[0]):
text_runs = [r for r in para.runs if r.text.strip()]
self.assertTrue(text_runs)
for r in text_runs:
self.assertTrue(r.font.all_caps)
self.assertEqual(r.font.size.pt, 10)
self.assertEqual(r.font.name, "Caladea")
for para in (ch_sec.footer.paragraphs[0],
ch_sec.even_page_footer.paragraphs[0]):
sizes = {r.font.size.pt for r in para.runs if r.font.size is not None}
self.assertEqual(sizes, {11.0})
names = {r.font.name for r in para.runs if r.font.name is not None}
self.assertEqual(names, {"Caladea"})
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_disabled(self):
# Justified body without automatic hyphenation (spec section 3.2).
d = self._format()
self.assertIsNone(d.settings.element.find(qn("w:autoHyphenation")))
def test_sectpr_child_order_is_ooxml_valid(self):
# Word validates CT_SectPr child order strictly (ECMA-376); LibreOffice
# ignores it. headerReference/footerReference must come FIRST.
d = self._format()
# Independent canonical sequence (subset actually produced here).
order = [
"headerReference", "footerReference", "footnotePr", "endnotePr",
"type", "pgSz", "pgMar", "paperSrc", "pgBorders", "lnNumType",
"pgNumType", "cols", "formProt", "vAlign", "noEndnote",
"titlePg", "mirrorMargins", "textDirection", "bidi", "rtlGutter",
"docGrid", "printerSettings", "sectPrChange",
]
rank = {qn("w:" + t): i for i, t in enumerate(order)}
for sec in d.sections:
tags = [c.tag for c in sec._sectPr]
ranks = [rank[t] for t in tags]
self.assertEqual(
ranks, sorted(ranks),
"sectPr children out of CT_SectPr order: "
+ ", ".join(t.split("}")[-1] for t in tags))
# References must precede every layout element.
self.assertLess(tags.index(qn("w:headerReference")), tags.index(qn("w:pgSz")))
self.assertLess(tags.index(qn("w:footerReference")), tags.index(qn("w:pgSz")))
def test_settings_child_order_is_ooxml_valid(self):
# Word validates CT_Settings child order strictly (ECMA-376).
d = self._format()
order = [
"writeProtection", "view", "zoom", "removePersonalInformation",
"doNotDisplayPageBoundaries", "displayBackgroundShape",
"printPostScriptOverText", "printFractionalCharacterWidth",
"printFormsData", "embedTrueTypeFonts", "embedSystemFonts",
"saveSubsetFonts", "saveFormsData", "mirrorMargins",
"alignBordersAndEdges", "bordersDoNotSurroundHeader",
"bordersDoNotSurroundFooter", "gutterAtTop", "hideSpellingErrors",
"hideGrammaticalErrors", "activeWritingStyle", "proofState",
"formsDesign", "attachedTemplate", "linkStyles",
"stylePaneFormatFilter", "stylePaneSortMethod", "documentType",
"mailMerge", "revisionView", "trackChanges", "doNotTrackMoves",
"doNotTrackFormatting", "documentProtection", "autoFormatOverride",
"styleLockTheme", "styleLockQFSet", "defaultTabStop",
"autoHyphenation", "consecutiveHyphenLimit", "hyphenationZone",
"doNotHyphenateCaps", "showEnvelope", "summaryLength",
"clickAndTypeStyle", "defaultTableStyle", "evenAndOddHeaders",
"bookFoldRevPrinting", "bookFoldPrinting",
"bookFoldPrintingSheets", "drawingGridHorizontalSpacing",
"drawingGridVerticalSpacing",
"displayHorizontalDrawingGridEvery",
"displayVerticalDrawingGridEvery",
"doNotUseMarginsForDrawingGridOrigin",
"drawingGridHorizontalOrigin", "drawingGridVerticalOrigin",
"doNotShadeFormData", "noPunctuationKerning",
"characterSpacingControl", "printTwoOnOne",
"strictFirstAndLastChars", "noLineBreaksAfter",
"noLineBreaksBefore", "savePreviewPicture",
"doNotValidateAgainstSchema", "saveInvalidXml",
"ignoreMixedContent", "alwaysShowPlaceholderText",
"doNotDemarcateInvalidXml", "saveXmlDataOnly",
"useXSLTWhenSaving", "saveThroughXslt", "showXMLTags",
"alwaysMergeEmptyNamespace", "updateFields", "hdrShapeDefaults",
"footnotePr", "endnotePr", "compat", "rsids", "mathPr",
"themeFontLang", "clrSchemeMapping",
"doNotIncludeSubdocsInStats", "doNotAutoCompressPictures",
"forceUpgrade", "captions", "readModeInkLockDown",
"smartTagType", "shapeDefaults", "doNotEmbedSmartTags",
"decimalSymbol", "listSeparator",
]
rank = {qn("w:" + t): i for i, t in enumerate(order)}
# w:mathPr lives in the math namespace, not w:.
rank['{http://schemas.openxmlformats.org/officeDocument/2006/math}mathPr'] = rank.pop(qn("w:mathPr"))
settings = d.settings.element
tags = [c.tag for c in settings]
# Cross-namespace extensions (e.g. w14:docId from the source doc) are
# interleaved and tolerated by Word; only the canonical w:/m: sequence
# must be monotonic.
canonical = [t for t in tags if t in rank]
ranks = [rank[t] for t in canonical]
self.assertEqual(
ranks, sorted(ranks),
"w:settings children out of CT_Settings order: "
+ ", ".join(t.split("}")[-1] for t in canonical))
def test_toc_fields_carry_no_dirty_flag(self):
# Per-field w:dirty crashes Word on this book (isolated over 24
# bisection files); PAGEREF begin runs must stay clean and rely on
# the settings-level updateFields batch path instead.
d = self._format()
n_begin = 0
for fc in d.element.body.iter(qn("w:fldChar")):
if fc.get(qn("w:fldCharType")) != "begin":
continue
rpr = fc.getparent().find(qn("w:rPr"))
if rpr is not None:
self.assertIsNone(
rpr.find(qn("w:dirty")),
"PAGEREF begin run must not carry w:dirty")
n_begin += 1
# The book ships two chapters in the test manuscript -> 2 TOC fields.
self.assertGreaterEqual(n_begin, 2)
def test_settings_request_field_update_on_open(self):
# Word recomputes the TOC page numbers through the settings-level
# <w:updateFields> batch path (proved to open the full book).
d = self._format()
uf = d.settings.element.find(qn("w:updateFields"))
self.assertIsNotNone(uf)
self.assertEqual(uf.get(qn("w:val")), "true")
def test_style_references_resolve(self):
# Word rejects documents whose paragraphs reference styles that are
# not defined in styles.xml (LO silently falls back to Normal).
d = self._format()
styles = d.styles.element
defined = set()
for s in styles.findall(qn("w:style")):
sid = s.get(qn("w:styleId"))
if sid:
defined.add(sid)
missing = set()
for el in d.element.body.iter():
if el.tag in (qn("w:pStyle"), qn("w:rStyle")):
val = el.get(qn("w:val"))
if val and val not in defined:
missing.add(val)
self.assertEqual(missing, set())
def test_body_pPr_children_schema_ordered(self):
# Generated paragraphs (e.g. TOC entries) must keep CT_PPr order so
# Word does not flag the section properties or paragraph properties.
d = self._format()
order = [
"pStyle", "keepNext", "keepLines", "pageBreakBefore", "framePr",
"widowControl", "numPr", "suppressLineNumbers", "pBdr", "shd",
"tabs", "suppressAutoHyphens", "kinsoku", "wordWrap",
"overflowPunct", "topLinePunct", "autoSpaceDE", "autoSpaceDN",
"bidi", "adjustRightInd", "snapToGrid", "spacing", "ind",
"contextualSpacing", "mirrorIndents", "suppressOverlap", "jc",
"textDirection", "textAlignment", "textboxTightWrap", "outlineLvl",
"divId", "cnfStyle", "rPr", "sectPr", "pPrChange",
]
rank = {qn("w:" + t): i for i, t in enumerate(order)}
bad = []
for p in d.paragraphs:
ppr = p._p.find(qn("w:pPr"))
if ppr is None:
continue
tags = [c.tag for c in ppr]
ranks = [rank[t] for t in tags]
if ranks != sorted(ranks):
bad.append(", ".join(t.split("}")[-1] for t in tags))
self.assertEqual(bad, [])
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, resolve_gutter=False, **kw)
return Document(out)
def test_heading_styles_keep_with_next(self):
d = self._format()
# WA Chapter: keep with next + keep lines together (spec 2.2)
ch_pf = d.styles["WA Chapter"].paragraph_format
self.assertTrue(ch_pf.keep_with_next)
self.assertTrue(ch_pf.keep_together)
# WA Section is the scene-break style: keep lines together, but keep
# with next OFF so the break is not dragged to the next page (5.1.2).
sec_pf = d.styles["WA Section"].paragraph_format
self.assertFalse(sec_pf.keep_with_next)
self.assertTrue(sec_pf.keep_together)
def test_body_styles_widow_control(self):
d = self._format()
for name in ("WA Nrml Pgr", "WA NChp Pgr", "WA TOC"):
self.assertTrue(d.styles[name].paragraph_format.widow_control,
f"{name} must enable widow/orphan control")
def test_chapter_title_para_uses_style_not_direct_flags(self):
d = self._format()
paras = _section_paragraphs(d, 5)
title = paras[1]
self.assertEqual(title.style.name, "WA Chapter")
# No direct paragraph overrides: the style governs (spec 2.2)
self.assertIsNone(title.paragraph_format.keep_with_next)
self.assertIsNone(title.paragraph_format.keep_together)
self.assertIsNone(title.paragraph_format.alignment)
self.assertIsNone(title.paragraph_format.line_spacing)
self.assertTrue(d.styles["WA Chapter"].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 = {"resolve_gutter": False, "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,
resolve_gutter=False)
d = Document(out)
self.assertTrue(_has_page_field(d.sections[5].footer.paragraphs[0]))
class DynamicGutterTests(unittest.TestCase):
"""Spec section 2.1: the inside/gutter margin is derived from the final
page count through a fixed-point build/render loop. The render is mocked
so the loop is exercised deterministically and quickly."""
def setUp(self):
self.src_dir, self.src = _build_manuscript()
self.out_dir = tempfile.mkdtemp(prefix="umbraformat_gutter_")
def tearDown(self):
for p in (self.src_dir, self.out_dir):
shutil.rmtree(p, ignore_errors=True)
def _run(self, page_counts):
out = os.path.join(self.out_dir, "out.docx")
with mock.patch("umbraformat.formatter._rendered_page_count",
side_effect=list(page_counts)) as counter:
format_document(self.src, out)
return Document(out), counter.call_count
def test_low_page_count_keeps_lowest_bucket(self):
d, calls = self._run([120])
self.assertEqual(calls, 1)
self.assertAlmostEqual(d.sections[0].left_margin.inches, 0.50, places=3)
def test_long_book_rebuilds_with_wider_gutter(self):
d, calls = self._run([400, 400])
self.assertEqual(calls, 2)
self.assertAlmostEqual(d.sections[0].left_margin.inches, 0.75, places=3)
def test_oscillation_settles_on_larger_gutter(self):
d, calls = self._run([151, 149])
self.assertAlmostEqual(d.sections[0].left_margin.inches, 0.625,
places=3)
self.assertEqual(calls, 2)
def test_render_unavailable_keeps_provisional_gutter(self):
d, calls = self._run([0])
self.assertEqual(calls, 1)
self.assertAlmostEqual(d.sections[0].left_margin.inches, 0.50, places=3)
def test_every_section_shares_the_resolved_gutter(self):
d, _ = self._run([400, 400])
for sec in d.sections:
self.assertAlmostEqual(sec.left_margin.inches, 0.75, places=3)
self.assertAlmostEqual(sec.right_margin.inches, 0.375, places=3)
class WAStyleFamilyTests(unittest.TestCase):
"""Spec section 2.2: the WA style family is defined with the spec's exact
values and is applied to content (no direct paragraph overrides)."""
def setUp(self):
self.src_dir, self.src = _build_manuscript()
self.out_dir = tempfile.mkdtemp(prefix="umbraformat_styles_")
def tearDown(self):
for p in (self.src_dir, self.out_dir):
shutil.rmtree(p, ignore_errors=True)
def _format(self, **kw):
kw.setdefault("resolve_gutter", False)
out = os.path.join(self.out_dir, "out.docx")
format_document(self.src, out, **kw)
return Document(out)
def test_wa_style_values_match_spec(self):
st = self._format().styles
for name in ("WA Chapter", "WA Section", "WA NChp Pgr", "WA Nrml Pgr",
"WA Thkng"):
self.assertEqual(st[name].font.name, "Caladea", name)
self.assertEqual(st["WA Chapter"].font.size.pt, 18)
self.assertEqual(st["WA Section"].font.size.pt, 16)
self.assertEqual(st["WA NChp Pgr"].font.size.pt, 11)
self.assertEqual(st["WA Nrml Pgr"].font.size.pt, 11)
self.assertEqual(st["WA Thkng"].font.size.pt, 11)
self.assertTrue(st["WA Thkng"].font.italic)
for name in ("WA Chapter", "WA Section"):
self.assertEqual(st[name].paragraph_format.alignment,
WD_ALIGN_PARAGRAPH.CENTER, name)
for name in ("WA NChp Pgr", "WA Nrml Pgr", "WA TOC"):
self.assertEqual(st[name].paragraph_format.alignment,
WD_ALIGN_PARAGRAPH.JUSTIFY
if name != "WA TOC" else WD_ALIGN_PARAGRAPH.LEFT,
name)
pf = st[name].paragraph_format
self.assertEqual(pf.line_spacing_rule, WD_LINE_SPACING.EXACTLY, name)
self.assertAlmostEqual(pf.line_spacing.pt, 15.0, places=1, msg=name)
self.assertEqual(pf.space_before.pt, 0, name)
self.assertEqual(pf.space_after.pt, 0, name)
self.assertEqual(st["WA Section"].paragraph_format.space_before.pt, 6)
self.assertEqual(st["WA Section"].paragraph_format.space_after.pt, 12)
self.assertAlmostEqual(st["WA Section"].paragraph_format.line_spacing,
1.15, places=2)
self.assertAlmostEqual(st["WA Chapter"].paragraph_format.line_spacing,
4.0, places=2)
self.assertAlmostEqual(
st["WA Nrml Pgr"].paragraph_format.first_line_indent.inches,
0.20, places=2)
self.assertAlmostEqual(
st["WA NChp Pgr"].paragraph_format.first_line_indent.inches,
0.0, places=3)
def test_wa_style_bases_and_next_styles(self):
st = self._format().styles
self.assertEqual(st["WA Chapter"].base_style.name, "Heading 1")
self.assertEqual(st["WA Section"].base_style.name, "Normal")
self.assertEqual(st["WA Thkng"].base_style.name, "WA Nrml Pgr")
self.assertEqual(st["WA Chapter"].next_paragraph_style.name, "WA NChp Pgr")
self.assertEqual(st["WA Section"].next_paragraph_style.name, "WA NChp Pgr")
self.assertEqual(st["WA NChp Pgr"].next_paragraph_style.name, "WA Nrml Pgr")
self.assertEqual(st["WA Nrml Pgr"].next_paragraph_style.name, "WA Nrml Pgr")
def test_wa_chapter_outline_level_and_automatic_color(self):
el = self._format().styles["WA Chapter"].element
outline = el.find(qn("w:pPr")).find(qn("w:outlineLvl"))
self.assertEqual(outline.get(qn("w:val")), "0")
color = el.find(qn("w:rPr")).find(qn("w:color"))
self.assertEqual(color.get(qn("w:val")), "auto")
def test_toc_entries_use_wa_toc_style(self):
d = self._format()
entries = [p for p in _section_paragraphs(d, 4)
if p._p.find(".//" + qn("w:instrText")) is not None]
self.assertTrue(entries, "TOC should contain PAGEREF field entries")
for p in entries:
self.assertEqual(p.style.name, "WA TOC")
# The right dot-leader tab stop is the one allowed per-paragraph
# override (position depends on the final text width).
self.assertEqual(len(p.paragraph_format.tab_stops), 1)
def test_body_paragraphs_carry_no_direct_typography(self):
d = self._format()
for p in d.paragraphs:
if p.style.name in ("WA Nrml Pgr", "WA NChp Pgr", "WA Chapter"):
self.assertIsNone(p.paragraph_format.alignment)
self.assertIsNone(p.paragraph_format.line_spacing)
self.assertIsNone(p.paragraph_format.space_before)
self.assertIsNone(p.paragraph_format.space_after)
for r in p.runs:
# Font/size come from the style, not the run
self.assertIsNone(r.font.size)
self.assertIsNone(r.font.name)
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, resolve_gutter=False)
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, resolve_gutter=False, **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 (gutter bucket) - 0.375 (outside, spec 2.1)
self.assertLessEqual(cx, int(Inches(4.625)))
# 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, resolve_gutter=False,
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, resolve_gutter=False)
self.assertTrue(os.path.exists(path))
self.assertTrue(os.path.getsize(path) > 0)
if __name__ == "__main__":
unittest.main()