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
136 changes: 106 additions & 30 deletions docxtpl/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,62 @@ def write_xml(self, filename):
with open(filename, "w") as fh:
fh.write(self.get_xml())

def patch_xml(self, src_xml):
@staticmethod
def _get_delim_repr(env, attr, default):
"""Return the regex-escaped representation of a jinja2 delimiter."""
if env is None:
return re.escape(default)
return re.escape(getattr(env, attr))

def patch_xml(self, src_xml, jinja_env=None):
"""Make a lots of cleaning to have a raw xml understandable by jinja2 :
strip all unnecessary xml tags, manage table cell background color and colspan,
unescape html entities, etc..."""

# replace {<something>{ by {{ ( works with {{ }} {% and %} {# and #})
src_xml = re.sub(
r"(?<={)(<[^>]*>)+(?=[\{%\#])|(?<=[%\}\#])(<[^>]*>)+(?=\})",
"",
src_xml,
flags=re.DOTALL,
)
# Resolve delimiter strings (regex-escaped) for dynamic patterns.
# When jinja_env is None, defaults to standard Jinja2 delimiters.
vo = self._get_delim_repr(jinja_env, "variable_start_string", "{{")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The space-preservation pass remains hardcoded to {{ ... }} and {% ... %}. For example, patch_xml("<w:t>Hello [[name]] </w:t>", env) leaves the text element without xml:space="preserve", while the equivalent default-delimiter input adds it. Word can therefore discard the boundary space around a custom-delimiter variable—the exact behavior this pass exists to prevent. Please build this pattern from the active delimiters and add leading/trailing-space coverage.

vc = self._get_delim_repr(jinja_env, "variable_end_string", "}}")
bo = self._get_delim_repr(jinja_env, "block_start_string", "{%")
bc = self._get_delim_repr(jinja_env, "block_end_string", "%}")
co = self._get_delim_repr(jinja_env, "comment_start_string", "{#")
cc = self._get_delim_repr(jinja_env, "comment_end_string", "#}")

# Build a union pattern matching any Jinja2 tag:
# block_start ... block_end | comment_start ... comment_end | variable_start ... variable_end
def _tag_union():
parts = []
if bo and bc:
parts.append(f"{bo}(?:(?!{bc}).)*")
if co and cc:
parts.append(f"{co}(?:(?!{cc}).)*")
if vo and vc:
parts.append(f"{vo}(?:(?!{vc}).)*")
return "|".join(parts)

# Join delimiter characters split across XML runs.
# E.g. "<w:t>[</w:t></w:r><w:r><w:t>[name]]</w:t>" → "[[name]]"
# Works for default {{ / {% / {# delimiters and any custom ones.
def _raw(attr, default):
return getattr(jinja_env, attr) if jinja_env else default

_join_parts = []
for delim in (
_raw("variable_start_string", "{{"),
_raw("variable_end_string", "}}"),
_raw("block_start_string", "{%"),
_raw("block_end_string", "%}"),
_raw("comment_start_string", "{#"),
_raw("comment_end_string", "#}"),
):
if len(delim) >= 2:
for i in range(1, len(delim)):
left = re.escape(delim[:i])
right = re.escape(delim[i:])
_join_parts.append(f"(?<={left})(<[^>]*>)+(?={right})")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only removes a boundary when either the complete prefix or suffix is already contiguous. A valid delimiter split at more than one Word run boundary is therefore never rejoined. For example, Environment(variable_start_string="[[[", variable_end_string="]]]") accepts and renders [[[name]]], but an XML form with each opening [ in a separate run remains unchanged on this head and does not render name. Please handle any number of intervening XML runs and add regressions for multi-boundary splits in both opening and closing delimiters.


if _join_parts:
src_xml = re.sub("|".join(_join_parts), "", src_xml, flags=re.DOTALL)

# replace {{<some tags>jinja2 stuff<some other tags>}} by {{jinja2 stuff}}
# same thing with {% ... %} and {# #}
Expand All @@ -103,12 +147,14 @@ def striptags(m):
"</w:t>.*?(<w:t>|<w:t [^>]*>)", "", m.group(0), flags=re.DOTALL
)

src_xml = re.sub(
r"{%(?:(?!%}).)*|{#(?:(?!#}).)*|{{(?:(?!}}).)*",
striptags,
src_xml,
flags=re.DOTALL,
)
tag_pat = _tag_union()
if tag_pat:
src_xml = re.sub(
tag_pat,
striptags,
src_xml,
flags=re.DOTALL,
)

