Skip to main content

Python 绑定到 PDFium

项目描述

pypdfium2

pypdfium2是一个 ABI 级别的 Python 3 绑定到PDFium,这是一个用于 PDF 创建、检查、操作和渲染的强大且自由许可的库。

该项目是使用ctypesgen和外部PDFium 二进制文件构建的。其自定义设置基础架构提供了无缝的打包和安装过程。Wheel 包支持多种平台和 Python 版本。

pypdfium2 包含帮助程序类以简化常见用例,而原始 PDFium/ctypes API 仍然可以访问。

安装

  • 安装最新的 PyPI 版本(推荐)

    python3 -m pip install -U pypdfium2
    

    这将使用预构建的 wheel 包,这是安装 pypdfium2 的最简单方法。

  • 从源安装

    • 使用外部 PDFium 二进制文件

      # In the directory containing the source code of pypdfium2
      python3 -m pip install .
      
    • 使用本地构建的 PDFium 二进制文件

      python3 setupsrc/pl_setup/build_pdfium.py
      PDFIUM_BINARY="sourcebuild" python3 -m pip install .
      

      构建脚本提供了一些选项,可以通过调用它来列出--help。构建 PDFium 可能需要很长时间,因为它带有自己的工具链和捆绑的依赖项,而不是使用系统提供的组件。[^pdfium_buildsystem]

      [^pdfium_buildsystem]:将 PDFium 的工具链替换为更精简、更优雅的构建系统,该构建系统旨在在任何主机平台上运行,这是一项长期任务。sdist这将是在安装软件包时能够可靠地执行本地源代码构建所必需的。如果您有时间和专业知识来设置这样的构建系统,请启动一个存储库并通知我们。

    主机系统需要提供gitgcc. 安装代码还取决于 Python 包ctypesgenwheelsetuptools,它们通常会自动安装。

    从源代码安装时,pip包管理器的一些附加选项可能是相关的:

    • -v:请求更详细的日志输出。对调试很有用。
    • -e:以可编辑模式安装,这样安装将指向源代码树。这样,更改直接生效,无需重新安装。推荐用于开发。
    • --no-build-isolation:不要在虚拟环境中隔离安装,而是使用系统包。在这种情况下,pyproject.toml(PEP 518)中指定的依赖项将不会生效,应该由调用者预先安装。如果想要使用自定义版本的安装依赖项运行安装,这是必不可少的选项。[^no_build_isolation]

    [^no_build_isolation]:可能的场景包括使用本地修改版本的依赖项,或提供从某个提交构建的依赖项(同时不更改代码)

  • 安装非官方发行版

    据作者所知,除了 PyPI 和 GitHub 上的官方 wheel 版本之外,目前没有其他 pypdfium2 发行版。还没有 conda 包。到目前为止,pypdfium2 尚未包含在任何操作系统存储库中。虽然我们有兴趣与外部包维护者合作以使这成为可能,但该项目的作者无法控制并且不负责 pypdfium2 的第三方分发。

设置魔法

由于 pypdfium2 使用外部二进制文件,因此需要考虑一些特殊的设置方面。

  • data/二进制文件与绑定和版本信息一起存储在特定于平台的子目录中。
  • 环境变量PDFIUM_BINARY控制安装时要包含的二进制文件。
    • 如果未设置 或auto,则检测到主机平台并选择相应的二进制文件。平台文件会自动下载/生成(如果尚不存在)。默认情况下,如果有更新的版本可用,现有平台文件也将被更新,但这可以通过创建一个名为.lock_autoupdate.txtin的空文件来防止data/
    • 如果设置为某个平台标识符,将使用请求平台的二进制文件。[^platform_ids] 在这种情况下,平台文件将不会自动下载/生成,但需要使用update_pdfium.py脚本预先提供。
    • 如果设置为sourcebuild,则二进制文件将从构建脚本放置其工件的位置获取,假设先前运行build_pdfium.py.
    • 如果设置为none,则不会注入平台相关文件,从而创建源分发。

[^platform_ids]:这主要是为了包装的内部利益,因此可以为任何平台制作轮子,而无需访问本机主机。

运行时依赖

除了 Python 及其标准库之外,pypdfium2 没有任何强制性的运行时依赖项。

但是,一些可选的支持模型功能需要额外的软件包:

  • Pillow(模块名称PIL)是一个非常流行的 Python 成像库。pypdfium2 提供了在处理光栅图形时直接返回 PIL 图像对象的便捷方法。
  • NumPy是一个科学计算库。与 类似Pillow,pypdfium2 提供了帮助程序以多维 numpy 数组的形式获取光栅图形。
  • uharfbuzz是文本插入助手使用的文本整形引擎,用于支持外国书写系统。如果您不关心这一点,您可以使用原始 PDFium 函数FPDFPageObj_NewTextObj()(或FPDFPageObj_CreateTextObj())插入文本,FPDFText_SetText()而不依赖于 uharfbuzz。

