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
5 changes: 5 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,11 @@ calling method `new_subdoc()` ::
In the above example, the content of 'templates/merge_docx_subdoc.docx' will be inserted into the parent document in place of the declared
variable `{{p mysubdoc }}`.

When the ``{{p var }}`` placeholder paragraph carries a paragraph style or
alignment, that style is applied at ``render()`` time to plain body paragraphs
of the Subdoc (Heading, List, Title, and Subtitle styles are left unchanged).
See ``tests/subdoc_p_style.py``.

See `tests/merge_docx.py` for full code.

.. _Escaping:
Expand Down
1 change: 1 addition & 0 deletions docxtpl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# flake8: noqa
from .inline_image import InlineImage
from .listing import Listing
from .p_style import scan_p_placeholder_styles
from .richtext import RichText, R, RichTextParagraph, RP
from .template import DocxTemplate

Expand Down
129 changes: 129 additions & 0 deletions docxtpl/p_style.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
"""Inherit {{p var }} paragraph style / alignment onto Subdoc body paragraphs."""
from __future__ import annotations

import re
from collections import namedtuple
from typing import Any, Dict, Optional

from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn

PlaceholderStyle = namedtuple("PlaceholderStyle", ["p_style", "alignment"])

_P_VAR_RE = re.compile(
r"\{\{\s*p\s+([A-Za-z_][A-Za-z0-9_]*)\s*\}\}",
re.DOTALL,
)

_ALIGN_MAP = {
"left": WD_ALIGN_PARAGRAPH.LEFT,
"center": WD_ALIGN_PARAGRAPH.CENTER,
"right": WD_ALIGN_PARAGRAPH.RIGHT,
"both": WD_ALIGN_PARAGRAPH.JUSTIFY,
"justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
}

_SKIP_STYLE_PREFIXES = ("Heading", "List", "Title", "Subtitle")


def scan_p_placeholder_styles(tpl) -> Dict[str, PlaceholderStyle]:
"""Map variable name -> style from paragraphs containing {{p var }}.

Must run on template XML before patch_xml strips those paragraphs.
First occurrence wins.
"""
tpl.init_docx()
styles: Dict[str, PlaceholderStyle] = {}
body = tpl.docx._element.body
for p in body.iter(qn("w:p")):
texts = [t.text or "" for t in p.iter(qn("w:t"))]
joined = "".join(texts)
compact = re.sub(r"\s+", " ", joined).strip()
m = _P_VAR_RE.search(compact)
if not m:
continue
without = _P_VAR_RE.sub("", compact).strip()
if without:
continue
name = m.group(1)
if name in styles:
continue
p_style = None
alignment = None
pPr = p.find(qn("w:pPr"))
if pPr is not None:
pStyle_el = pPr.find(qn("w:pStyle"))
if pStyle_el is not None:
p_style = pStyle_el.get(qn("w:val"))
jc = pPr.find(qn("w:jc"))
if jc is not None:
alignment = jc.get(qn("w:val"))
styles[name] = PlaceholderStyle(p_style=p_style, alignment=alignment)
return styles


def _apply_placeholder_style_to_subdoc(subdoc, style_info: Optional[PlaceholderStyle]):
if not style_info:
return
if not style_info.p_style and not style_info.alignment:
return
body = subdoc.element.body
for child in list(body):
if child.tag != qn("w:p"):
continue
para = None
for p in subdoc.paragraphs:
if p._element is child:
para = p
break
if para is None:
continue
style_name = para.style.name if para.style is not None else "Normal"
if any(style_name.startswith(prefix) for prefix in _SKIP_STYLE_PREFIXES):
continue
if style_info.p_style:
try:
para.style = style_info.p_style
except (KeyError, ValueError):
pass
if style_info.alignment and style_info.alignment in _ALIGN_MAP:
para.alignment = _ALIGN_MAP[style_info.alignment]


def _is_subdoc(value: Any) -> bool:
try:
from .subdoc import Subdoc
except ImportError:
return False
return isinstance(value, Subdoc)


