pdf2docx开发概要:获取图片及其位置

发布于:2020-10-15 | 分类:process automation


本文初稿基于 PyMuPDF v1.18.0 版本撰写,后续版本更新,部分问题得以改善/解决。参见文中具体注释。

为了在docx中重建图片,我们需要提取图片的内容(二进制流)及其位置(坐标)。PyMuPDF提供了两个获取页面图片的API:page.getText('rawdict')page.getImageList(),但是截至v1.18.0版本,其中任意单独一个方法都无法完成这个任务,本文记录综合这两个API,提取PDF页面图片及其位置的方法。

page.getText('rawdict')page.getImageList()

v1.18.0版本为例,这两个API(下文分别以rawdictgetImageList指代)的使用说明参考官方文档:

简要概括一下:

  • rawdict返回当前页面内元素(文本、图片)字典,其中与图片相关的主要键及其意义如下

    key description
    bbox 图片区域:左上角、右下角坐标 (x0, y0, x1, y1)
    width, height 图片原始宽度和高度
    ext 图片后缀名
    image bytes格式的图片内容
  • getImageList返回当前页面内图片列表,其中每个元素是一个元组

    (xref, smask, width, height, bpc, colorspace, alt. colorspace, name, filter, referencer)
    key description
    xref 图片内容索引号,fitz.Pixmap(doc, xref)获取到位图内容
    smask 图片遮罩(Soft Mask)索引号,存储通道数据
    width, height 图片原始宽度和高度

初步对比,二者相同点是都能获取到图片内容,不同点在于rawdict可以直接获取到位置信息,而getImageList得到了图片的原始索引xref,可以获取包括图片位置(page.getImageBbox(item))在内的更多信息。

注意

page.getImageBbox(item)获取的位置信息并非100%可靠 Issue-699

这样看来,rawdict足以满足获取图片内容和位置的要求。但是,具体实践中却发现了一些特殊情形。

1. rawdictgetImageList获取图片的背景色错误

对于某些图片,无论是rawdictimage,还是getImageListfitz.Pixmap(doc, xref)得到的图片颜色都不正确。具体又分为以下两种情形:

(a) CMYK颜色空间或透明图片,参见Issue-670

PyMuPDF官方示例借助pillow库进行alpha通道修复,本文采用了PyMuPDF原生方法:

  • 如果存在则加入alpha通道
  • 如果不是常规的RGBgray颜色空间(例如CMYK),则转换到RGB空间

根据作者的解释,原生方法并非100%稳定;但是于我而言,可以避免仅仅因为较小概率的特殊情况而引入pillow库依赖。

def recover_pixmap(doc:fitz.Document, item:list):
    '''Restore pixmap with soft mask considered.
        ---
        - doc: fitz document
        - item: an image item got from page.getImageList()
    '''
    # data structure of `item`:
    # (xref, smask, width, height, bpc, colorspace, ...)
    x = item[0]  # xref of PDF image
    s = item[1]  # xref of its /SMask

    # base image
    pix = fitz.Pixmap(doc, x)

    # reconstruct the alpha channel with the smask if exists
    if s > 0:        
        # copy of base image, with an alpha channel added
        pix = fitz.Pixmap(pix, 1)  

        # create pixmap of the /SMask entry
        ba = bytearray(fitz.Pixmap(doc, s).samples)
        for i in range(len(ba)):
            if ba[i] > 0: ba[i] = 255
        pix.setAlpha(ba)

    # we may need to adjust something for CMYK pixmaps here -> 
    # recreate pixmap in RGB color space if necessary
    # NOTE: pix.colorspace may be None for images with alpha channel values only
    if pix.colorspace and not pix.colorspace.name in (fitz.csGRAY.name, fitz.csRGB.name):
        pix = fitz.Pixmap(fitz.csRGB, pix)

    return pix

(b) 只有alpha通道的图片,参见Issue-677

注意上面代码中,当颜色空间pix.colorspace为空,也就是一副仅有alpha通道的图片,那么上述为基本图片添加alpha通道的方法将失效。

The problem here is that the image at xref 9 consists of alpha values only, i.e. colorspace None as you noted. The turquoise color you see in the PDF is not part of the image, but part of PDF background, established right before that image is invoked for display.

解决这个问题的一个取巧的方法是:获取该图片的区域,然后在该区域内截图。同时处理两个具体问题:

  • 为避免重叠的文本出现在截图区域,需要在截图前隐藏文本
  • 调整分辨率以保证截图的质量
