diff --git a/docs/pixmap.rst b/docs/pixmap.rst index 5f9b723d5..c9fc7c0d1 100644 --- a/docs/pixmap.rst +++ b/docs/pixmap.rst @@ -29,6 +29,7 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". :meth:`Pixmap.color_count` determine used colors :meth:`Pixmap.color_topusage` determine share of most used color :meth:`Pixmap.copy` copy parts of another pixmap +:meth:`Pixmap.flip_rotate` rotate or flip the pixmap :meth:`Pixmap.gamma_with` apply a gamma factor to the pixmap :meth:`Pixmap.invert_irect` invert the pixels of a given area :meth:`Pixmap.pdfocr_save` save the pixmap as an OCRed 1-page PDF @@ -214,6 +215,20 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". :arg float gamma: *gamma = 1.0* does nothing, *gamma < 1.0* lightens, *gamma > 1.0* darkens the image. + .. method:: flip_rotate(mode) + + Rotate the pixmap by a multiple of 90 degrees or flip it horizontally or vertically. + + :arg int mode: ``mode`` is an integer to specify the desired action. + + * 1: Pixmap.ROTATE_90 - rotate 90 degrees clockwise + * 2: Pixmap.ROTATE_270 - rotate 270 degrees clockwise + * 3: Pixmap.ROTATE_180 - rotate 180 degrees clockwise + * 4: Pixmap.FLIP_LEFT_RIGHT - flip horizontally + * 5: Pixmap.FLIP_TOP_BOTTOM - flip vertically + + :returns: A new pixmap with the requested rotation or flip applied. The original pixmap is not changed. If an unsupported mode value is provided, a warning is issued and the original pixmap is returned. + .. method:: shrink(n) Shrink the pixmap by dividing both, its width and height by 2\ :sup:``n``. @@ -258,7 +273,7 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". .. method:: set_origin(x, y) * New in v1.17.7 - + Set the x and y values of the pixmap's top-left point. :arg int x: x coordinate @@ -374,9 +389,9 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". Perform text recognition using Tesseract and convert the image to a 1-page PDF with an OCR text layer. Internally invokes :meth:`Pixmap.pdfocr_save`. :returns: A 1-page PDF file in memory. Could be opened like `doc=pymupdf.open("pdf", pix.pdfocr_tobytes())`, and text extractions could be performed on its `page=doc[0]`. - + .. note:: - + Another possible use is insertion into some pdf. The following snippet reads the images of a folder and stores them as pages in a new PDF that contain an OCR text layer:: doc = pymupdf.open() @@ -405,7 +420,7 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". * If you do not provide dpi information, the values *xres*, *yres* stored with the pixmap are automatically used. A simple example: `pix.pil_save("some.webp", optimize=True, dpi=(150, 150))`. - + :arg bool unmultiply: If the pixmap's colorspace is RGB with transparency, the alpha values may or may not already be multiplied into the color components ref/green/blue (called "premultiplied"). To enforce undoing premultiplication, set this parameter to `True`. To learn about some background, e.g. look for `"Premultiplied alpha" on this page `_. @@ -420,7 +435,7 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". * New in v1.17.3 Return an image as a bytes object in the specified format using Pillow. For example `stream = pix.pil_tobytes(format="WEBP", optimize=True, dpi=(150, 150))`. Also see above. For details on other parameters see the Pillow documentation. - + :raises ImportError: if Pillow is not installed. :rtype: bytes @@ -454,7 +469,7 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". :arg rect_like clip: a rectangle inside :attr:`Pixmap.irect`. If provided, only those pixels are considered. This allows inspecting sub-rectangles of a given pixmap directly -- instead of building sub-pixmaps. :rtype: dict or int :returns: either the number of colors, or a dictionary with the items `pixel: count`. The pixel key is a `bytes` object of length :attr:`Pixmap.n`. - + .. note:: To recover the **tuple** of a pixel, use `tuple(colors.keys()[i])` for the i-th item. * The response time depends on the pixmap's samples size and may be more than a second for very large pixmaps. @@ -543,16 +558,16 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". Like :attr:`Pixmap.samples`, but in Python `memoryview` format. It is built pointing to the memory in the pixmap -- not from a copy of it. So its creation speed is independent from the pixmap size, and any changes to pixels will be available immediately. Copies like `bytearray(pix.samples_mv)`, or `bytes(pixmap.samples_mv)` are equivalent to and can be used in place of `pix.samples`. - + We also have `len(pix.samples) == len(pix.samples_mv)`. - + Look at this example from a 2 MB JPEG: the memoryview is **ten thousand times faster**:: In [3]: %timeit len(pix.samples_mv) 367 ns ± 1.75 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each) In [4]: %timeit len(pix.samples) 3.52 ms ± 57.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each) - + After the Pixmap has been destroyed, any attempt to use the memoryview will fail with ValueError. @@ -568,7 +583,7 @@ Have a look at the :ref:`FAQ` section to see some pixmap usage "at work". img = QtGui.QImage(pix.samples_ptr, pix.width, pix.height, format) # (2) Both of the above lead to the same Qt image, but (2) can be **many hundred times faster**, because it avoids an additional copy of the pixel area. - + Warning: after the Pixmap has been destroyed, the Python pointer will be invalid and attempting to use it may crash the Python interpreter. @@ -653,7 +668,7 @@ A number of image **output** formats are supported. You have the option to eithe ========== =============== ========= ============== ================================= **Format** **Colorspaces** **alpha** **Extensions** **Description** ========== =============== ========= ============== ================================= -jpg, jpeg gray, rgb, cmyk no .jpg, .jpeg Joint Photographic Experts Group +jpg, jpeg gray, rgb, cmyk no .jpg, .jpeg Joint Photographic Experts Group pam gray, rgb, cmyk yes .pam Portable Arbitrary Map pbm gray, rgb no .pbm Portable Bitmap pgm gray, rgb no .pgm Portable Graymap diff --git a/src/__init__.py b/src/__init__.py index f5d73e531..1a9e22003 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -13704,6 +13704,12 @@ def __init__(self, *args): text += f' {type(arg)}: {arg}\n' raise Exception( text) + ROTATE_90 = 1 + ROTATE_270 = 2 + ROTATE_180 = 3 + FLIP_LEFT_RIGHT = 4 + FLIP_TOP_BOTTOM = 5 + def __len__(self): return self.size @@ -13761,6 +13767,100 @@ def clear_with(self, value=None, bbox=None): else: JM_clear_pixmap_rect_with_value(self.this, value, JM_irect_from_py(bbox)) + def flip_rotate(self, mode: int) -> Pixmap: + """ + Rotate or flip a Pixmap. Supported are + angle: 90, 180, 270 + flip: "left-right" or "top-bottom" + """ + if mode < 1 or mode > 5: + message_warning(f"Invalid flip/rotate mode: {mode}.") + return self + + w, h, n, alpha = self.width, self.height, self.n, self.alpha + stride = w * n + src_mv = memoryview(self.samples) + + # FLIP LEFT-RIGHT (horizontal mirror) + if mode == self.FLIP_LEFT_RIGHT: + buf = bytearray(src_mv) + for y in range(h): + row_start = y * stride + for x in range(w // 2): + left = row_start + x * n + right = row_start + (w - 1 - x) * n + buf[left : left + n], buf[right : right + n] = ( + buf[right : right + n], + buf[left : left + n], + ) + return Pixmap(self.colorspace, w, h, bytes(buf), alpha) + + # FLIP TOP-BOTTOM (vertical mirror) + if mode == self.FLIP_TOP_BOTTOM: + buf = bytearray(src_mv) + for y in range(h // 2): + top = y * stride + bottom = (h - 1 - y) * stride + buf[top : top + stride], buf[bottom : bottom + stride] = ( + buf[bottom : bottom + stride], + buf[top : top + stride], + ) + return Pixmap(self.colorspace, w, h, bytes(buf), alpha) + + # ROTATION + # 180° ROTATION + if mode == self.ROTATE_180: + buf = bytearray(src_mv) + + # vertical flip + for y in range(h // 2): + top = y * stride + bottom = (h - 1 - y) * stride + buf[top : top + stride], buf[bottom : bottom + stride] = ( + buf[bottom : bottom + stride], + buf[top : top + stride], + ) + + # horizontal flip (pixelwise) + for y in range(h): + row_start = y * stride + for x in range(w // 2): + left = row_start + x * n + right = row_start + (w - 1 - x) * n + buf[left : left + n], buf[right : right + n] = ( + buf[right : right + n], + buf[left : left + n], + ) + + return Pixmap(self.colorspace, w, h, bytes(buf), alpha) + + # 90° / 270° ROTATION + new_w, new_h = h, w + dst = bytearray(new_w * new_h * n) + dst_mv = memoryview(dst) + + if mode == self.ROTATE_90: + for y in range(h): + row = src_mv[y * stride : y * stride + stride] + for x in range(w): + dst_x = h - 1 - y + dst_y = x + dst_pos = (dst_y * new_w + dst_x) * n + src_pos = x * n + dst_mv[dst_pos : dst_pos + n] = row[src_pos : src_pos + n] + + else: # self.ROTATE_270 + for y in range(h): + row = src_mv[y * stride : y * stride + stride] + for x in range(w): + dst_x = y + dst_y = w - 1 - x + dst_pos = (dst_y * new_w + dst_x) * n + src_pos = x * n + dst_mv[dst_pos : dst_pos + n] = row[src_pos : src_pos + n] + + return Pixmap(self.colorspace, new_w, new_h, dst, alpha) + def color_count(self, colors=0, clip=None): ''' Return count of each color. diff --git a/tests/test_pixmap.py b/tests/test_pixmap.py index 9a89d32b0..dfab01a8e 100644 --- a/tests/test_pixmap.py +++ b/tests/test_pixmap.py @@ -758,3 +758,22 @@ def test_natural(): ) pix=pymupdf.Pixmap(pm) print(f"{pix=}") + +def text_pixmap_transpositions(): + """Test that flip_rotate() is consistent with repeated applications.""" + path = os.path.normpath(f'{__file__}/../../tests/resources/test_natural.pdf') + doc=pymupdf.open(path) + pix = doc[0].get_pixmap() + samples0 = pix.samples + modes = { + pymupdf.Pixmap.ROTATE_90: 3, + pymupdf.Pixmap.ROTATE_180: 1, + pymupdf.Pixmap.ROTATE_270: 3, + pymupdf.Pixmap.FLIP_LEFT_RIGHT: 1, + pymupdf.Pixmap.FLIP_TOP_BOTTOM: 1, + } + for mode, repeat in modes.items(): + new = pix.flip_rotate(mode) + for _ in range(repeat): + new = new.flip_rotate(mode) + assert new.samples == samples0, "Failed for mode %s" % mode