bitmap.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. # SPDX-FileCopyrightText: 2026 geisserml <geisserml@gmail.com>
  2. # SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
  3. __all__ = ("PdfBitmap", "PdfPosConv")
  4. import ctypes
  5. import logging
  6. import pypdfium2.raw as pdfium_c
  7. import pypdfium2.internal as pdfium_i
  8. from pypdfium2._helpers.misc import PdfiumError
  9. from pypdfium2._lazy import Lazy
  10. from pypdfium2.version import PDFIUM_INFO
  11. logger = logging.getLogger(__name__)
  12. class PdfBitmap (pdfium_i.AutoCloseable):
  13. """
  14. Bitmap helper class.
  15. .. _PIL Modes: https://pillow.readthedocs.io/en/stable/handbook/concepts.html#concept-modes
  16. Warning:
  17. ``bitmap.close()``, which frees the buffer of foreign bitmaps, is not validated for safety.
  18. A bitmap must not be closed while other objects still depend on its buffer!
  19. Attributes:
  20. raw (FPDF_BITMAP):
  21. The underlying PDFium bitmap handle.
  22. buffer (~ctypes.c_ubyte):
  23. A ctypes array representation of the pixel data (each item is an unsigned byte, i. e. a number ranging from 0 to 255).
  24. width (int):
  25. Width of the bitmap (horizontal size).
  26. height (int):
  27. Height of the bitmap (vertical size).
  28. stride (int):
  29. Number of bytes per line in the bitmap buffer.
  30. Depending on how the bitmap was created, there may be a padding of unused bytes at the end of each line, so this value can be greater than ``width * n_channels``.
  31. format (int):
  32. PDFium bitmap format constant (:attr:`FPDFBitmap_*`)
  33. rev_byteorder (bool):
  34. Whether the bitmap is using reverse byte order.
  35. n_channels (int):
  36. Number of channels per pixel.
  37. mode (str):
  38. The bitmap format as string (see `PIL Modes`_).
  39. """
  40. def __init__(self, raw, buffer, width, height, stride, format, rev_byteorder, needs_free):
  41. self.raw = raw
  42. self.buffer = buffer
  43. self.width = width
  44. self.height = height
  45. self.stride = stride
  46. self.format = format
  47. self.rev_byteorder = rev_byteorder
  48. self.n_channels = pdfium_i.BitmapTypeToNChannels[self.format]
  49. self.mode = (
  50. pdfium_i.BitmapTypeToStrReverse if self.rev_byteorder else \
  51. pdfium_i.BitmapTypeToStr
  52. )[self.format]
  53. # slot to store arguments for PdfPosConv, set on page rendering
  54. self._pos_args = None
  55. super().__init__(pdfium_c.FPDFBitmap_Destroy, needs_free=needs_free, obj=self.buffer)
  56. @property
  57. def parent(self): # AutoCloseable hook
  58. return None
  59. # NOTE To test all bitmap creation strategies through the CLI:
  60. # MAKERS=(native foreign foreign_packed foreign_simple)
  61. # DOCPATH="..." # ideally use a short one or pass e.g. --pages "1-3"
  62. # for MAKER in ${MAKERS[@]}; do echo "$MAKER"; mkdir -p out/$MAKER; pypdfium2 render "$DOCPATH" -o out/$MAKER --bitmap-maker $MAKER; done
  63. # To test .from_raw():
  64. # pypdfium2 extract-images "$DOCPATH" -o out/ --use-bitmap
  65. @staticmethod
  66. def _get_buffer(raw, stride, height):
  67. buffer_ptr = pdfium_c.FPDFBitmap_GetBuffer(raw)
  68. if not buffer_ptr:
  69. raise PdfiumError("Failed to get bitmap buffer (null pointer returned)")
  70. buffer_ptr = ctypes.cast(buffer_ptr, ctypes.POINTER(ctypes.c_ubyte))
  71. return pdfium_i.get_buffer(buffer_ptr, stride*height)
  72. @classmethod
  73. def from_raw(cls, raw, rev_byteorder=False, ex_buffer=None):
  74. """
  75. Construct a :class:`.PdfBitmap` wrapper around a raw PDFium bitmap handle.
  76. Note:
  77. This method is primarily meant for bitmaps provided by pdfium (as in :meth:`.PdfImage.get_bitmap`). For bitmaps created by the caller, where the parameters are already known, it may be preferable to call the :class:`.PdfBitmap` constructor directly.
  78. Parameters:
  79. raw (FPDF_BITMAP):
  80. PDFium bitmap handle.
  81. rev_byteorder (bool):
  82. Whether the bitmap uses reverse byte order.
  83. ex_buffer (~ctypes.c_ubyte | None):
  84. If the bitmap was created from a buffer allocated by Python/ctypes, pass in the ctypes array to keep it referenced.
  85. """
  86. width = pdfium_c.FPDFBitmap_GetWidth(raw)
  87. height = pdfium_c.FPDFBitmap_GetHeight(raw)
  88. stride = pdfium_c.FPDFBitmap_GetStride(raw)
  89. format = pdfium_c.FPDFBitmap_GetFormat(raw)
  90. if ex_buffer is None:
  91. needs_free, buffer = True, cls._get_buffer(raw, stride, height)
  92. else:
  93. needs_free, buffer = False, ex_buffer
  94. return cls(raw, buffer, width, height, stride, format, rev_byteorder, needs_free)
  95. @classmethod
  96. def new_native(cls, width, height, format, rev_byteorder=False, buffer=None, stride=None):
  97. """
  98. Create a new bitmap using :func:`FPDFBitmap_CreateEx`, with a buffer allocated by Python/ctypes, or provided by the caller.
  99. * If buffer and stride are None, a packed buffer is created.
  100. * If a custom buffer is given but no stride, the buffer is assumed to be packed.
  101. * If a custom stride is given but no buffer, a stride-agnostic buffer is created.
  102. * If both custom buffer and stride are given, they are used as-is.
  103. Caller-provided buffer/stride are subject to a logical validation.
  104. """
  105. bpc = pdfium_i.BitmapTypeToNChannels[format]
  106. if stride is None:
  107. stride = width * bpc
  108. else:
  109. assert stride >= width * bpc
  110. if buffer is None:
  111. buffer = (ctypes.c_ubyte * (stride * height))()
  112. else:
  113. assert len(buffer) >= stride * height
  114. raw = pdfium_c.FPDFBitmap_CreateEx(width, height, format, buffer, stride)
  115. return cls(raw, buffer, width, height, stride, format, rev_byteorder, needs_free=False)
  116. # Alternatively, we could do:
  117. # return cls.from_raw(raw, rev_byteorder, buffer)
  118. # This implies some (technically unnecessary) API calls. Note, for a short time, there was a bug in pdfium where retrieving the params of a caller-created bitmap through the FPDFBitmap_Get*() APIs didn't work correctly, so better avoid doing this if we can help it.
  119. @classmethod
  120. def new_foreign(cls, width, height, format, rev_byteorder=False, force_packed=False):
  121. """
  122. Create a new bitmap using :func:`FPDFBitmap_CreateEx`, with a buffer allocated by PDFium.
  123. There may be a padding of unused bytes at line end, unless *force_packed=True* is given.
  124. Note, the recommended default bitmap creation strategy is :meth:`.new_native`.
  125. """
  126. stride = width * pdfium_i.BitmapTypeToNChannels[format] if force_packed else 0
  127. raw = pdfium_c.FPDFBitmap_CreateEx(width, height, format, None, stride)
  128. # Retrieve stride set by pdfium, if we passed in 0. Otherwise, trust in pdfium to use the requested stride.
  129. if not force_packed: # stride == 0
  130. stride = pdfium_c.FPDFBitmap_GetStride(raw)
  131. buffer = cls._get_buffer(raw, stride, height)
  132. return cls(raw, buffer, width, height, stride, format, rev_byteorder, needs_free=True)
  133. @classmethod
  134. def new_foreign_simple(cls, width, height, use_alpha, rev_byteorder=False):
  135. """
  136. Create a new bitmap using :func:`FPDFBitmap_Create`. The buffer is allocated by PDFium.
  137. PDFium docs specify that each line uses width * 4 bytes, with no gap between adjacent lines, i.e. the resulting buffer should be packed.
  138. Contrary to the other ``PdfBitmap.new_*()`` methods, this method does not take a format constant, but a *use_alpha* boolean. If True, the format will be :attr:`FPDFBitmap_BGRA`, :attr:`FPFBitmap_BGRx` otherwise. Other bitmap formats cannot be used with this method.
  139. Note, the recommended default bitmap creation strategy is :meth:`.new_native`.
  140. """
  141. raw = pdfium_c.FPDFBitmap_Create(width, height, use_alpha)
  142. stride = width * 4 # see above
  143. buffer = cls._get_buffer(raw, stride, height)
  144. format = pdfium_c.FPDFBitmap_BGRA if use_alpha else pdfium_c.FPDFBitmap_BGRx
  145. return cls(raw, buffer, width, height, stride, format, rev_byteorder, needs_free=True)
  146. def fill_rect(self, color, left, top, width, height):
  147. """
  148. Fill a rectangle on the bitmap with the given color.
  149. The coordinate system's origin is the top left corner of the image.
  150. Note:
  151. This function replaces the color values in the given rectangle. It does not perform alpha compositing.
  152. Parameters:
  153. color (tuple[int, int, int, int]):
  154. RGBA fill color (a tuple of 4 integers ranging from 0 to 255).
  155. """
  156. c_color = pdfium_i.color_tohex(color, self.rev_byteorder)
  157. ok = pdfium_c.FPDFBitmap_FillRect(self, left, top, width, height, c_color)
  158. if not ok and PDFIUM_INFO.build >= 6635:
  159. raise PdfiumError("Failed to fill bitmap rectangle.")
  160. # Requirement: If the result is a view of the buffer (not a copy), it keeps the referenced memory valid.
  161. #
  162. # Note that memory management differs between native and foreign bitmap buffers:
  163. # - With native bitmaps, the memory is allocated by python on creation of the buffer object (transparent).
  164. # - With foreign bitmaps, the buffer object is merely a view of memory allocated by pdfium and will be freed by finalizer (opaque).
  165. #
  166. # It is necessary that receivers correctly handle both cases, e.g. by keeping the buffer object itself alive.
  167. # As of May 2023, this seems to hold true for NumPy and PIL. New converters should be carefully tested.
  168. #
  169. # We could consider attaching a buffer keep-alive finalizer to any converted objects referencing the buffer,
  170. # but then we'd have to rely on third parties to actually create a reference at all times, otherwise we would unnecessarily delay releasing memory.
  171. def to_numpy(self):
  172. """
  173. Get a :mod:`numpy` array view of the bitmap.
  174. The array contains as many rows as the bitmap is high.
  175. Each row contains as many pixels as the bitmap is wide.
  176. Each pixel will be an array holding the channel values, or just a value if there is only one channel (see :attr:`.n_channels` and :attr:`.format`).
  177. The resulting array is supposed to share memory with the original bitmap buffer,
  178. so changes to the buffer should be reflected in the array, and vice versa.
  179. Returns:
  180. numpy.ndarray: NumPy array (representation of the bitmap buffer).
  181. """
  182. # https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html#numpy.ndarray
  183. array = Lazy.numpy.ndarray(
  184. # layout: row major
  185. shape = (self.height, self.width, self.n_channels) if self.n_channels > 1 else (self.height, self.width),
  186. dtype = ctypes.c_ubyte,
  187. buffer = self.buffer,
  188. # number of bytes per item for each nesting level (outer->inner: row, pixel, value - or row, value for a single-channel bitmap)
  189. strides = (self.stride, self.n_channels, 1) if self.n_channels > 1 else (self.stride, 1),
  190. )
  191. return array
  192. def to_pil(self):
  193. """
  194. Get a :mod:`PIL` image of the bitmap, using :func:`PIL.Image.frombuffer`.
  195. For ``RGBA``, ``RGBX`` and ``L`` bitmaps, PIL is supposed to share memory with
  196. the original buffer, so changes to the buffer should be reflected in the image, and vice versa.
  197. Otherwise, PIL will make a copy of the data.
  198. Returns:
  199. PIL.Image.Image: PIL image (representation or copy of the bitmap buffer).
  200. """
  201. # https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.frombuffer
  202. # https://pillow.readthedocs.io/en/stable/handbook/writing-your-own-image-plugin.html#the-raw-decoder
  203. dest_mode = pdfium_i.BitmapTypeToStrReverse[self.format]
  204. image = Lazy.PIL_Image.frombuffer(
  205. dest_mode, # target color format
  206. (self.width, self.height), # size
  207. self.buffer, # buffer
  208. "raw", # decoder
  209. self.mode, # input color format
  210. self.stride, # bytes per line
  211. 1, # orientation (top->bottom)
  212. )
  213. # set `readonly = False` so changes to the image are reflected in the buffer, if the original buffer is used
  214. image.readonly = False
  215. return image
  216. @classmethod
  217. def from_pil(cls, pil_image):
  218. """
  219. Convert a :mod:`PIL` image to a PDFium bitmap.
  220. Due to the limited number of color formats and bit depths supported by :attr:`FPDF_BITMAP`, this may be a lossy operation.
  221. Bitmaps returned by this function should be treated as immutable.
  222. Parameters:
  223. pil_image (PIL.Image.Image):
  224. The image.
  225. Returns:
  226. PdfBitmap: PDFium bitmap (with a copy of the PIL image's data).
  227. """
  228. # FIXME possibility to get mutable buffer from PIL image?
  229. if pil_image.mode in pdfium_i.BitmapStrToConst:
  230. # PIL always seems to represent BGR(A/X) input as RGB(A/X), so this code passage would only be reached for L
  231. format = pdfium_i.BitmapStrToConst[pil_image.mode]
  232. else:
  233. pil_image = _pil_convert_for_pdfium(pil_image)
  234. format = pdfium_i.BitmapStrReverseToConst[pil_image.mode]
  235. w, h = pil_image.size
  236. return cls.new_native(w, h, format, rev_byteorder=False, buffer=pil_image.tobytes())
  237. def get_posconv(self, page):
  238. """
  239. Acquire a :class:`.PdfPosConv` object to translate between coordinates on the bitmap and the page it was rendered from.
  240. This method requires passing in the page explicitly, to avoid holding a strong reference, so that bitmap and page can be independently freed by finalizer.
  241. """
  242. # if the bitmap was rendered from a page, resolve the weakref and check identity
  243. # before that, make sure *page* isn't None because that's what the weakref may resolve to if the referenced object is not alive anymore.
  244. assert page, "Page must be non-null"
  245. if not self._pos_args or self._pos_args[0]() is not page:
  246. raise RuntimeError("This bitmap does not belong to the given page.")
  247. return PdfPosConv(page, self._pos_args[1:])
  248. def _pil_convert_for_pdfium(pil_image):
  249. if pil_image.mode == "1":
  250. pil_image = pil_image.convert("L")
  251. elif pil_image.mode.startswith("RGB"):
  252. pass
  253. elif "A" in pil_image.mode:
  254. pil_image = pil_image.convert("RGBA")
  255. else:
  256. pil_image = pil_image.convert("RGB")
  257. # convert RGB(A/X) to BGR(A) for PDFium
  258. if pil_image.mode == "RGB":
  259. r, g, b = pil_image.split()
  260. pil_image = Lazy.PIL_Image.merge("RGB", (b, g, r))
  261. elif pil_image.mode == "RGBA":
  262. r, g, b, a = pil_image.split()
  263. pil_image = Lazy.PIL_Image.merge("RGBA", (b, g, r, a))
  264. elif pil_image.mode == "RGBX":
  265. # technically the x channel may be unnecessary, but preserve what the caller passes in
  266. r, g, b, x = pil_image.split()
  267. pil_image = Lazy.PIL_Image.merge("RGBX", (b, g, r, x))
  268. return pil_image
  269. class PdfPosConv:
  270. """
  271. Pdf coordinate translator.
  272. Hint:
  273. You may want to use :meth:`.PdfBitmap.get_posconv` to obtain an instance of this class.
  274. Parameters:
  275. page (PdfPage):
  276. Handle to the page.
  277. pos_args (tuple[int*5]):
  278. pdfium canvas args (start_x, start_y, size_x, size_y, rotate), as in ``FPDF_RenderPageBitmap()`` etc.
  279. """
  280. # FIXME do we have to do overflow checking against too large sizes?
  281. def __init__(self, page, pos_args):
  282. self.page = page
  283. self.pos_args = pos_args
  284. def __repr__(self):
  285. return f"{PdfPosConv.__name__}({self.page}, {self.pos_args})"
  286. def to_page(self, bitmap_x, bitmap_y):
  287. """
  288. Translate coordinates from bitmap to page.
  289. """
  290. page_x, page_y = ctypes.c_double(), ctypes.c_double()
  291. ok = pdfium_c.FPDF_DeviceToPage(self.page, *self.pos_args, bitmap_x, bitmap_y, page_x, page_y)
  292. if not ok:
  293. raise PdfiumError("Failed to translate to page coordinates.")
  294. return (page_x.value, page_y.value)
  295. def to_bitmap(self, page_x, page_y):
  296. """
  297. Translate coordinates from page to bitmap.
  298. """
  299. bitmap_x, bitmap_y = ctypes.c_int(), ctypes.c_int()
  300. ok = pdfium_c.FPDF_PageToDevice(self.page, *self.pos_args, page_x, page_y, bitmap_x, bitmap_y)
  301. if not ok:
  302. raise PdfiumError("Failed to translate to bitmap coordinates.")
  303. return (bitmap_x.value, bitmap_y.value)