用法

支持型号

以下是使用支持模型 API 的一些示例。

  • 导入库

    import pypdfium2 as pdfium
    
  • 使用帮助类打开 PDF PdfDocument(支持文件路径字符串、字节和字节缓冲区)

    pdf = pdfium.PdfDocument("./path/to/document.pdf")
    version = pdf.get_version()  # get the PDF standard version
    n_pages = len(pdf)  # get the number of pages in the document
    
  • 同时渲染多个页面

    page_indices = [i for i in range(n_pages)]  # all pages
    renderer = pdf.render_to(
        pdfium.BitmapConv.pil_image,
        page_indices = page_indices,
        scale = 300/72,  # 300dpi resolution
    )
    for i, image in zip(page_indices, renderer):
        image.save("out_%s.jpg" % str(i).zfill(n_pages))
        image.close()
    
  • 阅读目录

    for item in toc:
      
        if item.n_kids == 0:
            state = "*"
        elif item.is_closed:
            state = "-"
        else:
            state = "+"
        
        if item.page_index is None:
            target = "?"
        else:
            target = item.page_index + 1
        
        print(
            "    " * item.level +
            "[%s] %s -> %s  # %s %s" % (
                state, item.title, target,
                pdfium.ViewmodeToStr[item.view_mode],
                [round(c, n_digits) for c in item.view_pos],
            )
        )
    
  • 加载要使用的页面

    page = pdf[0]  # or pdf.get_page(0)
    
    # Get page dimensions in PDF canvas units (1pt->1/72in by default)
    width, height = page.get_size()
    # Set the absolute page rotation to 90° clockwise
    page.set_rotation(90)
    
    # Locate objects on the page
    for obj in page.get_objects():
        print("    "*obj.level + pdfium.ObjectTypeToStr[obj.type], obj.get_pos())
    
  • 渲染单个页面

    image = page.render_to(
        # defaults
        scale = 1,                           # 72dpi resolution
        rotation = 0,                        # no additional rotation
        crop = (0, 0, 0, 0),                 # no crop (form: left, right, bottom, top)
        greyscale = False,                   # coloured output
        fill_colour = (255, 255, 255, 255),  # fill bitmap with white background before rendering (form: RGBA)
        colour_scheme = None,                # no custom colour scheme
        optimise_mode = OptimiseMode.NONE,   # no optimisations (e. g. subpixel rendering)
        draw_annots = True,                  # show annotations
        draw_forms = True,                   # show forms
        no_smoothtext = False,               # anti-alias text
        no_smoothimage = False,              # anti-alias images
        no_smoothpath = False,               # anti-alias paths
        force_halftone = False,              # don't force halftone for image stretching
        rev_byteorder = False,               # don't reverse byte order
        prefer_bgrx = False,                 # don't prefer four channels for coloured output
        force_bitmap_format = None,          # don't force a specific bitmap format
        extra_flags = 0,                     # no extra flags
        allocator = None,                    # no custom allocator
        memory_limit = 2**30,                # maximum allocation (1 GiB)
    )
    image.show()
    image.close()
    
  • 处理文本

    # Load a text page helper
    textpage = page.get_textpage()
    
    # Extract text from the whole page
    text_all = textpage.get_text()
    # Extract text from a specific rectangular area
    text_part = textpage.get_text(left=50, bottom=100, right=width-50, top=height-100)
    
    # Extract URLs from the page
    links = [l for l in textpage.get_links()]
    
    # Locate text on the page
    searcher = textpage.search("something", match_case=False, match_whole_word=False)
    # This will be a list of bounding boxes of the form (left, right, bottom, top)
    first_occurrence = searcher.get_next()
    
  • 通过关闭完成的对象释放分配的内存

    # Attention: objects must be closed in correct order!
    for garbage in (searcher, textpage, page, pdf):
        garbage.close()
    
  • 使用 A4 大小的空白页面创建新 PDF

    pdf = pdfium.PdfDocument.new()
    width, height = (595, 842)
    page = pdf.new_page(width, height)
    
  • 添加文字内容

    NotoSans = "./tests/resources/NotoSans-Regular.ttf"
    hb_font = pdfium.HarfbuzzFont(NotoSans)
    pdf_font = pdf.add_font(
        NotoSans,
        type = pdfium.FPDF_FONT_TRUETYPE,
        is_cid = True,
    )
    page.insert_text(
        text = "मैं घोषणा, पुष्टि और सहमत हूँ कि:",
        pos_x = 50,
        pos_y = height - 75,
        font_size = 25,
        hb_font = hb_font,
        pdf_font = pdf_font,
    )
    
  • 保存文档(并关闭对象)

    with open("output.pdf", "wb") as buffer:
        pdf.save(buffer, version=17)  # use PDF 1.7 standard
    for garbage in (page, pdf_font, pdf):
        garbage.close()
    

