diff --git a/screenplain/export/docx.py b/screenplain/export/docx.py new file mode 100644 index 0000000..c032dad --- /dev/null +++ b/screenplain/export/docx.py @@ -0,0 +1,332 @@ +# Copyright (c) 2011 Martin Vilcans +# Licensed under the MIT license: +# http://www.opensource.org/licenses/mit-license.php + +import io +import os +import xml.etree.ElementTree as ET +import zipfile +from contextlib import contextmanager +from xml.sax.saxutils import XMLGenerator +from xml.sax.xmlreader import AttributesNSImpl + +from screenplain.richstring import Bold, Italic, Underline, plain +from screenplain.types import ( + Action, + Dialog, + DualDialog, + PageBreak, + Section, + Slug, + Transition, +) + +_TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "docx_template.docx") + +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +XML_NS = "http://www.w3.org/XML/1998/namespace" + +ET.register_namespace("w", W) +ET.register_namespace("r", R) + +_EMPTY_ATTRS = AttributesNSImpl({}, {}) + +_NS_PREFIX = {W: "w", R: "r", XML_NS: "xml"} + + +def _attrs(*pairs): + by_name = {(W, k): v for k, v in pairs} + qnames = {(W, k): f"w:{k}" for k, _ in pairs} + return AttributesNSImpl(by_name, qnames) + + +def _xml_attrs(**kwargs): + by_name = {(XML_NS, k): v for k, v in kwargs.items()} + qnames = {(XML_NS, k): f"xml:{k}" for k in kwargs} + return AttributesNSImpl(by_name, qnames) + + +def _split_tag(tag): + """Split a '{namespace}local' tag into (namespace, local).""" + if tag.startswith("{"): + uri, local = tag[1:].split("}", 1) + return uri, local + return None, tag + + +def _ns_qname(uri, local): + if uri is None: + return local + prefix = _NS_PREFIX.get(uri) + if prefix is None: + raise ValueError( + f"No prefix registered for namespace {uri!r}; " + "add it to _NS_PREFIX and declare it via startPrefixMapping" + ) + return f"{prefix}:{local}" + + +def _emit_element(gen, elem): + """Emit an ElementTree element and its subtree through the SAX generator.""" + name = _split_tag(elem.tag) + by_name = {} + qnames = {} + for key, value in elem.attrib.items(): + akey = _split_tag(key) + by_name[akey] = value + qnames[akey] = _ns_qname(*akey) + attrs = AttributesNSImpl(by_name, qnames) + qname = _ns_qname(*name) + gen.startElementNS(name, qname, attrs) + if elem.text: + gen.characters(elem.text) + for child in elem: + _emit_element(gen, child) + if child.tail: + gen.characters(child.tail) + gen.endElementNS(name, qname) + + +def _extract_dual_dialog_xml(template_root): + """Return the tblPr + tblGrid elements of the table marked by the + DualDialogTable bookmark, used as the basis for dual dialogue tables.""" + for tbl in template_root.iter(f"{{{W}}}tbl"): + if any( + bm.get(f"{{{W}}}name") == "DualDialogTable" + for bm in tbl.iter(f"{{{W}}}bookmarkStart") + ): + found = (tbl.find(f"{{{W}}}tblPr"), tbl.find(f"{{{W}}}tblGrid")) + return [child for child in found if child is not None] + raise ValueError("DualDialogTable bookmark not found in template") + + +class Formatter: + """Class for converting paragraphs into DOCX XML.""" + + def __init__(self, gen, template_root): + """Initializes the formatter. + + `gen` is an XMLGenerator to write to. + `template_root` is the parsed template document""" + self.gen = gen + self._template_root = template_root + self._dual_tbl_elems = None + self._format_functions = { + Slug: self.format_slug, + Action: self.format_action, + Dialog: self.format_dialog, + DualDialog: self.format_dual, + Transition: self.format_transition, + PageBreak: self.format_page_break, + Section: self.format_section, + } + + def _dual_table_elements(self): + """Lazily extract the dual dialogue table grid from the template so + documents without dual dialogue never touch (or require) it.""" + if self._dual_tbl_elems is None: + self._dual_tbl_elems = _extract_dual_dialog_xml(self._template_root) + return self._dual_tbl_elems + + def convert(self, screenplay): + """Converts a screenplay into DOCX XML and writes it to the generator. + `screenplay` is a sequence of paragraphs.""" + self._write_title_page(screenplay) + for para in screenplay: + format_fn = self._format_functions.get(type(para)) + if format_fn: + format_fn(para) + + @contextmanager + def _elem(self, tag, attrs=None): + self.gen.startElementNS((W, tag), f"w:{tag}", attrs or _EMPTY_ATTRS) + yield + self.gen.endElementNS((W, tag), f"w:{tag}") + + def _empty(self, tag, *pairs): + with self._elem(tag, _attrs(*pairs)): + pass + + def _write_runs(self, rich_string): + for segment in rich_string.segments: + styles = set(segment.get_ordered_styles()) + with self._elem("r"): + if styles: + with self._elem("rPr"): + if Bold in styles: + self._empty("b") + if Italic in styles: + self._empty("i") + if Underline in styles: + self._empty("u", ("val", "single")) + with self._elem("t", _xml_attrs(space="preserve")): + self.gen.characters(segment.text) + + def _write_para(self, style_id, rich_lines, jc=None, ind=None): + for rich in rich_lines: + with self._elem("p"): + with self._elem("pPr"): + self._empty("pStyle", ("val", style_id)) + if jc: + self._empty("jc", ("val", jc)) + if ind is not None: + self._empty( + "ind", ("left", str(ind[0])), ("right", str(ind[1])) + ) + self._write_runs(rich) + + def _write_page_break(self): + with self._elem("p"): + with self._elem("r"): + self._empty("br", ("type", "page")) + + def _write_dialog(self, dialog, cell=False): + ind = (0, 0) if cell else None + self._write_para("SPCharacter", [dialog.character], ind=ind) + for is_parenthetical, line in dialog.blocks: + style = "SPParenthetical" if is_parenthetical else "SPDialogue" + self._write_para(style, [line], ind=ind) + + def _write_synopsis(self, synopsis): + if synopsis: + self._write_para("SPSynopsis", [plain(synopsis)]) + + def _write_title_page(self, screenplay): + first_centered = True + first_left = True + added = False + for key in _CENTERED_TITLE_KEYS: + for line in screenplay.get_rich_attribute(key): + style = ( + "SPTitlePageCenterFirst" if first_centered else "SPTitlePageCenter" + ) + self._write_para(style, [line]) + first_centered = False + added = True + for key in _LEFT_TITLE_KEYS: + for line in screenplay.get_rich_attribute(key): + style = "SPTitlePageLeftFirst" if first_left else "SPTitlePageLeft" + self._write_para(style, [line]) + first_left = False + added = True + if added: + self._write_page_break() + + def format_slug(self, slug): + line = slug.line + if slug.scene_number: + # Mirror the scene number on both margins, as the other exporters do + num = slug.scene_number + line = num + plain("\t") + line + plain("\t") + num + self._write_para("SPSceneHeading", [line]) + self._write_synopsis(slug.synopsis) + + def format_action(self, para): + self._write_para("SPAction", para.lines, jc="center" if para.centered else None) + + def format_dialog(self, dialog): + self._write_dialog(dialog) + + def format_dual(self, dual): + with self._elem("tbl"): + for elem in self._dual_table_elements(): + _emit_element(self.gen, elem) + with self._elem("tr"): + for dialog in (dual.left, dual.right): + with self._elem("tc"): + with self._elem("tcPr"): + self._empty("tcW", ("w", "0"), ("type", "auto")) + self._write_dialog(dialog, cell=True) + + def format_transition(self, para): + self._write_para("SPTransition", para.lines) + + def format_page_break(self, para): + self._write_page_break() + + def format_section(self, section): + # The template defines Heading1..Heading9; clamp into that range. + level = max(1, min(section.level, 9)) + self._write_para(f"Heading{level}", [section.text]) + self._write_synopsis(section.synopsis) + + +_CENTERED_TITLE_KEYS = ("Title", "Credit", "Author", "Authors", "Source") +_LEFT_TITLE_KEYS = ("Draft date", "Contact", "Copyright", "Notes") +_TITLE_PAGE_KEYS = _CENTERED_TITLE_KEYS + _LEFT_TITLE_KEYS + +# Per the OOXML CT_SectPr schema, pgNumType must precede these child elements. +_SECTPR_AFTER_PGNUM = frozenset( + f"{{{W}}}{tag}" + for tag in ( + "cols", + "formProt", + "vAlign", + "noEndnote", + "titlePg", + "textDirection", + "bidi", + "rtlGutter", + "docGrid", + ) +) + + +def _set_page_numbering_start(sect_pr_elem, start): + """Set pgNumType/@start, inserting pgNumType at its schema-correct + position (before cols/titlePg/docGrid) if it is not already present.""" + tag = f"{{{W}}}pgNumType" + pg_num = sect_pr_elem.find(tag) + if pg_num is None: + index = len(sect_pr_elem) + for i, child in enumerate(sect_pr_elem): + if child.tag in _SECTPR_AFTER_PGNUM: + index = i + break + pg_num = ET.Element(tag) + sect_pr_elem.insert(index, pg_num) + pg_num.set(f"{{{W}}}start", start) + + +def _generate_document_xml(screenplay, template_doc): + template_root = ET.fromstring(template_doc) + sect_pr_elem = template_root.find(f".//{{{W}}}sectPr") + + # Start page numbering at 0 so the first script page carries number 1. + if sect_pr_elem is not None and any( + screenplay.get_rich_attribute(k) for k in _TITLE_PAGE_KEYS + ): + _set_page_numbering_start(sect_pr_elem, "0") + + buf = io.BytesIO() + gen = XMLGenerator(buf, encoding="utf-8", short_empty_elements=False) + gen.startDocument() + gen.startPrefixMapping("w", W) + gen.startPrefixMapping("r", R) + gen.startElementNS((W, "document"), "w:document", _EMPTY_ATTRS) + gen.startElementNS((W, "body"), "w:body", _EMPTY_ATTRS) + + Formatter(gen, template_root).convert(screenplay) + + # sectPr must be the last child of the body + if sect_pr_elem is not None: + _emit_element(gen, sect_pr_elem) + + gen.endElementNS((W, "body"), "w:body") + gen.endElementNS((W, "document"), "w:document") + gen.endDocument() + + return buf.getvalue() + + +def to_docx(screenplay, out): + with zipfile.ZipFile(_TEMPLATE_PATH, "r") as template: + template_doc = template.read("word/document.xml").decode("utf-8") + document_xml = _generate_document_xml(screenplay, template_doc) + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as output: + for item in template.infolist(): + if item.filename == "word/document.xml": + output.writestr(item, document_xml) + else: + output.writestr(item, template.read(item.filename)) diff --git a/screenplain/export/docx_template.docx b/screenplain/export/docx_template.docx new file mode 100644 index 0000000..8d09dbc Binary files /dev/null and b/screenplain/export/docx_template.docx differ diff --git a/screenplain/main.py b/screenplain/main.py index 660a993..c1f0dc3 100644 --- a/screenplain/main.py +++ b/screenplain/main.py @@ -11,7 +11,7 @@ from screenplain.parsers import fountain output_formats = ( - 'fdx', 'html', 'pdf' + 'fdx', 'html', 'pdf', 'docx' ) description = """Convert text file to viewable screenplay. @@ -126,6 +126,8 @@ def main(argv): format = 'html' elif output_file.endswith('.pdf'): format = 'pdf' + elif output_file.endswith('.docx'): + format = 'docx' else: invalid_format( parser, @@ -147,7 +149,7 @@ def main(argv): input.errors = args.encoding_errors screenplay = fountain.parse(input) - if format == 'pdf': + if format in ('pdf', 'docx'): output_encoding = None else: output_encoding = 'utf-8' @@ -173,6 +175,9 @@ def main(argv): screenplay, output, css_file=args.css, bare=args.bare ) + elif format == 'docx': + from screenplain.export.docx import to_docx + to_docx(screenplay, output) elif format == 'pdf': from screenplain.export import pdf font_settings = None diff --git a/setup.py b/setup.py index c0ea021..e0c9576 100755 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ }, license='MIT', install_requires=[ - 'reportlab' + 'reportlab', ], packages=[ 'screenplain', @@ -31,7 +31,7 @@ 'screenplain.parsers', ], package_data={ - 'screenplain.export': ['default.css', 'courier_prime/**'] + 'screenplain.export': ['default.css', 'courier_prime/**', 'docx_template.docx'] }, entry_points={ 'console_scripts': [ diff --git a/tests/docx_reader.py b/tests/docx_reader.py new file mode 100644 index 0000000..2d2fa70 --- /dev/null +++ b/tests/docx_reader.py @@ -0,0 +1,195 @@ +# Copyright (c) 2011 Martin Vilcans +# Licensed under the MIT license: +# http://www.opensource.org/licenses/mit-license.php + +"""Minimal DOCX reader for tests. + +A .docx file is a zip of XML parts. This module reads just enough of the +WordprocessingML structure (paragraphs, runs, styles and tables) to let the +tests inspect generated documents without depending on python-docx. +""" + +import re +import xml.etree.ElementTree as ET +import zipfile + +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + +_HEADING_NAME = re.compile(r"^heading (\d)$") + + +def _q(tag): + return f"{{{W}}}{tag}" + + +def _toggle(rpr, tag): + """Read an on/off run property such as the way python-docx does: + True if present and enabled, False if explicitly disabled, None if absent. + """ + if rpr is None: + return None + el = rpr.find(_q(tag)) + if el is None: + return None + val = el.get(_q("val")) + if val in ("false", "0", "off", "none"): + return False + return True + + +class Run: + def __init__(self, r_elem): + self._r = r_elem + + @property + def text(self): + return "".join(t.text or "" for t in self._r.iter(_q("t"))) + + @property + def _rpr(self): + return self._r.find(_q("rPr")) + + @property + def bold(self): + return _toggle(self._rpr, "b") + + @property + def italic(self): + return _toggle(self._rpr, "i") + + @property + def underline(self): + return _toggle(self._rpr, "u") + + +class Paragraph: + def __init__(self, p_elem, styles): + self._p = p_elem + self._styles = styles + + @property + def _ppr(self): + return self._p.find(_q("pPr")) + + @property + def style_id(self): + ppr = self._ppr + if ppr is None: + return None + ps = ppr.find(_q("pStyle")) + return ps.get(_q("val")) if ps is not None else None + + @property + def style_name(self): + sid = self.style_id + if sid is None: + return "Normal" + return self._styles.get(sid, sid) + + @property + def text(self): + return "".join(t.text or "" for t in self._p.iter(_q("t"))) + + @property + def runs(self): + return [Run(r) for r in self._p.findall(_q("r"))] + + @property + def alignment(self): + ppr = self._ppr + if ppr is None: + return None + jc = ppr.find(_q("jc")) + return jc.get(_q("val")) if jc is not None else None + + @property + def xml(self): + return ET.tostring(self._p, encoding="unicode") + + +class Cell: + def __init__(self, tc_elem, styles): + self._tc = tc_elem + self._styles = styles + + @property + def paragraphs(self): + return [Paragraph(p, self._styles) for p in self._tc.findall(_q("p"))] + + +class Row: + def __init__(self, tr_elem, styles): + self._tr = tr_elem + self._styles = styles + + @property + def cells(self): + return [Cell(tc, self._styles) for tc in self._tr.findall(_q("tc"))] + + +class Table: + def __init__(self, tbl_elem, styles): + self._tbl = tbl_elem + self._styles = styles + + @property + def rows(self): + return [Row(tr, self._styles) for tr in self._tbl.findall(_q("tr"))] + + @property + def columns(self): + grid = self._tbl.find(_q("tblGrid")) + return grid.findall(_q("gridCol")) if grid is not None else [] + + +def _parse_styles(styles_xml): + """Map styleId -> human-readable style name from word/styles.xml.""" + mapping = {} + if not styles_xml: + return mapping + root = ET.fromstring(styles_xml) + for style in root.findall(_q("style")): + sid = style.get(_q("styleId")) + if sid is None: + continue + name_el = style.find(_q("name")) + name = name_el.get(_q("val")) if name_el is not None else sid + # Word stores built-in heading styles under the internal name "heading N" + heading = _HEADING_NAME.match(name) + if heading: + name = f"Heading {heading.group(1)}" + mapping[sid] = name + return mapping + + +class Document: + """Reads a .docx from a path or file-like object.""" + + def __init__(self, source): + with zipfile.ZipFile(source) as z: + doc_xml = z.read("word/document.xml") + try: + styles_xml = z.read("word/styles.xml") + except KeyError: + styles_xml = None + self._styles = _parse_styles(styles_xml) + self._body = ET.fromstring(doc_xml).find(_q("body")) + + @property + def body_items(self): + """Top-level paragraphs and tables in document order.""" + items = [] + for el in self._body: + if el.tag == _q("p"): + items.append(Paragraph(el, self._styles)) + elif el.tag == _q("tbl"): + items.append(Table(el, self._styles)) + return items + + @property + def paragraphs(self): + return [i for i in self.body_items if isinstance(i, Paragraph)] + + @property + def tables(self): + return [i for i in self.body_items if isinstance(i, Table)] diff --git a/tests/docx_test.py b/tests/docx_test.py new file mode 100644 index 0000000..66308ec --- /dev/null +++ b/tests/docx_test.py @@ -0,0 +1,212 @@ +# Copyright (c) 2011 Martin Vilcans +# Licensed under the MIT license: +# http://www.opensource.org/licenses/mit-license.php + +from io import BytesIO +from unittest import TestCase + +from screenplain.export.docx import to_docx +from screenplain.richstring import bold, italic, plain, underline +from screenplain.types import ( + Action, + Dialog, + DualDialog, + PageBreak, + Screenplay, + Section, + Slug, + Transition, +) +from tests.docx_reader import Document + + +def _convert(paragraphs): + screenplay = Screenplay(paragraphs=paragraphs) + buf = BytesIO() + to_docx(screenplay, buf) + buf.seek(0) + return Document(buf) + + +class SlugTests(TestCase): + def test_slug_uses_scene_heading_style(self): + doc = _convert([Slug(plain("INT. ROOM - DAY"))]) + self.assertEqual(doc.paragraphs[0].style_name, "SP Scene Heading") + + def test_slug_text(self): + doc = _convert([Slug(plain("INT. ROOM - DAY"))]) + self.assertEqual(doc.paragraphs[0].text, "INT. ROOM - DAY") + + def test_slug_plain_run_inherits_bold_from_style(self): + doc = _convert([Slug(plain("INT. ROOM - DAY"))]) + self.assertIsNone(doc.paragraphs[0].runs[0].bold) + + +class ActionTests(TestCase): + def test_action_uses_action_style(self): + doc = _convert([Action([plain("Some action.")])]) + self.assertEqual(doc.paragraphs[0].style_name, "SP Action") + + def test_action_text(self): + doc = _convert([Action([plain("Some action.")])]) + self.assertEqual(doc.paragraphs[0].text, "Some action.") + + def test_multiline_action_produces_multiple_paragraphs(self): + doc = _convert([Action([plain("Line one."), plain("Line two.")])]) + self.assertEqual(len(doc.paragraphs), 2) + self.assertEqual(doc.paragraphs[0].text, "Line one.") + self.assertEqual(doc.paragraphs[1].text, "Line two.") + + def test_centered_action_has_center_alignment(self): + doc = _convert([Action([plain("Centered.")], centered=True)]) + self.assertEqual(doc.paragraphs[0].alignment, "center") + + +class TransitionTests(TestCase): + def test_transition_uses_transition_style(self): + doc = _convert([Transition(plain("CUT TO:"))]) + self.assertEqual(doc.paragraphs[0].style_name, "SP Transition") + + def test_transition_text(self): + doc = _convert([Transition(plain("CUT TO:"))]) + self.assertEqual(doc.paragraphs[0].text, "CUT TO:") + + +class DialogTests(TestCase): + def _make_dialog(self): + d = Dialog(plain("ALICE")) + d.add_line(plain("Hello there.")) + return d + + def test_character_style(self): + doc = _convert([self._make_dialog()]) + self.assertEqual(doc.paragraphs[0].style_name, "SP Character") + self.assertEqual(doc.paragraphs[0].text, "ALICE") + + def test_dialogue_style(self): + doc = _convert([self._make_dialog()]) + self.assertEqual(doc.paragraphs[1].style_name, "SP Dialogue") + self.assertEqual(doc.paragraphs[1].text, "Hello there.") + + def test_parenthetical_style(self): + d = Dialog(plain("ALICE")) + d.add_line(plain("(quietly)")) + d.add_line(plain("Hello there.")) + doc = _convert([d]) + self.assertEqual(doc.paragraphs[1].style_name, "SP Parenthetical") + self.assertEqual(doc.paragraphs[1].text, "(quietly)") + self.assertEqual(doc.paragraphs[2].style_name, "SP Dialogue") + + +class DualDialogTests(TestCase): + def _make_dual(self): + left = Dialog(plain("ALICE")) + left.add_line(plain("Hello.")) + right = Dialog(plain("BOB")) + right.add_line(plain("Hi.")) + return DualDialog(left, right) + + def test_dual_dialog_creates_table(self): + doc = _convert([self._make_dual()]) + self.assertEqual(len(doc.tables), 1) + self.assertEqual(len(doc.tables[0].columns), 2) + + def test_dual_dialog_left_cell_content(self): + doc = _convert([self._make_dual()]) + left_cell = doc.tables[0].rows[0].cells[0] + texts = [p.text for p in left_cell.paragraphs] + self.assertIn("ALICE", texts) + self.assertIn("Hello.", texts) + + def test_dual_dialog_right_cell_content(self): + doc = _convert([self._make_dual()]) + right_cell = doc.tables[0].rows[0].cells[1] + texts = [p.text for p in right_cell.paragraphs] + self.assertIn("BOB", texts) + self.assertIn("Hi.", texts) + + +class RichTextTests(TestCase): + def test_bold_run(self): + doc = _convert([Action([bold("Important")])]) + self.assertTrue(doc.paragraphs[0].runs[0].bold) + + def test_italic_run(self): + doc = _convert([Action([italic("emphasis")])]) + self.assertTrue(doc.paragraphs[0].runs[0].italic) + + def test_underline_run(self): + doc = _convert([Action([underline("underlined")])]) + self.assertTrue(doc.paragraphs[0].runs[0].underline) + + def test_mixed_styles_produce_multiple_runs(self): + doc = _convert([Action([plain("normal") + bold("bold")])]) + runs = doc.paragraphs[0].runs + self.assertEqual(len(runs), 2) + self.assertIsNone(runs[0].bold) + self.assertTrue(runs[1].bold) + + +class SectionTests(TestCase): + def test_section_uses_heading_style(self): + doc = _convert([Section(plain("ACT ONE"), level=1)]) + self.assertEqual(doc.paragraphs[0].style_name, "Heading 1") + self.assertEqual(doc.paragraphs[0].text, "ACT ONE") + + def test_section_level_maps_to_heading_number(self): + doc = _convert([Section(plain("Scene"), level=2)]) + self.assertEqual(doc.paragraphs[0].style_name, "Heading 2") + + def test_section_synopsis(self): + s = Section(plain("ACT ONE"), level=1) + s.set_synopsis("The beginning.") + doc = _convert([s]) + self.assertEqual(doc.paragraphs[1].style_name, "SP Synopsis") + self.assertEqual(doc.paragraphs[1].text, "The beginning.") + + def test_slug_synopsis(self): + slug = Slug(plain("INT. ROOM - DAY")) + slug.set_synopsis("A quiet room.") + doc = _convert([slug]) + self.assertEqual(doc.paragraphs[1].style_name, "SP Synopsis") + self.assertEqual(doc.paragraphs[1].text, "A quiet room.") + + +class TitlePageTests(TestCase): + def test_title_page_centered_fields(self): + screenplay = Screenplay( + title_page={"Title": ["My Film"], "Author": ["Jane Smith"]}, + ) + buf = BytesIO() + to_docx(screenplay, buf) + buf.seek(0) + doc = Document(buf) + texts = [p.text for p in doc.paragraphs] + self.assertIn("My Film", texts) + self.assertIn("Jane Smith", texts) + + def test_title_page_followed_by_page_break(self): + screenplay = Screenplay( + title_page={"Title": ["My Film"]}, + paragraphs=[Action([plain("INT. ROOM - DAY")])], + ) + buf = BytesIO() + to_docx(screenplay, buf) + buf.seek(0) + doc = Document(buf) + xmls = [p.xml for p in doc.paragraphs] + self.assertTrue(any('w:type="page"' in x for x in xmls)) + + +class PageBreakTests(TestCase): + def test_page_break_is_inserted(self): + doc = _convert( + [ + Action([plain("Before.")]), + PageBreak(), + Action([plain("After.")]), + ] + ) + self.assertEqual(len(doc.paragraphs), 3) + xml = doc.paragraphs[1].xml + self.assertIn('w:type="page"', xml) diff --git a/tests/files/boneyard.docx b/tests/files/boneyard.docx new file mode 100644 index 0000000..a872532 Binary files /dev/null and b/tests/files/boneyard.docx differ diff --git a/tests/files/dialogue-character-extension.docx b/tests/files/dialogue-character-extension.docx new file mode 100644 index 0000000..4efc2af Binary files /dev/null and b/tests/files/dialogue-character-extension.docx differ diff --git a/tests/files/dialogue-with-blank-line.docx b/tests/files/dialogue-with-blank-line.docx new file mode 100644 index 0000000..24568f9 Binary files /dev/null and b/tests/files/dialogue-with-blank-line.docx differ diff --git a/tests/files/dialogue.docx b/tests/files/dialogue.docx new file mode 100644 index 0000000..6608ff0 Binary files /dev/null and b/tests/files/dialogue.docx differ diff --git a/tests/files/dual-dialogue.docx b/tests/files/dual-dialogue.docx new file mode 100644 index 0000000..fd878a6 Binary files /dev/null and b/tests/files/dual-dialogue.docx differ diff --git a/tests/files/extended-characters.docx b/tests/files/extended-characters.docx new file mode 100644 index 0000000..765b050 Binary files /dev/null and b/tests/files/extended-characters.docx differ diff --git a/tests/files/forced-action.docx b/tests/files/forced-action.docx new file mode 100644 index 0000000..45fbdae Binary files /dev/null and b/tests/files/forced-action.docx differ diff --git a/tests/files/forced-transition.docx b/tests/files/forced-transition.docx new file mode 100644 index 0000000..d71dead Binary files /dev/null and b/tests/files/forced-transition.docx differ diff --git a/tests/files/indentation.docx b/tests/files/indentation.docx new file mode 100644 index 0000000..3125a70 Binary files /dev/null and b/tests/files/indentation.docx differ diff --git a/tests/files/notes.docx b/tests/files/notes.docx new file mode 100644 index 0000000..4694933 Binary files /dev/null and b/tests/files/notes.docx differ diff --git a/tests/files/page-break.docx b/tests/files/page-break.docx new file mode 100644 index 0000000..9b99adb Binary files /dev/null and b/tests/files/page-break.docx differ diff --git a/tests/files/parenthetical.docx b/tests/files/parenthetical.docx new file mode 100644 index 0000000..4cd57d7 Binary files /dev/null and b/tests/files/parenthetical.docx differ diff --git a/tests/files/scene-numbers.docx b/tests/files/scene-numbers.docx new file mode 100644 index 0000000..c821d39 Binary files /dev/null and b/tests/files/scene-numbers.docx differ diff --git a/tests/files/sections.docx b/tests/files/sections.docx new file mode 100644 index 0000000..f99b5e3 Binary files /dev/null and b/tests/files/sections.docx differ diff --git a/tests/files/simple.docx b/tests/files/simple.docx new file mode 100644 index 0000000..af2f3a0 Binary files /dev/null and b/tests/files/simple.docx differ diff --git a/tests/files/title-page.docx b/tests/files/title-page.docx new file mode 100644 index 0000000..d8da4d2 Binary files /dev/null and b/tests/files/title-page.docx differ diff --git a/tests/files/utf-8-bom.docx b/tests/files/utf-8-bom.docx new file mode 100644 index 0000000..af2f3a0 Binary files /dev/null and b/tests/files/utf-8-bom.docx differ diff --git a/tests/files_test.py b/tests/files_test.py index 0162af6..92868f9 100644 --- a/tests/files_test.py +++ b/tests/files_test.py @@ -107,7 +107,9 @@ def _create_tests(): source_files = [f for f in test_files if f.endswith('.fountain')] expect_files = [ f for f in test_files - if not f.endswith('.fountain') and not f.endswith('.pdf') + if not f.endswith('.fountain') + and not f.endswith('.pdf') + and not f.endswith('.docx') ] for source in source_files: diff --git a/tests/visual/docx_test.py b/tests/visual/docx_test.py new file mode 100644 index 0000000..f4d9666 --- /dev/null +++ b/tests/visual/docx_test.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 + +"""Regression test for DOCX generation. + +Runs screenplain on each .fountain file in the given directory, +generating a DOCX file and comparing its structure to the corresponding +reference DOCX file. + +If no reference DOCX file exists, one is created from the current output. +""" + +import shutil +import sys +import tempfile +from pathlib import Path + +import screenplain.main +from tests.docx_reader import Document, Paragraph, Table + + +def _extract_structure(doc): + """Extract a comparable structure from a DOCX document.""" + items = [] + for item in doc.body_items: + if isinstance(item, Paragraph): + items.append( + { + "paragraph": { + "style": item.style_name, + "text": item.text, + "runs": [ + { + "text": r.text, + "bold": r.bold, + "italic": r.italic, + "underline": r.underline, + } + for r in item.runs + if r.text + ], + } + } + ) + elif isinstance(item, Table): + items.append( + { + "table": [ + [ + [ + {"style": p.style_name, "text": p.text} + for p in cell.paragraphs + ] + for cell in row.cells + ] + for row in item.rows + ] + } + ) + return items + + +FILES_DIR = Path(__file__).resolve().parent.parent / "files" + + +def test_docx_output_matches_references(): + """Collected by pytest so the DOCX reference fixtures are actually + exercised in CI (not only via the __main__ runner below).""" + assert compare(FILES_DIR), "DOCX output diverged from reference fixtures" + + +def compare(directory) -> bool: + reference_dir = Path(directory) + fountain_files = sorted(reference_dir.glob("*.fountain")) + failed = False + + for fountain_file in fountain_files: + reference_docx = fountain_file.with_suffix(".docx") + + with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as f: + actual_path = Path(f.name) + try: + screenplain.main.main([str(fountain_file), str(actual_path)]) + + if not reference_docx.exists(): + shutil.copy2(actual_path, reference_docx) + print(f"Generated reference: {reference_docx}") + continue + + actual = _extract_structure(Document(str(actual_path))) + finally: + actual_path.unlink(missing_ok=True) + + expected = _extract_structure(Document(str(reference_docx))) + if actual != expected: + print(f"FAILED: {fountain_file.name}") + failed = True + else: + print(f"OK: {fountain_file.name}") + + return not failed + + +if __name__ == "__main__": + if not compare(FILES_DIR): + sys.exit(1)