Skip to content
Open
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
15 changes: 15 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,21 @@ One can use `docxtpl` module directly on command line to generate a docx from a

See tests/module_execute.py for an example.

JSON context may include typed objects via a ``__docxtpl__`` envelope so the CLI can
build ``RichText`` and ``Subdoc`` values before render:

* RichText (use with ``{{r var }}``)::

{"__docxtpl__": "richtext", "runs": [{"text": "Hello ", "bold": false}, {"text": "world", "bold": true}]}

* Subdoc (use with ``{{p var }}``, alone on its paragraph). Provide either
``docx_base64`` or ``path`` (mutually exclusive). Subdoc envelopes require
``docxcompose`` (``pip install "docxtpl[subdoc]"``)::

{"__docxtpl__": "subdoc", "path": "fragment.docx"}

See tests/envelope_execute.py for a full CLI example.

Examples
--------

Expand Down
1 change: 1 addition & 0 deletions docxtpl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
__version__ = "0.20.2"

# flake8: noqa
from .envelope import expand_context
from .inline_image import InlineImage
from .listing import Listing
from .richtext import RichText, R, RichTextParagraph, RP
Expand Down
2 changes: 2 additions & 0 deletions docxtpl/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
import os

from .envelope import expand_context
from .template import DocxTemplate, TemplateError

TEMPLATE_ARG = "template_path"
Expand Down Expand Up @@ -130,6 +131,7 @@ def make_docxtemplate(template_path):

def render_docx(doc, json_data):
try:
json_data = expand_context(doc, json_data)
doc.render(json_data)
return doc
except TemplateError:
Expand Down
95 changes: 95 additions & 0 deletions docxtpl/envelope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""JSON-serializable envelopes for Subdoc and RichText in CLI / embedders."""
from __future__ import annotations

import base64
import io
from typing import Any, Optional

from .richtext import RichText

DOCXTPL_KEY = "__docxtpl__"


def is_envelope(value: Any) -> bool:
return isinstance(value, dict) and DOCXTPL_KEY in value


def _expand_subdoc(tpl, envelope: dict):
try:
from .subdoc import Subdoc # noqa: F401
except ImportError as e:
raise RuntimeError(
'Subdoc envelopes require docxcompose. Install with: pip install "docxtpl[subdoc]"'
) from e

has_b64 = "docx_base64" in envelope and envelope["docx_base64"] is not None
has_path = "path" in envelope and envelope["path"]
if has_b64 and has_path:
raise RuntimeError("Subdoc envelope must not set both docx_base64 and path")
if not has_b64 and not has_path:
raise RuntimeError("Subdoc envelope requires docx_base64 or path")

if has_path:
return tpl.new_subdoc(envelope["path"])
try:
raw = base64.b64decode(envelope["docx_base64"])
except Exception as e:
raise RuntimeError("Invalid docx_base64 in subdoc envelope") from e
return tpl.new_subdoc(io.BytesIO(raw))


def _expand_richtext(tpl, envelope: dict) -> RichText:
runs = envelope.get("runs")
if runs is None:
raise RuntimeError("RichText envelope requires a runs array")
if not isinstance(runs, list):
raise RuntimeError("RichText envelope runs must be a list")

rt = RichText()
for i, run in enumerate(runs):
if not isinstance(run, dict):
raise RuntimeError("RichText run #%s must be an object" % i)
if "text" not in run:
raise RuntimeError("RichText run #%s requires text" % i)
kwargs = {}
for key in (
"bold",
"italic",
"underline",
"strike",
"subscript",
"superscript",
"color",
"size",
"font",
"style",
):
if key in run and run[key] is not None:
kwargs[key] = run[key]
url = run.get("url")
if url:
kwargs["url_id"] = tpl.build_url_id(url)
rt.add(run["text"], **kwargs)
return rt


def expand_value(tpl, value: Any) -> Any:
if is_envelope(value):
kind = value.get(DOCXTPL_KEY)
if kind == "subdoc":
return _expand_subdoc(tpl, value)
if kind == "richtext":
return _expand_richtext(tpl, value)
raise RuntimeError("Unknown __docxtpl__ envelope type: %r" % kind)

if isinstance(value, list):
return [expand_value(tpl, item) for item in value]
if isinstance(value, dict):
return {k: expand_value(tpl, v) for k, v in value.items()}
return value


def expand_context(tpl, context: Any) -> Any:
"""Expand __docxtpl__ envelopes in a JSON-loaded context before render()."""
return expand_value(tpl, context)
214 changes: 214 additions & 0 deletions tests/envelope_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""Tests for JSON CLI envelopes (subdoc + richtext)."""
import base64
import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
import zipfile

from docx import Document

from docxtpl import DocxTemplate, RichText, expand_context

os.chdir(os.path.dirname(__file__))
os.makedirs("output", exist_ok=True)


def _docx_bytes_with_paragraph(text):
doc = Document()
doc.add_paragraph(text)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()


def _template_with_p_placeholder(varname):
"""Build a minimal .docx whose body is a single {{p varname }} paragraph."""
doc = Document()
p = doc.add_paragraph()
p.add_run("{{p %s }}" % varname)
buf = io.BytesIO()
doc.save(buf)
buf.seek(0)
return buf


def _template_with_r_placeholder(varname, prefix="Label: "):
doc = Document()
p = doc.add_paragraph()
p.add_run(prefix)
p.add_run("{{r %s }}" % varname)
buf = io.BytesIO()
doc.save(buf)
buf.seek(0)
return buf


