1278 lines
56 KiB
Python
1278 lines
56 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""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
|
|
|
|
def next_id(sid):
|
|
# Assert the w:next ELEMENT exists — the python-docx getter falls
|
|
# back to the default style when w:next is absent, which would
|
|
# otherwise let self-referential next styles pass vacuously.
|
|
el = st[sid].element.find(qn("w:next"))
|
|
return el.get(qn("w:val")) if el is not None else None
|
|
|
|
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(next_id("WAChapter"), "WANChpPgr")
|
|
self.assertEqual(next_id("WASection"), "WANChpPgr")
|
|
self.assertEqual(next_id("WANChpPgr"), "WANrmlPgr")
|
|
self.assertEqual(next_id("WANrmlPgr"), "WANrmlPgr")
|
|
self.assertEqual(next_id("WATOC"), "WATOC")
|
|
|
|
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()
|