From 832ab64c6377a932b7a66cefc3fdf71d024d6f86 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 21 Dec 2018 22:24:39 +0100 Subject: [PATCH 01/30] Greater control over Kobo covers processing. * Optional dithering down to the exact eInk color palette. Note that, on some devices, FW >= 4.11 will do a much better job of it than us. That's true on the H2O, for instance, but not on the Forma, where it only does so on thumbnails, not on the sleep cover... When it does, grayscaling is done in sRGB space, and not linear light, like we do, but that's a much more minor issue, and one where no-one is actually wrong ;). Depends on B&W covers to avoid nonsensical results. * Optional letterboxing of full-screen covers to avoid extra Nickel processing. Depends on Keep AR to avoid nonsensical results. * Optional storage as PNG to avoid JPG wrecking the dithering Depends on B&W covers to avoid storing stupidly large color PNGs. * Fix rounding errors when calculating thumbnail sizes: AR should be a float, and we want to honor the *height* expected by Nickel when there's potential for rounding mistakes (which is pretty much always for thumbnails, given that the perfect AR should be 0.75). Meaning we'll want to round properly before truncating ;). * Fix thumbnail sizes on the Forma. Apparently, quite a few bits of the FW behave as if the screen was Aura One sized... Try to do it right on our end instead of following suit ;). Unfortunately, full-screen cover processing is slightly broken on nickel's side right now: it appears to be treating them as Aura One sized, which incurs an ugly and unavoidable scaling pass, one way or the other... c.f., http://www.mobileread.com/forums/showpost.php?p=3025725&postcount=225 and the few pages around it. --- src/calibre/devices/kobo/driver.py | 177 ++++++++++++++----- src/calibre/devices/kobo/kobotouch_config.py | 68 ++++++- src/calibre/utils/img.py | 36 +++- 3 files changed, 231 insertions(+), 50 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index bd5458ac62..57797e7101 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -3,11 +3,11 @@ from __future__ import absolute_import, division, print_function, unicode_literals __license__ = 'GPL v3' -__copyright__ = '2010-2012, Timothy Legge , Kovid Goyal and David Forrester ' +__copyright__ = '2010-2018, Timothy Legge , Kovid Goyal and David Forrester ' __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 . Extended to support Touch firmware 2.0.0 and later and newer devices by David Forrester @@ -154,10 +154,13 @@ class KOBO(USBMS): OPT_COLLECTIONS = 0 OPT_UPLOAD_COVERS = 1 OPT_UPLOAD_GRAYSCALE_COVERS = 2 - OPT_SHOW_EXPIRED_BOOK_RECORDS = 3 - OPT_SHOW_PREVIEWS = 4 - OPT_SHOW_RECOMMENDATIONS = 5 - OPT_SUPPORT_NEWER_FIRMWARE = 6 + OPT_DITHERED_COVERS = 3 + OPT_LETTERBOX_FULLSCREEN_COVERS = 4 + OPT_PNG_COVERS = 5 + OPT_SHOW_EXPIRED_BOOK_RECORDS = 6 + OPT_SHOW_PREVIEWS = 7 + OPT_SHOW_RECOMMENDATIONS = 8 + OPT_SUPPORT_NEWER_FIRMWARE = 9 def __init__(self, *args, **kwargs): USBMS.__init__(self, *args, **kwargs) @@ -998,13 +1001,28 @@ class KOBO(USBMS): else: uploadgrayscale = True + if not opts.extra_customization[self.OPT_DITHERED_COVERS]: + ditheredcovers = False + else: + ditheredcovers = True + + if not opts.extra_customization[self.OPT_LETTERBOX_FULLSCREEN_COVERS]: + letterboxcovers = False + else: + letterboxcovers = True + + if not opts.extra_customization[self.OPT_PNG_COVERS]: + pngcovers = False + else: + pngcovers = True + debug_print('KOBO: uploading cover') try: - self._upload_cover(path, filename, metadata, filepath, uploadgrayscale) + self._upload_cover(path, filename, metadata, filepath, uploadgrayscale, ditheredcovers, letterboxcovers, pngcovers) except: debug_print('FAILED to upload cover', filepath) - def _upload_cover(self, path, filename, metadata, filepath, uploadgrayscale): + def _upload_cover(self, path, filename, metadata, filepath, uploadgrayscale, ditheredcovers, letterboxcovers, pngcovers): from calibre.utils.img import save_cover_data_to if metadata.cover: cover = self.normalize_path(metadata.cover.replace('/', os.sep)) @@ -1051,9 +1069,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/dithered/letterboxed if # required - data = save_cover_data_to(data, grayscale=uploadgrayscale, resize_to=resize) + data = save_cover_data_to(data, grayscale=uploadgrayscale, eink=ditheredcovers, resize_to=resize, minify_to=resize, letterbox=letterboxcovers, data_fmt="png" if pngcovers else "jpeg") with lopen(fpath, 'wb') as f: f.write(data) @@ -1098,10 +1116,13 @@ class KOBO(USBMS): OPT_COLLECTIONS = 0 OPT_UPLOAD_COVERS = 1 OPT_UPLOAD_GRAYSCALE_COVERS = 2 - OPT_SHOW_EXPIRED_BOOK_RECORDS = 3 - OPT_SHOW_PREVIEWS = 4 - OPT_SHOW_RECOMMENDATIONS = 5 - OPT_SUPPORT_NEWER_FIRMWARE = 6 + OPT_DITHERED_COVERS = 3 + OPT_LETTERBOX_FULLSCREEN_COVERS = 4 + OPT_PNG_COVERS = 5 + OPT_SHOW_EXPIRED_BOOK_RECORDS = 6 + OPT_SHOW_PREVIEWS = 7 + OPT_SHOW_RECOMMENDATIONS = 8 + OPT_SUPPORT_NEWER_FIRMWARE = 9 p = {} p['format_map'] = old_settings.format_map @@ -1115,6 +1136,9 @@ class KOBO(USBMS): p['upload_covers'] = old_settings.extra_customization[OPT_UPLOAD_COVERS] p['upload_grayscale'] = old_settings.extra_customization[OPT_UPLOAD_GRAYSCALE_COVERS] + p['dithered_covers'] = old_settings.extra_customization[OPT_DITHERED_COVERS] + p['letterbox_fs_covers'] = old_settings.extra_customization[OPT_LETTERBOX_FULLSCREEN_COVERS] + p['png_covers'] = old_settings.extra_customization[OPT_PNG_COVERS] p['show_expired_books'] = old_settings.extra_customization[OPT_SHOW_EXPIRED_BOOK_RECORDS] p['show_previews'] = old_settings.extra_customization[OPT_SHOW_PREVIEWS] @@ -1339,12 +1363,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') @@ -1459,6 +1483,20 @@ class KOBOTOUCH(KOBO): # 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: Top 11px are dead. Confirmed w/ fbgrab. + ' - N3_FULL.parsed': [(1080,1429), 0, 200,True,], + # Used for Details screen before FW2.8.1, then for current book tile on home screen + # NOTE: Should probably be 354x472 or 357x476 to keep honoring the 0.75 AR, + # but that's not what Nickel does... + ' - N3_LIBRARY_FULL.parsed':[(355, 473), 0, 200,False,], + # Used for library lists + # NOTE: Again, 147x196 or 150x200 would match the 0.75 AR perfectly... + ' - 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_ONE_COVER_FILE_ENDINGS = { # Used for screensaver, home screen ' - N3_FULL.parsed': [(1404,1872), 0, 200,True,], @@ -1469,11 +1507,24 @@ class KOBOTOUCH(KOBO): } FORMA_COVER_FILE_ENDINGS = { # Used for screensaver, home screen + # NOTE: Nickel keeps generating smaller images (1404x1872) for sideloaded content, + # and will *also* download Aura One sized images for kePubs, which is stupid. + # What's worse is that it expects that size during the full pipeline, + # which means sleep covers get mangled by a terrible upscaling pass... + # Hopefully that's just a teething quirk that'll be fixed in a later FW. ' - 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,], + # NOTE: Same thing, Nickel generates tiny thumbnails (355x473), + # but *will* download slightly larger ones for kePubs. + # That's still probably A1 sized, I'd expect roughly x636 instead... + # The actual widget itself has space for a 316x421 image... + ' - N3_LIBRARY_FULL.parsed':[(398, 530), 0, 200,False,], # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,], # Used for library lists + # NOTE: Same thing, Nickel generates tiny thumbnails (149x198), + # but downloads larger ones for kePubs. + # Again, probably still A1 sized, I'd expect roughly x266 instead... + # The actual widget itself has space for a 155x207 image... + ' - N3_LIBRARY_GRID.parsed':[(167, 223), 0, 200,False,], # Used for library lists } # Following are the sizes used with pre2.1.4 firmware # COVER_FILE_ENDINGS = { @@ -1506,7 +1557,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 @@ -2558,7 +2609,7 @@ 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))) @@ -2587,17 +2638,26 @@ 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, 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): + if keep_cover_aspect: + # NOTE: Py3k wouldn't need explicit casts to return a float + # NOTE: Ideally, the target AR should be 0.75, but that's rarely exactly the case for thumbnails, + # which is why we try to limit accumulating even more rounding errors on top of Nickel's. + library_aspect = library_size[0] / float(library_size[1]) + kobo_aspect = kobo_size[0] / float(kobo_size[1]) + if library_aspect > kobo_aspect: + kobo_size = (kobo_size[0], int(round(kobo_size[0] / library_aspect))) + else: + kobo_size = (int(round(library_aspect * kobo_size[1])), kobo_size[1]) + # 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): ''' 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, @@ -2605,23 +2665,26 @@ 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 + :param dithered_covers: boolean True if driver configured 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 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 at calibre end + :param png_covers: True if we were asked to encode those images in PNG instead of JPG ''' 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, 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)) + debug_print("KoboTouch:_upload_cover - filename='%s' upload_grayscale='%s' dithered_covers='%s' "%(filename, upload_grayscale, dithered_covers)) if not metadata.cover: return @@ -2664,7 +2727,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: @@ -2676,8 +2739,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: @@ -2685,10 +2748,16 @@ 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 if is_full_size else False - # 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) + resize_to, minify_to = self._calculate_kobo_cover_size(library_cover_size, kobo_size, keep_cover_aspect, letterbox) + if show_debug: + debug_print("KoboTouch:_calculate_kobo_cover_size - minify_to=%s (vs. kobo_size=%s) & resize_to=%s, keep_cover_aspect=%s & letterbox_fs_covers=%s, png_covers=%s" % ( + minify_to, kobo_size, resize_to, keep_cover_aspect, letterbox_fs_covers, png_covers)) + + # Return the data resized and properly grayscaled/dithered/letterboxed if requested + data = self._create_cover_data(cover_data, resize_to, minify_to, kobo_size, upload_grayscale, dithered_covers, keep_cover_aspect, is_full_size, letterbox, png_covers) with lopen(fpath, 'wb') as f: f.write(data) @@ -3170,8 +3239,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) @@ -3249,7 +3321,7 @@ class KOBOTOUCH(KOBO): 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(): @@ -3355,6 +3427,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() @@ -3501,8 +3585,14 @@ class KOBOTOUCH(KOBO): count_options += 1 OPT_UPLOAD_GRAYSCALE_COVERS = count_options count_options += 1 + OPT_DITHERED_COVERS = count_options + count_options += 1 OPT_KEEP_COVER_ASPECT_RATIO = count_options count_options += 1 + OPT_LETTERBOX_FULLSCREEN_COVERS = count_options + count_options += 1 + OPT_PNG_COVERS = count_options + count_options += 1 OPT_SHOW_ARCHIVED_BOOK_RECORDS = count_options count_options += 1 OPT_SHOW_PREVIEWS = count_options @@ -3533,6 +3623,9 @@ class KOBOTOUCH(KOBO): settings.upload_covers = settings.extra_customization[OPT_UPLOAD_COVERS] settings.keep_cover_aspect = settings.extra_customization[OPT_KEEP_COVER_ASPECT_RATIO] settings.upload_grayscale = settings.extra_customization[OPT_UPLOAD_GRAYSCALE_COVERS] + settings.dithered_covers = settings.extra_customization[OPT_DITHERED_COVERS] + settings.letterbox_fs_covers = settings.extra_customization[OPT_LETTERBOX_FULLSCREEN_COVERS] + settings.png_covers = settings.extra_customization[OPT_PNG_COVERS] settings.show_archived_books = settings.extra_customization[OPT_SHOW_ARCHIVED_BOOK_RECORDS] settings.show_previews = settings.extra_customization[OPT_SHOW_PREVIEWS] diff --git a/src/calibre/devices/kobo/kobotouch_config.py b/src/calibre/devices/kobo/kobotouch_config.py index 8a2d0629cd..6f6c8a8e51 100644 --- a/src/calibre/devices/kobo/kobotouch_config.py +++ b/src/calibre/devices/kobo/kobotouch_config.py @@ -3,7 +3,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals __license__ = 'GPL v3' -__copyright__ = '2015, Kovid Goyal ' +__copyright__ = '2015-2018, Kovid Goyal ' __docformat__ = 'restructuredtext en' import textwrap @@ -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,67 @@ 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.' + ' Note that in some cases, you might want to leave this disabled,' + ' as Nickel will do a better job than Calibre, especially on newer FW versions (>= 4.11)!' + ' Unfortunately, this desirable behavior appears to depend on the exact device and FW version combo...' + ' 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.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 also 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" Kobo firmwares,' + ' last tested on FW 4.9 to 4.12.' + ' 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.keep_cover_aspect_checkbox, 0, 0, 1, 1) self.options_layout.addWidget(self.upload_grayscale_checkbox, 1, 0, 1, 1) + self.options_layout.addWidget(self.dithered_covers_checkbox, 2, 0, 1, 1) + self.options_layout.addWidget(self.letterbox_fs_covers_checkbox, 3, 0, 1, 1) + self.options_layout.addWidget(self.png_covers_checkbox, 4, 0, 1, 1) @property def upload_covers(self): @@ -326,10 +378,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): diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index e2e32f4c9c..f60d24ddb0 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -1,6 +1,6 @@ #!/usr/bin/env python2 # vim:fileencoding=utf-8 -# License: GPLv3 Copyright: 2015, Kovid Goyal +# License: GPLv3 Copyright: 2015-2018, Kovid Goyal from __future__ import absolute_import, division, print_function, unicode_literals @@ -175,7 +175,7 @@ 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 @@ -193,6 +193,13 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr :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,10 +214,16 @@ 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) @@ -218,6 +231,17 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr if not img.allGray(): changed = True img = grayscale_image(img) + if eink: + eink_cmap = ['#000000', '#111111', '#222222', '#333333', '#444444', '#555555', '#666666', '#777777', + '#888888', '#999999', '#AAAAAA', '#BBBBBB', '#CCCCCC', '#DDDDDD', '#EEEEEE', '#FFFFFF'] + # 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 generate smaller files to boot, because they're properly color indexed ;). + img = quantize_image(img, max_colors=16, dither=True, palette=eink_cmap) + ''' + # NOTE: Neither Grayscale8 nor Indexed8 actually do any kind of dithering?... :/. + img = img.convertToFormat(QImage.Format_Grayscale8, [QColor(x).rgb() for x in eink_cmap], Qt.AutoColor | Qt.DiffuseDither | Qt.ThresholdAlphaDither | Qt.PreferDither) + ''' + changed = True if path is None: return image_to_data(img, compression_quality, fmt) if changed else data with lopen(path, 'wb') as f: From 1f0bc97a150c6865c0dcd3a592d4bb0473223e3d Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Thu, 23 May 2019 23:14:49 +0200 Subject: [PATCH 02/30] Update thumbnail dimensions, based on @geek1011's findings c.f., https://github.com/shermp/Kobo-UNCaGED/issues/16#issuecomment-494229994 --- src/calibre/devices/kobo/driver.py | 86 ++++++++++++++++-------------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 57797e7101..c4c7d8f33c 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -1439,98 +1439,104 @@ 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. + # 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. COVER_FILE_ENDINGS = { # Used for screensaver, home screen - ' - N3_FULL.parsed':[(600,800),0, 200,True,], # Used for screensaver, home screen + ' - N3_FULL.parsed':[(600,800),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,], + ' - 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,], # 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. + # 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,], + ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 200,False,], # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,201),0, 200,False,], + ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355,479), 88, 100,False,], + ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 88, 100,False,], + } + # 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,], + # Used for Details screen before FW2.8.1, then for current book tile on home screen + ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 200,False,], + # Used for library lists + ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], + # Used for Details screen from FW2.8.1 + ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 88, 100,False,], } # 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,], + ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 200,False,], # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149, 201), 0, 200,False,], + ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355, 471), 88, 100,False,], + ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 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,], + ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 200,False,], # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,], + ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355, 471), 88, 100,False,], + ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 88, 100,False,], } AURA_H2O_COVER_FILE_ENDINGS = { # Used for screensaver, home screen - # NOTE: Top 11px are dead. Confirmed w/ fbgrab. + # 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,], # Used for Details screen before FW2.8.1, then for current book tile on home screen - # NOTE: Should probably be 354x472 or 357x476 to keep honoring the 0.75 AR, - # but that's not what Nickel does... - ' - N3_LIBRARY_FULL.parsed':[(355, 473), 0, 200,False,], + ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 200,False,], # Used for library lists - # NOTE: Again, 147x196 or 150x200 would match the 0.75 AR perfectly... - ' - N3_LIBRARY_GRID.parsed':[(149, 198), 0, 200,False,], + ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355, 471), 88, 100,False,], + ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 88, 100,False,], } 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,], + ' - 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,], } FORMA_COVER_FILE_ENDINGS = { # Used for screensaver, home screen - # NOTE: Nickel keeps generating smaller images (1404x1872) for sideloaded content, - # and will *also* download Aura One sized images for kePubs, which is stupid. - # What's worse is that it expects that size during the full pipeline, - # which means sleep covers get mangled by a terrible upscaling pass... - # Hopefully that's just a teething quirk that'll be fixed in a later FW. + # 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 - # NOTE: Same thing, Nickel generates tiny thumbnails (355x473), - # but *will* download slightly larger ones for kePubs. - # That's still probably A1 sized, I'd expect roughly x636 instead... - # The actual widget itself has space for a 316x421 image... - ' - N3_LIBRARY_FULL.parsed':[(398, 530), 0, 200,False,], + ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 200,False,], # Used for library lists - # NOTE: Same thing, Nickel generates tiny thumbnails (149x198), - # but downloads larger ones for kePubs. - # Again, probably still A1 sized, I'd expect roughly x266 instead... - # The actual widget itself has space for a 155x207 image... - ' - N3_LIBRARY_GRID.parsed':[(167, 223), 0, 200,False,], # Used for library lists + ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], } # 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. @@ -3315,7 +3321,7 @@ 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(): From cf1b855ea021fbb3b99793aadfeee6a615ba53e4 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Thu, 23 May 2019 23:51:25 +0200 Subject: [PATCH 03/30] Rejig thumbnail dimension calculations Instead of bounding the the requested values, we *expand* around those, like downloaded thumbnails for store-bought KePubs. Also handle wonky landscape source ARs properly while we're there. --- src/calibre/devices/kobo/driver.py | 36 ++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index c4c7d8f33c..56a0b1c395 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -2644,20 +2644,27 @@ class KOBOTOUCH(KOBO): path = os.path.join(path, imageId) return path - def _calculate_kobo_cover_size(self, library_size, kobo_size, keep_cover_aspect, letterbox): + def _calculate_kobo_cover_size(self, library_size, kobo_size, expand, keep_cover_aspect, letterbox): # Remember the canvas size canvas_size = kobo_size + # NOTE: Loosely based on Qt's QSize::scaled implementation if keep_cover_aspect: # NOTE: Py3k wouldn't need explicit casts to return a float - # NOTE: Ideally, the target AR should be 0.75, but that's rarely exactly the case for thumbnails, - # which is why we try to limit accumulating even more rounding errors on top of Nickel's. - library_aspect = library_size[0] / float(library_size[1]) - kobo_aspect = kobo_size[0] / float(kobo_size[1]) - if library_aspect > kobo_aspect: - kobo_size = (kobo_size[0], int(round(kobo_size[0] / library_aspect))) + # NOTE: Unlike Qt, we round to avoid accumulating errors + aspect_ratio = library_size[0] / float(library_size[1]) + rescaled_width = int(round(kobo_size[1] * aspect_ratio)) + + if expand: + use_height = (rescaled_width >= kobo_size[0]) else: - kobo_size = (int(round(library_aspect * kobo_size[1])), kobo_size[1]) + 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 @@ -2757,13 +2764,18 @@ class KOBOTOUCH(KOBO): # Never letterbox thumbnails, that's ugly. But for fullscreen covers, honor the setting. letterbox = letterbox_fs_covers if is_full_size else False - resize_to, minify_to = self._calculate_kobo_cover_size(library_cover_size, kobo_size, keep_cover_aspect, letterbox) + # 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, expand=not is_full_size, keep_cover_aspect, letterbox) if show_debug: - debug_print("KoboTouch:_calculate_kobo_cover_size - minify_to=%s (vs. kobo_size=%s) & resize_to=%s, keep_cover_aspect=%s & letterbox_fs_covers=%s, png_covers=%s" % ( - minify_to, kobo_size, resize_to, keep_cover_aspect, letterbox_fs_covers, png_covers)) + 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)) # Return the data resized and properly grayscaled/dithered/letterboxed if requested - data = self._create_cover_data(cover_data, resize_to, minify_to, kobo_size, upload_grayscale, dithered_covers, keep_cover_aspect, is_full_size, letterbox, png_covers) + 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) with lopen(fpath, 'wb') as f: f.write(data) From 7e6347486bad1f3b10659ebd7af188cdc2929ae3 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 00:54:36 +0200 Subject: [PATCH 04/30] Port ImageMagick's OrderedDither algorithm, and use that instead of ImageOps' quantize to dither images to the eInk palette. It works much better for our intended purpose, and it's pretty fast. --- COPYRIGHT | 110 ++++++++++++++++++ setup/extensions.json | 2 +- src/calibre/devices/kobo/driver.py | 5 +- src/calibre/utils/imageops/imageops.h | 1 + src/calibre/utils/imageops/imageops.sip | 9 +- src/calibre/utils/imageops/ordered_dither.cpp | 93 +++++++++++++++ src/calibre/utils/img.py | 19 +-- 7 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 src/calibre/utils/imageops/ordered_dither.cpp diff --git a/COPYRIGHT b/COPYRIGHT index 3ac5369ff7..b790d3190a 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -399,6 +399,9 @@ Files: src/calibre/translations/msgfmt.py Copyright: Martin v. Loewis License: Python Software Foundation License +Files: src/calibre/utils/imageops/ordered_dither.cpp +Copyright: Copyright 1999-2019 ImageMagick Studio LLC +License: ImageMagick License BSD License (for all the BSD licensed code indicated above) ----------------------------------------------------------- @@ -581,3 +584,110 @@ whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. + +ImageMagick License (for all the ImageMagick licensed code indicated above) +----------------------------------------------------------- + +Before we get to the text of the license, lets just review what the license says in simple terms: + +It allows you to: + + * freely download and use ImageMagick software, in whole or in part, for personal, company internal, or commercial purposes; + * use ImageMagick software in packages or distributions that you create; + * link against a library under a different license; + * link code under a different license against a library under this license; + * merge code into a work under a different license; + * extend patent grants to any code using code under this license; + * and extend patent protection. + +It forbids you to: + + * redistribute any piece of ImageMagick-originated software without proper attribution; + * use any marks owned by ImageMagick Studio LLC in any way that might state or imply that ImageMagick Studio LLC endorses your distribution; + * use any marks owned by ImageMagick Studio LLC in any way that might state or imply that you created the ImageMagick software in question. + +It requires you to: + + * include a copy of the license in any redistribution you may make that includes ImageMagick software; + * provide clear attribution to ImageMagick Studio LLC for any distributions that include ImageMagick software. + +It does not require you to: + + * include the source of the ImageMagick software itself, or of any modifications you may have made to it, in any redistribution you may assemble that includes it; + * submit changes that you make to the software back to the ImageMagick Studio LLC (though such feedback is encouraged). + +A few other clarifications include: + + * ImageMagick is freely available without charge; + * you may include ImageMagick on a DVD as long as you comply with the terms of the license; + * you can give modified code away for free or sell it under the terms of the ImageMagick license or distribute the result under a different license, but you need to acknowledge the use of the ImageMagick software; + * the license is compatible with the GPL V3. + * when exporting the ImageMagick software, review its export classification. + +Terms and Conditions for Use, Reproduction, and Distribution + +The legally binding and authoritative terms and conditions for use, reproduction, and distribution of ImageMagick follow: + +Copyright 1999-2019 ImageMagick Studio LLC, a non-profit organization dedicated to making software imaging solutions freely available. + +1. Definitions. + +License shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +Licensor shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +Legal Entity shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, control means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +You (or Your) shall mean an individual or Legal Entity exercising permissions granted by this License. + +Source form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +Object form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +Work shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +Derivative Works shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +Contribution shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as Not a Contribution. + +Contributor shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + * You must give any other recipients of the Work or Derivative Works a copy of this License; and + * You must cause any modified files to carry prominent notices stating that You changed the files; and + * You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + * If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an AS IS BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +How to Apply the License to your Work + +To apply the ImageMagick License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information (don't include the brackets). The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/setup/extensions.json b/setup/extensions.json index a43568541d..106b8f8f06 100644 --- a/setup/extensions.json +++ b/setup/extensions.json @@ -139,7 +139,7 @@ }, { "name": "imageops", - "sources": "calibre/utils/imageops/imageops.cpp calibre/utils/imageops/quantize.cpp", + "sources": "calibre/utils/imageops/imageops.cpp calibre/utils/imageops/quantize.cpp calibre/utils/imageops/ordered_dither.cpp", "headers": "calibre/utils/imageops/imageops.h", "sip_files": "calibre/utils/imageops/imageops.sip", "inc_dirs": "calibre/utils/imageops" diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 56a0b1c395..b3d31f4376 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -3,7 +3,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals __license__ = 'GPL v3' -__copyright__ = '2010-2018, Timothy Legge , Kovid Goyal and David Forrester ' +__copyright__ = '2010-2019, Timothy Legge , Kovid Goyal and David Forrester ' __docformat__ = 'restructuredtext en' ''' @@ -2651,7 +2651,8 @@ class KOBOTOUCH(KOBO): # NOTE: Loosely based on Qt's QSize::scaled implementation if keep_cover_aspect: # NOTE: Py3k wouldn't need explicit casts to return a float - # NOTE: Unlike Qt, we round to avoid accumulating errors + # NOTE: Unlike Qt, we round to avoid accumulating errors, + # as ImageOps will then floor via fit_image aspect_ratio = library_size[0] / float(library_size[1]) rescaled_width = int(round(kobo_size[1] * aspect_ratio)) diff --git a/src/calibre/utils/imageops/imageops.h b/src/calibre/utils/imageops/imageops.h index e477eb8105..208cb6ef03 100644 --- a/src/calibre/utils/imageops/imageops.h +++ b/src/calibre/utils/imageops/imageops.h @@ -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: diff --git a/src/calibre/utils/imageops/imageops.sip b/src/calibre/utils/imageops/imageops.sip index 686c81a2a4..d76c6b2a99 100644 --- a/src/calibre/utils/imageops/imageops.sip +++ b/src/calibre/utils/imageops/imageops.sip @@ -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 diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp new file mode 100644 index 0000000000..abf9c46c60 --- /dev/null +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -0,0 +1,93 @@ +/* + * 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 +#endif + +// 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 black (v = 0xFF) input pixels + // that get shifted to the next step (i.e., q = 272 (0xFF + 17)). + return (q > UINT8_MAX ? UINT8_MAX : reinterpret_cast(q); +} + +QImage ordered_dither(const QImage &image) { // {{{ + ScopedGILRelease PyGILRelease; + QImage img = image; + QRgb *row = NULL, *pixel = NULL; + int y = 0, x = 0, width = img.width(), height = img.height(); + uint8_t gray = 0, dithered = 0; + + // 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(); + } + + for (y = 0; y < height; y++) { + row = reinterpret_cast(img.scanLine(y)); + for (x = 0, pixel = row; x < width; x++, pixel++) { + // We're running behind grayscale_image, so R = G = B + gray = qRed(*pixel); + dithered = dither_o8x8(x, y, gray); + *pixel = qRgb(dithered, dithered, dithered); + } + } + return img; +} // }}} diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index f60d24ddb0..cf4b26a9d7 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -1,6 +1,6 @@ #!/usr/bin/env python2 # vim:fileencoding=utf-8 -# License: GPLv3 Copyright: 2015-2018, Kovid Goyal +# License: GPLv3 Copyright: 2015-2019, Kovid Goyal from __future__ import absolute_import, division, print_function, unicode_literals @@ -232,15 +232,9 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr changed = True img = grayscale_image(img) if eink: - eink_cmap = ['#000000', '#111111', '#222222', '#333333', '#444444', '#555555', '#666666', '#777777', - '#888888', '#999999', '#AAAAAA', '#BBBBBB', '#CCCCCC', '#DDDDDD', '#EEEEEE', '#FFFFFF'] # 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 generate smaller files to boot, because they're properly color indexed ;). - img = quantize_image(img, max_colors=16, dither=True, palette=eink_cmap) - ''' - # NOTE: Neither Grayscale8 nor Indexed8 actually do any kind of dithering?... :/. - img = img.convertToFormat(QImage.Format_Grayscale8, [QColor(x).rgb() for x in eink_cmap], Qt.AutoColor | Qt.DiffuseDither | Qt.ThresholdAlphaDither | Qt.PreferDither) - ''' + img = eink_dither_image(img) changed = True if path is None: return image_to_data(img, compression_quality, fmt) if changed else data @@ -466,6 +460,15 @@ 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. + ''' + img = image_from_data(img) + if img.hasAlphaChannel(): + img = blend_image(img) + return imageops.ordered_dither(img) + # }}} # Optimization of images {{{ From 90ab7573d91392e61a4943ac93d0ccc87fd8a3d0 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 01:14:47 +0200 Subject: [PATCH 05/30] Return an Indexed8 QImage Ensures it'll be encoded as such when saved as a PNG --- src/calibre/utils/imageops/ordered_dither.cpp | 12 +++++++----- src/calibre/utils/img.py | 8 ++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index abf9c46c60..dcf1be5d82 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -70,9 +70,9 @@ static uint8_t QImage ordered_dither(const QImage &image) { // {{{ ScopedGILRelease PyGILRelease; QImage img = image; - QRgb *row = NULL, *pixel = NULL; int y = 0, x = 0, width = img.width(), height = img.height(); uint8_t gray = 0, dithered = 0; + QImage dst(width, height, QImage::Format_Indexed8); // We're running behind blend_image, so, we should only ever be fed RGB32 as input... if (img.format() != QImage::Format_RGB32) { @@ -81,13 +81,15 @@ QImage ordered_dither(const QImage &image) { // {{{ } for (y = 0; y < height; y++) { - row = reinterpret_cast(img.scanLine(y)); - for (x = 0, pixel = row; x < width; x++, pixel++) { + const QRgb *src_row = reinterpret_cast(img.constScanLine(y)); + uint8_t *dst_row = dst.scanLine(r); + for (x = 0; x < width; x++) { + const QRgb pixel = *(src_row + x); // We're running behind grayscale_image, so R = G = B gray = qRed(*pixel); dithered = dither_o8x8(x, y, gray); - *pixel = qRgb(dithered, dithered, dithered); + *(dst_row + x) = dithered; } } - return img; + return dst; } // }}} diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index cf4b26a9d7..d66cf20753 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -463,10 +463,14 @@ def quantize_image(img, max_colors=256, dither=True, 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: Expects input as a grayscale image in RGB32 pixel format (as returned by grayscale_image). + Running blend_image if the image has an alpha channel, + or grayscale_image if it's not already grayscaled is the caller's responsibility. + + Returns a QImage in Indexed8 pixel format. ''' img = image_from_data(img) - if img.hasAlphaChannel(): - img = blend_image(img) return imageops.ordered_dither(img) # }}} From 5e7907b0aa52170c316982071449ddcfbd3a842a Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 01:28:11 +0200 Subject: [PATCH 06/30] Ups, actually Grayscale8, as I'm not writing a palette... Let's see what QImageWriter makes of that before checking if I really need to bother with Indexed8... --- src/calibre/utils/imageops/ordered_dither.cpp | 2 +- src/calibre/utils/img.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index dcf1be5d82..7634b07c35 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -72,7 +72,7 @@ QImage ordered_dither(const QImage &image) { // {{{ QImage img = image; int y = 0, x = 0, width = img.width(), height = img.height(); uint8_t gray = 0, dithered = 0; - QImage dst(width, height, QImage::Format_Indexed8); + QImage dst(width, height, QImage::Format_Grayscale8); // We're running behind blend_image, so, we should only ever be fed RGB32 as input... if (img.format() != QImage::Format_RGB32) { diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index d66cf20753..f333755d54 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -468,7 +468,7 @@ def eink_dither_image(img): Running blend_image if the image has an alpha channel, or grayscale_image if it's not already grayscaled is the caller's responsibility. - Returns a QImage in Indexed8 pixel format. + Returns a QImage in Grayscale8 pixel format. ''' img = image_from_data(img) return imageops.ordered_dither(img) From 81b303aa18226194d55b6966950085d3776e3e6c Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 01:40:09 +0200 Subject: [PATCH 07/30] Tweak help messages a bit --- src/calibre/devices/kobo/driver.py | 5 ++--- src/calibre/devices/kobo/kobotouch_config.py | 10 ++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index b3d31f4376..ed85aceb29 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -1442,7 +1442,7 @@ class KOBOTOUCH(KOBO): # 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, + # Note that all Kobo devices share a common AR at roughly 0.75, # so results should be similar, no matter the exact device. COVER_FILE_ENDINGS = { # Used for screensaver, home screen @@ -2683,12 +2683,11 @@ class KOBOTOUCH(KOBO): :param kobo_size: Size of the cover image on the device. :param upload_grayscale: boolean True if driver configured to send grayscale thumbnails :param dithered_covers: boolean True if driver configured to quantize to 16-col grayscale - at calibre end :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 at calibre end + :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 ''' diff --git a/src/calibre/devices/kobo/kobotouch_config.py b/src/calibre/devices/kobo/kobotouch_config.py index 6f6c8a8e51..aafad31e58 100644 --- a/src/calibre/devices/kobo/kobotouch_config.py +++ b/src/calibre/devices/kobo/kobotouch_config.py @@ -316,9 +316,7 @@ class CoversGroupBox(DeviceOptionsGroupBox): _('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.' - ' Note that in some cases, you might want to leave this disabled,' - ' as Nickel will do a better job than Calibre, especially on newer FW versions (>= 4.11)!' - ' Unfortunately, this desirable behavior appears to depend on the exact device and FW version combo...' + ' 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') ) @@ -337,10 +335,10 @@ class CoversGroupBox(DeviceOptionsGroupBox): self.letterbox_fs_covers_checkbox = create_checkbox( _('Letterbox full-screen covers'), - _('Do it on our end, instead of letting nickel handle it.' + _('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 also probably undesirable if you disable the "Show book covers full screen"' + ' 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! @@ -355,7 +353,7 @@ class CoversGroupBox(DeviceOptionsGroupBox): ' Higher quality, especially with "Upload dithered covers" enabled,' ' which will also help generate potentially smaller files.' ' Behavior completely unknown on "old" Kobo firmwares,' - ' last tested on FW 4.9 to 4.12.' + ' known to behave on FW >= 4.9.' ' 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. From 4f754d518ad45f0dad69ae40ae5e9ef15a767b0c Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 02:05:19 +0200 Subject: [PATCH 08/30] I should probably have tried to build that first xD --- src/calibre/utils/imageops/ordered_dither.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index 7634b07c35..bf85727b0c 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -25,6 +25,13 @@ typedef unsigned __int32 uint32_t; #include #endif +// 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, @@ -64,7 +71,7 @@ static uint8_t // 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 black (v = 0xFF) input pixels // that get shifted to the next step (i.e., q = 272 (0xFF + 17)). - return (q > UINT8_MAX ? UINT8_MAX : reinterpret_cast(q); + return (q > UINT8_MAX ? UINT8_MAX : static_cast(q)); } QImage ordered_dither(const QImage &image) { // {{{ @@ -82,11 +89,11 @@ QImage ordered_dither(const QImage &image) { // {{{ for (y = 0; y < height; y++) { const QRgb *src_row = reinterpret_cast(img.constScanLine(y)); - uint8_t *dst_row = dst.scanLine(r); + uint8_t *dst_row = dst.scanLine(y); for (x = 0; x < width; x++) { const QRgb pixel = *(src_row + x); // We're running behind grayscale_image, so R = G = B - gray = qRed(*pixel); + gray = qRed(pixel); dithered = dither_o8x8(x, y, gray); *(dst_row + x) = dithered; } From 96a98bbc297580e5aeb7e59e5812a69e5fab2b69 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 02:11:28 +0200 Subject: [PATCH 09/30] Fix syntax error --- src/calibre/devices/kobo/driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index ed85aceb29..19aac74d4b 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -2769,7 +2769,7 @@ class KOBOTOUCH(KOBO): # (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, expand=not is_full_size, keep_cover_aspect, letterbox) + 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)) From 38ad6a5b2dc4ce796142971b1a4a51f7d9f5cc2d Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 03:12:38 +0200 Subject: [PATCH 10/30] See what happens if we switch to Indexed8... --- src/calibre/utils/imageops/ordered_dither.cpp | 18 ++++++++++++++++-- src/calibre/utils/img.py | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index bf85727b0c..55293ddf04 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -25,6 +25,8 @@ typedef unsigned __int32 uint32_t; #include #endif +#include + // 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) { @@ -79,7 +81,19 @@ QImage ordered_dither(const QImage &image) { // {{{ QImage img = image; int y = 0, x = 0, width = img.width(), height = img.height(); uint8_t gray = 0, dithered = 0; - 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 palette(16); + QVector 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) { @@ -95,7 +109,7 @@ QImage ordered_dither(const QImage &image) { // {{{ // We're running behind grayscale_image, so R = G = B gray = qRed(pixel); dithered = dither_o8x8(x, y, gray); - *(dst_row + x) = dithered; + *(dst_row + x) = palette.indexOf(dithered); } } return dst; diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index f333755d54..d66cf20753 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -468,7 +468,7 @@ def eink_dither_image(img): Running blend_image if the image has an alpha channel, or grayscale_image if it's not already grayscaled is the caller's responsibility. - Returns a QImage in Grayscale8 pixel format. + Returns a QImage in Indexed8 pixel format. ''' img = image_from_data(img) return imageops.ordered_dither(img) From b5554bd0da758c10ba74438b1440a484cc0650f9 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 03:33:20 +0200 Subject: [PATCH 11/30] Revert "See what happens if we switch to Indexed8..." This reverts commit a602b8b872c5b4c4c0772b82bb43e2384d92fd63. What happens is we end up with an alpha channel in our PNGs, which screws with our first entry in the palette: black, which is no longer at full opacity :/. --- src/calibre/utils/imageops/ordered_dither.cpp | 18 ++---------------- src/calibre/utils/img.py | 2 +- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index 55293ddf04..bf85727b0c 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -25,8 +25,6 @@ typedef unsigned __int32 uint32_t; #include #endif -#include - // 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) { @@ -81,19 +79,7 @@ QImage ordered_dither(const QImage &image) { // {{{ QImage img = image; int y = 0, x = 0, width = img.width(), height = img.height(); uint8_t gray = 0, dithered = 0; - 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 palette(16); - QVector 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); + QImage dst(width, height, QImage::Format_Grayscale8); // We're running behind blend_image, so, we should only ever be fed RGB32 as input... if (img.format() != QImage::Format_RGB32) { @@ -109,7 +95,7 @@ QImage ordered_dither(const QImage &image) { // {{{ // We're running behind grayscale_image, so R = G = B gray = qRed(pixel); dithered = dither_o8x8(x, y, gray); - *(dst_row + x) = palette.indexOf(dithered); + *(dst_row + x) = dithered; } } return dst; diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index d66cf20753..f333755d54 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -468,7 +468,7 @@ def eink_dither_image(img): Running blend_image if the image has an alpha channel, or grayscale_image if it's not already grayscaled is the caller's responsibility. - Returns a QImage in Indexed8 pixel format. + Returns a QImage in Grayscale8 pixel format. ''' img = image_from_data(img) return imageops.ordered_dither(img) From d6efb350bfe034242c7eaf8bfe6838079475ea51 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 04:41:35 +0200 Subject: [PATCH 12/30] Experiment w/ optipng... --- src/calibre/devices/kobo/driver.py | 9 ++++++++- src/calibre/utils/imageops/ordered_dither.cpp | 1 + src/calibre/utils/img.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 19aac74d4b..99eab9c433 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -1023,7 +1023,7 @@ class KOBO(USBMS): debug_print('FAILED to upload cover', filepath) def _upload_cover(self, path, filename, metadata, filepath, uploadgrayscale, ditheredcovers, letterboxcovers, pngcovers): - from calibre.utils.img import save_cover_data_to + from calibre.utils.img import save_cover_data_to, optimize_png if metadata.cover: cover = self.normalize_path(metadata.cover.replace('/', os.sep)) @@ -1077,6 +1077,9 @@ class KOBO(USBMS): f.write(data) fsync(f) + if pngcovers: + optimize_png(fpath) + else: debug_print("ImageID could not be retreived from the database") @@ -2697,6 +2700,7 @@ class KOBOTOUCH(KOBO): 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 + 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: @@ -2780,6 +2784,9 @@ class KOBOTOUCH(KOBO): with lopen(fpath, 'wb') as f: f.write(data) fsync(f) + + if png_covers: + optimize_png(fpath) except Exception as e: err = unicode_type(e) debug_print("KoboTouch:_upload_cover - Exception string: %s"%err) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index bf85727b0c..3d766a059b 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -79,6 +79,7 @@ QImage ordered_dither(const QImage &image) { // {{{ 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); // We're running behind blend_image, so, we should only ever be fed RGB32 as input... diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index f333755d54..097ce0ca37 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -233,7 +233,7 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr 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 generate smaller files to boot, because they're properly color indexed ;). + # 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: From bdb767c9b7eb42be88afe796fe1134648e891789 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 04:42:43 +0200 Subject: [PATCH 13/30] Forget about optipng Granted, it helps, but it's prohibitively expensive. --- src/calibre/devices/kobo/driver.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 99eab9c433..19aac74d4b 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -1023,7 +1023,7 @@ class KOBO(USBMS): debug_print('FAILED to upload cover', filepath) def _upload_cover(self, path, filename, metadata, filepath, uploadgrayscale, ditheredcovers, letterboxcovers, pngcovers): - from calibre.utils.img import save_cover_data_to, optimize_png + from calibre.utils.img import save_cover_data_to if metadata.cover: cover = self.normalize_path(metadata.cover.replace('/', os.sep)) @@ -1077,9 +1077,6 @@ class KOBO(USBMS): f.write(data) fsync(f) - if pngcovers: - optimize_png(fpath) - else: debug_print("ImageID could not be retreived from the database") @@ -2700,7 +2697,6 @@ class KOBOTOUCH(KOBO): 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 - 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: @@ -2784,9 +2780,6 @@ class KOBOTOUCH(KOBO): with lopen(fpath, 'wb') as f: f.write(data) fsync(f) - - if png_covers: - optimize_png(fpath) except Exception as e: err = unicode_type(e) debug_print("KoboTouch:_upload_cover - Exception string: %s"%err) From d2772a2916b202a0f6f1f2b4fd54b180c8268ea8 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 06:37:05 +0200 Subject: [PATCH 14/30] Eh, okay-ish optipng compromise --- src/calibre/devices/kobo/driver.py | 21 +++++++++++++++++---- src/calibre/utils/img.py | 6 ++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 19aac74d4b..3403fd5185 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -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 @@ -2697,6 +2697,7 @@ class KOBOTOUCH(KOBO): 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 + from calibre.utils.img import optimize_png_fast debug_print("KoboTouch:_upload_cover - filename='%s' upload_grayscale='%s' dithered_covers='%s' "%(filename, upload_grayscale, dithered_covers)) if not metadata.cover: @@ -2777,9 +2778,21 @@ class KOBOTOUCH(KOBO): # 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) - with lopen(fpath, 'wb') as f: - f.write(data) - fsync(f) + # 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_fast(tmp_cover) + # 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) diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index 097ce0ca37..376d04d344 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -560,6 +560,12 @@ def optimize_png(file_path): cmd = [exe] + '-fix -clobber -strip all -o7 -out'.split() + [False, True] return run_optimizer(file_path, cmd) +# Use -o1 to make it much, much faster, when we only care about re-encoding, and not recompression. +def optimize_png_fast(file_path): + exe = get_exe_path('optipng') + cmd = [exe] + '-fix -clobber -strip all -o1 -out'.split() + [False, True] + return run_optimizer(file_path, cmd) + def encode_jpeg(file_path, quality=80): from calibre.utils.speedups import ReadOnlyFileBuffer From 227b5f97bd7f822531971b115c3e8ea64c9fb1f3 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 19:48:00 +0200 Subject: [PATCH 15/30] Stick the commented out Indexed8 code in there, for posterity's sake (in preparation of a squash) --- src/calibre/utils/imageops/ordered_dither.cpp | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index 3d766a059b..327a551d4c 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -25,6 +25,9 @@ typedef unsigned __int32 uint32_t; #include #endif +// Only needed for the (commented out) Indexed8 codepath +//#include + // 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) { @@ -82,6 +85,22 @@ QImage ordered_dither(const QImage &image) { // {{{ // 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 palette(16); + QVector 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); @@ -96,7 +115,7 @@ QImage ordered_dither(const QImage &image) { // {{{ // We're running behind grayscale_image, so R = G = B gray = qRed(pixel); dithered = dither_o8x8(x, y, gray); - *(dst_row + x) = dithered; + *(dst_row + x) = dithered; // ... or palette.indexOf(dithered); for Indexed8 } } return dst; From c465d7f4e77ee8142df498cdc0073a1bd256526b Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 19:51:45 +0200 Subject: [PATCH 16/30] Tweak copyright header a bit, now that it's done --- src/calibre/utils/imageops/ordered_dither.cpp | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index 327a551d4c..a7cbd4e673 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -1,17 +1,19 @@ /* - * Copyright 1999-2019 ImageMagick Studio LLC + * ordered_dither.cpp + * Glue code based on quantize.cpp, Copyright (C) 2016 Kovid Goyal + * 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 + * 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 + * 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. + * 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" From 4326bb9d7ff9d868f20fb32def39c27a8ccfc496 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 19:53:54 +0200 Subject: [PATCH 17/30] Unify indentation style --- src/calibre/utils/imageops/ordered_dither.cpp | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index a7cbd4e673..2fe3d29828 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -48,35 +48,35 @@ static uint32_t DIV255(uint32_t v) { 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 }; + // 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)); + // 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 black (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(q)); + // 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 black (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(q)); } QImage ordered_dither(const QImage &image) { // {{{ From 8e7b519b8d3ce5e13ee3c57e01a272b2598ddecb Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 20:06:51 +0200 Subject: [PATCH 18/30] Inline the grayscaling pass in ordered_dither Saving one QImage pixel-loop dance in the process... --- src/calibre/utils/imageops/ordered_dither.cpp | 10 ++++++++-- src/calibre/utils/img.py | 8 ++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index 2fe3d29828..577fe1c181 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -109,13 +109,19 @@ QImage ordered_dither(const QImage &image) { // {{{ 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(img.constScanLine(y)); uint8_t *dst_row = dst.scanLine(y); for (x = 0; x < width; x++) { const QRgb pixel = *(src_row + x); - // We're running behind grayscale_image, so R = G = B - gray = qRed(pixel); + 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 } diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index 376d04d344..a929b09866 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -227,7 +227,7 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr 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) @@ -464,13 +464,13 @@ 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: Expects input as a grayscale image in RGB32 pixel format (as returned by grayscale_image). - Running blend_image if the image has an alpha channel, - or grayscale_image if it's not already grayscaled is the caller's responsibility. + 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) # }}} From cc8a3b445073594354707cf7d27ba84b0b4d1b2d Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 24 May 2019 20:26:01 +0200 Subject: [PATCH 19/30] Refactor that to avoid code duplication --- src/calibre/devices/kobo/driver.py | 73 +++++++++--------------------- 1 file changed, 21 insertions(+), 52 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 3403fd5185..dbdc8be99a 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -1444,62 +1444,42 @@ class KOBOTOUCH(KOBO): # 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. - COVER_FILE_ENDINGS = { - # Used for screensaver, home screen - ' - N3_FULL.parsed':[(600,800),0, 200,True,], + # 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,530),0, 200,False,], + ' - N3_LIBRARY_FULL.parsed': [(355,530),0, 200,False,], # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], + ' - 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,530), 82, 100,False,], + ' - AndroidBookLoadTablet_Aspect.parsed': [(355,530), 82, 100,False,], + } + # 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,530),0, 200,False,], - # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], - # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 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,], - # Used for Details screen before FW2.8.1, then for current book tile on home screen - ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 200,False,], - # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], - # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 88, 100,False,], + ' - 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,530),0, 200,False,], - # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], - # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 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,530),0, 200,False,], - # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], - # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 88, 100,False,], } AURA_H2O_COVER_FILE_ENDINGS = { # Used for screensaver, home screen @@ -1507,30 +1487,16 @@ class KOBOTOUCH(KOBO): # 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,], - # Used for Details screen before FW2.8.1, then for current book tile on home screen - ' - N3_LIBRARY_FULL.parsed':[(355,530),0, 200,False,], - # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], - # Used for Details screen from FW2.8.1 - ' - AndroidBookLoadTablet_Aspect.parsed':[(355,530), 88, 100,False,], } 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,530),0, 200,False,], - # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], } 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,530),0, 200,False,], - # Used for library lists - ' - N3_LIBRARY_GRID.parsed':[(149,223),0, 200,False,], } # Following are the sizes used with pre2.1.4 firmware # COVER_FILE_ENDINGS = { @@ -3366,15 +3332,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 From aaa2fa936a4ca0361c9c82ffed5eedec9c451ea6 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Sat, 25 May 2019 06:14:05 +0200 Subject: [PATCH 20/30] Use a lower compression level for the first PNG pass optipng will fix it anyway --- src/calibre/devices/kobo/driver.py | 11 ++++++++--- src/calibre/utils/img.py | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index dbdc8be99a..1a59f08690 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -2637,7 +2637,7 @@ class KOBOTOUCH(KOBO): 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): + 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, @@ -2655,10 +2655,11 @@ class KOBOTOUCH(KOBO): 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, resize_to=resize_to, minify_to=minify_to, grayscale=upload_grayscale, eink=dithered_covers, letterbox=letterbox, data_fmt="png" if png_covers else "jpeg") + 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, dithered_covers=False, keep_cover_aspect=False, letterbox_fs_covers=False, png_covers=False): @@ -2741,8 +2742,12 @@ class KOBOTOUCH(KOBO): 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)) + # 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) + 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... diff --git a/src/calibre/utils/img.py b/src/calibre/utils/img.py index a929b09866..b88f327a3a 100644 --- a/src/calibre/utils/img.py +++ b/src/calibre/utils/img.py @@ -237,9 +237,9 @@ def save_cover_data_to(data, path=None, bgcolor='#ffffff', resize_to=None, compr 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 {{{ From 04c7a8aa4afe48a2c51277f9c11e391d425cba7d Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Sun, 2 Jun 2019 20:44:00 +0200 Subject: [PATCH 21/30] Make the GCC 4.8 bot happy ;). --- src/calibre/utils/imageops/ordered_dither.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index 577fe1c181..52db0fde3d 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -24,7 +24,7 @@ typedef unsigned __int8 uint8_t; #define UINT8_MAX _UI8_MAX typedef unsigned __int32 uint32_t; #else -#include +#include #endif // Only needed for the (commented out) Indexed8 codepath From 7c02419eed55b5d2a2523d1065c17325d613938e Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Mon, 3 Jun 2019 14:09:49 +0200 Subject: [PATCH 22/30] Start addressing review comments Explicit cast to float is superfluous, thanks to __future__.division --- src/calibre/devices/kobo/driver.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 1a59f08690..e0df4123f5 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -2616,10 +2616,9 @@ class KOBOTOUCH(KOBO): # NOTE: Loosely based on Qt's QSize::scaled implementation if keep_cover_aspect: - # NOTE: Py3k wouldn't need explicit casts to return a float # NOTE: Unlike Qt, we round to avoid accumulating errors, # as ImageOps will then floor via fit_image - aspect_ratio = library_size[0] / float(library_size[1]) + aspect_ratio = library_size[0] / library_size[1] rescaled_width = int(round(kobo_size[1] * aspect_ratio)) if expand: From d0cf06a5e5f6eb28dabd8a604c433a24aa62aeb5 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Mon, 3 Jun 2019 14:22:20 +0200 Subject: [PATCH 23/30] Continue addressing review comments Less destructive to the legacy KOBO driver (only honor letterboxing, and only do it to full-screen covers like KOBOTOUCH). Also, unbreak legacy settings by not re-ordering them. --- src/calibre/devices/kobo/driver.py | 47 +++++++++++------------------- 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index e0df4123f5..306f2391af 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -154,13 +154,11 @@ class KOBO(USBMS): OPT_COLLECTIONS = 0 OPT_UPLOAD_COVERS = 1 OPT_UPLOAD_GRAYSCALE_COVERS = 2 - OPT_DITHERED_COVERS = 3 - OPT_LETTERBOX_FULLSCREEN_COVERS = 4 - OPT_PNG_COVERS = 5 - OPT_SHOW_EXPIRED_BOOK_RECORDS = 6 - OPT_SHOW_PREVIEWS = 7 - OPT_SHOW_RECOMMENDATIONS = 8 - OPT_SUPPORT_NEWER_FIRMWARE = 9 + OPT_SHOW_EXPIRED_BOOK_RECORDS = 3 + OPT_SHOW_PREVIEWS = 4 + OPT_SHOW_RECOMMENDATIONS = 5 + OPT_SUPPORT_NEWER_FIRMWARE = 6 + OPT_LETTERBOX_FULLSCREEN_COVERS = 7 def __init__(self, *args, **kwargs): USBMS.__init__(self, *args, **kwargs) @@ -1001,28 +999,18 @@ class KOBO(USBMS): else: uploadgrayscale = True - if not opts.extra_customization[self.OPT_DITHERED_COVERS]: - ditheredcovers = False - else: - ditheredcovers = True - if not opts.extra_customization[self.OPT_LETTERBOX_FULLSCREEN_COVERS]: letterboxcovers = False else: letterboxcovers = True - if not opts.extra_customization[self.OPT_PNG_COVERS]: - pngcovers = False - else: - pngcovers = True - debug_print('KOBO: uploading cover') try: - self._upload_cover(path, filename, metadata, filepath, uploadgrayscale, ditheredcovers, letterboxcovers, pngcovers) + self._upload_cover(path, filename, metadata, filepath, uploadgrayscale, letterboxcovers) except: debug_print('FAILED to upload cover', filepath) - def _upload_cover(self, path, filename, metadata, filepath, uploadgrayscale, ditheredcovers, letterboxcovers, pngcovers): + def _upload_cover(self, path, filename, metadata, filepath, uploadgrayscale, letterboxcovers): from calibre.utils.img import save_cover_data_to if metadata.cover: cover = self.normalize_path(metadata.cover.replace('/', os.sep)) @@ -1065,13 +1053,16 @@ class KOBO(USBMS): fpath = path + ending fpath = self.normalize_path(fpath.replace('/', os.sep)) + # Only full-screen covers should be letterboxed + letterbox = letterboxcovers and "N3_FULL" in ending + if os.path.exists(fpath): with lopen(cover, 'rb') as f: data = f.read() - # Return the data resized and grayscaled/dithered/letterboxed if + # Return the data resized and grayscaled/letterboxed if # required - data = save_cover_data_to(data, grayscale=uploadgrayscale, eink=ditheredcovers, resize_to=resize, minify_to=resize, letterbox=letterboxcovers, data_fmt="png" if pngcovers else "jpeg") + data = save_cover_data_to(data, grayscale=uploadgrayscale, resize_to=resize, minify_to=resize, letterbox=letterbox) with lopen(fpath, 'wb') as f: f.write(data) @@ -1116,13 +1107,11 @@ class KOBO(USBMS): OPT_COLLECTIONS = 0 OPT_UPLOAD_COVERS = 1 OPT_UPLOAD_GRAYSCALE_COVERS = 2 - OPT_DITHERED_COVERS = 3 - OPT_LETTERBOX_FULLSCREEN_COVERS = 4 - OPT_PNG_COVERS = 5 - OPT_SHOW_EXPIRED_BOOK_RECORDS = 6 - OPT_SHOW_PREVIEWS = 7 - OPT_SHOW_RECOMMENDATIONS = 8 - OPT_SUPPORT_NEWER_FIRMWARE = 9 + OPT_SHOW_EXPIRED_BOOK_RECORDS = 3 + OPT_SHOW_PREVIEWS = 4 + OPT_SHOW_RECOMMENDATIONS = 5 + OPT_SUPPORT_NEWER_FIRMWARE = 6 + OPT_LETTERBOX_FULLSCREEN_COVERS = 7 p = {} p['format_map'] = old_settings.format_map @@ -1136,9 +1125,7 @@ class KOBO(USBMS): p['upload_covers'] = old_settings.extra_customization[OPT_UPLOAD_COVERS] p['upload_grayscale'] = old_settings.extra_customization[OPT_UPLOAD_GRAYSCALE_COVERS] - p['dithered_covers'] = old_settings.extra_customization[OPT_DITHERED_COVERS] p['letterbox_fs_covers'] = old_settings.extra_customization[OPT_LETTERBOX_FULLSCREEN_COVERS] - p['png_covers'] = old_settings.extra_customization[OPT_PNG_COVERS] p['show_expired_books'] = old_settings.extra_customization[OPT_SHOW_EXPIRED_BOOK_RECORDS] p['show_previews'] = old_settings.extra_customization[OPT_SHOW_PREVIEWS] From 62e2925e4bc3e2a90bdde0ecf553c89f3cb147d6 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Mon, 3 Jun 2019 14:26:19 +0200 Subject: [PATCH 24/30] Address some more review comments No need to handle old settings migration for those new settings in KOBOTOUCH --- src/calibre/devices/kobo/driver.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 306f2391af..ed95d62da2 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -3576,14 +3576,8 @@ class KOBOTOUCH(KOBO): count_options += 1 OPT_UPLOAD_GRAYSCALE_COVERS = count_options count_options += 1 - OPT_DITHERED_COVERS = count_options - count_options += 1 OPT_KEEP_COVER_ASPECT_RATIO = count_options count_options += 1 - OPT_LETTERBOX_FULLSCREEN_COVERS = count_options - count_options += 1 - OPT_PNG_COVERS = count_options - count_options += 1 OPT_SHOW_ARCHIVED_BOOK_RECORDS = count_options count_options += 1 OPT_SHOW_PREVIEWS = count_options @@ -3614,9 +3608,6 @@ class KOBOTOUCH(KOBO): settings.upload_covers = settings.extra_customization[OPT_UPLOAD_COVERS] settings.keep_cover_aspect = settings.extra_customization[OPT_KEEP_COVER_ASPECT_RATIO] settings.upload_grayscale = settings.extra_customization[OPT_UPLOAD_GRAYSCALE_COVERS] - settings.dithered_covers = settings.extra_customization[OPT_DITHERED_COVERS] - settings.letterbox_fs_covers = settings.extra_customization[OPT_LETTERBOX_FULLSCREEN_COVERS] - settings.png_covers = settings.extra_customization[OPT_PNG_COVERS] settings.show_archived_books = settings.extra_customization[OPT_SHOW_ARCHIVED_BOOK_RECORDS] settings.show_previews = settings.extra_customization[OPT_SHOW_PREVIEWS] From cff3c4ad689364e99c30ed1b41841b5cbff585e3 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Mon, 3 Jun 2019 14:29:17 +0200 Subject: [PATCH 25/30] Tweak the PNG help message a bit Given that Nickel has handled PNGs as books as early as 2.9.0, if not earlier, I can't see this actually being an issue anywhere in practice, but, better be safe than sorry ;). --- src/calibre/devices/kobo/kobotouch_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/calibre/devices/kobo/kobotouch_config.py b/src/calibre/devices/kobo/kobotouch_config.py index aafad31e58..594b3236e5 100644 --- a/src/calibre/devices/kobo/kobotouch_config.py +++ b/src/calibre/devices/kobo/kobotouch_config.py @@ -352,8 +352,8 @@ class CoversGroupBox(DeviceOptionsGroupBox): _('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" Kobo firmwares,' - ' known to behave on FW >= 4.9.' + ' 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. From 5883667ebe72eb932cc14e7aaaa0209328c76de7 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Mon, 3 Jun 2019 15:46:28 +0200 Subject: [PATCH 26/30] Forgot the KOBO letterbox checkbox in the settings Which made me realize that there's no "keep AR" function there, so this doesn't make sense \o/. --- src/calibre/devices/kobo/driver.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index ed95d62da2..34121e01fc 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -139,6 +139,12 @@ class KOBO(USBMS): 'to perform full read-write functionality - Here be Dragons!! ' 'Enable only if you are comfortable with restoring your kobo ' 'to factory defaults and testing software'), + _('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.'), ] EXTRA_CUSTOMIZATION_DEFAULT = [ @@ -148,6 +154,7 @@ class KOBO(USBMS): True, False, False, + False, False ] From a79f9b9b54e263ce3f0d1f0bd7fa062451b22389 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Mon, 3 Jun 2019 15:51:31 +0200 Subject: [PATCH 27/30] Kill letterboxing in KOBO It makes no sense without an "honor AR" option ;). --- src/calibre/devices/kobo/driver.py | 26 +++----------------- src/calibre/devices/kobo/kobotouch_config.py | 2 +- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 34121e01fc..4c5692fa0a 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -139,12 +139,6 @@ class KOBO(USBMS): 'to perform full read-write functionality - Here be Dragons!! ' 'Enable only if you are comfortable with restoring your kobo ' 'to factory defaults and testing software'), - _('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.'), ] EXTRA_CUSTOMIZATION_DEFAULT = [ @@ -154,7 +148,6 @@ class KOBO(USBMS): True, False, False, - False, False ] @@ -165,7 +158,6 @@ class KOBO(USBMS): OPT_SHOW_PREVIEWS = 4 OPT_SHOW_RECOMMENDATIONS = 5 OPT_SUPPORT_NEWER_FIRMWARE = 6 - OPT_LETTERBOX_FULLSCREEN_COVERS = 7 def __init__(self, *args, **kwargs): USBMS.__init__(self, *args, **kwargs) @@ -1006,18 +998,13 @@ class KOBO(USBMS): else: uploadgrayscale = True - if not opts.extra_customization[self.OPT_LETTERBOX_FULLSCREEN_COVERS]: - letterboxcovers = False - else: - letterboxcovers = True - debug_print('KOBO: uploading cover') try: - self._upload_cover(path, filename, metadata, filepath, uploadgrayscale, letterboxcovers) + self._upload_cover(path, filename, metadata, filepath, uploadgrayscale) except: debug_print('FAILED to upload cover', filepath) - def _upload_cover(self, path, filename, metadata, filepath, uploadgrayscale, letterboxcovers): + def _upload_cover(self, path, filename, metadata, filepath, uploadgrayscale): from calibre.utils.img import save_cover_data_to if metadata.cover: cover = self.normalize_path(metadata.cover.replace('/', os.sep)) @@ -1060,16 +1047,13 @@ class KOBO(USBMS): fpath = path + ending fpath = self.normalize_path(fpath.replace('/', os.sep)) - # Only full-screen covers should be letterboxed - letterbox = letterboxcovers and "N3_FULL" in ending - if os.path.exists(fpath): with lopen(cover, 'rb') as f: data = f.read() - # Return the data resized and grayscaled/letterboxed if + # Return the data resized and grayscaled if # required - data = save_cover_data_to(data, grayscale=uploadgrayscale, resize_to=resize, minify_to=resize, letterbox=letterbox) + data = save_cover_data_to(data, grayscale=uploadgrayscale, resize_to=resize, minify_to=resize) with lopen(fpath, 'wb') as f: f.write(data) @@ -1118,7 +1102,6 @@ class KOBO(USBMS): OPT_SHOW_PREVIEWS = 4 OPT_SHOW_RECOMMENDATIONS = 5 OPT_SUPPORT_NEWER_FIRMWARE = 6 - OPT_LETTERBOX_FULLSCREEN_COVERS = 7 p = {} p['format_map'] = old_settings.format_map @@ -1132,7 +1115,6 @@ class KOBO(USBMS): p['upload_covers'] = old_settings.extra_customization[OPT_UPLOAD_COVERS] p['upload_grayscale'] = old_settings.extra_customization[OPT_UPLOAD_GRAYSCALE_COVERS] - p['letterbox_fs_covers'] = old_settings.extra_customization[OPT_LETTERBOX_FULLSCREEN_COVERS] p['show_expired_books'] = old_settings.extra_customization[OPT_SHOW_EXPIRED_BOOK_RECORDS] p['show_previews'] = old_settings.extra_customization[OPT_SHOW_PREVIEWS] diff --git a/src/calibre/devices/kobo/kobotouch_config.py b/src/calibre/devices/kobo/kobotouch_config.py index 594b3236e5..f20d72ea99 100644 --- a/src/calibre/devices/kobo/kobotouch_config.py +++ b/src/calibre/devices/kobo/kobotouch_config.py @@ -3,7 +3,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals __license__ = 'GPL v3' -__copyright__ = '2015-2018, Kovid Goyal ' +__copyright__ = '2015-2019, Kovid Goyal ' __docformat__ = 'restructuredtext en' import textwrap From 43d53f8dbe9c4b56ac17e78162812529a3267bda Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Mon, 3 Jun 2019 15:59:38 +0200 Subject: [PATCH 28/30] Simplify that I was *probably* trying to mimic a C ternary operator ;p --- src/calibre/devices/kobo/driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/devices/kobo/driver.py b/src/calibre/devices/kobo/driver.py index 4c5692fa0a..15e90160e0 100644 --- a/src/calibre/devices/kobo/driver.py +++ b/src/calibre/devices/kobo/driver.py @@ -2705,7 +2705,7 @@ class KOBOTOUCH(KOBO): fpath = self.normalize_path(fpath.replace('/', os.sep)) # Never letterbox thumbnails, that's ugly. But for fullscreen covers, honor the setting. - letterbox = letterbox_fs_covers if is_full_size else False + letterbox = letterbox_fs_covers and 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 From 474d76e645ef5d78279dd41d7e435fd4370b4271 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Fri, 7 Jun 2019 04:12:34 +0200 Subject: [PATCH 29/30] Fix that comment ;). --- src/calibre/utils/imageops/ordered_dither.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/calibre/utils/imageops/ordered_dither.cpp b/src/calibre/utils/imageops/ordered_dither.cpp index 52db0fde3d..c03a65087b 100644 --- a/src/calibre/utils/imageops/ordered_dither.cpp +++ b/src/calibre/utils/imageops/ordered_dither.cpp @@ -74,7 +74,7 @@ static uint8_t // 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 black (v = 0xFF) input pixels + // 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(q)); } From 09ffb629a1270030ffd12b3e6fc5387fd4104a21 Mon Sep 17 00:00:00 2001 From: NiLuJe Date: Mon, 10 Jun 2019 18:35:19 +0200 Subject: [PATCH 30/30] Tweak checkbox layout to be more logical --- src/calibre/devices/kobo/kobotouch_config.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/calibre/devices/kobo/kobotouch_config.py b/src/calibre/devices/kobo/kobotouch_config.py index f20d72ea99..174390d581 100644 --- a/src/calibre/devices/kobo/kobotouch_config.py +++ b/src/calibre/devices/kobo/kobotouch_config.py @@ -8,7 +8,7 @@ __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 @@ -362,11 +362,11 @@ class CoversGroupBox(DeviceOptionsGroupBox): self.upload_grayscale_checkbox.toggled.connect( lambda checked: not checked and self.png_covers_checkbox.setChecked(False)) - 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.options_layout.addWidget(self.dithered_covers_checkbox, 2, 0, 1, 1) - self.options_layout.addWidget(self.letterbox_fs_covers_checkbox, 3, 0, 1, 1) - self.options_layout.addWidget(self.png_covers_checkbox, 4, 0, 1, 1) + 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):