def clip_page(page:fitz.Page, bbox:fitz.Rect=None, zoom:float=3.0):
    '''Clip page pixmap (without text) according to `bbox` (entire page by default).
    '''
    # hide text before clip the image only
    # render Tr: set the text rendering mode
    # - 3: neither fill nor stroke the text -> invisible
    # read more:
    # - https://github.com/pymupdf/PyMuPDF/issues/257
    # - https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/pdf_reference_archives/PDFReference.pdf
    doc = page.parent
    for xref in page._getContents():
        stream = doc._getXrefStream(xref).replace(b'BT', b'BT 3 Tr') \
                                            .replace(b'Tm', b'Tm 3 Tr') \
                                            .replace(b'Td', b'Td 3 Tr')
        doc._updateStream(xref, stream)

    # improve resolution
    # - https://pymupdf.readthedocs.io/en/latest/faq.html#how-to-increase-image-resolution
    # - https://github.com/pymupdf/PyMuPDF/issues/181
    bbox = page.rect if bbox is None else bbox & page.rect
    image = page.getPixmap(clip=bbox, matrix=fitz.Matrix(zoom, zoom)) # type: fitz.Pixmap
    return image

综上,我们需要一一处理getImageList获取的图片(rawdict无法给出alpha通道的数据)

def extract_images(page:fitz.Page, clip_image_res_ratio:float=3.0):
    ''' Get images list with contents recovered.'''    
    doc = page.parent # pdf document

    # check each image item:
    # (xref, smask, width, height, bpc, colorspace, ...)
    images = []
    for item in page.getImageList(full=True):
        try:
            item = list(item)
            item[-1] = 0
            bbox = page.getImageBbox(item)
        except ValueError:
            continue

        # ignore images outside page
        if not bbox.intersects(page.rect): continue

        # recover cmyk / transparent images
        pix = recover_pixmap(doc, item)

        # clip page for image with alpha values only
        if not pix.colorspace:
            pix = clip_page(page, bbox, zoom=clip_image_res_ratio)

        images.append(pix)

    return images

2. getImageList不包含同一图片的不同实例

上面基于getImageList的方法貌似完美了,但实际上,如果同一图片复制多次后粘贴在页面上,getImageList仅仅包含其中一个实例,而rawdict则会计算该图片出现的每个地方。

Image blocks in a textpage are generated for every image location – whether or not there are any duplicates. This is in contrast to Page.getImageList(), which will contain each image only once.

1.18.13 版本新增了get_image_rects方法,它是getImageBbox(即后续改用下划线方式的get_image_bbox)的改进方法,可以获取同一图片的每一个引用实例的具体位置。 因此,不必再借助rawdict获取所有实例,即以下自删除线段落起的(1)-(4)步骤已经过期。

The result is a list of Rect or (Rect, Matrix) objects – depending on transform. Each list item represents one location of the image on the page. Multiple occurrences might not be detectable by Page.get_image_bbox().

所以还不能放弃 rawdict。此时,我们已知:

  • getImageList得到了图片的正确内容,以及重复图片中的某一个位置
  • rawdict得到了所有重复图片的正确位置

结合二者即可得到正确的内容和所有位置:

  • (1) 以rawdictimage值为键分组,每一组即为重复图片列表,列表中图片的位置正确,但是内容可能不正确(例如丢失alpha通道)
  • (2) 如果getImageList某一图片的位置bbox出现在上述某一分组的bbox列表中,则将改组所有图片的内容修正为getImageList该图片的内容

3. rawdict仅包含完全显示在页面内的图片

上一步的处理以rawdict为基础,以getImageList进行修正。如果一副图片有任何部分出现在页面之外,则并不会被rawdict统计。所以需要在上面处理逻辑中再加一步:

  • (3) 如果getImageList某一图片的位置bbox不出现在任何rawdict分组中,则加入该图片

4. rawdict可能包含“虚假”图片

上一步补救了getImageList含有rawdict中不存在的图片的情形;相反,rawdict也可能含有getImageList中不存在的图片,但实际上,这些是“虚假”图片——因为getImageList统计了所有已显示或者未显示的图片。本文遇到的一个实例,某些复杂的矢量图形被错误地转换为图片而计算到rawdict中去了。

此时,再追加一步:

  • (4) 如果getImageList所有图片的位置bbox都不出现在某一rawdict分组,则删除该分组

5. PDF扫描图片的一些例外

自初稿以来,实践中又发现通常出现在PDF扫描件中的两类例外:

  • 人眼可见的一张完整图片在PDF中却是由许多张子图组成的,虽然直接提取多幅子图逻辑上没错,但是通过相交检测并将其合并为一张图片输出,可以得到跟所见更为相符的结果。

  • 当原始PDF坐标系与渲染后页面之间存在非零的旋转角度时,人眼可见的正立图片实际是以一定旋转角度存储在PDF中的,因此直接提取的图片是旋转的,需要借助图像处理库逆向旋转回去才能得到和所见一致的图片。

总结

  • getImageListrawdict得到图片的内容可能不正确,需要进行修复或截图处理
  • getImageList获取的图片(显示/未显示)才算真正的图片
  • rawdict获取重复图片的每一个位置
  • 注意 图片分割页面旋转 引起直接提取的图片和最终渲染结果之间的差异