diff --git a/docs/user/documents.rst b/docs/user/documents.rst index ecdfefab1..face61b2c 100644 --- a/docs/user/documents.rst +++ b/docs/user/documents.rst @@ -92,3 +92,28 @@ Linux to allow Zipfile to open the file. Okay, so you've got a document open and are pretty sure you can save it somewhere later. Next step is to get some content in there ... + +Instantiate a document from DOTX +------------------------------- + +A macro-free Word template can be passed to the existing document factory:: + + document = Document("brand.dotx") + document.add_paragraph("Report content") + document.save("report.docx") + +Binary streams are also supported. The package main content type determines the +input format, independent of the filename. DOTX input is instantiated as a DOCX +document. Saving writes the document main content type, not the template main +content type. Always use a ``.docx`` output filename. A different extension does +not select a different output format. + +Styles, theme, numbering, sections, body content, headers, footers, images, and +relationships follow the library's normal preservation behavior. The library +does not require a blank template or modify the source file when loading it or +saving to a separate output. It does not attach the source template to the output +or enable automatic style updates. + +Template authoring and macro-enabled DOCM and DOTM input are unsupported. The +lower-level OPC ``Package.open()`` retains the original template content type. +The document factory performs the conversion when instantiating a document. diff --git a/features/doc-dotx.feature b/features/doc-dotx.feature new file mode 100644 index 000000000..1da8f6de8 --- /dev/null +++ b/features/doc-dotx.feature @@ -0,0 +1,5 @@ +Feature: Instantiate documents from macro-free Word templates + Scenario: Save a real document from a DOTX template + Given a Word-authored DOTX template with styles and content + When I instantiate a document from that template and save it + Then the saved package is a DOCX and retains its template content diff --git a/features/steps/dotx.py b/features/steps/dotx.py new file mode 100644 index 000000000..d8abdb00b --- /dev/null +++ b/features/steps/dotx.py @@ -0,0 +1,34 @@ +"""Acceptance steps for DOTX instantiation.""" + +from io import BytesIO +from pathlib import Path + +from behave import given, then, when + +from docx import Document +from docx.opc.constants import CONTENT_TYPE as CT + + +@given("a Word-authored DOTX template with styles and content") +def given_dotx(context): + path = Path(__file__).parents[2] / "tests" / "test_files" / "rich-template-word.dotx" + context.template_bytes = path.read_bytes() + + +@when("I instantiate a document from that template and save it") +def when_dotx_saved(context): + document = Document(BytesIO(context.template_bytes)) + document.add_paragraph("Generated report", "Brand Text") + stream = BytesIO() + document.save(stream) + context.document = Document(stream) + + +@then("the saved package is a DOCX and retains its template content") +def then_dotx_instantiated(context): + document = context.document + assert document.part.content_type == CT.WML_DOCUMENT_MAIN + assert document.paragraphs[0].text == "Template heading" + assert document.paragraphs[-1].text == "Generated report" + assert document.sections[0].header.paragraphs[0].text == "Brand header" + assert len(document.inline_shapes) == 1 diff --git a/src/docx/api.py b/src/docx/api.py index 276bc58c0..300b83851 100644 --- a/src/docx/api.py +++ b/src/docx/api.py @@ -18,7 +18,11 @@ def Document(docx: str | IO[bytes] | None = None) -> DocumentObject: """Return a |Document| object loaded from `docx`, where `docx` can be either a path - to a ``.docx`` file (a string) or a file-like object. + to a ``.docx`` document or macro-free ``.dotx`` template, or a binary stream. + + DOTX input instantiates a document. Saving produces DOCX package content, + regardless of the output filename. The source template is unchanged. Template + authoring and macro-enabled DOCM or DOTM input are unsupported. If `docx` is missing or ``None``, the built-in default document "template" is loaded. @@ -28,6 +32,8 @@ def Document(docx: str | IO[bytes] | None = None) -> DocumentObject: if document_part.content_type not in (CT.WML_DOCUMENT_MAIN, CT.WML_TEMPLATE_MAIN): tmpl = "file '%s' is not a Word file, content type is '%s'" raise ValueError(tmpl % (docx, document_part.content_type)) + if document_part.content_type == CT.WML_TEMPLATE_MAIN: + document_part.convert_to_document() return document_part.document diff --git a/src/docx/opc/constants.py b/src/docx/opc/constants.py index 30b856614..20c37536b 100644 --- a/src/docx/opc/constants.py +++ b/src/docx/opc/constants.py @@ -136,7 +136,7 @@ class CONTENT_TYPE: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" ) WML_TEMPLATE_MAIN = ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml" + "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml" ) WML_ENDNOTES = "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml" WML_FONT_TABLE = "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml" diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 4960264b1..8f0828a3c 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -5,6 +5,7 @@ from typing import IO, TYPE_CHECKING, cast from docx.document import Document +from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.parts.comments import CommentsPart from docx.parts.hdrftr import FooterPart, HeaderPart @@ -108,6 +109,15 @@ def numbering_part(self) -> NumberingPart: self.relate_to(numbering_part, RT.NUMBERING) return numbering_part + def convert_to_document(self) -> None: + """Instantiate a DOCX main part from a macro-free template main part. + + Content and relationships remain unchanged. Package.open() retains the + original template type until the document factory calls this method. + """ + if self.content_type == CT.WML_TEMPLATE_MAIN: + self._content_type = CT.WML_DOCUMENT_MAIN + def save(self, path_or_stream: str | IO[bytes]): """Save this document to `path_or_stream`, which can be either a path to a filesystem location (a string) or a file-like object.""" diff --git a/tests/test_dotx_template.py b/tests/test_dotx_template.py index 14ea32714..04b0f5fbd 100644 --- a/tests/test_dotx_template.py +++ b/tests/test_dotx_template.py @@ -1,36 +1,113 @@ -"""Tests for Issue #1532: .dotx template support.""" +"""Instantiate editable DOCX documents from real Word templates.""" -import os +from io import BytesIO +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile import pytest from docx import Document +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.package import Package from docx.parts.document import DocumentPart +FIXTURES = Path(__file__).parent / "test_files" -class DescribeDotxTemplate: - """Unit-test suite for .dotx file support.""" - def it_can_load_a_dotx_template_file(self): - """Test loading a real .dotx file.""" - template_path = "test_dotx_real.dotx" +def changed_content_type(data, replacement): + output = BytesIO() + with ZipFile(BytesIO(data)) as source, ZipFile(output, "w", ZIP_DEFLATED) as target: + for item in source.infolist(): + blob = source.read(item.filename) + if item.filename == "[Content_Types].xml": + blob = blob.replace(CT.WML_TEMPLATE_MAIN.encode(), replacement.encode()) + target.writestr(item, blob) + output.seek(0) + return output - if not os.path.exists(template_path): - pytest.skip(f"Template file {template_path} not found") - # It should load the file successfully - doc = Document(template_path) - assert doc is not None - assert hasattr(doc, "paragraphs") +@pytest.mark.parametrize("stream_input", [False, True]) +def it_instantiates_a_document_and_preserves_template_content(tmp_path, stream_input): + template = FIXTURES / "rich-template-word.dotx" + original = template.read_bytes() + raw = Package.open(BytesIO(original)) + assert isinstance(raw.main_document_part, DocumentPart) + assert raw.main_document_part.content_type == CT.WML_TEMPLATE_MAIN + document = Document(BytesIO(original) if stream_input else str(template)) + assert document.part.content_type == CT.WML_DOCUMENT_MAIN + assert document.paragraphs[0].text == "Template heading" + assert document.styles["Brand Text"].font.name == "Arial" + assert document.sections[0].left_margin.pt == 63 + assert document.sections[0].header.paragraphs[0].text == "Brand header" + assert document.sections[0].footer.paragraphs[0].text == "Brand footer" + assert len(document.inline_shapes) == 1 + source_parts = {str(part.partname): part.blob for part in raw.parts} + document.add_paragraph("Generated report", "Brand Text") + output = tmp_path / "report.docx" + document.save(output) + reopened = Document(str(output)) + assert reopened.paragraphs[-1].text == "Generated report" + assert reopened.paragraphs[-1].style.name == "Brand Text" + assert reopened.part.content_type == CT.WML_DOCUMENT_MAIN + for part in reopened.part.package.parts: + if part is not reopened.part: + assert part.blob == source_parts[str(part.partname)] + for original_part in raw.parts: + saved_part = next( + p for p in reopened.part.package.parts if p.partname == original_part.partname + ) + assert [ + (r.rId, r.reltype, r.target_ref, r.is_external) for r in saved_part.rels.values() + ] == [(r.rId, r.reltype, r.target_ref, r.is_external) for r in original_part.rels.values()] + assert template.read_bytes() == original + with ZipFile(output) as package: + content_types = package.read("[Content_Types].xml") + assert CT.WML_DOCUMENT_MAIN.encode() in content_types + assert CT.WML_TEMPLATE_MAIN.encode() not in content_types + assert b"