Skip to content

Commit

Permalink
feat: Modify Wand builder and replace Imagemagick and Pillow with it
Browse files Browse the repository at this point in the history
* modify image__wand builder so that it is like common thumbnail generation strategies
* deprecate  Pillow and ImageMagick (command line) builder
* add doc about how to enable eps format support

PR ref: #274 
issue ref: #253
  • Loading branch information
a523 authored Nov 18, 2021
1 parent 47517e5 commit 2f8ed37
Show file tree
Hide file tree
Showing 20 changed files with 271 additions and 154 deletions.
10 changes: 10 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,16 @@ Building ImageMagick with heic support: `Building ImageMagick with heic support`

.. _`Building ImageMagick with heic support`: doc/build_im_with_heic_support.rst

EPS support
~~~~~~~~~~~~

You need to edit the policies of ImageMagick in /etc/ImageMagick-*/policy.xml.
.. code:: xhtml
<policy domain="coder" rights="none" pattern="ESP" />
Just wrap it between <!-- and --> to comment it.

-----
Usage
Expand Down
82 changes: 6 additions & 76 deletions doc/supported_mimetypes.rst

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions preview_generator/preview/builder/cad__vtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from preview_generator.exception import BuilderDependencyNotFound
from preview_generator.exception import UnsupportedMimeType
from preview_generator.extension import mimetypes_storage
from preview_generator.preview.builder.image__pillow import ImagePreviewBuilderPillow # nopep8
from preview_generator.preview.builder.image__wand import ImagePreviewBuilderWand # nopep8
from preview_generator.preview.generic_preview import PreviewBuilder
from preview_generator.utils import ImgDims
from preview_generator.utils import MimetypeMapping
Expand Down Expand Up @@ -188,7 +188,7 @@ def build_jpeg_preview(
writer.SetInputConnection(windowto_image_filter.GetOutputPort())
writer.Write()

return ImagePreviewBuilderPillow().build_jpeg_preview(
return ImagePreviewBuilderWand().build_jpeg_preview(
tmp_png.name, preview_name, cache_path, page_id, extension, size, mimetype
)

Expand Down
4 changes: 2 additions & 2 deletions preview_generator/preview/builder/document__drawio.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from preview_generator.exception import BuilderDependencyNotFound
from preview_generator.exception import IntermediateFileBuildingFailed
from preview_generator.preview.builder.image__pillow import ImagePreviewBuilderPillow
from preview_generator.preview.builder.image__wand import ImagePreviewBuilderWand
from preview_generator.preview.generic_preview import PreviewBuilder
from preview_generator.utils import ImgDims
from preview_generator.utils import MimetypeMapping
Expand Down Expand Up @@ -90,7 +90,7 @@ def build_jpeg_preview(
"failed with status {}".format(build_jpg_result_code)
)

ImagePreviewBuilderPillow().build_jpeg_preview(
ImagePreviewBuilderWand().build_jpeg_preview(
tmp_jpg.name, preview_name, cache_path, page_id, extension, size, mimetype
)

Expand Down
4 changes: 2 additions & 2 deletions preview_generator/preview/builder/document__sketch.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typing
import zipfile

from preview_generator.preview.builder.image__pillow import ImagePreviewBuilderPillow # nopep8
from preview_generator.preview.builder.image__wand import ImagePreviewBuilderWand # nopep8
from preview_generator.preview.generic_preview import PreviewBuilder
from preview_generator.utils import ImgDims
from preview_generator.utils import MimetypeMapping
Expand Down Expand Up @@ -46,7 +46,7 @@ def build_jpeg_preview(
zip.extract("previews/preview.png", tmp_dir)
zip.close()

ImagePreviewBuilderPillow().build_jpeg_preview(
ImagePreviewBuilderWand().build_jpeg_preview(
tmp_dir + "/previews/preview.png",
preview_name,
cache_path,
Expand Down
4 changes: 2 additions & 2 deletions preview_generator/preview/builder/image__cairosvg.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# HACK - G.M - 2020-12-26 - Hack to allow loading modules without cairosvg installed
from preview_generator.exception import BuilderDependencyNotFound
from preview_generator.preview.builder.image__pillow import ImagePreviewBuilderPillow # nopep8
from preview_generator.preview.builder.image__wand import ImagePreviewBuilderWand # nopep8
from preview_generator.preview.generic_preview import ImagePreviewBuilder
from preview_generator.utils import ImgDims

Expand Down Expand Up @@ -55,7 +55,7 @@ def build_jpeg_preview(
) as tmp_png:
cairosvg.svg2png(url=file_path, write_to=tmp_png.name, dpi=96)

return ImagePreviewBuilderPillow().build_jpeg_preview(
return ImagePreviewBuilderWand().build_jpeg_preview(
tmp_png.name, preview_name, cache_path, page_id, extension, size, mimetype
)

Expand Down
5 changes: 3 additions & 2 deletions preview_generator/preview/builder/image__imconvert.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@


class ImagePreviewBuilderIMConvert(ImagePreviewBuilder):
"""IM means Image Magick"""
"""WARNING : This builder is deprecated, prefer ImagePreviewBuilderWand instead which
support the same list of format."""

MIMETYPES = [] # type: typing.List[str]
# TODO - G.M - 2019-11-21 - find better storage solution for mimetype mapping
Expand Down Expand Up @@ -53,7 +54,7 @@ class ImagePreviewBuilderIMConvert(ImagePreviewBuilder):
MimetypeMapping("image/heic", ".heif"),
]

weight = 30
weight = 0

@classmethod
def get_label(cls) -> str:
Expand Down
4 changes: 2 additions & 2 deletions preview_generator/preview/builder/image__inkscape.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from preview_generator.exception import BuilderDependencyNotFound
from preview_generator.exception import IntermediateFileBuildingFailed
from preview_generator.preview.builder.image__pillow import ImagePreviewBuilderPillow # nopep8
from preview_generator.preview.builder.image__wand import ImagePreviewBuilderWand # nopep8
from preview_generator.preview.generic_preview import ImagePreviewBuilder
from preview_generator.utils import ImgDims
from preview_generator.utils import executable_is_available
Expand Down Expand Up @@ -83,6 +83,6 @@ def build_jpeg_preview(
"failed with status {}".format(build_png_result_code)
)

return ImagePreviewBuilderPillow().build_jpeg_preview(
return ImagePreviewBuilderWand().build_jpeg_preview(
tmp_png.name, preview_name, cache_path, page_id, extension, size, mimetype
)
3 changes: 3 additions & 0 deletions preview_generator/preview/builder/image__pillow.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ def get_strategy(self, image: PIL.Image) -> ImageConvertStrategy:


class ImagePreviewBuilderPillow(ImagePreviewBuilder):
"""WARNING : This builder is deprecated, prefer ImagePreviewBuilderWand instead which
support the same list of format."""

weight = 20

def __init__(
Expand Down
177 changes: 115 additions & 62 deletions preview_generator/preview/builder/image__wand.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,64 @@
# -*- coding: utf-8 -*-

from io import BytesIO
import os
import typing

from wand.image import Color
from wand.image import Image as WImage
from wand.color import Color
from wand.image import Image
from wand.exceptions import CoderError, CoderFatalError, CoderWarning
import wand.version

from preview_generator.preview.builder.image__imconvert import ImagePreviewBuilderIMConvert
from preview_generator.extension import mimetypes_storage
from preview_generator.exception import BuilderDependencyNotFound
from preview_generator.preview.generic_preview import ImagePreviewBuilder
from preview_generator.utils import ImgDims
from preview_generator.utils import MimetypeMapping
from preview_generator.utils import compute_resize_dims
from preview_generator.utils import executable_is_available
from preview_generator.utils import imagemagick_supported_mimes

DEFAULT_JPEG_QUALITY = 85
DEFAULT_JPEG_PROGRESSIVE = True


class ImagePreviewBuilderWand(ImagePreviewBuilder):
"""
WARNING : This builder is deprecated, prefer ImagePreviewBuilderIMConvert instead which
support the same list of format.
"""

weight = 10
weight = 30
MIMETYPES = [] # type: typing.List[str]
# TODO - G.M - 2019-11-21 - find better storage solution for mimetype mapping
# dict and/or list.
# see https://github.com/algoo/preview-generator/pull/148#discussion_r346381508
SUPPORTED_RAW_CAMERA_MIMETYPE_MAPPING = [
MimetypeMapping("image/x-sony-arw", ".arw"),
MimetypeMapping("image/x-adobe-dng", ".dng"),
MimetypeMapping("image/x-sony-sr2", ".sr2"),
MimetypeMapping("image/x-sony-srf", ".srf"),
MimetypeMapping("image/x-sigma-x3f", ".x3f"),
MimetypeMapping("image/x-canon-crw", ".crw"),
MimetypeMapping("image/x-canon-cr2", ".cr2"),
MimetypeMapping("image/x-epson-erf", ".erf"),
MimetypeMapping("image/x-fuji-raf", ".raf"),
MimetypeMapping("image/x-nikon-nef", ".nef"),
MimetypeMapping("image/x-olympus-orf", ".orf"),
MimetypeMapping("image/x-panasonic-raw", ".raw"),
MimetypeMapping("image/x-panasonic-rw2", ".rw2"),
MimetypeMapping("image/x-pentax-pef", ".pef"),
MimetypeMapping("image/x-kodak-dcr", ".dcr"),
MimetypeMapping("image/x-kodak-k25", ".k25"),
MimetypeMapping("image/x-kodak-kdc", ".kdc"),
MimetypeMapping("image/x-minolta-mrw", ".mrw"),
]

SUPPORTED_HEIC_MIMETYPE_MAPPING = [
MimetypeMapping("image/heic", ".heic"),
MimetypeMapping("image/heic", ".heif"),
]

def __init__(
self, quality: int = DEFAULT_JPEG_QUALITY, progressive: bool = DEFAULT_JPEG_PROGRESSIVE,
):
super().__init__()
self.quality = quality
self.progressive = progressive

@classmethod
def get_label(cls) -> str:
Expand All @@ -37,7 +74,29 @@ def __load_mimetypes(cls) -> typing.List[str]:
Load supported mimetypes from WAND library
:return: list of supported mime types
"""
return imagemagick_supported_mimes()

mimes = imagemagick_supported_mimes() # type: typing.List[str]
# HACK - G.M - 2019-10-31 - Handle raw format only if ufraw-batch is installed as most common
# default imagemagick configuration delegate raw format to ufraw-batch.
if executable_is_available("ufraw-batch"):
for mimetype_mapping in cls.SUPPORTED_RAW_CAMERA_MIMETYPE_MAPPING:
mimes.append(mimetype_mapping.mimetype)
return mimes

@classmethod
def get_mimetypes_mapping(cls) -> typing.List[MimetypeMapping]:
mimetypes_mapping = [] # type: typing.List[MimetypeMapping]
mimetypes_mapping = (
mimetypes_mapping
+ cls.SUPPORTED_RAW_CAMERA_MIMETYPE_MAPPING
+ cls.SUPPORTED_HEIC_MIMETYPE_MAPPING
)
return mimetypes_mapping

@classmethod
def check_dependencies(cls) -> None:
if not executable_is_available("convert"):
raise BuilderDependencyNotFound("this builder requires convert to be available")

@classmethod
def get_supported_mimetypes(cls) -> typing.List[str]:
Expand All @@ -48,21 +107,9 @@ def get_supported_mimetypes(cls) -> typing.List[str]:
ImagePreviewBuilderWand.MIMETYPES = cls.__load_mimetypes()
mimetypes = ImagePreviewBuilderWand.MIMETYPES

# INFO - G.M - 2021-04-30
# Disable support for postscript,xcf and raw image format in wand, to ensure
# proper builder is used (either imagemagick convert or pillow)

invalid_mimetypes = ["application/postscript", "application/x-xcf", "image/x-xcf"]
for (
mimetype_mapping
) in ImagePreviewBuilderIMConvert().SUPPORTED_RAW_CAMERA_MIMETYPE_MAPPING:
invalid_mimetypes.append(mimetype_mapping.mimetype)

for invalid_mimetype in invalid_mimetypes:
try:
mimetypes.remove(invalid_mimetype)
except ValueError:
pass
extra_mimetypes = ["application/x-xcf", "image/x-xcf"]
mimetypes.extend(extra_mimetypes)

return mimetypes

def build_jpeg_preview(
Expand All @@ -77,41 +124,47 @@ def build_jpeg_preview(
) -> None:
if not size:
size = self.default_size
with open(file_path, "rb") as img:
result = self.image_to_jpeg_wand(img, ImgDims(width=size.width, height=size.height))

with open(
"{path}{extension}".format(path=cache_path + preview_name, extension=extension),
"wb",
) as jpeg:
buffer = result.read(1024)
while buffer:
jpeg.write(buffer)
buffer = result.read(1024)

def image_to_jpeg_wand(
self, jpeg: typing.Union[str, typing.IO[bytes]], preview_dims: ImgDims
) -> BytesIO:
preview_name = preview_name + extension
dest_path = os.path.join(cache_path, preview_name)
self.image_to_jpeg_wand(file_path, size, dest_path, mimetype=mimetype)

def image_to_jpeg_wand(self, file_path: str, preview_dims: ImgDims, dest_path: str,
mimetype: typing.Optional[str]) -> None:
try:
with self._convert_image(file_path, preview_dims) as img:
img.save(filename=dest_path)
except (CoderError, CoderFatalError, CoderWarning) as e:
assert mimetype
file_ext = mimetypes_storage.guess_extension(mimetype, strict=False) or ""
if file_ext:
file_path = file_ext.lstrip(".") + ":" + file_path
with self._convert_image(file_path, preview_dims) as img:
img.save(filename=dest_path)
else:
raise e

def _convert_image(self, file_path: str, preview_dims: ImgDims) -> Image:
"""
for jpeg, gif and bmp
:param jpeg:
:param size:
:return:
refer: https://legacy.imagemagick.org/Usage/thumbnails/
like cmd: convert -layers merge -background white -thumbnail widthxheight \
-auto-orient -quality 85 -interlace plane input.jpeg output.jpeg
"""
self.logger.info("Converting image to jpeg using wand")

with WImage(file=jpeg, background=Color("white")) as image:

preview_dims = ImgDims(width=preview_dims.width, height=preview_dims.height)

resize_dim = compute_resize_dims(
dims_in=ImgDims(width=image.size[0], height=image.size[1]), dims_out=preview_dims
)
image.resize(resize_dim.width, resize_dim.height)
# INFO - jumenzel - 2019-03-12 - remove metadata, color-profiles from this image.
image.strip()
content_as_bytes = image.make_blob("jpeg")
output = BytesIO()
output.write(content_as_bytes)
output.seek(0, 0)
return output

img = Image(filename=file_path)
resize_dim = compute_resize_dims(
dims_in=ImgDims(width=img.width, height=img.height), dims_out=preview_dims
)

img.auto_orient()
img.iterator_reset()
img.background_color = Color("white")
img.merge_layers("merge")

if self.progressive:
img.interlace_scheme = "plane"

img.compression_quality = self.quality

img.thumbnail(resize_dim.width, resize_dim.height)

return img
5 changes: 3 additions & 2 deletions preview_generator/preview/builder_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ def load_builders(self, force: bool = False) -> None:
if is_abstract(cls):
# INFO - G.M - 2021-06-22 - Skip abstract classes from loaded builders
pass
elif cls.__name__ == "ImagePreviewBuilderWand":
elif cls.__name__ in ("ImagePreviewBuilderPillow", "ImagePreviewBuilderIMConvert"):
self.logger.info(
"ImagePreviewBuilderWand builder is deprecated and is not registered by default. Consider using ImagePreviewBuilderIMConvert instead"
"%s builder is deprecated and is not registered by default. "
"Consider using ImagePreviewBuilderIMConvert instead".format(cls.__name__)
)
else:
self.register_builder(cls, overwrite=False)
Expand Down
33 changes: 33 additions & 0 deletions tests/builders/test_image_wand_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import os
from PIL import Image
from preview_generator.preview.builder.image__wand import ImagePreviewBuilderWand
from preview_generator.preview.builder.image__imconvert import ImagePreviewBuilderIMConvert
from preview_generator.utils import ImgDims

CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
CACHE_DIR = "/tmp/preview-generator-tests/cache"


def test_build_jpeg_preview() -> None:
wand_builder = ImagePreviewBuilderWand()
test_orient_path = os.path.join(CURRENT_DIR, "the_img.png")
extension = ".jpg"
preview_name = "preview_the_img"
width = 512
height = 256
size = ImgDims(width=width, height=height)
wand_builder.build_jpeg_preview(
file_path=test_orient_path,
preview_name=preview_name,
cache_path=CACHE_DIR,
page_id=-1,
size=size,
extension=extension
)
preview_name = preview_name + extension
dest_path = os.path.join(CACHE_DIR, preview_name)
assert os.path.exists(dest_path)
assert os.path.getsize(dest_path) > 0
with Image.open(dest_path) as jpg:
assert jpg.height == height
assert jpg.width in range(288, 290)
Binary file added tests/builders/the_img.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/input/arw_raw/DSC08523
Binary file not shown.
Loading

0 comments on commit 2f8ed37

Please sign in to comment.