Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1079691
fix: move font below style to create correct order for OOXML specs
StuCM Dec 5, 2025
5ea2340
perf: parse_xml + body mutation optimization
Bonggoprasetyanto Dec 24, 2025
2dd1a29
Fix poetry configuration - add required fields
Bonggoprasetyanto Jan 8, 2026
ec0b7e1
fix: improve XML handling and cleanup code
Bonggoprasetyanto Jan 8, 2026
e455da7
Small comment clean-up
JackByrne Jan 9, 2026
3727096
Merge pull request #630 from start-software/develop
JackByrne May 11, 2026
e0fb809
perf: optimize body replacement and header/footer processing in DocxT…
bonggo-pras May 12, 2026
c82d2a4
Remove logging warnings in template.py
JackByrne May 18, 2026
efd473b
Clarify body-swap docstring and comments
JackByrne May 18, 2026
84c1420
Improve header/footer Jinja detection and fallback
JackByrne May 18, 2026
e5106f3
Optimize resolve_listing with early exit
JackByrne May 18, 2026
a5c3286
Precompile tag-stripping regexes in DocxTemplate
JackByrne May 18, 2026
c042ae2
Remove unused imports from template.py
JackByrne May 18, 2026
ac57d57
Clarify header/footer fallback comment
JackByrne May 18, 2026
a0564e1
Merge pull request #1 from start-software/performance-optimizations
JackByrne May 18, 2026
10079b1
Merge pull request #637 from start-software/develop
JackByrne May 18, 2026
8d48612
Prebuild and cache inline image XML
JackByrne May 18, 2026
ddf1687
Optimize image part deduplication
JackByrne May 18, 2026
4a96bc4
Use descriptor cache for image deduplication
JackByrne May 18, 2026
98d8aba
Cache image metadata instead of XML
JackByrne May 18, 2026
e488653
Handle non-hashable descriptors; escape quotes
JackByrne May 18, 2026
7c52c56
Scan image partnames to derive counter
JackByrne May 18, 2026
7581a33
Always use str(partname) for image parts
JackByrne May 18, 2026
82fd69c
Initialize docx_ids_index from existing docPr ids
JackByrne May 18, 2026
ef56632
Normalize None image filename before escaping
JackByrne May 18, 2026
f316ca8
Skip caching unhashable image descriptors
JackByrne May 18, 2026
47ca344
Merge pull request #2 from start-software/image-optimizations
JackByrne May 18, 2026
177822b
Merge pull request #638 from start-software/develop
JackByrne May 18, 2026
5e7aa13
Implemented upstream PR #626
chrismaddalena Jun 15, 2026
6bb1bfb
Updated ignore files
chrismaddalena Jun 15, 2026
89d8956
Preserve rendering for custom header delimiters
chrismaddalena Jun 15, 2026
7f5ee66
Added comments
chrismaddalena Jun 15, 2026
1918b65
Ensure file-like descriptors are not cached
chrismaddalena Jun 15, 2026
f66d422
Switch from generic `.replace()` to explicit repalcements
chrismaddalena Jun 15, 2026
5612d37
Updated comments for consistency
chrismaddalena Jun 15, 2026
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
__pycache__/
*.py[cod]

# OS-generated files
.DS_Store

# C extensions
*.so

Expand Down Expand Up @@ -61,4 +64,4 @@ target/
.project

#Pycharm
.idea
.idea
135 changes: 129 additions & 6 deletions docxtpl/inline_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,93 @@

@author: Eric Lapouyade
"""
from xml.sax.saxutils import escape as xml_escape

from docx.opc.constants import RELATIONSHIP_TYPE as RT
from docx.oxml import OxmlElement, parse_xml
from docx.oxml.ns import qn
from docx.oxml.shape import CT_Inline
from docx.shared import Emu


def _get_single_xpath(element, xpath, description):
matches = element.xpath(xpath)
if len(matches) != 1:
raise RuntimeError(
"python-docx generated inline image XML is incompatible with "
"docxtpl's fast inline image template: expected exactly one "
"%s at %s, found %d." % (description, xpath, len(matches))
)
return matches[0]


def _build_inline_image_xml_template():
"""Generate the XML format string by calling python-docx once.