class EnvelopeTests(unittest.TestCase):
def test_expand_richtext_envelope(self):
tpl = DocxTemplate(_template_with_r_placeholder("body"))
ctx = expand_context(
tpl,
{
"body": {
"__docxtpl__": "richtext",
"runs": [
{"text": "Hello ", "bold": False},
{"text": "world", "bold": True},
],
}
},
)
self.assertIsInstance(ctx["body"], RichText)
tpl.render(ctx)
out = "output/envelope_richtext.docx"
tpl.save(out)
self.assertTrue(os.path.isfile(out))

def test_expand_subdoc_envelope(self):
mini = _docx_bytes_with_paragraph("Inserted block")
tpl = DocxTemplate(_template_with_p_placeholder("body"))
ctx = expand_context(
tpl,
{
"body": {
"__docxtpl__": "subdoc",
"docx_base64": base64.b64encode(mini).decode("ascii"),
}
},
)
from docxtpl.subdoc import Subdoc

self.assertIsInstance(ctx["body"], Subdoc)
tpl.render(ctx)
out = "output/envelope_subdoc.docx"
tpl.save(out)
self.assertTrue(os.path.isfile(out))
with zipfile.ZipFile(out) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
self.assertIn("Inserted block", xml)

def test_expand_subdoc_path_envelope(self):
from docxtpl.subdoc import Subdoc

tpl = DocxTemplate(_template_with_p_placeholder("body"))
ctx = expand_context(
tpl,
{
"body": {
"__docxtpl__": "subdoc",
"path": "templates/envelope_cli_sub.docx",
}
},
)
self.assertIsInstance(ctx["body"], Subdoc)
tpl.render(ctx)
out = "output/envelope_subdoc_path.docx"
tpl.save(out)
with zipfile.ZipFile(out) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
self.assertIn("Inserted via CLI", xml)

def test_cli_end_to_end_envelopes(self):
out = "output/envelope_cli_e2e.docx"
if os.path.exists(out):
os.unlink(out)
result = subprocess.run(
[
sys.executable,
"-m",
"docxtpl",
"templates/envelope_cli_tpl.docx",
"templates/envelope_cli.json",
out,
"-o",
"-q",
],
capture_output=True,
text=True,
)
self.assertEqual(
result.returncode,
0,
"CLI failed: stdout=%r stderr=%r" % (result.stdout, result.stderr),
)
self.assertTrue(os.path.isfile(out))
with zipfile.ZipFile(out) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
self.assertIn("Hello", xml)
self.assertIn("world", xml)
self.assertIn("Inserted via CLI", xml)

def test_cli_end_to_end_base64_subdoc(self):
mini = _docx_bytes_with_paragraph("Base64 block")
with tempfile.TemporaryDirectory() as tmp:
tpl_path = os.path.join(tmp, "tpl.docx")
json_path = os.path.join(tmp, "ctx.json")
out_path = os.path.join(tmp, "out.docx")
buf = _template_with_p_placeholder("body")
with open(tpl_path, "wb") as fh:
fh.write(buf.read())
with open(json_path, "w") as fh:
json.dump(
{
"body": {
"__docxtpl__": "subdoc",
"docx_base64": base64.b64encode(mini).decode("ascii"),
}
},
fh,
)
result = subprocess.run(
[
sys.executable,
"-m",
"docxtpl",
tpl_path,
json_path,
out_path,
"-o",
"-q",
],
capture_output=True,
text=True,
)
self.assertEqual(
result.returncode,
0,
"CLI failed: stdout=%r stderr=%r" % (result.stdout, result.stderr),
)
with zipfile.ZipFile(out_path) as zf:
xml = zf.read("word/document.xml").decode("utf-8")
self.assertIn("Base64 block", xml)

def test_unknown_envelope_type(self):
tpl = DocxTemplate(_template_with_r_placeholder("body"))
with self.assertRaises(RuntimeError):
expand_context(
tpl, {"body": {"__docxtpl__": "nope", "runs": []}}
)

def test_nested_list_expansion(self):
tpl = DocxTemplate(_template_with_r_placeholder("body"))
ctx = expand_context(
tpl,
{
"rows": [
{
"body": {
"__docxtpl__": "richtext",
"runs": [{"text": "row1", "bold": True}],
}
}
]
},
)
self.assertIsInstance(ctx["rows"][0]["body"], RichText)


if __name__ == "__main__":
unittest.main()
29 changes: 29 additions & 0 deletions tests/envelope_execute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import os
import sys

TEMPLATE_PATH = "templates/envelope_cli_tpl.docx"
JSON_PATH = "templates/envelope_cli.json"
OUTPUT_FILENAME = "output/envelope_cli.docx"
OVERWRITE = "-o"
QUIET = "-q"


os.chdir(os.path.dirname(__file__))
os.makedirs("output", exist_ok=True)

if os.path.exists(OUTPUT_FILENAME):
os.unlink(OUTPUT_FILENAME)

cmd = "%s -m docxtpl %s %s %s %s %s" % (
sys.executable,
TEMPLATE_PATH,
JSON_PATH,
OUTPUT_FILENAME,
OVERWRITE,
QUIET,
)
print('Executing "%s" ...' % cmd)
os.system(cmd)

if os.path.exists(OUTPUT_FILENAME):
print(" --> File %s has been generated." % OUTPUT_FILENAME)
Loading