PDFium 提供了大量的功能,其中很多功能还没有被支持模型覆盖。您可以与这些函数无缝交互,同时仍然使用可用的辅助类,因为它们提供了raw访问底层 PDFium/ctypes 对象的属性,例如

permission_flags = pdfium.FPDF_GetDocPermission(pdf.raw)
has_transparency = pdfium.FPDFPage_HasTransparency(page.raw)

原始 PDFium API

虽然帮助类方便地包装了原始 PDFium API,但它仍然可以直接访问,并且在 pypdfium2 的主命名空间中公开公开。由于绝大多数 PDFium 成员都以 为前缀FPDF,因此它们与支持模型组件有明显的区别。

对于 PDFium 文档,请查看其公共头文件中的注释。[^pdfium_docs]支持模型源代码ctypes已经提供了大量关于如何使用原始 API 进行接口的示例。尽管如此,以下指南可能有助于开始使用原始 API,尤其是对于还不熟悉的开发人员。ctypes

[^pdfium_docs]:不幸的是,目前没有可用于 PDFium 的最新 HTML 渲染文档。虽然大部分旧的Foxit 文档看起来仍然与 PDFium 的当前 API 相似,但实际上缺少许多修改和新功能,这可能会令人困惑。

  • 通常,PDFium 函数可以像普通 Python 函数一样被调用。但是,参数只能按位置传递,即不能使用关键字参数。没有默认值,因此您始终需要为每个参数提供一个值。
    # arguments: filepath (str|bytes), password (str|bytes|None)
    pdf = pdfium.FPDF_LoadDocument(filepath.encode("utf-8"), None)
    
    这是底层绑定声明,[^bindings_decl],它从二进制文件加载函数并包含将 Python 类型转换为其 C 等效项所需的信息。
    if _libs["pdfium"].has("FPDF_LoadDocument", "cdecl"):
        FPDF_LoadDocument = _libs["pdfium"].get("FPDF_LoadDocument", "cdecl")
        FPDF_LoadDocument.argtypes = [FPDF_STRING, FPDF_BYTESTRING]
        FPDF_LoadDocument.restype = FPDF_DOCUMENT
    
    例如,Pythonstr或被自动bytes转换为FPDF_STRING。如果str提供了 a,则将使用其 UTF-8 编码。但是,通常建议显式编码字符串。

