From b98ff321d31bdeceff9c4f306374c1b701361afc Mon Sep 17 00:00:00 2001 From: Alessandro Ranellucci Date: Thu, 25 Jun 2026 21:20:13 +0200 Subject: [PATCH 1/2] 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 zINZF)%kNq(;*d=!AsXftzN;FUi5a_XwTg#sKc3Zir|J8n)rVBkilt z=6S*9#Cp1y5USP_-6_T7SEAIv-H^RRk{t=WUo2}epLl%DL1*x4Q%5{dyR0le|X z1{(|O({BNyx(blByE_J-jC+dEFj2;a9Yd;$!b*1b*Ct3;w9_-5n+NvJ$5afCY1QH< zJBAS3^obGJ94w%8>uyqV$w7an7FS0rCGIASy;hLf!{i%V#|B_e&NhY5SO8z}On@<1 zjjh{61Aj`&3Y5&=*IHU^;1xkuim>HzuW(oA_US6hK8dh1kYup{T&yb?(OIW+)*4Z9 zC(=sdojhJRbG=|TQ4d1>iDocd{>pbFQ%?w~KztG&Sy?2MO2Oy582XYoXDUj3BPgO# z@-BHH3_oUpoivL*Fwh~e3e9$C{7BUnYA0KJHxQ?W$Pfv-{f=rGDbpU1)GwOGy7yMo z1JrAjnkW;>pH3qojFN<&8VrgOmjV6ScAgiZiOI-`*Rj!R=1feqqqtwq#6Q;h_%f;B z#jdGNNmkGDbwbs8CM9Uh66|?D>YwXLi{6a_5B&h))PGI;?j;Dfe+vT3B?x{{PdkzS z6_Cq@@p%Yw^TuHDx0q;Yw~10k9?3;6%X!=niqK7W!^$U>;$vwBEVdWenjVIj7&HL6 zOdR=N#ekFcHUoA>`VyZVSfP2_?dlWiyFL4xB^xD663&W)8sD!zbAHemW}s?$AF5QbH!A>VOhfA^DTd%Jr#7Ga(-_qtBe#*OkyKchlF(z`{LUxr=Xc+AP!hofVYy>x{+rnRF(MuDjBF`Q7ix)z z*|o(4Nqzasl&fd1r&N!rR&eBRecj3J)?O$@w24}+1xBmN1%hLqH@v?<00LBO!TGdI zGn#ygAA9xJK!~|Uw>c5Matd#fiqY4DTqcj2f@;EY1avue=cj}@5RqPEaIxM7=1nbQ z#39VsiB1NZnw~>oQC|diy_M3Jf`f3Pg$D69_T6mp(EG|8IW@*fz%BFVd!aj!8$+3a zFD!9XCsqOSY9R1rLbwKUkz+6QF%>MlIAotHBdDn7h%JX>fSvXhF(qV!9)&ebUMC15 zpTPQJeUds`InL}U*MNn!+L$Q5rn=TW|vv{#YHL#P}TeH82P3iGm?)=+_If`$Q zxv-NO!yu{8$1{I4O{B=1oNF@Y!NarIRZl-bfR98SXQa?U_81NPK{hvY#yMlONt z{#&=&MpL=)<8siC*d-G47`CnC8r^^dSSN9$1D@HC2yyYY=C|Mm+}` zeB&mMNzRyB$2<4JmlD)iF9BQ7+=_F^Ee!wAq55aywzwG+DpK8 zh;%ro#c1VW*=`u?Ws43G{f=OmVfLds(^rz_ z4lsNChnCs`XpH`q${w7hmdg*RQ*)Yi)3vbnkeH@YKvS(cleA09?4I=fuuzY8hg|mh z*6Svsvm$TfLg23THQ>}kMO)iC71M_b_u@<)hlTBCSJioqeG_n!N3$(zsK>!m+t6b_ zb(N-bZsQ{Ye$pt9)iP>z-vE00lczmMQWA=E+lU5$7ru8e{Hs}ay*S04BZ`1TF_r6& zk4#9ue%&O8)*+H8D_3kQp;pyW!*r;(Cg6D|7v(O&prNXlt!z`Rg!hgQy7U{i&=HDAynr#~RU+4CZBx5P$Z+y;Qs-o03oM<7ugOo)}neNC&tVH>17cO=( z18WgqTOK~FmoAV#%xA539#bo(Ul8bT-{p!jHVWA-f03#_ac#c*Z5@{2wF~bdVe6`G z)HUY#&Swb}O-pDt4A)8$t!jlXxi*3SJFZdw!!@y~lwCpKY9*ez=V)fd+E@uC4S@X_z}Kv`>i|9}BYO`J>EhW%>NCW80eQoT z;(QwOyCZplylB=k(DrC*fs;2vinRRKjjXXzA zr2DwY$Bg`>v4X(oue_y<$nzlST}w#=Emi^C;nx}8&`f`b8fHb~IL{1vG9{$INxQ<) z-EDoHc8)UiE#XURq#w;O%zn=L|V8_=@hFK?`?I)8b9qd5y{jq62iE z+SlGd2X#I@y{x>IG-B^Yg9dKOXb^_yJ_cH1m~jE ze@SW27}sI16I!I*i-f`zgaAzt!9(A9feL ze;lVy;=4Rf?N#Iv?@M(UAY`n+{|m4lw%PWV2@Z4Jy5Ca4h5Q58W#E1*6c1=^>7GsF zkjrCF@|`Z|O>%HDiQ>CC5mn_)x%>gt+wYEgHPhA1{4FIGhqR-wpPDDJd40*&zpH=h zUpZMp60CLhqIn|Wf&u&|gDlS=Ycz`T8*m{Agb3V|tAM@6WVnM{*LZ(-JLIzv9emshbMS&;KL71 zl~L-ArIspj_ucWP!VV%{fjE0R&fJndOzGLh_eS^X_waO9>&N(nQ}}P1zAYNNE%Wr4 z$(R4Q>|yvsclu0*!AO?BE(KW+QtMlEY^h83E_$OuIi^8PfmBSVIo8Rzf54mb6`rti zX{<7XhUUUYV5YF5G!>(cEMeIz!LOemQ+ct_ha*v2KN4yO1h zxn#Ks@8aU&GC$*0JK!@^EzJJp4^;Ksv(S-+)Q+)5t=PT3Ao}Vo#rXaSjR4uZ?bpqn zkOv~S=jPPOJ#`Lk4?5FMNyyrj>(4zew2lj_-cW)r@Gy1n7mSURxwM~6zco(( znQLAUZ*M0zhaYN_1OJHeoDn-9@Di}fwB2&FG7NX2H6N@xZRRjyG&$QnD-n_Fz@Z1M4-WZ80kNIJALk{U_3>RFJmT^9H87HLkI=J zrbgZGB<5H)=Wx#ANxqrFRIe@W%3k)sdOy{l`KY4JNQ#>LZF}}?Pvm{WVHf817%q9L z$K%W7cW^_$-BKq$=1$c8GCuTRM=FBclwyj0!DNP_!i|{i^j%bDG>pY3Mu;rx5uIgS z=g?mXZiCXPon!Wnoh6xaeyrMC{kJsEX_MHEoU_kICQWI@tij@}@kR#Y&3+CZfPRtO z?zv2<7E;tT=b-Y~srnf%>Jv32ZS#dkmDi+}sGmg3pWTUzryq7lif;e#rUzt-aKL4W z1Ago9X)md5s(eZ7OIvTLAK(u0GRH`ILt-zc$l#;%m~PX=T1Y6B`V@W~xA(pA?#YBv zDtS9p&N-}8O&`H85y>DG4^M#&^l2>wR^RifAXv>A!cVcYg@>%DsG8F+1?;}GNI-ih zic~WMhBZuGv%4PFfEeB5=35cBRkY2K&@KgMH?^^}G{c;x zxHKWB(+ZZ>ObI{oXR>lc#n{}Z?4NI|CJ6q1!(>h|FuEHbRvf$=OQ}_cWbMaNQut{3 zc2L%j>$&F?%g~4prU{@~v;o$a2o3!hxY#&!SU*p2uAX9GPH_GmSI%>EHU6D=b-Tv& z&(C1EeAWLs^7u9V>RyOh!G8uuCjXQE_d@<__SJm|vpfC_5Z>=M$zPvz)%arer=Jma zS*;&t_t$Oe*GF9SGykr{BH@4W%fF^y9Ss=M^D}fv{!agCfqqTCs&AO-@iUak|4#nb zEcrG6YV-d4x1%WjdHEGh{5Ab5voIb0GgvP{{V)BltAS5|xryZR6MPAD^N*MQ56=CY Az5oCK literal 0 HcmV?d00001 From 93b4cfbd52b11e062174438d4cfcce772e50b5d9 Mon Sep 17 00:00:00 2001 From: Alessandro Ranellucci Date: Wed, 14 Oct 2026 12:19:26 +0200 Subject: [PATCH 2/2] Add support for custom delimiters in hyperlinks and add regression tests --- docxtpl/template.py | 22 +++++-- tests/hyperlink.py | 151 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 150 insertions(+), 23 deletions(-) diff --git a/docxtpl/template.py b/docxtpl/template.py index 027df22..d598d34 100644 --- a/docxtpl/template.py +++ b/docxtpl/template.py @@ -377,6 +377,17 @@ 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: @@ -386,18 +397,15 @@ def render_hyperlinks( 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 in target and "{%" not in target and "{#" not in target: + if not self._has_jinja_markers(target, env): continue - if jinja_env: - template = jinja_env.from_string(target) - else: - template = Template(target) - rel._target = template.render(context) + rel._target = env.from_string(target).render(context) def resolve_listing(self, xml): @@ -945,7 +953,7 @@ def get_undeclared_template_variables( 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: + if not self._has_jinja_markers(target, env): continue all_variables |= meta.find_undeclared_variables(env.parse(target)) diff --git a/tests/hyperlink.py b/tests/hyperlink.py index 4ffd202..b42a47e 100644 --- a/tests/hyperlink.py +++ b/tests/hyperlink.py @@ -1,26 +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 -tpl = DocxTemplate("templates/hyperlink_tpl.docx") -context = {"foo": "https://example.com"} -tpl.render(context) -output_path = "output/hyperlink.docx" -tpl.save(output_path) +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 }}") -with zipfile.ZipFile(output_path) as docx: - rels = docx.read("word/_rels/document.xml.rels").decode() - document = docx.read("word/document.xml").decode() + 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) -assert 'Target="https://example.com"' in rels, rels -assert "{{ foo }}" not in rels, rels -assert re.search(r"]*>https://example.com", document), document + 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 -undeclared = DocxTemplate("templates/hyperlink_tpl.docx").get_undeclared_template_variables( - context=context -) -assert undeclared == set(), undeclared -print("hyperlink test passed") +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")