-
Notifications
You must be signed in to change notification settings - Fork 449
feat: make patch_xml respect custom Jinja2 delimiters #651
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
44cba6b
290bbd7
e87c172
c367024
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", "{{") | ||
| 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})") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, |
||
|
|
||
| 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 {# #} | ||
|
|
@@ -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): | ||
|
|
@@ -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">', | ||
|
|
@@ -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"‘", "'") | ||
| text.replace("‘", "'") | ||
| .replace("<", "<") | ||
| .replace(">", ">") | ||
| .replace("“", '"') | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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") | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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) | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
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 withoutxml: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.