This ensures the template always matches the installed python-docx version's
XML structure, even after upgrades. We create one inline image element with
valid values, then replace the exact XML attributes with Python format
placeholders before serializing it.
"""
inline = CT_Inline.new_pic_inline(
1,
"rId",
"filename",
Emu(1),
Emu(1),
)

extent = _get_single_xpath(inline, "./wp:extent", "drawing extent")
doc_pr = _get_single_xpath(inline, "./wp:docPr", "drawing properties")
c_nv_pr = _get_single_xpath(inline, ".//pic:cNvPr", "picture properties")
blip = _get_single_xpath(inline, ".//a:blip", "image relationship")
shape_extent = _get_single_xpath(inline, ".//a:ext", "picture extent")

extent.set("cx", "{cx}")
extent.set("cy", "{cy}")
doc_pr.set("id", "{shape_id}")
doc_pr.set("name", "Picture {shape_id}")
c_nv_pr.set("name", "{filename}")
blip.set(qn("r:embed"), "{rId}")
shape_extent.set("cx", "{cx}")
shape_extent.set("cy", "{cy}")

return inline.xml


# Pre-built XML template for inline images, derived from the installed
# python-docx version. Using str.format() on this template avoids calling
# CT_Inline.new_pic_inline() per image (which does 2x parse_xml() +
# element manipulation + .xml serialization each time).
_INLINE_IMAGE_XML = None


def _get_inline_image_xml_template():
global _INLINE_IMAGE_XML
if _INLINE_IMAGE_XML is None:
_INLINE_IMAGE_XML = _build_inline_image_xml_template()
return _INLINE_IMAGE_XML


def _format_inline_image_xml(shape_id, rId, filename, cx, cy):
try:
template = _get_inline_image_xml_template()
except RuntimeError:
return CT_Inline.new_pic_inline(
shape_id,
rId,
filename or "",
Emu(int(cx)),
Emu(int(cy)),
).xml

return template.format(
cx=int(cx),
cy=int(cy),
shape_id=shape_id,
filename=xml_escape(filename or "", {'"': """}),
rId=rId,
)


class InlineImage(object):
Expand Down Expand Up @@ -50,16 +135,54 @@ def _add_hyperlink(self, run, url, part):
return run

def _insert_image(self):
pic = self.tpl.current_rendering_part.new_pic_inline(
self.image_descriptor,
self.width,
self.height,
).xml
part = self.tpl.current_rendering_part
image_descriptor = self.image_descriptor

# Cache the expensive parts (image part lookup, rId, dimensions) per
# (part, descriptor, width, height). The XML string itself is NOT
# cached because each insertion needs a unique shape_id - header/footer
# and footnote parts are not renumbered by fix_docpr_ids().
cache = self.tpl._image_cache
# For hashable, value-stable descriptors (strings, paths), cache by
# value. File-like objects are mutable even when hashable (BytesIO,
# open file handles), so never cache their image metadata.
try:
if hasattr(image_descriptor, "read"):
raise TypeError
cache_key = (id(part), image_descriptor, self.width, self.height)
Comment thread
chrismaddalena marked this conversation as resolved.
hash(cache_key) is not None # trigger TypeError if unhashable
except TypeError:
cache_key = None

if cache_key is not None and cache_key in cache:
rId, cx, cy, filename = cache[cache_key]
else:
# Get or add the image part with O(1) descriptor-based dedup,
# avoiding the O(n) linear scan in python-docx's default path.
image_part, image = self.tpl._get_or_add_image_part(image_descriptor)
rId = part.relate_to(image_part, RT.IMAGE)
cx, cy = image.scaled_dimensions(self.width, self.height)
# image.filename is None for file-like descriptors (BytesIO);
# normalize to empty string to match python-docx's behavior.
filename = image.filename or ""
if cache_key is not None:
cache[cache_key] = (rId, int(cx), int(cy), filename)

# Always assign a fresh shape_id per insertion so that drawing IDs
# are unique in every part (including headers/footers/footnotes
# which are not renumbered by fix_docpr_ids()).
self.tpl.docx_ids_index += 1
shape_id = self.tpl.docx_ids_index

# Generate XML from the fast template when compatible, with a native
# python-docx fallback if its generated XML shape ever changes.
pic = _format_inline_image_xml(shape_id, rId, filename, cx, cy)

if self.anchor:
run = parse_xml(pic)
if run.xpath(".//a:blip"):
hyperlink = self._add_hyperlink(
run, self.anchor, self.tpl.current_rendering_part
run, self.anchor, part
)
pic = hyperlink.xml

Expand Down
16 changes: 8 additions & 8 deletions docxtpl/richtext.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ def add(

if style:
prop += '<w:rStyle w:val="%s"/>' % style
if font:
regional_font = ""
if ":" in font:
region, font = font.split(":", 1)
regional_font = ' w:{region}="{font}"'.format(font=font, region=region)
prop += '<w:rFonts w:ascii="{font}" w:hAnsi="{font}" w:cs="{font}"{regional_font}/>'.format(
font=font, regional_font=regional_font
)
if color:
if color[0] == "#":
color = color[1:]
Expand Down Expand Up @@ -100,14 +108,6 @@ def add(
prop += '<w:u w:val="%s"/>' % underline
if strike:
prop += "<w:strike/>"
if font:
regional_font = ""
if ":" in font:
region, font = font.split(":", 1)
regional_font = ' w:{region}="{font}"'.format(font=font, region=region)
prop += '<w:rFonts w:ascii="{font}" w:hAnsi="{font}" w:cs="{font}"{regional_font}/>'.format(
font=font, regional_font=regional_font
)
if rtl:
prop += '<w:rtl w:val="true"/>'
if lang:
Expand Down
Loading