def _apply_styles_to_value(
value: Any,
placeholder_styles: Dict[str, PlaceholderStyle],
var_name: Optional[str] = None,
) -> None:
if _is_subdoc(value):
if var_name and var_name in placeholder_styles:
_apply_placeholder_style_to_subdoc(value, placeholder_styles[var_name])
return
if isinstance(value, list):
for item in value:
_apply_styles_to_value(item, placeholder_styles, var_name=None)
return
if isinstance(value, dict):
for k, v in value.items():
_apply_styles_to_value(
v, placeholder_styles, var_name=k if isinstance(k, str) else None
)


def apply_p_placeholder_styles(tpl, context: Any) -> None:
"""Apply {{p var }} placeholder styles onto Subdoc values in context (in place)."""
if not isinstance(context, dict):
return
placeholder_styles = scan_p_placeholder_styles(tpl)
if not placeholder_styles:
return
_apply_styles_to_value(context, placeholder_styles)
6 changes: 6 additions & 0 deletions docxtpl/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,12 @@ def render(
# init template working attributes
self.render_init()

# Inherit {{p var }} paragraph style onto Subdoc body paragraphs before
# patch_xml strips those placeholder paragraphs.
from .p_style import apply_p_placeholder_styles

apply_p_placeholder_styles(self, context)

if autoescape:
if not jinja_env:
jinja_env = Environment(autoescape=autoescape)
Expand Down
100 changes: 100 additions & 0 deletions tests/subdoc_p_style.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""Tests for {{p var }} paragraph style inheritance onto Subdoc bodies."""
import io
import os
import unittest

from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn

from docxtpl import DocxTemplate, scan_p_placeholder_styles

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


def _template_with_p_placeholder(varname, p_style=None, alignment=None):
doc = Document()
p = doc.add_paragraph()
if p_style:
try:
p.style = p_style
except KeyError:
pPr = p._element.get_or_add_pPr()
pStyle = pPr.find(qn("w:pStyle"))
if pStyle is None:
pStyle = OxmlElement("w:pStyle")
pPr.insert(0, pStyle)
pStyle.set(qn("w:val"), p_style)
if alignment is not None:
p.alignment = alignment
p.add_run("{{p %s }}" % varname)
buf = io.BytesIO()
doc.save(buf)
buf.seek(0)
return buf


def _etree_tostring_body(subdoc):
from lxml import etree

return etree.tostring(subdoc.element.body, encoding="unicode")


class SubdocPStyleTests(unittest.TestCase):
def test_scan_p_placeholder_styles(self):
tpl = DocxTemplate(
_template_with_p_placeholder(
"notes", p_style="Quote", alignment=WD_ALIGN_PARAGRAPH.CENTER
)
)
styles = scan_p_placeholder_styles(tpl)
self.assertIn("notes", styles)
self.assertEqual(styles["notes"].p_style, "Quote")
self.assertEqual(styles["notes"].alignment, "center")

def test_subdoc_inherits_placeholder_style_on_render(self):
tpl = DocxTemplate(
_template_with_p_placeholder("notes", p_style="Quote")
)
sd = tpl.new_subdoc()
sd.add_paragraph("Styled body")
tpl.render({"notes": sd})
tpl.save("output/subdoc_p_style.docx")

styled = False
for p in sd.paragraphs:
if p.text.strip() == "Styled body":
if p.style and p.style.name == "Quote":
styled = True
if not styled:
xml = _etree_tostring_body(sd)
self.assertTrue(
"Quote" in xml,
"expected Quote style on Subdoc body after render",
)

def test_heading_paragraphs_not_overwritten(self):
tpl = DocxTemplate(
_template_with_p_placeholder("notes", p_style="Quote")
)
sd = tpl.new_subdoc()
heading = sd.add_paragraph("Keep heading")
try:
heading.style = "Heading 1"
except KeyError:
self.skipTest("Heading 1 style unavailable")
plain = sd.add_paragraph("Plain body")
tpl.render({"notes": sd})

self.assertEqual(heading.style.name, "Heading 1")
if plain.style and plain.style.name == "Quote":
return
xml = _etree_tostring_body(sd)
self.assertIn("Quote", xml)


if __name__ == "__main__":
unittest.main()