diff --git a/docs/index.rst b/docs/index.rst index 46cf69f..358790a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 -------- diff --git a/docxtpl/__init__.py b/docxtpl/__init__.py index 5559d4f..bb3a0dc 100644 --- a/docxtpl/__init__.py +++ b/docxtpl/__init__.py @@ -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 diff --git a/docxtpl/__main__.py b/docxtpl/__main__.py index 59cf049..0923f97 100644 --- a/docxtpl/__main__.py +++ b/docxtpl/__main__.py @@ -2,6 +2,7 @@ import json import os +from .envelope import expand_context from .template import DocxTemplate, TemplateError TEMPLATE_ARG = "template_path" @@ -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: diff --git a/docxtpl/envelope.py b/docxtpl/envelope.py new file mode 100644 index 0000000..8231c93 --- /dev/null +++ b/docxtpl/envelope.py @@ -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) diff --git a/tests/envelope_cli.py b/tests/envelope_cli.py new file mode 100644 index 0000000..32bae4c --- /dev/null +++ b/tests/envelope_cli.py @@ -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() diff --git a/tests/envelope_execute.py b/tests/envelope_execute.py new file mode 100644 index 0000000..a5570af --- /dev/null +++ b/tests/envelope_execute.py @@ -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) diff --git a/tests/templates/envelope_cli.json b/tests/templates/envelope_cli.json new file mode 100644 index 0000000..27d34e7 --- /dev/null +++ b/tests/templates/envelope_cli.json @@ -0,0 +1,13 @@ +{ + "highlight": { + "__docxtpl__": "richtext", + "runs": [ + {"text": "Hello ", "bold": false}, + {"text": "world", "bold": true} + ] + }, + "block": { + "__docxtpl__": "subdoc", + "path": "templates/envelope_cli_sub.docx" + } +} diff --git a/tests/templates/envelope_cli_sub.docx b/tests/templates/envelope_cli_sub.docx new file mode 100644 index 0000000..28de051 Binary files /dev/null and b/tests/templates/envelope_cli_sub.docx differ diff --git a/tests/templates/envelope_cli_tpl.docx b/tests/templates/envelope_cli_tpl.docx new file mode 100644 index 0000000..a544aed Binary files /dev/null and b/tests/templates/envelope_cli_tpl.docx differ