From d45fb1acc3a46bf08a0c4d15d1bcb1db23382879 Mon Sep 17 00:00:00 2001 From: Alessandro Ranellucci Date: Wed, 14 Oct 2026 17:17:29 +0200 Subject: [PATCH] Inherit {{p }} paragraph style onto Subdoc bodies Scan {{p var }} placeholder style and alignment before patch_xml and apply them to plain Subdoc body paragraphs during render(). --- docs/index.rst | 5 ++ docxtpl/__init__.py | 1 + docxtpl/p_style.py | 129 ++++++++++++++++++++++++++++++++++++++++ docxtpl/template.py | 6 ++ tests/subdoc_p_style.py | 100 +++++++++++++++++++++++++++++++ 5 files changed, 241 insertions(+) create mode 100644 docxtpl/p_style.py create mode 100644 tests/subdoc_p_style.py diff --git a/docs/index.rst b/docs/index.rst index 46cf69f..cee3bcc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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: diff --git a/docxtpl/__init__.py b/docxtpl/__init__.py index 5559d4f..2eba7c9 100644 --- a/docxtpl/__init__.py +++ b/docxtpl/__init__.py @@ -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 diff --git a/docxtpl/p_style.py b/docxtpl/p_style.py new file mode 100644 index 0000000..42d232a --- /dev/null +++ b/docxtpl/p_style.py @@ -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) diff --git a/docxtpl/template.py b/docxtpl/template.py index f20280a..5c1f084 100644 --- a/docxtpl/template.py +++ b/docxtpl/template.py @@ -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) diff --git a/tests/subdoc_p_style.py b/tests/subdoc_p_style.py new file mode 100644 index 0000000..bcd06a1 --- /dev/null +++ b/tests/subdoc_p_style.py @@ -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()