diff --git a/docxtpl/template.py b/docxtpl/template.py index f20280a..d598d34 100644 --- a/docxtpl/template.py +++ b/docxtpl/template.py @@ -377,6 +377,36 @@ def render_footnotes( xml = self.render_xml_part(xml, part, context, jinja_env) part._blob = xml.encode("utf-8") + @staticmethod + def _has_jinja_markers( + text: str, jinja_env: Optional[Environment] = None + ) -> bool: + env = jinja_env if jinja_env is not None else Environment() + return ( + env.variable_start_string in text + or env.block_start_string in text + or env.comment_start_string in text + ) + + def render_hyperlinks( + self, context: Dict[str, Any], jinja_env: Optional[Environment] = None + ) -> None: + """Render jinja placeholders in external hyperlink URL targets. + + Hyperlink labels are stored in document XML and are already handled by + render_xml_part(), but their URLs are stored as external relationships + (e.g. word/_rels/document.xml.rels) and must be rendered separately. + """ + env = jinja_env if jinja_env is not None else Environment() + for part in self.docx.part.package.parts: + for rel in part.rels.values(): + if not rel.is_external or rel.reltype != REL_TYPE.HYPERLINK: + continue + target = rel.target_ref + if not self._has_jinja_markers(target, env): + continue + rel._target = env.from_string(target).render(context) + def resolve_listing(self, xml): def resolve_text(run_properties, paragraph_properties, m): @@ -511,6 +541,8 @@ def render( self.render_footnotes(context, jinja_env) + self.render_hyperlinks(context, jinja_env) + # set rendered flag self.is_rendered = True @@ -915,8 +947,15 @@ def get_undeclared_template_variables( else: env = Environment() - parse_content = env.parse(xml) - all_variables = meta.find_undeclared_variables(parse_content) + all_variables = meta.find_undeclared_variables(env.parse(xml)) + for part in temp_doc.part.package.parts: + for rel in part.rels.values(): + if not rel.is_external or rel.reltype != REL_TYPE.HYPERLINK: + continue + target = rel.target_ref + if not self._has_jinja_markers(target, env): + continue + all_variables |= meta.find_undeclared_variables(env.parse(target)) # If context is provided, return only variables that are not in the context if context is not None: diff --git a/tests/hyperlink.py b/tests/hyperlink.py new file mode 100644 index 0000000..b42a47e --- /dev/null +++ b/tests/hyperlink.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +import os +import re +import shutil +import zipfile +from xml.sax.saxutils import escape as xml_escape + +from jinja2 import Environment + +from docxtpl import DocxTemplate + +TEMPLATE = "templates/hyperlink_tpl.docx" +OUTPUT_DIR = "output" + + +def _patch_hyperlink_docx(src, dest, target, label=None): + """Copy a docx and rewrite the external hyperlink Target (and optional label).""" + shutil.copy(src, dest) + with zipfile.ZipFile(dest, "r") as zin: + parts = {name: zin.read(name) for name in zin.namelist()} + + rels_name = "word/_rels/document.xml.rels" + rels = parts[rels_name].decode() + target_xml = xml_escape(target, {"\"": """}) + + def replace_target(match): + return match.group(1) + target_xml + match.group(2) + + rels, count = re.subn( + r'(Type="[^"]*hyperlink"[^>]*\bTarget=")[^"]*(")', + replace_target, + rels, + count=1, + ) + if count == 0: + rels, count = re.subn( + r'(\bTarget=")[^"]*("[^>]*Type="[^"]*hyperlink")', + replace_target, + rels, + count=1, + ) + assert count == 1, "hyperlink Target not found in relationships" + parts[rels_name] = rels.encode() + + if label is not None: + document = parts["word/document.xml"].decode() + + def replace_label(match): + return match.group(1) + label + match.group(3) + + document, count = re.subn( + r"(]*>.*?]*>)(.*?)()", + replace_label, + document, + count=1, + flags=re.DOTALL, + ) + assert count == 1, "hyperlink label not found in document" + parts["word/document.xml"] = document.encode() + + os.remove(dest) + with zipfile.ZipFile(dest, "w") as zout: + for name, data in parts.items(): + zout.writestr(name, data) + + +def _read_rels_and_document(path): + with zipfile.ZipFile(path) as docx: + return ( + docx.read("word/_rels/document.xml.rels").decode(), + docx.read("word/document.xml").decode(), + ) + + +def test_basic_hyperlink(): + tpl = DocxTemplate(TEMPLATE) + context = {"foo": "https://example.com"} + tpl.render(context) + output_path = os.path.join(OUTPUT_DIR, "hyperlink.docx") + tpl.save(output_path) + + rels, document = _read_rels_and_document(output_path) + assert 'Target="https://example.com"' in rels, rels + assert "{{ foo }}" not in rels, rels + assert re.search(r"]*>https://example.com", document), document + + undeclared = DocxTemplate(TEMPLATE).get_undeclared_template_variables( + context=context + ) + assert undeclared == set(), undeclared + + +def test_custom_delimiters(): + patched = os.path.join(OUTPUT_DIR, "hyperlink_custom_delim_tpl.docx") + _patch_hyperlink_docx(TEMPLATE, patched, "[[ foo ]]", label="[[ foo ]]") + + env = Environment(variable_start_string="[[", variable_end_string="]]") + context = {"foo": "https://example.com"} + + undeclared = DocxTemplate(patched).get_undeclared_template_variables(jinja_env=env) + assert "foo" in undeclared, undeclared + + undeclared = DocxTemplate(patched).get_undeclared_template_variables( + jinja_env=env, context=context + ) + assert undeclared == set(), undeclared + + tpl = DocxTemplate(patched) + tpl.render(context, jinja_env=env) + output_path = os.path.join(OUTPUT_DIR, "hyperlink_custom_delim.docx") + tpl.save(output_path) + + rels, document = _read_rels_and_document(output_path) + assert 'Target="https://example.com"' in rels, rels + assert "[[ foo ]]" not in rels, rels + assert re.search(r"]*>https://example.com", document), document + + +def test_mixed_percent_and_braces(): + url_tpl = "https://example.com/{{ path }}?q=%7Bbar%7D&id={{ id }}" + patched = os.path.join(OUTPUT_DIR, "hyperlink_mixed_tpl.docx") + _patch_hyperlink_docx(TEMPLATE, patched, url_tpl, label="{{ path }}") + + context = {"path": "docs/page", "id": "42"} + tpl = DocxTemplate(patched) + tpl.render(context) + output_path = os.path.join(OUTPUT_DIR, "hyperlink_mixed.docx") + tpl.save(output_path) + + rels, _document = _read_rels_and_document(output_path) + # & is stored as & in relationship XML attributes + expected = "https://example.com/docs/page?q=%7Bbar%7D&id=42" + assert 'Target="%s"' % expected in rels, rels + assert "{{ path }}" not in rels, rels + assert "{{ id }}" not in rels, rels + assert "%7Bbar%7D" in rels, rels + + +if __name__ == "__main__": + if not os.path.exists(OUTPUT_DIR): + os.mkdir(OUTPUT_DIR) + test_basic_hyperlink() + test_custom_delimiters() + test_mixed_percent_and_braces() + print("hyperlink test passed") diff --git a/tests/templates/hyperlink_tpl.docx b/tests/templates/hyperlink_tpl.docx new file mode 100644 index 0000000..7f4511e Binary files /dev/null and b/tests/templates/hyperlink_tpl.docx differ