Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/user/documents.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions features/doc-dotx.feature
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions features/steps/dotx.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion src/docx/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion src/docx/opc/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/docx/parts/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
119 changes: 98 additions & 21 deletions tests/test_dotx_template.py
Original file line number Diff line number Diff line change
@@ -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"<w:numPr" in package.read("word/document.xml") or b"ListNumber" in package.read(
"word/styles.xml"
)

def it_creates_document_part_for_dotx_files(self):
"""Test that .dotx files create DocumentPart, not generic Part."""
template_path = "test_dotx_real.dotx"

if not os.path.exists(template_path):
pytest.skip(f"Template file {template_path} not found")
def it_accepts_blank_word_templates_and_preserves_numbering_and_theme():
template = FIXTURES / "blank-template-word.dotx"
raw = Package.open(str(template)).main_document_part
document = Document(str(template))
for kind in (RT.NUMBERING, RT.THEME, RT.STYLES):
assert document.part.part_related_by(kind).blob == raw.part_related_by(kind).blob
assert not any(paragraph.text for paragraph in document.paragraphs)

pkg = Package.open(template_path)
# It should create a DocumentPart, not a generic Part
assert isinstance(pkg.main_document_part, DocumentPart)

def it_detects_template_content_independently_of_input_filename(tmp_path):
path = tmp_path / "template.data"
path.write_bytes((FIXTURES / "blank-template-word.dotx").read_bytes())
document = Document(str(path))
assert document.part.content_type == CT.WML_DOCUMENT_MAIN
output = tmp_path / "output.dotx"
document.save(output)
assert Package.open(str(output)).main_document_part.content_type == CT.WML_DOCUMENT_MAIN


@pytest.mark.parametrize(
"content_type",
[
"application/vnd.ms-word.document.macroEnabled.main+xml",
"application/vnd.ms-word.template.macroEnabledTemplate.main+xml",
"application/octet-stream",
],
)
def it_rejects_macro_enabled_and_unknown_main_part_types(content_type):
source = (FIXTURES / "blank-template-word.dotx").read_bytes()
with pytest.raises(ValueError, match="not a Word file"):
Document(changed_content_type(source, content_type))


def it_preserves_existing_docx_behavior():
document = Document()
document.add_paragraph("Normal document")
output = BytesIO()
document.save(output)
reopened = Document(output)
assert reopened.part.content_type == CT.WML_DOCUMENT_MAIN
assert reopened.paragraphs[0].text == "Normal document"
Binary file added tests/test_files/blank-template-word.dotx
Binary file not shown.
Binary file added tests/test_files/rich-template-word.dotx
Binary file not shown.
8 changes: 8 additions & 0 deletions tests/test_files/templates-word.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Word template fixtures
======================

Both fixtures were saved as macro-free DOTX templates by Microsoft Word.
The rich template contains a heading, a numbered paragraph, a Brand Text style,
section margins, populated header and footer, and an inline Python icon.
The blank fixture was saved from the markdown-docx formatting template and retains
its styles, theme, and numbering. Author metadata has been cleared.