# manage table cell colspan
def colspan(m):
Expand Down Expand Up @@ -156,13 +202,19 @@ def cellbg(m):
flags=re.DOTALL,
)

# ensure space preservation
src_xml = re.sub(
r"<w:t>((?:(?!<w:t>).)*)({{.*?}}|{%.*?%})",
r'<w:t xml:space="preserve">\1\2',
src_xml,
flags=re.DOTALL,
)
# ensure space preservation for all user-configured delimiters
_space_tag_parts = []
if vo and vc:
_space_tag_parts.append(f"{vo}.*?{vc}")
if bo and bc:
_space_tag_parts.append(f"{bo}.*?{bc}")
if _space_tag_parts:
src_xml = re.sub(
r"<w:t>((?:(?!<w:t>).)*)(" + "|".join(_space_tag_parts) + ")",
r'<w:t xml:space="preserve">\1\2',
src_xml,
flags=re.DOTALL,
)
src_xml = re.sub(
r"({{r\s.*?}}|{%r\s.*?%})",
r'</w:t></w:r><w:r><w:t xml:space="preserve">\1</w:t></w:r><w:r><w:t xml:space="preserve">',
Expand Down Expand Up @@ -286,10 +338,9 @@ def without_gridspan(m2):
flags=re.DOTALL,
)

def clean_tags(m):
def _clean_inner(text):
return (
m.group(0)
.replace(r"&#8216;", "'")
text.replace("&#8216;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("“", '"')
Expand All @@ -298,7 +349,31 @@ def clean_tags(m):
.replace("’", "'")
)

src_xml = re.sub(r"(?<=\{[\{%])(.*?)(?=[\}%]})", clean_tags, src_xml)
# HTML entity cleanup inside Jinja2 tags.
# Each tag type is processed separately so that an opening
# delimiter only pairs with its own closing delimiter -- e.g.
# [[ ... ]] never stops at a stray %] inside a string literal.
if vo and vc:
src_xml = re.sub(
f"({vo})(.*?)({vc})",
lambda m: m.group(1) + _clean_inner(m.group(2)) + m.group(3),
src_xml,
flags=re.DOTALL,
)
if bo and bc:
src_xml = re.sub(
f"({bo})(.*?)({bc})",
lambda m: m.group(1) + _clean_inner(m.group(2)) + m.group(3),
src_xml,
flags=re.DOTALL,
)
if co and cc:
src_xml = re.sub(
f"({co})(.*?)({cc})",
lambda m: m.group(1) + _clean_inner(m.group(2)) + m.group(3),
src_xml,
flags=re.DOTALL,
)

return src_xml

Expand Down Expand Up @@ -372,7 +447,8 @@ def render_footnotes(
xml = self.patch_xml(
part.blob.decode("utf-8")
if isinstance(part.blob, bytes)
else part.blob
else part.blob,
jinja_env,
)
xml = self.render_xml_part(xml, part, context, jinja_env)
part._blob = xml.encode("utf-8")
Expand Down Expand Up @@ -432,7 +508,7 @@ def resolve_paragraph(m):

def build_xml(self, context, jinja_env=None):
xml = self.get_xml()
xml = self.patch_xml(xml)
xml = self.patch_xml(xml, jinja_env)
xml = self.render_xml_part(xml, self.docx._part, context, jinja_env)
return xml

Expand All @@ -459,7 +535,7 @@ def build_headers_footers_xml(self, context, uri, jinja_env=None):
for relKey, part in self.get_headers_footers(uri):
xml = self.get_part_xml(part)
encoding = self.get_headers_footers_encoding(xml)
xml = self.patch_xml(xml)
xml = self.patch_xml(xml, jinja_env)
xml = self.render_xml_part(xml, part, context, jinja_env)
yield relKey, xml.encode(encoding)

Expand Down Expand Up @@ -901,14 +977,14 @@ def get_undeclared_template_variables(

# Get XML from the temporary document
xml = self.xml_to_string(temp_doc._element.body)
xml = self.patch_xml(xml)
xml = self.patch_xml(xml, jinja_env)

# Add headers and footers
for uri in [self.HEADER_URI, self.FOOTER_URI]:
for relKey, val in temp_doc._part.rels.items():
if (val.reltype == uri) and (val.target_part.blob):
_xml = self.xml_to_string(parse_xml(val.target_part.blob))
xml += self.patch_xml(_xml)
xml += self.patch_xml(_xml, jinja_env)

if jinja_env:
env = jinja_env
Expand Down
Loading