Files
mirror-komikku/komikku/utils.py
Colin 318fc0c975 crop_borders: don't crash on empty image
PIL's `Image.getbbox` documentation shows:
```
:returns: The bounding box is returned as a 4-tuple defining the
   left, upper, right, and lower pixel coordinate. See
   :ref:`coordinate-system`. If the image is completely empty, this
   method returns None.
```

when this returns `None`, komikku would error with a trace like so:
```
Traceback (most recent call last):
  File "komikku/reader/pager/image.py", line 426, in do_snapshot
    self.texture_crop = Gdk.Texture.new_for_pixbuf(crop_borders())
  File "komikku/reader/pager/image.py", line 419, in crop_borders
    if bbox[2] - bbox[0] < self.pixbuf.get_width() or bbox[3] - bbox[1] < self.pixbuf.get_height():
TypeError: 'NoneType' object is not subscriptable
```
2023-07-12 08:55:29 +00:00

654 lines
20 KiB
Python

# Copyright (C) 2019-2023 Valéry Febvre
# SPDX-License-Identifier: GPL-3.0-only or GPL-3.0-or-later
# Author: Valéry Febvre <vfebvre@easter-eggs.com>
import datetime
from functools import cache
from functools import wraps
from gettext import gettext as _
import gi
import html
from io import BytesIO
import logging
import math
import os
from PIL import Image
from PIL import ImageChops
import requests
import subprocess
import traceback
gi.require_version('Gdk', '4.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository import Gdk
from gi.repository import Gio
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository.GdkPixbuf import Colorspace
from gi.repository.GdkPixbuf import InterpType
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository.GdkPixbuf import PixbufAnimation
logger = logging.getLogger('komikku')
def check_cmdline_tool(cmd):
try:
p = subprocess.Popen(cmd, bufsize=0, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
out, _ = p.communicate()
except Exception:
return False, None
else:
return p.returncode == 0, out.decode('utf-8').strip()
def create_picture_from_data(data, static_animation=False, subdivided=False):
mime_type, _result_uncertain = Gio.content_type_guess(None, data)
if mime_type == 'image/gif' and not static_animation:
return PictureAnimation.new_from_data(data)
if subdivided:
return PictureSubdivided.new_from_data(data)
return Picture.new_from_data(data)
def create_picture_from_file(path, static_animation=False, subdivided=False):
format_, _width, _height = Pixbuf.get_file_info(path)
if format_ is None:
return None
if 'image/gif' in format_.get_mime_types() and not static_animation:
return PictureAnimation.new_from_file(path)
if subdivided:
return PictureSubdivided.new_from_file(path)
return Picture.new_from_file(path)
def create_picture_from_resource(path):
return Picture.new_from_resource(path)
def create_paintable_from_data(data, width=None, height=None, static_animation=False, preserve_aspect_ratio=True):
mime_type, _result_uncertain = Gio.content_type_guess(None, data)
if not mime_type:
return None
if mime_type == 'image/gif' and not static_animation:
return PaintablePixbufAnimation.new_from_data(data)
return PaintablePixbuf.new_from_data(data, width, height, preserve_aspect_ratio)
def create_paintable_from_file(path, width=None, height=None, static_animation=False, preserve_aspect_ratio=True):
format_, _width, _height = Pixbuf.get_file_info(path)
if format_ is None:
return None
if 'image/gif' in format_.get_mime_types() and not static_animation:
return PaintablePixbufAnimation.new_from_file(path, width, height)
return PaintablePixbuf.new_from_file(path, width, height, preserve_aspect_ratio)
def create_paintable_from_resource(path, width=None, height=None, preserve_aspect_ratio=True):
return PaintablePixbuf.new_from_resource(path, width, height, preserve_aspect_ratio)
def crop_pixbuf(pixbuf, src_x, src_y, width, height):
pixbuf_cropped = Pixbuf.new(Colorspace.RGB, pixbuf.get_has_alpha(), 8, width, height)
pixbuf.copy_area(src_x, src_y, width, height, pixbuf_cropped, 0, 0)
return pixbuf_cropped
def expand_and_resize_cover(buffer):
"""Convert and resize a cover (except animated GIF)
Covers in landscape format are convert to portrait format"""
def get_dominant_color(img):
# Resize image to reduce number of colors
colors = img.resize((150, 150), resample=0).getcolors(150 * 150)
sorted_colors = sorted(colors, key=lambda t: t[0])
return sorted_colors[-1][1]
def remove_alpha(img):
if img.mode not in ('P', 'RGBA'):
return img
img = img.convert('RGBA')
background = Image.new('RGBA', img.size, (255, 255, 255))
return Image.alpha_composite(background, img)
img = Image.open(BytesIO(buffer))
if img.format == 'GIF' and img.is_animated:
return buffer
width, height = img.size
new_width, new_height = (360, 512)
if width >= height:
img = remove_alpha(img)
new_ratio = new_height / new_width
new_img = Image.new(img.mode, (width, int(width * new_ratio)), get_dominant_color(img))
new_img.paste(img, (0, (int(width * new_ratio) - height) // 2))
new_img.thumbnail((new_width, new_height), Image.LANCZOS)
else:
img.thumbnail((new_width, new_height), Image.LANCZOS)
new_img = img
new_buffer = BytesIO()
new_img.convert('RGB').save(new_buffer, 'JPEG', quality=95)
return new_buffer.getbuffer()
def folder_size(path):
if not os.path.exists(path):
return 0
res = subprocess.run(['du', '-sh', path], stdout=subprocess.PIPE, check=False)
return res.stdout.split()[0].decode()
@cache
def get_cache_dir():
cache_dir_path = GLib.get_user_cache_dir()
# Check if inside flatpak sandbox
if is_flatpak():
return cache_dir_path
cache_dir_path = os.path.join(cache_dir_path, 'komikku')
if not os.path.exists(cache_dir_path):
os.mkdir(cache_dir_path)
return cache_dir_path
@cache
def get_cached_data_dir():
cached_data_dir_path = os.path.join(get_cache_dir(), 'tmp')
if not os.path.exists(cached_data_dir_path):
os.mkdir(cached_data_dir_path)
return cached_data_dir_path
@cache
def get_data_dir():
data_dir_path = GLib.get_user_data_dir()
app_profile = Gio.Application.get_default().profile
if not is_flatpak():
base_path = data_dir_path
data_dir_path = os.path.join(base_path, 'komikku')
if app_profile == 'development':
data_dir_path += '-devel'
elif app_profile == 'beta':
data_dir_path += '-beta'
if not os.path.exists(data_dir_path):
os.mkdir(data_dir_path)
# Until version 0.11.0, data files (chapters, database) were stored in a wrong place
from komikku.servers.utils import get_servers_list
must_be_moved = ['komikku.db', 'komikku_backup.db', ]
for server in get_servers_list(include_disabled=True):
must_be_moved.append(server['id'])
for name in must_be_moved:
data_path = os.path.join(base_path, name)
if os.path.exists(data_path):
os.rename(data_path, os.path.join(data_dir_path, name))
# Create folder for 'local' server
data_local_dir_path = os.path.join(data_dir_path, 'local')
if not os.path.exists(data_local_dir_path):
os.mkdir(data_local_dir_path)
return data_dir_path
def html_escape(s):
return html.escape(html.unescape(s), quote=False)
def if_network_available(func):
"""Decorator to disable an action when network is not avaibable"""
@wraps(func)
def wrapper(*args, **kwargs):
window = args[0].parent if hasattr(args[0], 'parent') else args[0].window
if not window.network_available:
window.show_notification(_('You are currently offline'))
return None
return func(*args, **kwargs)
return wrapper
def is_flatpak():
return os.path.exists(os.path.join(GLib.get_user_runtime_dir(), 'flatpak-info'))
def log_error_traceback(e):
from komikku.servers.exceptions import ServerException
if isinstance(e, requests.exceptions.RequestException):
return _('No Internet connection, timeout or server down')
if isinstance(e, ServerException):
return e.message
logger.info(traceback.format_exc())
return None
def skip_past(haystack, needle):
if (idx := haystack.find(needle)) >= 0:
return idx + len(needle)
return None
def subdivide_pixbuf(pixbuf, part_height):
"""Sub-divide a long vertical GdkPixbuf.Pixbuf into multiple GdkPixbuf.Pixbuf"""
parts = []
width = pixbuf.get_width()
full_height = pixbuf.get_height()
for index in range(math.ceil(full_height / part_height)):
y = index * part_height
height = part_height if y + part_height <= full_height else full_height - y
part_pixbuf = Pixbuf.new(Colorspace.RGB, pixbuf.get_has_alpha(), 8, width, height)
pixbuf.copy_area(0, y, width, height, part_pixbuf, 0, 0)
parts.append(part_pixbuf)
return parts
def trunc_filename(filename):
"""Reduce filename length to 255 (common FS limit) if it's too long"""
return filename.encode('utf-8')[:255].decode().strip()
class PaintablePixbuf(GObject.GObject, Gdk.Paintable):
def __init__(self, path, pixbuf):
super().__init__()
self.cropped = False
self.path = path
self.pixbuf = pixbuf
self.texture = Gdk.Texture.new_for_pixbuf(pixbuf)
self.texture_cropped = None
self.orig_width = self.pixbuf.get_width()
self.orig_height = self.pixbuf.get_height()
self.width = self.orig_width
self.height = self.orig_height
@classmethod
def new_from_data(cls, data, width=None, height=None, preserve_aspect_ratio=True):
mime_type, _result_uncertain = Gio.content_type_guess(None, data)
if not mime_type:
return None
try:
stream = Gio.MemoryInputStream.new_from_data(data, None)
if (not width and not height) or mime_type == 'image/gif':
pixbuf = Pixbuf.new_from_stream(stream)
if mime_type == 'image/gif':
if width == -1:
ratio = pixbuf.get_height() / height
width = pixbuf.get_width() / ratio
elif height == -1:
ratio = pixbuf.get_width() / width
height = pixbuf.get_height() / ratio
pixbuf = pixbuf.scale_simple(width, height, InterpType.BILINEAR)
else:
pixbuf = Pixbuf.new_from_stream_at_scale(stream, width, height, preserve_aspect_ratio)
stream.close()
except Exception:
# Invalid image, corrupted image, unsupported image format,...
return None
return cls(None, pixbuf)
@classmethod
def new_from_file(cls, path, width=None, height=None, preserve_aspect_ratio=True):
format_, orig_width, orig_height = Pixbuf.get_file_info(path)
if format_ is None:
return None
try:
if (not width and not height) or 'image/gif' in format_.get_mime_types():
pixbuf = Pixbuf.new_from_file(path)
if 'image/gif' in format_.get_mime_types():
if width == -1:
ratio = orig_height / height
width = orig_width / ratio
elif height == -1:
ratio = orig_width / width
height = orig_height / ratio
pixbuf = pixbuf.scale_simple(width, height, InterpType.BILINEAR)
else:
pixbuf = Pixbuf.new_from_file_at_scale(path, width, height, preserve_aspect_ratio)
except Exception:
# Invalid image, corrupted image, unsupported image format,...
return None
return cls(path, pixbuf)
@classmethod
def new_from_pixbuf(cls, pixbuf, width=None, height=None):
if width and height:
pixbuf = pixbuf.scale_simple(width, height, InterpType.BILINEAR)
return cls(None, pixbuf)
@classmethod
def new_from_resource(cls, path, width=None, height=None, preserve_aspect_ratio=True):
try:
if not width and not height:
pixbuf = Pixbuf.new_from_resource(path)
else:
pixbuf = Pixbuf.new_from_resource_at_scale(path, width, height, preserve_aspect_ratio)
except Exception:
# Invalid image, corrupted image, unsupported image format,...
return None
return cls(None, pixbuf)
def _compute_borders_crop_bbox(self):
# TODO: Add a slider in settings
threshold = 225
def lookup(x):
return 255 if x > threshold else 0
im = Image.open(self.path).convert('L').point(lookup, mode='1')
bg = Image.new(im.mode, im.size, 255)
return ImageChops.difference(im, bg).getbbox()
def dispose(self):
self.pixbuf = None
self.texture = None
self.texture_cropped = None
def do_get_intrinsic_height(self):
return self.height
def do_get_intrinsic_width(self):
return self.width
def do_snapshot(self, snapshot, width, height):
def crop_borders():
""""Crop white borders"""
if self.path is None:
return self.pixbuf
bbox = self._compute_borders_crop_bbox()
# Crop is possible if computed bbox is included in pixbuf
if bbox is not None and (bbox[2] - bbox[0] < self.orig_width or bbox[3] - bbox[1] < self.orig_height):
return crop_pixbuf(self.pixbuf, bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1])
return self.pixbuf
if self.cropped and self.texture_cropped is None:
self.texture_cropped = Gdk.Texture.new_for_pixbuf(crop_borders())
if self.cropped:
self.texture_cropped.snapshot(snapshot, width, height)
else:
self.texture.snapshot(snapshot, width, height)
def resize(self, width, height, cropped=False):
self.width = width
self.height = height
self.cropped = cropped
self.invalidate_size()
class PaintablePixbufAnimation(GObject.GObject, Gdk.Paintable):
def __init__(self, path, anim, width, height):
super().__init__()
self.anim = anim
self.iter = self.anim.get_iter(None)
self.path = path
self.orig_width = self.anim.get_width()
self.orig_height = self.anim.get_height()
if width == -1:
ratio = self.orig_height / height
self.width = self.orig_width / ratio
self.height = height
elif height == -1:
ratio = self.orig_width / width
self.height = self.orig_height / ratio
self.width = width
else:
self.width = width
self.height = height
self.__delay_cb()
@classmethod
def new_from_data(cls, data):
stream = Gio.MemoryInputStream.new_from_data(data, None)
anim = PixbufAnimation.new_from_stream(stream)
stream.close()
return cls(None, anim)
@classmethod
def new_from_file(cls, path, width=None, height=None):
anim = PixbufAnimation.new_from_file(path)
return cls(path, anim, width, height)
def __delay_cb(self):
if self.iter is None:
return
delay = self.iter.get_delay_time()
if delay == -1:
return
self.timeout_id = GLib.timeout_add(delay, self.__delay_cb)
self.invalidate_contents()
def dispose(self):
self.iter = None
self.anim = None
def do_get_intrinsic_height(self):
return self.height
def do_get_intrinsic_width(self):
return self.width
def do_snapshot(self, snapshot, width, height):
_res, timeval = GLib.TimeVal.from_iso8601(datetime.datetime.utcnow().isoformat())
self.iter.advance(timeval)
pixbuf = self.iter.get_pixbuf()
pixbuf = pixbuf.scale_simple(width, height, InterpType.BILINEAR)
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
texture.snapshot(snapshot, width, height)
def resize(self, width, height):
self.width = width
self.height = height
self.invalidate_size()
class Picture(Gtk.Picture):
def __init__(self, paintable):
super().__init__()
self.set_can_shrink(False)
self.set_paintable(paintable)
@classmethod
def new_from_data(cls, data):
if paintable := PaintablePixbuf.new_from_data(data):
return cls(paintable)
return None
@classmethod
def new_from_file(cls, path):
if paintable := PaintablePixbuf.new_from_file(path):
return cls(paintable)
return None
@classmethod
def new_from_pixbuf(cls, pixbuf):
if paintable := PaintablePixbuf.new_from_pixbuf(pixbuf):
return cls(paintable)
return None
@classmethod
def new_from_resource(cls, path):
if paintable := PaintablePixbuf.new_from_resource(path):
return cls(paintable)
return None
@property
def height(self):
return self.props.paintable.height
@property
def orig_height(self):
return self.props.paintable.orig_height
@property
def orig_width(self):
return self.props.paintable.orig_width
@property
def width(self):
return self.props.paintable.width
def dispose(self):
paintable = self.get_paintable()
self.set_paintable(None)
paintable.dispose()
def resize(self, width, height, cropped=False):
self.props.paintable.resize(width, height, cropped)
class PictureAnimation(Gtk.Picture):
def __init__(self, paintable):
super().__init__()
self.set_can_shrink(False)
self.set_paintable(paintable)
@classmethod
def new_from_data(cls, data):
if paintable := PaintablePixbufAnimation.new_from_data(data):
return cls(paintable)
return None
@classmethod
def new_from_file(cls, path):
if paintable := PaintablePixbufAnimation.new_from_file(path):
return cls(paintable)
return None
@property
def height(self):
return self.props.paintable.height
@property
def orig_height(self):
return self.props.paintable.orig_height
@property
def orig_width(self):
return self.props.paintable.orig_width
@property
def width(self):
return self.props.paintable.width
def dispose(self):
paintable = self.get_paintable()
self.set_paintable(None)
paintable.dispose()
def resize(self, width, height, _cropped=False):
self.props.paintable.resize(width, height)
class PictureSubdivided(Gtk.Box):
"""
A Gtk.Box containing an image subdivided into multiple Gtk.Picture.
Useful to display long vertical images commonly used in Webtoons
because images height are limited by GL_MAX_TEXTURE_SIZE.
"""
def __init__(self, path, pixbuf):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.path = path
# Pages of Webtoon pager have a minimum size (equal to reader view size)
# In rare cases where an image is smaller than page, it must be centered vertically
self.props.valign = Gtk.Align.CENTER
# TODO: find a way to replace 4096 by GL_MAX_TEXTURE_SIZE value
for pixbuf_tile in subdivide_pixbuf(pixbuf, 4096):
picture = Gtk.Picture()
picture.set_pixbuf(pixbuf_tile)
picture.set_can_shrink(True)
self.append(picture)
self.orig_width = pixbuf.get_width()
self.orig_height = pixbuf.get_height()
self.width = self.orig_width
self.height = self.orig_height
@classmethod
def new_from_data(cls, data):
stream = Gio.MemoryInputStream.new_from_data(data, None)
return cls(None, Pixbuf.new_from_stream(stream))
@classmethod
def new_from_file(cls, path):
return cls(path, Pixbuf.new_from_file(path))
def dispose(self):
picture = self.get_first_child()
while picture:
next_picture = picture.get_next_sibling()
picture.set_pixbuf(None)
picture = next_picture
def resize(self, width, height, _cropped=False):
self.width = width
self.height = height
self.set_size_request(width, height)