Cane's Blog

Cane

【Problem】docxtpl替换图片失败

109
2021-01-15

问题描述

使用 docxtpl 库,替换 docx 文件中的图片失败

# 代码

template = DocxTemplate(template_path)
template.replace_pic(base_pic_name, replace_pic_path)
template.save(save_path)

截图

...

解决方案

问题分析

doxctpl 源码中关于图片替换的部分

    def replace_pic(self, embedded_file, dst_file):
        """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
           object)

        Notes:
            1) embedded_file and dst_file must have the same extension/format
               in case dst_file is a file-like object, no check is done on
               format compatibility
            2) the aspect ratio will be the same as the replaced image
            3) There is no need to keep the original file (this is not the case
               for replace_embedded and replace_media)
        """

        if hasattr(dst_file, 'read'):
            # NOTE: file extension not checked
            self.pic_to_replace[embedded_file] = dst_file.read()
        else:
            emp_path, emb_ext = os.path.splitext(embedded_file)
            dst_path, dst_ext = os.path.splitext(dst_file)

            if emb_ext != dst_ext:
                raise ValueError('replace_pic: extensions must match')

            with open(dst_file, 'rb') as fh:
                self.pic_to_replace[embedded_file] = fh.read()

    def save(self, filename, *args, **kwargs):
        self.pre_processing()
        self.docx.save(filename, *args, **kwargs)
        self.post_processing(filename)

    def pre_processing(self):

        if self.pic_to_replace:
            self.build_pic_map()

            # Do the actual replacement
            for embedded_file, stream in six.iteritems(self.pic_to_replace):
                if embedded_file not in self.pic_map:
                    raise ValueError('Picture "%s" not found in the docx template'
                                     % embedded_file)
                self.pic_map[embedded_file][1]._blob = stream

    def build_pic_map(self):
        """Searches in docx template all the xml pictures tag and store them
        in pic_map dict"""
        if self.pic_to_replace:
            # Main document
            part = self.docx.part
            self.pic_map.update(self._img_filename_to_part(part))

            # Header/Footer
            for relid, rel in six.iteritems(self.docx.part.rels):
                if rel.reltype in (REL_TYPE.HEADER, REL_TYPE.FOOTER):
                    self.pic_map.update(self._img_filename_to_part(rel.target_part))

经分析,替换图片的整个代码逻辑是这样的:

  1. 执行 replace_pic() 方法的时候,他会把实例的 pic_to_replace 属性(字典类型,默认为空),添加一项,key 为被替换的图片名(不含路径的 basename),`value` 为用来替换的图片的二进制数据。

  2. 进行文档保存(执行 save 方法)的时候,会有一个预检查的过程,在遇见查的过程中,如果发现 pic_to_replace 属性不为空,说明有图片替换的操作,他会执行两个操作

    1. 先根据文档的元数据(part),来构建整个文档中关于图片的 pic_mappic_map 是一个字典, key 值为文档中的图片的 basename,`value` 包含该图片的所有相关信息,其中,_blob(self.pic_map[embedded_file][1]._blob)属性中含有该图片的二进制信息(embedded_file 就是basename

    2. 得到上述的 pic_map 以后,把被替换图片的 _blob 信息换成用来替换的图片的 _blob 信息

这里面有几个暗坑需要注意一下

a. 在执行 replace_pic 方法的时候,他会对来替换的图片和被替换的图片做类型检查,不一致会抛出异常。

b. 如果你要替换的图片,在文档中不存在,也会报错(比如,我要把文档中的 python.png 替换成 go.png,如果文档中不含有 python.png 图片则会抛出异常)

之所以要修复第二个问题,是因为在我的业务逻辑中,我有很多文件,要执行 python.png->go.png 的替换,但是并不是所有的文件都含有 python.png,这种情况无需替换,但也无需抛出异常

第一个问题坑的地方在于,在中文环境下,你直接插入图片,他默认的 basename 是不含有扩展名的「图片 1」、「图片 2」,如下图,这种情况下,无论你拿什么类型的图片来替换都是过不了类型检查的

2021-01-15-17-41-58.png

经过试验,中文环境下,只有「被替换图片」被插入的时候的时候选择「链接到文件」或者「插入和链接」才可以使的 basename 正常显示为「test.png」,才可以使「替换图片」通过类型检测

2021-01-15-17-43-48.png

2021-01-15-17-45-24.png

但是这个时候带来了另一个问题,就是在「链接到文件」和「插入和链接」的情况下,执行 replace_pic 是无效的,也就是说在上面的替换流程 b 里面

得到上述的 pic_map 以后,把被替换图片的 _blob信息换成用来替换的图片的 _blob 信息也不行

经验证,替换确实替换成功了,之所以还会显示替换之前的图片,我个人的一个猜想是,在「word」中,如果你采用了「链接到文件」或「插入和链接」的方式插入图片,它显示图片的逻辑是通过链接,而不是图片文件 _blob 信息,所以即使你替换了 _blob 信息,「word」 还是会链接到源文件,

经测试,上述操作以后,删除掉被链接的源文件也不行。猜测:有专门一个地方保存「原始文件」的「链接信息」 和 _blob 信息(也有可能是某些缓存问题?)

解决方法

  1. 源文件采用正常的「插入图片」方式

  2. 修复掉中文环境下的插入图片会自动变成「图片 1」这种格式(为了后续通过类型检查)

  3. hook 掉图片不存在时候的异常

修复代码

def pre_processing(self):
    if self.pic_to_replace:
        self.build_pic_map()
        print(self.pic_map)
        for embedded_file, stream in six.iteritems(self.pic_to_replace):
            if embedded_file not in self.pic_map:
                continue
            self.pic_map[embedded_file][1]._blob = stream
 
 
def _replace_pic(self, embedded_file, dst_file):
    if hasattr(dst_file, 'read'):
        self.pic_to_replace[embedded_file] = dst_file.read()
    else:
        with open(dst_file, 'rb') as fh:
            self.pic_to_replace[embedded_file] = fh.read()
            
template = DocxTemplate(template_path)
DocxTemplate.pre_processing = pre_processing
DocxTemplate.replace_pic = replace_pic
template.replace_pic(base_pic_name, replace_pic_path)
template.save(save_path)

这样的话,我们的完整操作就是:「插入图片」,然后执行 replace_pic(“图片 1”, "logo.png") 即可

补充

  1. 2021-01-15:现在最新版的 docxtpl 已经去掉了类型检查

  2. 2021-01-18:发现新问题

在用 docx 模块执行 doc->docx 的转换之后,在 doxctpl 中执行 build_pic_map() 后,self.pic_map为空,也就是说无法构建整个文档的图片映射,获取不到文档中的图片信息。

原因: 版本兼容性原因

正常docx文件的图片格式,他的图片选中框周围的圆点矩阵是空心圆形

正常docx图片样式.png

通过 docx 模块执行 doc->docx 转换之后的 docx 中的图片样式,图片选中框周围的圆点是实心矩形(是一种 docx 内置的元数据类型,不是 docx 标准下的图片)

原生docx转换后图片样式.png

解决方案:进行兼容性转换即可

docx解决方案.png