mirror of
https://github.com/kovidgoyal/calibre.git
synced 2026-06-04 13:05:31 -04:00
Kobo driver: Add options to more precisely control the generation of cover images, to reduce size and enhance quality
Merge branch 'master' of https://github.com/NiLuJe/calibre
This commit is contained in:
@@ -3,11 +3,11 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2010-2012, Timothy Legge <timlegge@gmail.com>, Kovid Goyal <kovid@kovidgoyal.net> and David Forrester <davidfor@internode.on.net>'
|
||||
__copyright__ = '2010-2019, Timothy Legge <timlegge@gmail.com>, Kovid Goyal <kovid@kovidgoyal.net> and David Forrester <davidfor@internode.on.net>'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
'''
|
||||
Driver for Kobo ereaders. Supports all e-ink devices.
|
||||
Driver for Kobo eReaders. Supports all e-ink devices.
|
||||
|
||||
Originally developed by Timothy Legge <timlegge@gmail.com>.
|
||||
Extended to support Touch firmware 2.0.0 and later and newer devices by David Forrester <davidfor@internode.on.net>
|
||||
@@ -30,7 +30,7 @@ from calibre.devices.kobo.books import ImageWrapper
|
||||
from calibre.devices.mime import mime_type_ext
|
||||
from calibre.devices.usbms.driver import USBMS, debug_print
|
||||
from calibre import prints, fsync
|
||||
from calibre.ptempfile import PersistentTemporaryFile
|
||||
from calibre.ptempfile import PersistentTemporaryFile, better_mktemp
|
||||
from calibre.constants import DEBUG
|
||||
from calibre.utils.config_base import prefs
|
||||
from polyglot.builtins import iteritems, itervalues, unicode_type, string_or_bytes
|
||||
@@ -1052,9 +1052,9 @@ class KOBO(USBMS):
|
||||
with lopen(cover, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
# Return the data resized and in Grayscale if
|
||||
# Return the data resized and grayscaled if
|
||||
# required
|
||||
data = save_cover_data_to(data, grayscale=uploadgrayscale, resize_to=resize)
|
||||
data = save_cover_data_to(data, grayscale=uploadgrayscale, resize_to=resize, minify_to=resize)
|
||||
|
||||
with lopen(fpath, 'wb') as f:
|
||||
f.write(data)
|
||||
@@ -1340,12 +1340,12 @@ class KOBO(USBMS):
|
||||
|
||||
class KOBOTOUCH(KOBO):
|
||||
name = 'KoboTouch'
|
||||
gui_name = 'Kobo Touch/Glo/Mini/Aura HD/Aura H2O/Glo HD/Touch 2'
|
||||
gui_name = 'Kobo eReader'
|
||||
author = 'David Forrester'
|
||||
description = _(
|
||||
'Communicate with the Kobo Touch, Glo, Mini, Aura HD,'
|
||||
' Aura H2O, Glo HD, Touch 2, Aura ONE, Aura Edition 2,'
|
||||
' Aura H2O Edition 2, Clara HD and Forma ereaders.'
|
||||
' Aura H2O Edition 2, Clara HD and Forma eReaders.'
|
||||
' Based on the existing Kobo driver by %s.') % KOBO.author
|
||||
# icon = I('devices/kobotouch.jpg')
|
||||
|
||||
@@ -1416,71 +1416,70 @@ class KOBOTOUCH(KOBO):
|
||||
# Image file name endings. Made up of: image size, min_dbversion, max_dbversion, isFullSize,
|
||||
# Note: "200" has been used just as a much larger number than the current versions. It is just a lazy
|
||||
# way of making it open ended.
|
||||
COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
' - N3_FULL.parsed':[(600,800),0, 200,True,], # Used for screensaver, home screen
|
||||
# NOTE: Values pulled from Nickel by @geek1011,
|
||||
# c.f., this handy recap: https://github.com/shermp/Kobo-UNCaGED/issues/16#issuecomment-494229994
|
||||
# Only the N3_FULL values differ, as they should match the screen's effective resolution.
|
||||
# Note that all Kobo devices share a common AR at roughly 0.75,
|
||||
# so results should be similar, no matter the exact device.
|
||||
# Common to all Kobo models
|
||||
COMMON_COVER_FILE_ENDINGS = {
|
||||
# Used for Details screen before FW2.8.1, then for current book tile on home screen
|
||||
' - N3_LIBRARY_FULL.parsed':[(355,473),0, 200,False,],
|
||||
' - N3_LIBRARY_FULL.parsed': [(355,530),0, 200,False,],
|
||||
# Used for library lists
|
||||
' - N3_LIBRARY_GRID.parsed':[(149,198),0, 200,False,], # Used for library lists
|
||||
' - N3_LIBRARY_GRID.parsed': [(149,223),0, 200,False,],
|
||||
# Used for library lists
|
||||
' - N3_LIBRARY_LIST.parsed':[(60,90),0, 53,False,],
|
||||
' - N3_LIBRARY_LIST.parsed': [(60,90),0, 53,False,],
|
||||
# Used for Details screen from FW2.8.1
|
||||
' - AndroidBookLoadTablet_Aspect.parsed':[(355,473), 82, 100,False,],
|
||||
' - AndroidBookLoadTablet_Aspect.parsed': [(355,530), 82, 100,False,],
|
||||
}
|
||||
# Glo and Aura share resolution, so the image sizes should be the same.
|
||||
# Legacy 6" devices
|
||||
LEGACY_COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
' - N3_FULL.parsed': [(600,800),0, 200,True,],
|
||||
}
|
||||
# Glo
|
||||
GLO_COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
' - N3_FULL.parsed':[(758,1024),0, 200,True,],
|
||||
# Used for Details screen before FW2.8.1, then for current book tile on home screen
|
||||
' - N3_LIBRARY_FULL.parsed':[(355,479),0, 200,False,],
|
||||
# Used for library lists
|
||||
' - N3_LIBRARY_GRID.parsed':[(149,201),0, 200,False,],
|
||||
# Used for Details screen from FW2.8.1
|
||||
' - AndroidBookLoadTablet_Aspect.parsed':[(355,479), 88, 100,False,],
|
||||
' - N3_FULL.parsed': [(758,1024),0, 200,True,],
|
||||
}
|
||||
# Aura
|
||||
AURA_COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
# NOTE: The Aura's bezel covers 10 pixels at the bottom.
|
||||
# Kobo officially advertised the screen resolution with those chopped off.
|
||||
' - N3_FULL.parsed': [(758,1014),0, 200,True,],
|
||||
}
|
||||
# Glo HD and Clara HD share resolution, so the image sizes should be the same.
|
||||
GLO_HD_COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
' - N3_FULL.parsed': [(1072,1448), 0, 200,True,],
|
||||
# Used for Details screen before FW2.8.1, then for current book tile on home screen
|
||||
' - N3_LIBRARY_FULL.parsed':[(355, 479), 0, 200,False,],
|
||||
# Used for library lists
|
||||
' - N3_LIBRARY_GRID.parsed':[(149, 201), 0, 200,False,],
|
||||
# Used for Details screen from FW2.8.1
|
||||
' - AndroidBookLoadTablet_Aspect.parsed':[(355, 471), 88, 100,False,],
|
||||
}
|
||||
AURA_HD_COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
' - N3_FULL.parsed': [(1080,1440), 0, 200,True,],
|
||||
# Used for Details screen before FW2.8.1, then for current book tile on home screen
|
||||
' - N3_LIBRARY_FULL.parsed':[(355, 471), 0, 200,False,],
|
||||
# Used for library lists
|
||||
' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,],
|
||||
# Used for Details screen from FW2.8.1
|
||||
' - AndroidBookLoadTablet_Aspect.parsed':[(355, 471), 88, 100,False,],
|
||||
}
|
||||
AURA_H2O_COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
# NOTE: The H2O's bezel covers 11 pixels at the top.
|
||||
# Unlike on the Aura, Nickel fails to account for this when generating covers.
|
||||
# c.f., https://github.com/shermp/Kobo-UNCaGED/pull/17#discussion_r286209827
|
||||
' - N3_FULL.parsed': [(1080,1429), 0, 200,True,],
|
||||
}
|
||||
AURA_ONE_COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
' - N3_FULL.parsed': [(1404,1872), 0, 200,True,],
|
||||
# Used for Details screen before FW2.8.1, then for current book tile on home screen
|
||||
' - N3_LIBRARY_FULL.parsed':[(355, 473), 0, 200,False,],
|
||||
# Used for library lists
|
||||
' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,], # Used for library lists
|
||||
}
|
||||
FORMA_COVER_FILE_ENDINGS = {
|
||||
# Used for screensaver, home screen
|
||||
# NOTE: Nickel currently fails to honor the real screen resolution when generating covers,
|
||||
# choosing instead to follow the Aura One codepath.
|
||||
' - N3_FULL.parsed': [(1440,1920), 0, 200,True,],
|
||||
# Used for Details screen before FW2.8.1, then for current book tile on home screen
|
||||
' - N3_LIBRARY_FULL.parsed':[(355, 473), 0, 200,False,],
|
||||
# Used for library lists
|
||||
' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,], # Used for library lists
|
||||
}
|
||||
# Following are the sizes used with pre2.1.4 firmware
|
||||
# COVER_FILE_ENDINGS = {
|
||||
# ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 99,], # Used for Details screen
|
||||
# ' - N3_LIBRARY_FULL.parsed':[(600,800),0, 99,],
|
||||
# ' - N3_LIBRARY_GRID.parsed':[(149,233),0, 99,], # Used for library lists
|
||||
# ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 99,], # Used for library lists
|
||||
# ' - N3_LIBRARY_LIST.parsed':[(60,90),0, 53,],
|
||||
# ' - N3_LIBRARY_SHELF.parsed': [(40,60),0, 52,],
|
||||
# ' - N3_FULL.parsed':[(600,800),0, 99,], # Used for screensaver if "Full screen" is checked.
|
||||
@@ -1507,7 +1506,7 @@ class KOBOTOUCH(KOBO):
|
||||
# Just dump some info to the logs.
|
||||
super(KOBOTOUCH, self).open_osx()
|
||||
|
||||
# Wrap some debugging output in a try/except so that it unlikely to break things completely.
|
||||
# Wrap some debugging output in a try/except so that it is unlikely to break things completely.
|
||||
try:
|
||||
if DEBUG:
|
||||
from calibre.constants import plugins
|
||||
@@ -2560,7 +2559,10 @@ class KOBOTOUCH(KOBO):
|
||||
|
||||
# debug_print('KoboTouch: uploading cover')
|
||||
try:
|
||||
self._upload_cover(path, filename, metadata, filepath, self.upload_grayscale, self.keep_cover_aspect)
|
||||
self._upload_cover(
|
||||
path, filename, metadata, filepath,
|
||||
self.upload_grayscale, self.dithered_covers,
|
||||
self.keep_cover_aspect, self.letterbox_fs_covers, self.png_covers)
|
||||
except Exception as e:
|
||||
debug_print('KoboTouch: FAILED to upload cover=%s Exception=%s'%(filepath, unicode_type(e)))
|
||||
|
||||
@@ -2589,17 +2591,36 @@ class KOBOTOUCH(KOBO):
|
||||
path = os.path.join(path, imageId)
|
||||
return path
|
||||
|
||||
def _calculate_kobo_cover_size(self, library_size, kobo_size, keep_cover_aspect, is_full_size):
|
||||
if keep_cover_aspect:
|
||||
library_aspect = library_size[0] / library_size[1]
|
||||
kobo_aspect = kobo_size[0] / kobo_size[1]
|
||||
if library_aspect > kobo_aspect:
|
||||
kobo_size = (kobo_size[0], int(kobo_size[0] / library_aspect))
|
||||
else:
|
||||
kobo_size = (int(library_aspect * kobo_size[1]), kobo_size[1])
|
||||
return kobo_size
|
||||
def _calculate_kobo_cover_size(self, library_size, kobo_size, expand, keep_cover_aspect, letterbox):
|
||||
# Remember the canvas size
|
||||
canvas_size = kobo_size
|
||||
|
||||
def _create_cover_data(self, cover_data, resize_to, kobo_size, upload_grayscale=False, keep_cover_aspect=False, is_full_size=False):
|
||||
# NOTE: Loosely based on Qt's QSize::scaled implementation
|
||||
if keep_cover_aspect:
|
||||
# NOTE: Unlike Qt, we round to avoid accumulating errors,
|
||||
# as ImageOps will then floor via fit_image
|
||||
aspect_ratio = library_size[0] / library_size[1]
|
||||
rescaled_width = int(round(kobo_size[1] * aspect_ratio))
|
||||
|
||||
if expand:
|
||||
use_height = (rescaled_width >= kobo_size[0])
|
||||
else:
|
||||
use_height = (rescaled_width <= kobo_size[0])
|
||||
|
||||
if use_height:
|
||||
kobo_size = (rescaled_width, kobo_size[1])
|
||||
else:
|
||||
kobo_size = (kobo_size[0], int(round(kobo_size[0] / aspect_ratio)))
|
||||
|
||||
# Did we actually want to letterbox?
|
||||
if not letterbox:
|
||||
canvas_size = kobo_size
|
||||
return (kobo_size, canvas_size)
|
||||
|
||||
def _create_cover_data(
|
||||
self, cover_data, resize_to, minify_to, kobo_size,
|
||||
upload_grayscale=False, dithered_covers=False, keep_cover_aspect=False, is_full_size=False, letterbox=False, png_covers=False, quality=90
|
||||
):
|
||||
'''
|
||||
This will generate the new cover image from the cover in the library. It is a wrapper
|
||||
for save_cover_data_to to allow it to be overriden in a subclass. For this reason,
|
||||
@@ -2607,23 +2628,32 @@ class KOBOTOUCH(KOBO):
|
||||
|
||||
:param cover_data: original cover data
|
||||
:param resize_to: Size to resize the cover to (width, height). None means do not resize.
|
||||
:param minify_to: Maximum canvas size for the resized cover (width, height).
|
||||
:param kobo_size: Size of the cover image on the device.
|
||||
:param upload_grayscale: boolean True if driver configured to send grayscale thumbnails
|
||||
Passed to allow ability to decide to quantize to 16-col grayscale
|
||||
at calibre end
|
||||
:param keep_cover_aspect: bookean - True if the aspect ratio of the cover in the library is to be kept.
|
||||
:param dithered_covers: boolean True if driver configured to quantize to 16-col grayscale
|
||||
:param keep_cover_aspect: boolean - True if the aspect ratio of the cover in the library is to be kept.
|
||||
:param is_full_size: True if this is the kobo_size is for the full size cover image
|
||||
Passed to allow ability to process screensaver differently
|
||||
to smaller thumbnails
|
||||
:param letterbox: True if we were asked to handle the letterboxing
|
||||
:param png_covers: True if we were asked to encode those images in PNG instead of JPG
|
||||
:param quality: 0-100 Output encoding quality (or compression level for PNG, àla IM)
|
||||
'''
|
||||
|
||||
from calibre.utils.img import save_cover_data_to
|
||||
data = save_cover_data_to(cover_data, grayscale=upload_grayscale, resize_to=resize_to)
|
||||
data = save_cover_data_to(
|
||||
cover_data, resize_to=resize_to, compression_quality=quality, minify_to=minify_to, grayscale=upload_grayscale, eink=dithered_covers,
|
||||
letterbox=letterbox, data_fmt="png" if png_covers else "jpeg")
|
||||
return data
|
||||
|
||||
def _upload_cover(self, path, filename, metadata, filepath, upload_grayscale, keep_cover_aspect=False):
|
||||
def _upload_cover(
|
||||
self, path, filename, metadata, filepath, upload_grayscale,
|
||||
dithered_covers=False, keep_cover_aspect=False, letterbox_fs_covers=False, png_covers=False
|
||||
):
|
||||
from calibre.utils.imghdr import identify
|
||||
debug_print("KoboTouch:_upload_cover - filename='%s' upload_grayscale='%s' "%(filename, upload_grayscale))
|
||||
from calibre.utils.img import optimize_png
|
||||
debug_print("KoboTouch:_upload_cover - filename='%s' upload_grayscale='%s' dithered_covers='%s' "%(filename, upload_grayscale, dithered_covers))
|
||||
|
||||
if not metadata.cover:
|
||||
return
|
||||
@@ -2666,7 +2696,7 @@ class KOBOTOUCH(KOBO):
|
||||
|
||||
image_dir = os.path.dirname(os.path.abspath(path))
|
||||
if not os.path.exists(image_dir):
|
||||
debug_print("KoboTouch:_upload_cover - Image directory does not exust. Creating path='%s'" % (image_dir))
|
||||
debug_print("KoboTouch:_upload_cover - Image directory does not exist. Creating path='%s'" % (image_dir))
|
||||
os.makedirs(image_dir)
|
||||
|
||||
with lopen(cover, 'rb') as f:
|
||||
@@ -2678,8 +2708,8 @@ class KOBOTOUCH(KOBO):
|
||||
for ending, cover_options in self.cover_file_endings().items():
|
||||
kobo_size, min_dbversion, max_dbversion, is_full_size = cover_options
|
||||
if show_debug:
|
||||
debug_print("KoboTouch:_upload_cover - library_cover_size=%s min_dbversion=%d max_dbversion=%d, is_full_size=%s" % (
|
||||
library_cover_size, min_dbversion, max_dbversion, is_full_size))
|
||||
debug_print("KoboTouch:_upload_cover - library_cover_size=%s -> kobo_size=%s, min_dbversion=%d max_dbversion=%d, is_full_size=%s" % (
|
||||
library_cover_size, kobo_size, min_dbversion, max_dbversion, is_full_size))
|
||||
|
||||
if self.dbversion >= min_dbversion and self.dbversion <= max_dbversion:
|
||||
if show_debug:
|
||||
@@ -2687,14 +2717,58 @@ class KOBOTOUCH(KOBO):
|
||||
fpath = path + ending
|
||||
fpath = self.normalize_path(fpath.replace('/', os.sep))
|
||||
|
||||
resize_to = self._calculate_kobo_cover_size(library_cover_size, kobo_size, keep_cover_aspect, is_full_size)
|
||||
# Never letterbox thumbnails, that's ugly. But for fullscreen covers, honor the setting.
|
||||
letterbox = letterbox_fs_covers and is_full_size
|
||||
|
||||
# Return the data resized and in Grayscale if required
|
||||
data = self._create_cover_data(cover_data, resize_to, kobo_size, upload_grayscale, keep_cover_aspect, is_full_size)
|
||||
# NOTE: Full size means we have to fit *inside* the
|
||||
# given boundaries. Thumbnails, on the other hand, are
|
||||
# *expanded* around those boundaries.
|
||||
# In Qt, it'd mean full-screen covers are resized
|
||||
# using Qt::KeepAspectRatio, while thumbnails are
|
||||
# resized using Qt::KeepAspectRatioByExpanding
|
||||
# (i.e., QSize's boundedTo() vs. expandedTo(). See also IM's '^' geometry token, for the same "expand" behavior.)
|
||||
# Note that Nickel itself will generate bounded thumbnails, while it will download expanded thumbnails for store-bought KePubs...
|
||||
# We chose to emulate the KePub behavior.
|
||||
resize_to, expand_to = self._calculate_kobo_cover_size(library_cover_size, kobo_size, not is_full_size, keep_cover_aspect, letterbox)
|
||||
if show_debug:
|
||||
debug_print(
|
||||
"KoboTouch:_calculate_kobo_cover_size - expand_to=%s"
|
||||
" (vs. kobo_size=%s) & resize_to=%s, keep_cover_aspect=%s & letterbox_fs_covers=%s, png_covers=%s" % (
|
||||
expand_to, kobo_size, resize_to, keep_cover_aspect, letterbox_fs_covers, png_covers))
|
||||
|
||||
with lopen(fpath, 'wb') as f:
|
||||
f.write(data)
|
||||
fsync(f)
|
||||
# NOTE: To speed things up, we enforce a lower
|
||||
# compression level for png_covers, as the final
|
||||
# optipng pass will then select a higher compression
|
||||
# level anyway,
|
||||
# so the compression level from that first pass
|
||||
# is irrelevant, and only takes up precious time
|
||||
# ;).
|
||||
quality = 10 if png_covers else 90
|
||||
|
||||
# Return the data resized and properly grayscaled/dithered/letterboxed if requested
|
||||
data = self._create_cover_data(
|
||||
cover_data, resize_to, expand_to, kobo_size, upload_grayscale,
|
||||
dithered_covers, keep_cover_aspect, is_full_size, letterbox, png_covers, quality)
|
||||
|
||||
# NOTE: If we're writing a PNG file, go through a quick
|
||||
# optipng pass to make sure it's encoded properly, as
|
||||
# Qt doesn't afford us enough control to do it right...
|
||||
# Unfortunately, optipng doesn't support reading
|
||||
# pipes, so this gets a bit clunky as we have go
|
||||
# through a temporary file...
|
||||
if png_covers:
|
||||
tmp_cover = better_mktemp()
|
||||
with lopen(tmp_cover, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
optimize_png(tmp_cover, level=1)
|
||||
# Crossing FS boundaries, can't rename, have to copy + delete :/
|
||||
shutil.copy2(tmp_cover, fpath)
|
||||
os.remove(tmp_cover)
|
||||
else:
|
||||
with lopen(fpath, 'wb') as f:
|
||||
f.write(data)
|
||||
fsync(f)
|
||||
except Exception as e:
|
||||
err = unicode_type(e)
|
||||
debug_print("KoboTouch:_upload_cover - Exception string: %s"%err)
|
||||
@@ -3172,8 +3246,11 @@ class KOBOTOUCH(KOBO):
|
||||
c.add_opt('ignore_collections_names', default='')
|
||||
|
||||
c.add_opt('upload_covers', default=False)
|
||||
c.add_opt('dithered_covers', default=False)
|
||||
c.add_opt('keep_cover_aspect', default=False)
|
||||
c.add_opt('upload_grayscale', default=False)
|
||||
c.add_opt('letterbox_fs_covers', default=False)
|
||||
c.add_opt('png_covers', default=False)
|
||||
|
||||
c.add_opt('show_archived_books', default=False)
|
||||
c.add_opt('show_previews', default=False)
|
||||
@@ -3245,13 +3322,13 @@ class KOBOTOUCH(KOBO):
|
||||
|
||||
def cover_file_endings(self):
|
||||
if self.isAura():
|
||||
_cover_file_endings = self.GLO_COVER_FILE_ENDINGS
|
||||
_cover_file_endings = self.AURA_COVER_FILE_ENDINGS
|
||||
elif self.isAuraEdition2():
|
||||
_cover_file_endings = self.GLO_COVER_FILE_ENDINGS
|
||||
elif self.isAuraHD():
|
||||
_cover_file_endings = self.AURA_HD_COVER_FILE_ENDINGS
|
||||
elif self.isAuraH2O():
|
||||
_cover_file_endings = self.AURA_HD_COVER_FILE_ENDINGS
|
||||
_cover_file_endings = self.AURA_H2O_COVER_FILE_ENDINGS
|
||||
elif self.isAuraH2OEdition2():
|
||||
_cover_file_endings = self.AURA_HD_COVER_FILE_ENDINGS
|
||||
elif self.isAuraOne():
|
||||
@@ -3265,15 +3342,18 @@ class KOBOTOUCH(KOBO):
|
||||
elif self.isGloHD():
|
||||
_cover_file_endings = self.GLO_HD_COVER_FILE_ENDINGS
|
||||
elif self.isMini():
|
||||
_cover_file_endings = self.COVER_FILE_ENDINGS
|
||||
_cover_file_endings = self.LEGACY_COVER_FILE_ENDINGS
|
||||
elif self.isTouch():
|
||||
_cover_file_endings = self.COVER_FILE_ENDINGS
|
||||
_cover_file_endings = self.LEGACY_COVER_FILE_ENDINGS
|
||||
elif self.isTouch2():
|
||||
_cover_file_endings = self.COVER_FILE_ENDINGS
|
||||
_cover_file_endings = self.LEGACY_COVER_FILE_ENDINGS
|
||||
else:
|
||||
_cover_file_endings = self.COVER_FILE_ENDINGS
|
||||
_cover_file_endings = self.LEGACY_COVER_FILE_ENDINGS
|
||||
|
||||
return _cover_file_endings
|
||||
# Don't forget to merge that on top of the common dictionary (c.f., https://stackoverflow.com/q/38987)
|
||||
_all_cover_file_endings = self.COMMON_COVER_FILE_ENDINGS.copy()
|
||||
_all_cover_file_endings.update(_cover_file_endings)
|
||||
return _all_cover_file_endings
|
||||
|
||||
def set_device_name(self):
|
||||
device_name = self.gui_name
|
||||
@@ -3357,6 +3437,18 @@ class KOBOTOUCH(KOBO):
|
||||
def upload_grayscale(self):
|
||||
return self.upload_covers and self.get_pref('upload_grayscale')
|
||||
|
||||
@property
|
||||
def dithered_covers(self):
|
||||
return self.upload_grayscale and self.get_pref('dithered_covers')
|
||||
|
||||
@property
|
||||
def letterbox_fs_covers(self):
|
||||
return self.keep_cover_aspect and self.get_pref('letterbox_fs_covers')
|
||||
|
||||
@property
|
||||
def png_covers(self):
|
||||
return self.upload_grayscale and self.get_pref('png_covers')
|
||||
|
||||
def modifying_epub(self):
|
||||
return self.modifying_css()
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
__copyright__ = '2015-2019, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
__docformat__ = 'restructuredtext en'
|
||||
|
||||
import textwrap
|
||||
|
||||
from PyQt5.Qt import (QWidget, QLabel, QGridLayout, QLineEdit, QVBoxLayout,
|
||||
from PyQt5.Qt import (Qt, QWidget, QLabel, QGridLayout, QLineEdit, QVBoxLayout,
|
||||
QDialog, QDialogButtonBox, QCheckBox, QPushButton)
|
||||
|
||||
from calibre.gui2.device_drivers.tabbed_device_config import TabbedDeviceConfig, DeviceConfigTab, DeviceOptionsGroupBox
|
||||
@@ -106,6 +106,9 @@ class KOBOTOUCHConfig(TabbedDeviceConfig):
|
||||
p['upload_covers'] = self.upload_covers
|
||||
p['keep_cover_aspect'] = self.keep_cover_aspect
|
||||
p['upload_grayscale'] = self.upload_grayscale
|
||||
p['dithered_covers'] = self.dithered_covers
|
||||
p['letterbox_fs_covers'] = self.letterbox_fs_covers
|
||||
p['png_covers'] = self.png_covers
|
||||
|
||||
p['show_recommendations'] = self.show_recommendations
|
||||
p['show_previews'] = self.show_previews
|
||||
@@ -305,18 +308,65 @@ class CoversGroupBox(DeviceOptionsGroupBox):
|
||||
|
||||
self.upload_grayscale_checkbox = create_checkbox(
|
||||
_('Upload black and white covers'),
|
||||
_('Convert covers to black and white when uploading'),
|
||||
_('Convert covers to grayscale when uploading.'),
|
||||
device.get_pref('upload_grayscale')
|
||||
)
|
||||
|
||||
self.dithered_covers_checkbox = create_checkbox(
|
||||
_('Upload dithered covers'),
|
||||
_('Dither cover images to the appropriate 16c grayscale palette for an eInk screen.'
|
||||
' This usually ensures greater accuracy and avoids banding, making sleep covers look better.'
|
||||
' On FW >= 4.11, Nickel itself may sometimes do a decent job of it.'
|
||||
' Has no effect without "Upload black and white covers"!'),
|
||||
device.get_pref('dithered_covers')
|
||||
)
|
||||
# Make it visually depend on B&W being enabled!
|
||||
# c.f., https://stackoverflow.com/q/36281103
|
||||
self.dithered_covers_checkbox.setEnabled(device.get_pref('upload_grayscale'))
|
||||
self.upload_grayscale_checkbox.toggled.connect(self.dithered_covers_checkbox.setEnabled)
|
||||
self.upload_grayscale_checkbox.toggled.connect(
|
||||
lambda checked: not checked and self.dithered_covers_checkbox.setChecked(False))
|
||||
|
||||
self.keep_cover_aspect_checkbox = create_checkbox(
|
||||
_('Keep cover aspect ratio'),
|
||||
_('When uploading covers, do not change the aspect ratio when resizing for the device.'
|
||||
' This is for firmware versions 2.3.1 and later.'),
|
||||
device.get_pref('keep_cover_aspect'))
|
||||
|
||||
self.options_layout.addWidget(self.keep_cover_aspect_checkbox, 0, 0, 1, 1)
|
||||
self.options_layout.addWidget(self.upload_grayscale_checkbox, 1, 0, 1, 1)
|
||||
self.letterbox_fs_covers_checkbox = create_checkbox(
|
||||
_('Letterbox full-screen covers'),
|
||||
_('Do it on our end, instead of letting Nickel handle it.'
|
||||
' Provides pixel-perfect results on devices where Nickel does not do extra processing.'
|
||||
' Obviously has no effect without "Keep cover aspect ratio".'
|
||||
' This is probably undesirable if you disable the "Show book covers full screen"'
|
||||
' setting on your device.'),
|
||||
device.get_pref('letterbox_fs_covers'))
|
||||
# Make it visually depend on AR being enabled!
|
||||
self.letterbox_fs_covers_checkbox.setEnabled(device.get_pref('keep_cover_aspect'))
|
||||
self.keep_cover_aspect_checkbox.toggled.connect(self.letterbox_fs_covers_checkbox.setEnabled)
|
||||
self.keep_cover_aspect_checkbox.toggled.connect(
|
||||
lambda checked: not checked and self.letterbox_fs_covers_checkbox.setChecked(False))
|
||||
|
||||
self.png_covers_checkbox = create_checkbox(
|
||||
_('Save covers as PNG'),
|
||||
_('Use the PNG image format instead of JPG.'
|
||||
' Higher quality, especially with "Upload dithered covers" enabled,'
|
||||
' which will also help generate potentially smaller files.'
|
||||
' Behavior completely unknown on old (< 3.x) Kobo firmwares,'
|
||||
' known to behave on FW >= 4.8.'
|
||||
' Has no effect without "Upload black and white covers"!'),
|
||||
device.get_pref('png_covers'))
|
||||
# Make it visually depend on B&W being enabled, to avoid storing ridiculously large color PNGs.
|
||||
self.png_covers_checkbox.setEnabled(device.get_pref('upload_grayscale'))
|
||||
self.upload_grayscale_checkbox.toggled.connect(self.png_covers_checkbox.setEnabled)
|
||||
self.upload_grayscale_checkbox.toggled.connect(
|
||||
lambda checked: not checked and self.png_covers_checkbox.setChecked(False))
|
||||
|
||||
self.options_layout.addWidget(self.upload_grayscale_checkbox, 0, 0, 1, 1)
|
||||
self.options_layout.addWidget(self.dithered_covers_checkbox, 0, 1, 1, 1)
|
||||
self.options_layout.addWidget(self.keep_cover_aspect_checkbox, 1, 0, 1, 1)
|
||||
self.options_layout.addWidget(self.letterbox_fs_covers_checkbox, 1, 1, 1, 1)
|
||||
self.options_layout.addWidget(self.png_covers_checkbox, 2, 0, 1, 2, Qt.AlignCenter)
|
||||
|
||||
@property
|
||||
def upload_covers(self):
|
||||
@@ -326,10 +376,22 @@ class CoversGroupBox(DeviceOptionsGroupBox):
|
||||
def upload_grayscale(self):
|
||||
return self.upload_grayscale_checkbox.isChecked()
|
||||
|
||||
@property
|
||||
def dithered_covers(self):
|
||||
return self.dithered_covers_checkbox.isChecked()
|
||||
|
||||
@property
|
||||
def keep_cover_aspect(self):
|
||||
return self.keep_cover_aspect_checkbox.isChecked()
|
||||
|
||||
@property
|
||||
def letterbox_fs_covers(self):
|
||||
return self.letterbox_fs_covers_checkbox.isChecked()
|
||||
|
||||
@property
|
||||
def png_covers(self):
|
||||
return self.png_covers_checkbox.isChecked()
|
||||
|
||||
|
||||
class DeviceListGroupBox(DeviceOptionsGroupBox):
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ QImage quantize(const QImage &image, unsigned int maximum_colors, bool dither, c
|
||||
bool has_transparent_pixels(const QImage &image);
|
||||
QImage set_opacity(const QImage &image, double alpha);
|
||||
QImage texture_image(const QImage &image, const QImage &texturei);
|
||||
QImage ordered_dither(const QImage &image);
|
||||
|
||||
class ScopedGILRelease {
|
||||
public:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
} catch (std::out_of_range &exc) { PyErr_SetString(PyExc_ValueError, exc.what()); return NULL; \
|
||||
} catch (std::bad_alloc &) { PyErr_NoMemory(); return NULL; \
|
||||
} catch (std::exception &exc) { PyErr_SetString(PyExc_Exception, exc.what()); return NULL; \
|
||||
} catch (...) { PyErr_SetString(PyExc_RuntimeError, "unknown error"); return NULL;}
|
||||
} catch (...) { PyErr_SetString(PyExc_RuntimeError, "unknown error"); return NULL;}
|
||||
%End
|
||||
|
||||
QImage* remove_borders(const QImage &image, double fuzz);
|
||||
@@ -100,3 +100,10 @@ QImage texture_image(const QImage &image, const QImage &texturei);
|
||||
sipRes = new QImage(texture_image(*a0, *a1));
|
||||
IMAGEOPS_SUFFIX
|
||||
%End
|
||||
|
||||
QImage ordered_dither(const QImage &image);
|
||||
%MethodCode
|
||||
IMAGEOPS_PREFIX
|
||||
sipRes = new QImage(ordered_dither(*a0));
|
||||
IMAGEOPS_SUFFIX
|
||||
%End
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* ordered_dither.cpp
|
||||
* Glue code based on quantize.cpp, Copyright (C) 2016 Kovid Goyal <kovid at kovidgoyal.net>
|
||||
* Actual ordered dithering routine (dither_o8x8) is Copyright 1999-2019 ImageMagick Studio LLC,
|
||||
*
|
||||
* Licensed under the ImageMagick License (the "License"); you may not use
|
||||
* this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at
|
||||
*
|
||||
* https://imagemagick.org/script/license.php
|
||||
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
#include "imageops.h"
|
||||
|
||||
// Just in case, as I don't want to deal with MSVC madness...
|
||||
#if defined _MSC_VER && _MSC_VER < 1700
|
||||
typedef unsigned __int8 uint8_t;
|
||||
#define UINT8_MAX _UI8_MAX
|
||||
typedef unsigned __int32 uint32_t;
|
||||
#else
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
|
||||
// Only needed for the (commented out) Indexed8 codepath
|
||||
//#include <QVector>
|
||||
|
||||
// NOTE: *May* not behave any better than a simple / 0xFF on modern x86_64 CPUs...
|
||||
// This was, however, tested on ARM, where it is noticeably faster.
|
||||
static uint32_t DIV255(uint32_t v) {
|
||||
v += 128;
|
||||
return (((v >> 8U) + v) >> 8U);
|
||||
}
|
||||
|
||||
// Quantize an 8-bit color value down to a palette of 16 evenly spaced colors, using an ordered 8x8 dithering pattern.
|
||||
// With a grayscale input, this happens to match the eInk palette perfectly ;).
|
||||
// If the input is not grayscale, and the output fb is not grayscale either,
|
||||
// this usually still happens to match the eInk palette after the EPDC's own quantization pass.
|
||||
// c.f., https://en.wikipedia.org/wiki/Ordered_dithering
|
||||
// & https://github.com/ImageMagick/ImageMagick/blob/ecfeac404e75f304004f0566557848c53030bad6/MagickCore/threshold.c#L1627
|
||||
// NOTE: As the references imply, this is straight from ImageMagick,
|
||||
// with only minor simplifications to enforce Q8 & avoid fp maths.
|
||||
static uint8_t
|
||||
dither_o8x8(int x, int y, uint8_t v)
|
||||
{
|
||||
// c.f., https://github.com/ImageMagick/ImageMagick/blob/ecfeac404e75f304004f0566557848c53030bad6/config/thresholds.xml#L107
|
||||
static const uint8_t threshold_map_o8x8[] = { 1, 49, 13, 61, 4, 52, 16, 64, 33, 17, 45, 29, 36, 20, 48, 32,
|
||||
9, 57, 5, 53, 12, 60, 8, 56, 41, 25, 37, 21, 44, 28, 40, 24,
|
||||
3, 51, 15, 63, 2, 50, 14, 62, 35, 19, 47, 31, 34, 18, 46, 30,
|
||||
11, 59, 7, 55, 10, 58, 6, 54, 43, 27, 39, 23, 42, 26, 38, 22 };
|
||||
|
||||
// Constants:
|
||||
// Quantum = 8; Levels = 16; map Divisor = 65
|
||||
// QuantumRange = 0xFF
|
||||
// QuantumScale = 1.0 / QuantumRange
|
||||
//
|
||||
// threshold = QuantumScale * v * ((L-1) * (D-1) + 1)
|
||||
// NOTE: The initial computation of t (specifically, what we pass to DIV255) would overflow an uint8_t.
|
||||
// With a Q8 input value, we're at no risk of ever underflowing, so, keep to unsigned maths.
|
||||
// Technically, an uint16_t would be wide enough, but it gains us nothing,
|
||||
// and requires a few explicit casts to make GCC happy ;).
|
||||
uint32_t t = DIV255(v * ((15U << 6) + 1U));
|
||||
// level = t / (D-1);
|
||||
uint32_t l = (t >> 6);
|
||||
// t -= l * (D-1);
|
||||
t = (t - (l << 6));
|
||||
|
||||
// map width & height = 8
|
||||
// c = ClampToQuantum((l+(t >= map[(x % mw) + mw * (y % mh)])) * QuantumRange / (L-1));
|
||||
uint32_t q = ((l + (t >= threshold_map_o8x8[(x & 7U) + 8U * (y & 7U)])) * 17);
|
||||
// NOTE: We're doing unsigned maths, so, clamping is basically MIN(q, UINT8_MAX) ;).
|
||||
// The only overflow we should ever catch should be for a few white (v = 0xFF) input pixels
|
||||
// that get shifted to the next step (i.e., q = 272 (0xFF + 17)).
|
||||
return (q > UINT8_MAX ? UINT8_MAX : static_cast<uint8_t>(q));
|
||||
}
|
||||
|
||||
QImage ordered_dither(const QImage &image) { // {{{
|
||||
ScopedGILRelease PyGILRelease;
|
||||
QImage img = image;
|
||||
int y = 0, x = 0, width = img.width(), height = img.height();
|
||||
uint8_t gray = 0, dithered = 0;
|
||||
// NOTE: We went with Grayscale8 because QImageWriter was doing some weird things with an Indexed8 input...
|
||||
QImage dst(width, height, QImage::Format_Grayscale8);
|
||||
|
||||
/*
|
||||
QImage dst(width, height, QImage::Format_Indexed8);
|
||||
|
||||
// Set up the eInk palette
|
||||
// FIXME: Make it const and switch to C++11 list init if MSVC is amenable...
|
||||
QVector<uint8_t> palette(16);
|
||||
QVector<QRgb> color_table(16);
|
||||
int i = 0;
|
||||
for (i = 0; i < 16; i++) {
|
||||
uint8_t color = i * 17;
|
||||
palette << color;
|
||||
color_table << qRgb(color, color, color);
|
||||
}
|
||||
dst.setColorTable(color_table);
|
||||
*/
|
||||
|
||||
// We're running behind blend_image, so, we should only ever be fed RGB32 as input...
|
||||
if (img.format() != QImage::Format_RGB32) {
|
||||
img = img.convertToFormat(QImage::Format_RGB32);
|
||||
if (img.isNull()) throw std::bad_alloc();
|
||||
}
|
||||
|
||||
const bool is_gray = img.isGrayscale();
|
||||
|
||||
for (y = 0; y < height; y++) {
|
||||
const QRgb *src_row = reinterpret_cast<const QRgb*>(img.constScanLine(y));
|
||||
uint8_t *dst_row = dst.scanLine(y);
|
||||
for (x = 0; x < width; x++) {
|
||||
const QRgb pixel = *(src_row + x);
|
||||
if (is_gray) {
|
||||
// Grayscale and RGB32, so R = G = B
|
||||
gray = qRed(pixel);
|
||||
} else {
|
||||
gray = qGray(pixel);
|
||||
}
|
||||
dithered = dither_o8x8(x, y, gray);
|
||||
*(dst_row + x) = dithered; // ... or palette.indexOf(dithered); for Indexed8
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
} // }}}
|
||||
+55
-12
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python2
|
||||
# vim:fileencoding=utf-8
|
||||
# License: GPLv3 Copyright: 2015, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
# License: GPLv3 Copyright: 2015-2019, Kovid Goyal <kovid at kovidgoyal.net>
|
||||
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
@@ -175,7 +175,16 @@ def save_image(img, path, **kw):
|
||||
f.write(image_to_data(image_from_data(img), **kw))
|
||||
|
||||
|
||||
def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compression_quality=90, minify_to=None, grayscale=False, data_fmt='jpeg'):
|
||||
def save_cover_data_to(
|
||||
data, path=None,
|
||||
bgcolor='#ffffff',
|
||||
resize_to=None,
|
||||
compression_quality=90,
|
||||
minify_to=None,
|
||||
grayscale=False,
|
||||
eink=False, letterbox=False,
|
||||
data_fmt='jpeg'
|
||||
):
|
||||
'''
|
||||
Saves image in data to path, in the format specified by the path
|
||||
extension. Removes any transparency. If there is no transparency and no
|
||||
@@ -187,12 +196,20 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr
|
||||
:param data_fmt: The fmt to return data in when path is None. Defaults to JPEG
|
||||
:param compression_quality: The quality of the image after compression.
|
||||
Number between 1 and 100. 1 means highest compression, 100 means no
|
||||
compression (lossless).
|
||||
compression (lossless). When generating PNG this number is divided by 10
|
||||
for the png_compression_level.
|
||||
:param bgcolor: The color for transparent pixels. Must be specified in hex.
|
||||
:param resize_to: A tuple (width, height) or None for no resizing
|
||||
:param minify_to: A tuple (width, height) to specify maximum target size.
|
||||
The image will be resized to fit into this target size. If None the
|
||||
value from the tweak is used.
|
||||
:param grayscale: If True, the image is converted to grayscale,
|
||||
if that's not already the case.
|
||||
:param eink: If True, the image is dithered down to the 16 specific shades
|
||||
of gray of the eInk palette.
|
||||
Works best with formats that actually support color indexing (i.e., PNG)
|
||||
:param letterbox: If True, in addition to fit resize_to inside minify_to,
|
||||
the image will be letterboxed (i.e., centered on a black background).
|
||||
'''
|
||||
fmt = normalize_format_name(data_fmt if path is None else os.path.splitext(path)[1][1:])
|
||||
if isinstance(data, QImage):
|
||||
@@ -207,21 +224,32 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr
|
||||
img = img.scaled(resize_to[0], resize_to[1], Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
||||
owidth, oheight = img.width(), img.height()
|
||||
nwidth, nheight = tweaks['maximum_cover_size'] if minify_to is None else minify_to
|
||||
scaled, nwidth, nheight = fit_image(owidth, oheight, nwidth, nheight)
|
||||
if scaled:
|
||||
changed = True
|
||||
img = img.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
||||
if letterbox:
|
||||
img = blend_on_canvas(img, nwidth, nheight, bgcolor='#000000')
|
||||
# Check if we were minified
|
||||
if oheight != nheight or owidth != nwidth:
|
||||
changed = True
|
||||
else:
|
||||
scaled, nwidth, nheight = fit_image(owidth, oheight, nwidth, nheight)
|
||||
if scaled:
|
||||
changed = True
|
||||
img = img.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
|
||||
if img.hasAlphaChannel():
|
||||
changed = True
|
||||
img = blend_image(img, bgcolor)
|
||||
if grayscale:
|
||||
if grayscale and not eink:
|
||||
if not img.allGray():
|
||||
changed = True
|
||||
img = grayscale_image(img)
|
||||
if eink:
|
||||
# NOTE: Keep in mind that JPG does NOT actually support indexed colors, so the JPG algorithm will then smush everything back into a 256c mess...
|
||||
# Thankfully, Nickel handles PNG just fine, and we potentially generate smaller files to boot, because they can be properly color indexed ;).
|
||||
img = eink_dither_image(img)
|
||||
changed = True
|
||||
if path is None:
|
||||
return image_to_data(img, compression_quality, fmt) if changed else data
|
||||
return image_to_data(img, compression_quality, fmt, compression_quality // 10) if changed else data
|
||||
with lopen(path, 'wb') as f:
|
||||
f.write(image_to_data(img, compression_quality, fmt) if changed else data)
|
||||
f.write(image_to_data(img, compression_quality, fmt, compression_quality // 10) if changed else data)
|
||||
# }}}
|
||||
|
||||
# Overlaying images {{{
|
||||
@@ -442,6 +470,20 @@ def quantize_image(img, max_colors=256, dither=True, palette=''):
|
||||
palette = palette.split()
|
||||
return imageops.quantize(img, max_colors, dither, [QColor(x).rgb() for x in palette])
|
||||
|
||||
|
||||
def eink_dither_image(img):
|
||||
''' Dither the source image down to the eInk palette of 16 shades of grey,
|
||||
using ImageMagick's OrderedDither algorithm.
|
||||
|
||||
NOTE: No need to call grayscale_image first, as this will inline a grayscaling pass if need be.
|
||||
|
||||
Returns a QImage in Grayscale8 pixel format.
|
||||
'''
|
||||
img = image_from_data(img)
|
||||
if img.hasAlphaChannel():
|
||||
img = blend_image(img)
|
||||
return imageops.ordered_dither(img)
|
||||
|
||||
# }}}
|
||||
|
||||
# Optimization of images {{{
|
||||
@@ -524,9 +566,10 @@ def optimize_jpeg(file_path):
|
||||
return run_optimizer(file_path, cmd)
|
||||
|
||||
|
||||
def optimize_png(file_path):
|
||||
def optimize_png(file_path, level=7):
|
||||
' level goes from 1 to 7 with 7 being maximum compression '
|
||||
exe = get_exe_path('optipng')
|
||||
cmd = [exe] + '-fix -clobber -strip all -o7 -out'.split() + [False, True]
|
||||
cmd = [exe] + '-fix -clobber -strip all -o{} -out'.format(level).split() + [False, True]
|
||||
return run_optimizer(file_path, cmd)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user