diff --git a/.github/workflows/codestyle.yml b/.github/workflows/codestyle.yml
index c6a855d..2d1d7f9 100644
--- a/.github/workflows/codestyle.yml
+++ b/.github/workflows/codestyle.yml
@@ -14,6 +14,7 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
+ allow-prereleases: true
- name: Install dependencies
run: |
python -m pip install --upgrade pip
@@ -23,3 +24,11 @@ jobs:
pip install flake8
# stop the build if there are code styling problems. The GitHub editor is 127 chars wide.
flake8 . --count --max-line-length=127 --show-source --statistics
+ - name: Check type hints
+ run: |
+ pip install mypy lxml-stubs
+ mypy .
+ - name: Ensure library work without docxcompose
+ run: |
+ pip uninstall -y docxcompose
+ python -c "from docxtpl import *"
diff --git a/.gitignore b/.gitignore
index 3944698..74095ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,4 +61,7 @@ target/
.project
#Pycharm
-.idea
\ No newline at end of file
+.idea
+
+# In Project Virtual Environment
+.venv/
diff --git a/docxtpl/__init__.py b/docxtpl/__init__.py
index 5559d4f..ebb72ea 100644
--- a/docxtpl/__init__.py
+++ b/docxtpl/__init__.py
@@ -6,11 +6,20 @@
"""
__version__ = "0.20.2"
+__all__ = (
+ "InlineImage",
+ "Listing",
+ "RichText",
+ "R",
+ "RichTextParagraph",
+ "RP",
+ "DocxTemplate",
+ "Subdoc",
+)
-# flake8: noqa
from .inline_image import InlineImage
from .listing import Listing
-from .richtext import RichText, R, RichTextParagraph, RP
+from .richtext import RP, R, RichText, RichTextParagraph
from .template import DocxTemplate
try:
diff --git a/docxtpl/__main__.py b/docxtpl/__main__.py
index 59cf049..9f0787c 100644
--- a/docxtpl/__main__.py
+++ b/docxtpl/__main__.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
import argparse
import json
import os
@@ -11,7 +13,7 @@
QUIET_ARG = "quiet"
-def make_arg_parser():
+def make_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
usage="python -m docxtpl [-h] [-o] [-q] {} {} {}".format(
TEMPLATE_ARG, JSON_ARG, OUTPUT_ARG
@@ -42,7 +44,7 @@ def make_arg_parser():
return parser
-def get_args(parser):
+def get_args(parser: argparse.ArgumentParser) -> dict:
try:
parsed_args = vars(parser.parse_args())
return parsed_args
@@ -57,7 +59,7 @@ def get_args(parser):
)
-def is_argument_valid(arg_name, arg_value, overwrite):
+def is_argument_valid(arg_name: str, arg_value: str, overwrite: bool) -> bool | None:
# Basic checks for the arguments
if arg_name == TEMPLATE_ARG:
return os.path.isfile(arg_value) and arg_value.endswith(".docx")
@@ -69,9 +71,10 @@ def is_argument_valid(arg_name, arg_value, overwrite):
)
elif arg_name in [OVERWRITE_ARG, QUIET_ARG]:
return arg_value in [True, False]
+ return None
-def check_exists_ask_overwrite(arg_value, overwrite):
+def check_exists_ask_overwrite(arg_value: str, overwrite: bool) -> bool:
# If output file does not exist or command was run with overwrite option,
# returns True, else asks for overwrite confirmation. If overwrite is
# confirmed returns True, else raises OSError.
@@ -93,7 +96,7 @@ def check_exists_ask_overwrite(arg_value, overwrite):
return True
-def validate_all_args(parsed_args):
+def validate_all_args(parsed_args: dict) -> None:
overwrite = parsed_args[OVERWRITE_ARG]
# Raises AssertionError if any of the arguments is not validated
try:
@@ -108,7 +111,7 @@ def validate_all_args(parsed_args):
)
-def get_json_data(json_path):
+def get_json_data(json_path: str) -> dict:
with open(json_path) as file:
try:
json_data = json.load(file)
@@ -121,14 +124,14 @@ def get_json_data(json_path):
raise RuntimeError("Failed to get json data.")
-def make_docxtemplate(template_path):
+def make_docxtemplate(template_path: str) -> DocxTemplate:
try:
return DocxTemplate(template_path)
except TemplateError:
raise RuntimeError("Could not create docx template.")
-def render_docx(doc, json_data):
+def render_docx(doc: DocxTemplate, json_data: dict) -> DocxTemplate:
try:
doc.render(json_data)
return doc
@@ -136,7 +139,7 @@ def render_docx(doc, json_data):
raise RuntimeError("An error ocurred while trying to render the docx")
-def save_file(doc, parsed_args):
+def save_file(doc: DocxTemplate, parsed_args: dict) -> None:
try:
output_path = parsed_args[OUTPUT_ARG]
doc.save(output_path)
@@ -151,7 +154,7 @@ def save_file(doc, parsed_args):
raise RuntimeError("Failed to save file.")
-def main():
+def main() -> None:
parser = make_arg_parser()
# Everything is in a try-except block that catches a RuntimeError that is
# raised if any of the individual functions called cause an error
diff --git a/docxtpl/inline_image.py b/docxtpl/inline_image.py
index 5b164b1..61bc299 100644
--- a/docxtpl/inline_image.py
+++ b/docxtpl/inline_image.py
@@ -4,9 +4,20 @@
@author: Eric Lapouyade
"""
+
+from __future__ import annotations
+
+from typing import IO, TYPE_CHECKING
+
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
+if TYPE_CHECKING:
+ from docx.parts.story import CT_Inline, StoryPart
+ from docx.shared import Length
+
+ from .template import DocxTemplate
+
class InlineImage(object):
"""Class to generate an inline image
@@ -14,20 +25,20 @@ class InlineImage(object):
This is much faster than using Subdoc class.
"""
- tpl = None
- image_descriptor = None
- width = None
- height = None
+ tpl: DocxTemplate | None = None
+ image_descriptor: str | IO[bytes] | None = None
+ width: int | Length | None = None
+ height: int | Length | None = None
anchor = None
title = None
descr = None
def __init__(
self,
- tpl,
- image_descriptor,
- width=None,
- height=None,
+ tpl: DocxTemplate,
+ image_descriptor: str | IO[bytes],
+ width: int | Length | None = None,
+ height: int | Length | None = None,
anchor=None,
title=None,
descr=None,
@@ -38,7 +49,7 @@ def __init__(
self.title = str(title) if title is not None else None
self.descr = str(descr) if descr is not None else None
- def _add_hyperlink(self, run, url, part):
+ def _add_hyperlink(self, run: CT_Inline, url: str, part: StoryPart) -> CT_Inline:
# Create a relationship for the hyperlink
r_id = part.relate_to(
url,
@@ -62,7 +73,7 @@ def _add_hyperlink(self, run, url, part):
return run
- def _set_doc_properties(self, run):
+ def _set_doc_properties(self, run: CT_Inline) -> None:
docPr = run.xpath(".//wp:docPr")[0]
cNvPr = run.xpath(".//pic:cNvPr")[0]
@@ -72,7 +83,10 @@ def _set_doc_properties(self, run):
if self.descr is not None:
elt.set("descr", self.descr)
- def _insert_image(self):
+ def _insert_image(self) -> str:
+ if TYPE_CHECKING:
+ assert self.tpl is not None
+ assert self.image_descriptor is not None
pic = self.tpl.current_rendering_part.new_pic_inline(
self.image_descriptor,
self.width,
@@ -80,23 +94,19 @@ def _insert_image(self):
)
if self.title is not None or self.descr is not None:
self._set_doc_properties(pic)
- if self.anchor:
- if pic.xpath(".//a:blip"):
- pic = self._add_hyperlink(
- pic, self.anchor, self.tpl.current_rendering_part
- )
- pic = pic.xml
+ if self.anchor and pic.xpath(".//a:blip"):
+ pic = self._add_hyperlink(pic, self.anchor, self.tpl.current_rendering_part)
return (
"%s"
- '' % pic
+ '' % pic.xml
)
- def __unicode__(self):
+ def __unicode__(self) -> str:
return self._insert_image()
- def __str__(self):
+ def __str__(self) -> str:
return self._insert_image()
- def __html__(self):
+ def __html__(self) -> str:
return self._insert_image()
diff --git a/docxtpl/listing.py b/docxtpl/listing.py
index 62cde54..c9e6916 100644
--- a/docxtpl/listing.py
+++ b/docxtpl/listing.py
@@ -4,11 +4,12 @@
@author: Eric Lapouyade
"""
+
try:
from html import escape
except ImportError:
# cgi.escape is deprecated in python 3.7
- from cgi import escape
+ from cgi import escape # type:ignore[attr-defined,no-redef]
class Listing(object):
diff --git a/docxtpl/py.typed b/docxtpl/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/docxtpl/richtext.py b/docxtpl/richtext.py
index c79ac1e..3203500 100644
--- a/docxtpl/richtext.py
+++ b/docxtpl/richtext.py
@@ -4,11 +4,12 @@
@author: Eric Lapouyade
"""
+
try:
from html import escape
except ImportError:
# cgi.escape is deprecated in python 3.7
- from cgi import escape
+ from cgi import escape # type:ignore
class RichText(object):
diff --git a/docxtpl/subdoc.py b/docxtpl/subdoc.py
index e8da093..da5cfbc 100644
--- a/docxtpl/subdoc.py
+++ b/docxtpl/subdoc.py
@@ -5,15 +5,15 @@
@author: Eric Lapouyade
"""
+import re
+
from docx import Document
-from docx.oxml import CT_SectPr
from docx.opc.constants import RELATIONSHIP_TYPE as RT
-from docxcompose.properties import CustomProperties
-from docxcompose.utils import xpath
+from docx.oxml import CT_SectPr
from docxcompose.composer import Composer
-from docxcompose.utils import NS
+from docxcompose.properties import CustomProperties
+from docxcompose.utils import NS, xpath
from lxml import etree
-import re
class SubdocComposer(Composer):
diff --git a/docxtpl/template.py b/docxtpl/template.py
index f20280a..23d0688 100644
--- a/docxtpl/template.py
+++ b/docxtpl/template.py
@@ -6,32 +6,44 @@
"""
from __future__ import annotations
-from os import PathLike
-from typing import TYPE_CHECKING, Any, Optional, IO, Union, Dict, Set
+import binascii
import functools
import io
-from lxml import etree
+import os
+import re
+import zipfile
+from os import PathLike
+from typing import IO, TYPE_CHECKING, Any, Generator
+
+import docx.oxml.ns
from docx import Document
+from docx.opc.constants import RELATIONSHIP_TYPE as REL_TYPE
from docx.opc.oxml import parse_xml
from docx.opc.part import XmlPart
-import docx.oxml.ns
-from docx.opc.constants import RELATIONSHIP_TYPE as REL_TYPE
from jinja2 import Environment, Template, meta
from jinja2.exceptions import TemplateError
+from lxml import etree
try:
from html import escape # noqa: F401
except ImportError:
# cgi.escape is deprecated in python 3.7
- from cgi import escape # noqa: F401
-import re
-import binascii
-import os
-import zipfile
+ from cgi import escape # noqa: F401 # type:ignore[attr-defined,no-redef]
if TYPE_CHECKING:
+ from docx.document import Document as _DocumentObject
+ from docx.oxml.document import CT_Document
+ from docx.parts.story import StoryPart
+
from .subdoc import Subdoc
+ # _element of docx.document.Document is CT_Document
+ # See: https://github.com/python-openxml/python-docx/blob/master/src/docx/document.py#L35
+ # But mypy cast it as 'BaseOxmlElement', and will raise 'has no attribute "body"' when calling `self.docx._element.body`
+ # So we have to use a custom DocumentObject class to reduce unnecessary `type:ignore` marks
+ class DocumentObject(_DocumentObject):
+ _element: CT_Document
+
class DocxTemplate(object):
"""Class for managing docx files as they were jinja2 templates"""
@@ -43,46 +55,46 @@ class DocxTemplate(object):
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer"
)
- def __init__(self, template_file: Union[IO[bytes], str, PathLike]) -> None:
+ def __init__(self, template_file: IO[bytes] | str | PathLike) -> None:
self.template_file = template_file
self.reset_replacements()
- self.docx = None
+ self.docx: DocumentObject = None # type:ignore
self.is_rendered = False
self.is_saved = False
self.allow_missing_pics = False
- def init_docx(self, reload: bool = True):
+ def init_docx(self, reload: bool = True) -> None:
if not self.docx or (self.is_rendered and reload):
- self.docx = Document(self.template_file)
+ self.docx = Document(self.template_file) # type:ignore
self.is_rendered = False
- def render_init(self):
+ def render_init(self) -> None:
self.init_docx()
- self.pic_map = {}
- self.current_rendering_part = None
+ self.pic_map: dict = {}
+ self.current_rendering_part: StoryPart = None # type:ignore[assignment]
self.docx_ids_index = 1000
self.is_saved = False
- def __getattr__(self, name):
+ def __getattr__(self, name: str) -> Any:
return getattr(self.docx, name)
- def xml_to_string(self, xml, encoding="unicode"):
+ def xml_to_string(self, xml: etree._Element, encoding="unicode") -> str:
# Be careful : pretty_print MUST be set to False, otherwise patch_xml()
# won't work properly
return etree.tostring(xml, encoding="unicode", pretty_print=False)
- def get_docx(self):
+ def get_docx(self) -> DocumentObject:
self.init_docx()
return self.docx
- def get_xml(self):
+ def get_xml(self) -> str:
return self.xml_to_string(self.docx._element.body)
- def write_xml(self, filename):
+ def write_xml(self, filename: str | PathLike) -> None:
with open(filename, "w") as fh:
fh.write(self.get_xml())
- def patch_xml(self, src_xml):
+ def patch_xml(self, src_xml: str) -> str:
"""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..."""
@@ -98,7 +110,7 @@ def patch_xml(self, src_xml):
# replace {{jinja2 stuff}} by {{jinja2 stuff}}
# same thing with {% ... %} and {# #}
# "jinja2 stuff" could a variable, a 'if' etc... anything jinja2 will understand
- def striptags(m):
+ def striptags(m) -> str:
return re.sub(
".*?(|]*>)", "", m.group(0), flags=re.DOTALL
)
@@ -111,7 +123,7 @@ def striptags(m):
)
# manage table cell colspan
- def colspan(m):
+ def colspan(m) -> str:
cell_xml = m.group(1) + m.group(3)
cell_xml = re.sub(
r"](?:(?!]).)*.*?",
@@ -134,7 +146,7 @@ def colspan(m):
)
# manage table cell background color
- def cellbg(m):
+ def cellbg(m) -> str:
cell_xml = m.group(1) + m.group(3)
cell_xml = re.sub(
r"](?:(?!]).)*.*?",
@@ -200,8 +212,8 @@ def cellbg(m):
# add vMerge
# use {% vm %} to make this table cell and its copies
# be vertically merged within a {% for %}
- def v_merge_tc(m):
- def v_merge(m1):
+ def v_merge_tc(m) -> str:
+ def v_merge(m1) -> str:
return (
''
+ m1.group(1) # Everything between ```` and ````.
@@ -229,12 +241,12 @@ def v_merge(m1):
# Use ``{% hm %}`` to make table cell become horizontally merged within
# a ``{% for %}``.
- def h_merge_tc(m):
+ def h_merge_tc(m) -> str:
xml_to_patch = (
m.group()
) # Everything between ```` and ```` with ``{% hm %}`` inside.
- def with_gridspan(m1):
+ def with_gridspan(m1) -> str:
return (
m1.group(1) # ``w:gridSpan w:val="``.
+ "{{ "
@@ -243,7 +255,7 @@ def with_gridspan(m1):
+ m1.group(3) # Closing quotation mark.
)
- def without_gridspan(m2):
+ def without_gridspan(m2) -> str:
return (
''
+ m2.group(1) # Everything between ```` and ````.
@@ -286,7 +298,7 @@ def without_gridspan(m2):
flags=re.DOTALL,
)
- def clean_tags(m):
+ def clean_tags(m) -> str:
return (
m.group(0)
.replace(r"‘", "'")
@@ -302,7 +314,9 @@ def clean_tags(m):
return src_xml
- def render_xml_part(self, src_xml, part, context, jinja_env=None):
+ def render_xml_part(
+ self, src_xml: str, part: StoryPart, context: dict[str, Any], jinja_env=None
+ ) -> str:
src_xml = re.sub(r"])", r"\n]+>", "", x),
src_xml.splitlines()[line_number: (line_number + 7)], # fmt: skip
)
@@ -331,7 +345,7 @@ def render_xml_part(self, src_xml, part, context, jinja_env=None):
return dst_xml
def render_properties(
- self, context: Dict[str, Any], jinja_env: Optional[Environment] = None
+ self, context: dict[str, Any], jinja_env: Environment | None = None
) -> None:
# List of string attributes of docx.opc.coreprops.CoreProperties which are strings.
# It seems that some attributes cannot be written as strings. Those are commented out.
@@ -358,7 +372,7 @@ def render_properties(
setattr(self.docx.core_properties, prop, rendered)
def render_footnotes(
- self, context: Dict[str, Any], jinja_env: Optional[Environment] = None
+ self, context: dict[str, Any], jinja_env: Environment | None = None
) -> None:
if jinja_env is None:
jinja_env = Environment()
@@ -377,9 +391,8 @@ def render_footnotes(
xml = self.render_xml_part(xml, part, context, jinja_env)
part._blob = xml.encode("utf-8")
- def resolve_listing(self, xml):
-
- def resolve_text(run_properties, paragraph_properties, m):
+ def resolve_listing(self, xml: str) -> str:
+ def resolve_text(run_properties, paragraph_properties, m) -> str:
xml = m.group(0).replace(
"\t",
""
@@ -402,9 +415,9 @@ def resolve_text(run_properties, paragraph_properties, m):
)
return xml
- def resolve_run(paragraph_properties, m):
- run_properties = re.search(r".*?", m.group(0))
- run_properties = run_properties.group(0) if run_properties else ""
+ def resolve_run(paragraph_properties, m) -> str:
+ m_run_properties = re.search(r".*?", m.group(0))
+ run_properties = m_run_properties.group(0) if m_run_properties else ""
return re.sub(
r"]*)?>.*?",
lambda x: resolve_text(run_properties, paragraph_properties, x),
@@ -412,10 +425,10 @@ def resolve_run(paragraph_properties, m):
flags=re.DOTALL,
)
- def resolve_paragraph(m):
- paragraph_properties = re.search(r".*?", m.group(0))
+ def resolve_paragraph(m) -> str:
+ m_paragraph_properties = re.search(r".*?", m.group(0))
paragraph_properties = (
- paragraph_properties.group(0) if paragraph_properties else ""
+ m_paragraph_properties.group(0) if m_paragraph_properties else ""
)
return re.sub(
r"]*)?>.*?",
@@ -430,32 +443,34 @@ def resolve_paragraph(m):
return xml
- def build_xml(self, context, jinja_env=None):
+ def build_xml(self, context: dict[str, Any], jinja_env=None) -> str:
xml = self.get_xml()
xml = self.patch_xml(xml)
xml = self.render_xml_part(xml, self.docx._part, context, jinja_env)
return xml
- def map_tree(self, tree):
+ def map_tree(self, tree) -> None:
root = self.docx._element
body = root.body
root.replace(body, tree)
- def get_headers_footers(self, uri):
+ def get_headers_footers(self, uri: str) -> Generator[tuple[str, StoryPart]]:
for relKey, val in self.docx._part.rels.items():
if (val.reltype == uri) and (val.target_part.blob):
yield relKey, val.target_part
- def get_part_xml(self, part):
+ def get_part_xml(self, part) -> str:
return self.xml_to_string(parse_xml(part.blob))
- def get_headers_footers_encoding(self, xml):
+ def get_headers_footers_encoding(self, xml: str) -> str:
m = re.match(r'<\?xml[^\?]+\bencoding="([^"]+)"', xml, re.I)
if m:
return m.group(1)
return "utf-8"
- def build_headers_footers_xml(self, context, uri, jinja_env=None):
+ def build_headers_footers_xml(
+ self, context: dict[str, Any], uri: str, jinja_env=None
+ ) -> Generator[tuple[str, bytes]]:
for relKey, part in self.get_headers_footers(uri):
xml = self.get_part_xml(part)
encoding = self.get_headers_footers_encoding(xml)
@@ -463,7 +478,7 @@ def build_headers_footers_xml(self, context, uri, jinja_env=None):
xml = self.render_xml_part(xml, part, context, jinja_env)
yield relKey, xml.encode(encoding)
- def map_headers_footers_xml(self, relKey, xml):
+ def map_headers_footers_xml(self, relKey, xml) -> None:
part = self.docx._part.rels[relKey].target_part
new_part = XmlPart.load(part.partname, part.content_type, xml, part.package)
for rId, rel in part.rels.items():
@@ -472,8 +487,8 @@ def map_headers_footers_xml(self, relKey, xml):
def render(
self,
- context: Dict[str, Any],
- jinja_env: Optional[Environment] = None,
+ context: dict[str, Any],
+ jinja_env: Environment | None = None,
autoescape: bool = False,
) -> None:
# init template working attributes
@@ -516,7 +531,7 @@ def render(
# using of TC tag in for cycle can cause that count of columns does not
# correspond to real count of columns in row. This function is able to fix it.
- def fix_tables(self, xml):
+ def fix_tables(self, xml: str) -> etree._Element:
parser = etree.XMLParser(recover=True)
tree = etree.fromstring(xml, parser=parser)
# get namespace
@@ -524,6 +539,8 @@ def fix_tables(self, xml):
# walk trough xml and find table
for t in tree.iter(ns + "tbl"):
tblGrid = t.find(ns + "tblGrid")
+ if tblGrid is None:
+ continue
columns = tblGrid.findall(ns + "gridCol")
to_add = 0
# walk trough all rows and try to find if there is higher cell count
@@ -537,25 +554,24 @@ def fix_tables(self, xml):
# (we want to preserve it)
width = 0.0
new_average = None
+ ns_w = ns + "w"
for c in columns:
- if c.get(ns + "w") is not None:
- width += float(c.get(ns + "w"))
+ w = c.get(ns_w)
+ if w is not None:
+ width += float(w)
# try to keep proportion of table
if width > 0:
old_average = width / len(columns)
new_average = width / (len(columns) + to_add)
# scale the old columns
for c in columns:
- c.set(
- ns + "w",
- str(
- int(float(c.get(ns + "w")) * new_average / old_average)
- ),
- )
+ w = c.get(ns_w)
+ if w is not None:
+ c.set(ns_w, str(int(float(w) * new_average / old_average)))
# add new columns
for i in range(to_add):
etree.SubElement(
- tblGrid, ns + "gridCol", {ns + "w": str(int(new_average))}
+ tblGrid, ns + "gridCol", {ns_w: str(int(new_average))}
)
# Refetch columns after columns addition.
@@ -564,7 +580,7 @@ def fix_tables(self, xml):
cells_len_max = 0
- def get_cell_len(total, cell):
+ def get_cell_len(total: int, cell) -> int:
tc_pr = cell.find(ns + "tcPr")
grid_span = None if tc_pr is None else tc_pr.find(ns + "gridSpan")
@@ -588,8 +604,11 @@ def get_cell_len(total, cell):
# table back to its original width.
removed_width = 0.0
+ ns_w = ns + "w"
for c in columns[-to_remove:]:
- removed_width += float(c.get(ns + "w"))
+ w = c.get(ns_w)
+ if w is not None:
+ removed_width += float(w)
tblGrid.remove(c)
@@ -599,15 +618,16 @@ def get_cell_len(total, cell):
# left after extras removal.
extra_space = 0
if len(columns_left) > 0:
- extra_space = removed_width / len(columns_left)
- extra_space = int(extra_space)
+ extra_space = int(removed_width / len(columns_left))
for c in columns_left:
- c.set(ns + "w", str(int(float(c.get(ns + "w")) + extra_space)))
+ w = c.get(ns_w)
+ if w is not None:
+ c.set(ns_w, str(int(float(w)) + extra_space))
return tree
- def fix_docpr_ids(self, tree):
+ def fix_docpr_ids(self, tree) -> None:
# some Ids may have some collisions : so renumbering all of them :
for elt in tree.xpath("//wp:docPr", namespaces=docx.oxml.ns.nsmap):
self.docx_ids_index += 1
@@ -620,7 +640,7 @@ def new_subdoc(self, docpath=None) -> Subdoc:
return Subdoc(self, docpath)
@staticmethod
- def get_file_crc(file_obj):
+ def get_file_crc(file_obj: IO[bytes] | str | PathLike) -> int:
if hasattr(file_obj, "read"):
buf = file_obj.read()
else:
@@ -630,7 +650,7 @@ def get_file_crc(file_obj):
crc = binascii.crc32(buf) & 0xFFFFFFFF
return crc
- def replace_media(self, src_file, dst_file):
+ def replace_media(self, src_file, dst_file) -> None:
"""Replace one media by another one into a docx
This has been done mainly because it is not possible to add images in
@@ -656,7 +676,7 @@ def replace_media(self, src_file, dst_file):
with open(dst_file, "rb") as fh:
self.crc_to_new_media[crc] = fh.read()
- def replace_pic(self, embedded_file, dst_file):
+ def replace_pic(self, embedded_file, dst_file) -> None:
"""Replace embedded picture with original-name given by embedded_file.
(give only the file basename, not the full path)
The new picture is given by dst_file (either a filename or a file-like
@@ -678,7 +698,7 @@ def replace_pic(self, embedded_file, dst_file):
with open(dst_file, "rb") as fh:
self.pics_to_replace[embedded_file] = fh.read()
- def replace_embedded(self, src_file, dst_file):
+ def replace_embedded(self, src_file, dst_file) -> None:
"""Replace one embedded object by another one into a docx
This has been done mainly because it is not possible to add images
@@ -695,7 +715,7 @@ def replace_embedded(self, src_file, dst_file):
crc = self.get_file_crc(src_file)
self.crc_to_new_embedded[crc] = fh.read()
- def replace_zipname(self, zipname, dst_file):
+ def replace_zipname(self, zipname, dst_file) -> None:
"""Replace one file in the docx file
First note that a MSWord .docx file is in fact a zip file.
@@ -725,7 +745,7 @@ def replace_zipname(self, zipname, dst_file):
with open(dst_file, "rb") as fh:
self.zipname_to_replace[zipname] = fh.read()
- def reset_replacements(self):
+ def reset_replacements(self) -> None:
"""Reset replacement dictionaries
This will reset data for image/embedded/zipname replacement
@@ -741,18 +761,17 @@ def reset_replacements(self):
If you instantiate DocxTemplate object before each render(),
this method is useless.
"""
- self.crc_to_new_media = {}
- self.crc_to_new_embedded = {}
- self.zipname_to_replace = {}
- self.pics_to_replace = {}
+ self.crc_to_new_media: dict = {}
+ self.crc_to_new_embedded: dict = {}
+ self.zipname_to_replace: dict = {}
+ self.pics_to_replace: dict = {}
- def post_processing(self, docx_file):
+ def post_processing(self, docx_file) -> None:
if self.crc_to_new_media or self.crc_to_new_embedded or self.zipname_to_replace:
-
if hasattr(docx_file, "read"):
- tmp_file = io.BytesIO()
+ tmp_file: IO[bytes] | str = io.BytesIO()
DocxTemplate(docx_file).save(tmp_file)
- tmp_file.seek(0)
+ tmp_file.seek(0) # type:ignore[union-attr]
docx_file.seek(0)
docx_file.truncate()
docx_file.seek(0)
@@ -785,12 +804,11 @@ def post_processing(self, docx_file):
if hasattr(docx_file, "read"):
docx_file.seek(0)
- def pre_processing(self):
-
+ def pre_processing(self) -> None:
if self.pics_to_replace:
self._replace_pics()
- def _replace_pics(self):
+ def _replace_pics(self) -> None:
"""Replaces pictures xml tags in the docx template with pictures provided by the user"""
replaced_pics = {key: False for key in self.pics_to_replace}
@@ -812,49 +830,49 @@ def _replace_pics(self):
"Picture %s not found in the docx template" % img_id
)
- def get_pic_map(self):
+ def get_pic_map(self) -> dict:
return self.pic_map
- def _replace_docx_part_pics(self, doc_part, replaced_pics):
+ def _replace_docx_part_pics(self, doc_part, replaced_pics) -> None:
et = etree.fromstring(doc_part.blob)
- part_map = {}
+ part_map: dict = {}
gds = et.xpath("//a:graphic/a:graphicData", namespaces=docx.oxml.ns.nsmap)
- for gd in gds:
+ for gd in gds: # type:ignore[union-attr]
rel = None
# Either IMAGE, CHART, SMART_ART, ...
try:
- if gd.attrib["uri"] == docx.oxml.ns.nsmap["pic"]:
+ if gd.attrib["uri"] == docx.oxml.ns.nsmap["pic"]: # type:ignore[union-attr]
# Either PICTURE or LINKED_PICTURE image
- blip = gd.xpath(
+ blip = gd.xpath( # type:ignore[union-attr,index]
"pic:pic/pic:blipFill/a:blip", namespaces=docx.oxml.ns.nsmap
)[0]
- dest = blip.xpath("@r:embed", namespaces=docx.oxml.ns.nsmap)
- if len(dest) > 0:
- rel = dest[0]
+ dest = blip.xpath("@r:embed", namespaces=docx.oxml.ns.nsmap) # type:ignore
+ if len(dest) > 0: # type:ignore[arg-type]
+ rel = dest[0] # type:ignore[index]
else:
continue
else:
continue
non_visual_properties = "pic:pic/pic:nvPicPr/pic:cNvPr/"
- filename = gd.xpath(
+ filename = gd.xpath( # type:ignore[union-attr,index]
"%s@name" % non_visual_properties, namespaces=docx.oxml.ns.nsmap
)[0]
- titles = gd.xpath(
+ titles = gd.xpath( # type:ignore[union-attr]
"%s@title" % non_visual_properties, namespaces=docx.oxml.ns.nsmap
)
if titles:
- title = titles[0]
+ title = titles[0] # type:ignore[index]
else:
title = ""
- descriptions = gd.xpath(
+ descriptions = gd.xpath( # type:ignore[union-attr]
"%s@descr" % non_visual_properties, namespaces=docx.oxml.ns.nsmap
)
if descriptions:
- description = descriptions[0]
+ description = descriptions[0] # type:ignore[index]
else:
description = ""
@@ -881,26 +899,26 @@ def build_url_id(self, url):
self.init_docx()
return self.docx._part.relate_to(url, REL_TYPE.HYPERLINK, is_external=True)
- def save(self, filename: Union[IO[bytes], str, PathLike], *args, **kwargs) -> None:
+ def save(self, filename: IO[bytes] | str | PathLike, *args, **kwargs) -> None:
# case where save() is called without doing rendering
# ( user wants only to replace image/embedded/zipname )
if not self.is_saved and not self.is_rendered:
- self.docx = Document(self.template_file)
+ self.docx = Document(self.template_file) # type:ignore[arg-type,assignment]
self.pre_processing()
- self.docx.save(filename, *args, **kwargs)
+ self.docx.save(filename, *args, **kwargs) # type:ignore[arg-type]
self.post_processing(filename)
self.is_saved = True
def get_undeclared_template_variables(
self,
- jinja_env: Optional[Environment] = None,
- context: Optional[Dict[str, Any]] = None,
- ) -> Set[str]:
+ jinja_env: Environment | None = None,
+ context: dict[str, Any] | None = None,
+ ) -> set[str]:
# Create a temporary document to analyze the template without affecting the current state
- temp_doc = Document(self.template_file)
+ temp_doc = Document(self.template_file) # type:ignore[arg-type]
# Get XML from the temporary document
- xml = self.xml_to_string(temp_doc._element.body)
+ xml = self.xml_to_string(temp_doc._element.body) # type:ignore[attr-defined]
xml = self.patch_xml(xml)
# Add headers and footers
diff --git a/tests/multi_rendering.py b/tests/multi_rendering.py
index f9a934f..8a17281 100644
--- a/tests/multi_rendering.py
+++ b/tests/multi_rendering.py
@@ -4,6 +4,7 @@
@author: Eric Lapouyade
"""
+from typing import cast
from docxtpl import DocxTemplate
@@ -35,6 +36,6 @@
for document_data in documents_data:
dest_file = document_data["dest_file"]
- context = document_data["context"]
+ context = cast(dict, document_data["context"])
tpl.render(context)
tpl.save("output/%s" % dest_file)
diff --git a/tests/replace_picture.py b/tests/replace_picture.py
index c30f2ce..d374f00 100644
--- a/tests/replace_picture.py
+++ b/tests/replace_picture.py
@@ -11,7 +11,7 @@
tpl = DocxTemplate("templates/replace_picture_tpl.docx")
-context = {}
+context: dict = {}
tpl.replace_pic("python_logo.png", "templates/python.png")
tpl.render(context)