[^bindings_decl]:来自自动生成的绑定文件,它不是存储库的一部分。它内置在轮子中,或在安装时创建。如果您有可编辑的安装,绑定文件可能位于src/_pypdfium.py.

  • 虽然有些功能很容易使用,但事情很快就会变得更加复杂。首先,函数参数不仅用于输入,还用于输出:

    # Initialise an integer object (defaults to 0)
    c_version = ctypes.c_int()
    # Let the function assign a value to the c_int object, and capture its return code (True for success, False for failure)
    success = pdfium.FPDF_GetFileVersion(pdf, c_version)
    # Get the Python int by accessing the `value` attribute of the c_int object
    py_version = c_version.value
    
  • 如果需要一个数组作为输出参数,您可以像这样初始化一个(一般概念):

    # long form
    array_type = (c_type * array_length)
    array_object = array_type()
    # short form
    array_object = (c_type * array_length)()
    

    示例:从某个其他函数返回的目标对象获取视图模式和目标位置。

    # (Assuming `dest` is an FPDF_DEST)
    n_params = ctypes.c_ulong()
    # Create a C array to store up to four coordinates
    view_pos = (pdfium.FS_FLOAT * 4)()
    view_mode = pdfium.FPDFDest_GetView(dest, n_params, view_pos)
    # Convert the C array to a Python list and cut it down to the actual number of coordinates
    view_pos = list(view_pos)[:n_params.value]
    
  • 对于字符串输出参数,调用者需要提供足够长的预分配缓冲区。根据函数需要的类型、使用的编码、是否返回字节数或字符数以及是否包含空终止符的空间,这可能会有所不同。仔细查看相关功能的文档以满足其要求。

    示例 A:获取书签的标题字符串。

    # (Assuming `bookmark` is an FPDF_BOOKMARK)
    # First call to get the required number of bytes (not characters!), including space for a null terminator
    n_bytes = pdfium.FPDFBookmark_GetTitle(bookmark, None, 0)
    # Initialise the output buffer
    buffer = ctypes.create_string_buffer(n_bytes)
    # Second call with the actual buffer
    pdfium.FPDFBookmark_GetTitle(bookmark, buffer, n_bytes)
    # Decode to string, cutting off the null terminator
    # Encoding: UTF-16LE (2 bytes per character)
    title = buffer.raw[:n_bytes-2].decode('utf-16-le')
    

    示例 B:在给定边界中提取文本。

    # (Assuming `textpage` is an FPDF_TEXTPAGE and the boundary variables are set)
    # Store common arguments for the two calls
    args = (textpage, left, top, right, bottom)
    # First call to get the required number of characters (not bytes!) - a possible null terminator is not included
    n_chars = pdfium.FPDFText_GetBoundedText(*args, None, 0)
    # If no characters were found, return an empty string
    if n_chars <= 0:
        return ""
    # Calculate the required number of bytes
    # Encoding: UTF-16LE (2 bytes per character)
    n_bytes = 2 * n_chars
    # Initialise the output buffer - this function can work without null terminator, so skip it
    buffer = ctypes.create_string_buffer(n_bytes)
    # Re-interpret the type from char to unsigned short as required by the function
    buffer_ptr = ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ushort))
    # Second call with the actual buffer
    pdfium.FPDFText_GetBoundedText(*args, buffer_ptr, n_chars)
    # Decode to string
    # (You may want to pass `errors="ignore"` to skip possible errors in the PDF's encoding)
    text = buffer.raw.decode("utf-16-le")
    
  • 不仅有不同的字符串输出方式需要根据相关函数的要求进行处理。字符串输入也可以根据编码、空终止和类型以不同的方式工作。如果一个函数采用 UTF-8 编码FPDF_STRINGFPDF_BYTESTRING(例如FPDF_LoadDocument()),您可以简单地传递 Python 字符串,绑定代码将处理其余部分。但是,某些功能有特殊需求。例如,FPDFText_FindStart()需要一个带有空终止符的 UTF-16LE 编码字符串,作为指向unsigned short数组的指针给出:

    # (Assuming `text` is a str and `textpage` an FPDF_TEXTPAGE)
    # Add the null terminator and encode as UTF-16LE
    enc_text = (text + "\x00").encode("utf-16-le")
    # Obtain a pointer of type c_ushort to `enc_text`
    text_ptr = ctypes.cast(enc_text, ctypes.POINTER(ctypes.c_ushort))
    search = pdfium.FPDFText_FindStart(textpage, text_ptr, 0, 0)
    
  • 假设您有一个由 PDFium 分配的 C 内存缓冲区并希望读取其数据。PDFium 将为您提供指向字节数组第一项的指针。要访问数据,您需要重新解释指针ctypes.cast()以包含整个数组:

    # (Assuming `bitmap` is an FPDF_BITMAP and `size` is the expected number of bytes in the buffer)
    first_item = pdfium.FPDFBitmap_GetBuffer(bitmap)
    buffer = ctypes.cast(first_item, ctypes.POINTER(ctypes.c_ubyte * size))
    # Buffer as ctypes array (referencing the original buffer, will be unavailable as soon as the bitmap is destroyed)
    c_array = buffer.contents
    # Buffer as Python bytes (independent copy)
    data = bytes(c_array)
    
  • 将数据从 Python 写入 C 缓冲区的工作方式类似:

    # (Assuming `first_item` is a pointer to the first item of a C buffer to write into,
    #  `size` the number of bytes it can store, and `py_buffer` a Python byte buffer)
    c_buffer = ctypes.cast(first_item, ctypes.POINTER(ctypes.c_char * size))
    # Read from the Python buffer, starting at its current position, directly into the C buffer
    # (until the target is full or the end of the source is reached)
    n_bytes = py_buffer.readinto(c_buffer.contents)  # returns the number of bytes read
    
  • 在许多情况下,回调函数会派上用场。[^callback_usecases] 多亏了ctypes,可以无缝地跨 Python/C 语言边界使用回调。

    [^callback_usecases]:例如增量读/写,进度条,暂停渐进式任务,...

    示例:从 Python 缓冲区加载文档。这样,可以在 Python 中控制文件访问,而无需一次将整个数据存储在内存中。

    # Factory class to create callable objects holding a reference to a Python buffer
    class _reader_class:
      
      def __init__(self, py_buffer):
          self.py_buffer = py_buffer
      
      def __call__(self, _, position, p_buf, size):
          # Write data from Python buffer into C buffer, as explained before
          c_buffer = ctypes.cast(p_buf, ctypes.POINTER(ctypes.c_char * size))
          self.py_buffer.seek(position)
          self.py_buffer.readinto(c_buffer.contents)
          return 1  # non-zero return code for success
    
    # (Assuming py_buffer is a Python file buffer, e. g. io.BufferedReader)
    # Get the length of the buffer
    py_buffer.seek(0, 2)
    file_len = py_buffer.tell()
    py_buffer.seek(0)
    
    # Set up an interface structure for custom file access
    fileaccess = pdfium.FPDF_FILEACCESS()
    fileaccess.m_FileLen = file_len
    # CFUNCTYPE declaration copied from the bindings file (unfortunately, this is not applied automatically)
    functype = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(None), ctypes.c_ulong, ctypes.POINTER(ctypes.c_ubyte), ctypes.c_ulong)
    # Alternatively, the CFUNCTYPE declaration can also be extracted dynamically using a helper function of pypdfium2
    functype = pdfium.get_functype(pdfium.FPDF_FILEACCESS, "m_GetBlock")
    # Instantiate a callable object, wrapped with the CFUNCTYPE declaration
    fileaccess.m_GetBlock = functype( _reader_class(py_buffer) )
    # Finally, load the document
    pdf = pdfium.FPDF_LoadCustomDocument(fileaccess, None)
    

    虽然可调用对象对于这样的任务非常方便,但它们是 Python 的一个特性。这就是为什么文件访问结构和回调实际上被设计为保存指向调用者缓冲区的指针。使用这种方法更加复杂,因为我们不能只设置和获取 Python 对象,因为事情是通过 C 传递的。因此,我们需要传递缓冲区对象的内存地址并在回调中取消引用它。

    # Declare a function decorated with the CFUNCTYPE
    @pdfium.get_functype(pdfium.FPDF_FILEACCESS, "m_GetBlock")
    def _reader_func(param, position, p_buf, size):
        # Dereference the memory address
        py_buffer = ctypes.cast(param, ctypes.py_object).value
        # The rest works as usual
        c_buffer = ctypes.cast(p_buf, ctypes.POINTER(ctypes.c_char * size))
        py_buffer.seek(position)
        py_buffer.readinto(c_buffer.contents)
        return 1
    
    # When setting up the file access structure, do the following things differently:
    # A) Just reference the function instead of creating a callable object
    fileaccess.m_GetBlock = _reader_func
    # B) Set the m_Param field to the memory address of the buffer, to be dereferenced later
    # This value will be passed to the callback as first argument
    # (Note: It's an implementation detail of CPython that the return value of id(obj)
    # corresponds to the object's memory address, so this is kind of wonky. In practice,
    # a callable object should be used instead.)
    fileaccess.m_Param = id(buffer)
    
  • 在使用原始 API 时,需要特别注意对象的生命周期,因为 Python 可能会在对象的引用计数达到零时立即对对象进行垃圾收集。但是,解释器无法神奇地知道 Python 对象的底层资源在 C 端可能仍需要多长时间,因此需要采取措施保持这些对象的引用,直到 PDFium 不再依赖它们。

    如果资源需要在函数调用之后保持有效,PDFium 文档通常会明确指出这一点。忽略对对象生存期的要求将导致内存损坏(通常导致分段错误)。

    例如,有关文件FPDF_LoadCustomDocument()指出

    应用程序必须保留文件资源 |pFileAccess| 指向有效,直到返回的 FPDF_DOCUMENT 关闭。|pFileAccess| 本身不需要比 FPDF_DOCUMENT 更长寿。

    这意味着只要使用了回调函数和 Python 缓冲区,就需要保持活动FPDF_DOCUMENT状态。这可以通过在附带的类中引用这些对象来实现,例如

     PdfDataHolder 
        
        def  __init__ ( self ,  buffer ,  function ): 
            self . 缓冲区 = 缓冲区
            自身功能 = 功能
        
        def  close ( self ): 
            #确保两个对象调用这个函数之前都保持可用
            no-op id() #调用强制资源保持在内存id ( self.function ) self . 缓冲区关闭()
            
            
            
            
    
    # ... 设置 FPDF_FILEACCESS 结构
    
    # (假设`py_buffer`是缓冲区,`fileaccess`是FPDF_FILEACCESS接口) 
    data_holder  =  PdfDataHolder ( py_buffer ,  fileaccess . m_GetBlock )