From b98ff321d31bdeceff9c4f306374c1b701361afc Mon Sep 17 00:00:00 2001 From: Alessandro Ranellucci Date: Thu, 25 Jun 2026 21:20:13 +0200 Subject: [PATCH] Render placeholders in hyperlinks URLs --- docxtpl/template.py | 35 +++++++++++++++++++++++++++-- tests/hyperlink.py | 26 +++++++++++++++++++++ tests/templates/hyperlink_tpl.docx | Bin 0 -> 5205 bytes 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/hyperlink.py create mode 100644 tests/templates/hyperlink_tpl.docx diff --git a/docxtpl/template.py b/docxtpl/template.py index f20280a..027df22 100644 --- a/docxtpl/template.py +++ b/docxtpl/template.py @@ -377,6 +377,28 @@ def render_footnotes( xml = self.render_xml_part(xml, part, context, jinja_env) part._blob = xml.encode("utf-8") + 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. + """ + 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 in target and "{%" not in target and "{#" not in target: + continue + if jinja_env: + template = jinja_env.from_string(target) + else: + template = Template(target) + rel._target = template.render(context) + def resolve_listing(self, xml): def resolve_text(run_properties, paragraph_properties, m): @@ -511,6 +533,8 @@ def render( self.render_footnotes(context, jinja_env) + self.render_hyperlinks(context, jinja_env) + # set rendered flag self.is_rendered = True @@ -915,8 +939,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 in target and "{%" not in target and "{#" not in target: + 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..4ffd202 --- /dev/null +++ b/tests/hyperlink.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +import re +import zipfile + +from docxtpl import DocxTemplate + +tpl = DocxTemplate("templates/hyperlink_tpl.docx") +context = {"foo": "https://example.com"} +tpl.render(context) +output_path = "output/hyperlink.docx" +tpl.save(output_path) + +with zipfile.ZipFile(output_path) as docx: + rels = docx.read("word/_rels/document.xml.rels").decode() + document = docx.read("word/document.xml").decode() + +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("templates/hyperlink_tpl.docx").get_undeclared_template_variables( + context=context +) +assert undeclared == set(), undeclared + +print("hyperlink test passed") diff --git a/tests/templates/hyperlink_tpl.docx b/tests/templates/hyperlink_tpl.docx new file mode 100644 index 0000000000000000000000000000000000000000..7f4511ed27eee543198caf7d658462427f52700d GIT binary patch literal 5205 zcmaJ_cT`h(x25;qL|~|)NJqK}(hSl;0qLF4CG=vD4ho7$K#=AT5a~5kL+=RETaY3h zse?3W@`!Kdlkt7eleO;3ANSmwoW0LJ`}}k@aPjD{h=_==@RHDGSXYAd^1G!c#MMhg z7;`O#=nl11*E(kXAF&=08MGqz%h8ffz=sq8CI%0PKLm15`)dyH@*xTP1syBZkb zjY7)jUrXAw87y-$(Ph!B6n@V1hT>?0dGereJ6=b>0H7lbG4N9w7kveHqeiAo>bco0 z=<+Jl>EtBcwBhu68}IR{dV)`0F7wZTf`&167z*3RU*n8Q&0W`7G$98azQl*XFQwH3*4b7k7}%T>j@fI9OP^|8G^OFYmB}+Uj~j-MvI?p`H+7 z|A(%z_sw;}Md=WpN@wVS#>))tQGXcvj~#j zHZCYGc@!R|mzPNp9YtWF5NCpCo`$4+v~tIobXq`0;Ex`)b4WYD_0zbEL&NAH+u+1l zo1T1ufL#qg1wex$w%rU@nUBSVigH

y@!7Exn3v0gffXSP^IuvuDkS??zeTJ8Yb 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 Az5oCK literal 0 HcmV?d00001