1
from __future__ import with_statement
3
__copyright__ = '2008, Kovid Goyal kovid@kovidgoyal.net'
4
__docformat__ = 'restructuredtext en'
7
Based on ideas from comiclrf created by FangornUK.
10
import os, sys, shutil, traceback, textwrap, fnmatch
11
from uuid import uuid4
16
from calibre import extract, terminal_controller, __appname__, __version__
17
from calibre.utils.config import Config, StringConfig
18
from calibre.ptempfile import PersistentTemporaryDirectory
19
from calibre.parallel import Server, ParallelJob
20
from calibre.utils.terminfo import ProgressBar
21
from calibre.ebooks.lrf.pylrs.pylrs import Book, BookSetting, ImageStream, ImageBlock
22
from calibre.ebooks.metadata import MetaInformation
23
from calibre.ebooks.metadata.opf import OPFCreator
24
from calibre.ebooks.epub.from_html import config as html2epub_config, convert as html2epub
25
from calibre.customize.ui import run_plugins_on_preprocess
27
from calibre.utils.PythonMagickWand import \
28
NewMagickWand, NewPixelWand, \
29
MagickSetImageBorderColor, \
30
MagickReadImage, MagickRotateImage, \
31
MagickTrimImage, PixelSetColor,\
32
MagickNormalizeImage, MagickGetImageWidth, \
33
MagickGetImageHeight, \
34
MagickResizeImage, MagickSetImageType, \
35
GrayscaleType, CatromFilter, MagickSetImagePage, \
36
MagickBorderImage, MagickSharpenImage, MagickDespeckleImage, \
37
MagickQuantizeImage, RGBColorspace, \
38
MagickWriteImage, DestroyPixelWand, \
39
DestroyMagickWand, CloneMagickWand, \
40
MagickThumbnailImage, MagickCropImage, ImageMagick
41
_imagemagick_loaded = True
43
_imagemagick_loaded = False
46
# Name : (width, height) in pixels
48
# The SONY's LRF renderer (on the PRS500) only uses the first 800x600 block of the image
49
'prs500-landscape': (784, 1012)
52
def extract_comic(path_to_comic_file):
54
Un-archive the comic file.
56
tdir = PersistentTemporaryDirectory(suffix='_comic_extract')
57
extract(path_to_comic_file, tdir)
60
def find_pages(dir, sort_on_mtime=False, verbose=False):
62
Find valid comic pages in a previously un-archived comic.
64
:param dir: Directory in which extracted comic lives
65
:param sort_on_mtime: If True sort pages based on their last modified time.
66
Otherwise, sort alphabetically.
68
extensions = ['jpeg', 'jpg', 'gif', 'png']
70
for datum in os.walk(dir):
71
for name in datum[-1]:
72
path = os.path.join(datum[0], name)
73
if '__MACOSX' in path: continue
74
for ext in extensions:
75
if path.lower().endswith('.'+ext):
79
comparator = lambda x, y : cmp(os.stat(x).st_mtime, os.stat(y).st_mtime)
81
comparator = lambda x, y : cmp(os.path.basename(x), os.path.basename(y))
83
pages.sort(cmp=comparator)
85
print 'Found comic pages...'
86
print '\t'+'\n\t'.join([os.path.basename(p) for p in pages])
89
class PageProcessor(list):
91
Contains the actual image rendering logic. See :method:`render` and
92
:method:`process_pages`.
95
def __init__(self, path_to_page, dest, opts, num):
97
self.path_to_page = path_to_page
106
img = NewMagickWand()
108
raise RuntimeError('Cannot create wand.')
109
if not MagickReadImage(img, self.path_to_page):
110
raise IOError('Failed to read image from: %'%self.path_to_page)
111
width = MagickGetImageWidth(img)
112
height = MagickGetImageHeight(img)
113
if self.num == 0: # First image so create a thumbnail from it
114
thumb = CloneMagickWand(img)
116
raise RuntimeError('Cannot create wand.')
117
MagickThumbnailImage(thumb, 60, 80)
118
MagickWriteImage(thumb, os.path.join(self.dest, 'thumbnail.png'))
119
DestroyMagickWand(thumb)
122
if self.opts.landscape:
125
split1, split2 = map(CloneMagickWand, (img, img))
126
DestroyMagickWand(img)
127
if split1 < 0 or split2 < 0:
128
raise RuntimeError('Cannot create wand.')
129
MagickCropImage(split1, (width/2)-1, height, 0, 0)
130
MagickCropImage(split2, (width/2)-1, height, width/2, 0 )
131
self.pages = [split2, split1] if self.opts.right2left else [split1, split2]
134
def process_pages(self):
135
for i, wand in enumerate(self.pages):
139
raise RuntimeError('Cannot create wand.')
140
PixelSetColor(pw, 'white')
142
MagickSetImageBorderColor(wand, pw)
144
MagickRotateImage(wand, pw, -90)
146
# 25 percent fuzzy trim?
147
if not self.opts.disable_trim:
148
MagickTrimImage(wand, 25*65535/100)
149
MagickSetImagePage(wand, 0,0,0,0) #Clear page after trim, like a "+repage"
150
# Do the Photoshop "Auto Levels" equivalent
151
if not self.opts.dont_normalize:
152
MagickNormalizeImage(wand)
153
sizex = MagickGetImageWidth(wand)
154
sizey = MagickGetImageHeight(wand)
156
SCRWIDTH, SCRHEIGHT = PROFILES[self.opts.profile]
158
if self.opts.keep_aspect_ratio:
159
# Preserve the aspect ratio by adding border
160
aspect = float(sizex) / float(sizey)
161
if aspect <= (float(SCRWIDTH) / float(SCRHEIGHT)):
163
newsizex = int(newsizey * aspect)
164
deltax = (SCRWIDTH - newsizex) / 2
168
newsizey = int(newsizex / aspect)
170
deltay = (SCRHEIGHT - newsizey) / 2
171
MagickResizeImage(wand, newsizex, newsizey, CatromFilter, 1.0)
172
MagickSetImageBorderColor(wand, pw)
173
MagickBorderImage(wand, pw, deltax, deltay)
175
# Keep aspect and Use device height as scaled image width so landscape mode is clean
176
aspect = float(sizex) / float(sizey)
177
screen_aspect = float(SCRWIDTH) / float(SCRHEIGHT)
178
# Get dimensions of the landscape mode screen
179
# Add 25px back to height for the battery bar.
180
wscreenx = SCRHEIGHT + 25
181
wscreeny = int(wscreenx / screen_aspect)
182
if aspect <= screen_aspect:
184
newsizex = int(newsizey * aspect)
185
deltax = (wscreenx - newsizex) / 2
189
newsizey = int(newsizex / aspect)
191
deltay = (wscreeny - newsizey) / 2
192
MagickResizeImage(wand, newsizex, newsizey, CatromFilter, 1.0)
193
MagickSetImageBorderColor(wand, pw)
194
MagickBorderImage(wand, pw, deltax, deltay)
196
MagickResizeImage(wand, SCRWIDTH, SCRHEIGHT, CatromFilter, 1.0)
198
if not self.opts.dont_sharpen:
199
MagickSharpenImage(wand, 0.0, 1.0)
201
MagickSetImageType(wand, GrayscaleType)
203
if self.opts.despeckle:
204
MagickDespeckleImage(wand)
206
MagickQuantizeImage(wand, self.opts.colors, RGBColorspace, 0, 1, 0)
207
dest = '%d_%d.png'%(self.num, i)
208
dest = os.path.join(self.dest, dest)
209
MagickWriteImage(wand, dest+'8')
210
os.rename(dest+'8', dest)
215
DestroyMagickWand(wand)
217
def render_pages(tasks, dest, opts, notification=None):
219
Entry point for the job server.
221
failures, pages = [], []
223
for num, path in tasks:
225
pages.extend(PageProcessor(path, dest, opts, num))
226
msg = _('Rendered %s')%path
228
failures.append(path)
229
msg = _('Failed %s')%path
231
msg += '\n' + traceback.format_exc()
232
if notification is not None:
233
notification(0.5, msg)
235
return pages, failures
238
class JobManager(object):
240
Simple job manager responsible for keeping track of overall progress.
243
def __init__(self, total, update):
247
self.add_job = lambda j: j
248
self.output = lambda j: j
249
self.start_work = lambda j: j
250
self.job_done = lambda j: j
252
def status_update(self, job):
254
#msg = msg%os.path.basename(job.args[0])
255
self.update(float(self.done)/self.total, job.msg)
257
def process_pages(pages, opts, update):
259
Render all identified comic pages.
261
if not _imagemagick_loaded:
262
raise RuntimeError('Failed to load ImageMagick')
264
tdir = PersistentTemporaryDirectory('_comic2lrf_pp')
265
job_manager = JobManager(len(pages), update)
268
tasks = server.split(pages)
270
jobs.append(ParallelJob('render_pages', lambda s:s, job_manager=job_manager,
271
args=[task, tdir, opts]))
272
server.add_job(jobs[-1])
276
ans, failures = [], []
279
if job.result is None:
280
raise Exception(_('Failed to process comic: %s\n\n%s')%(job.exception, job.traceback))
281
pages, failures_ = job.result
283
failures += failures_
284
return ans, failures, tdir
286
def config(defaults=None,output_format='lrf'):
287
desc = _('Options to control the conversion of comics (CBR, CBZ) files into ebooks')
289
c = Config('comic', desc)
291
c = StringConfig(defaults, desc)
292
c.add_opt('title', ['-t', '--title'],
293
help=_('Title for generated ebook. Default is to use the filename.'))
294
c.add_opt('author', ['-a', '--author'],
295
help=_('Set the author in the metadata of the generated ebook. Default is %default'),
296
default=_('Unknown'))
297
c.add_opt('output', ['-o', '--output'],
298
help=_('Path to output file. By default a file is created in the current directory.'))
299
c.add_opt('colors', ['-c', '--colors'], type='int', default=64,
300
help=_('Number of colors for grayscale image conversion. Default: %default'))
301
c.add_opt('dont_normalize', ['-n', '--disable-normalize'], default=False,
302
help=_('Disable normalize (improve contrast) color range for pictures. Default: False'))
303
c.add_opt('keep_aspect_ratio', ['-r', '--keep-aspect-ratio'], default=False,
304
help=_('Maintain picture aspect ratio. Default is to fill the screen.'))
305
c.add_opt('dont_sharpen', ['-s', '--disable-sharpen'], default=False,
306
help=_('Disable sharpening.'))
307
c.add_opt('disable_trim', ['--disable-trim'], default=False,
308
help=_('Disable trimming of comic pages. For some comics, '
309
'trimming might remove content as well as borders.'))
310
c.add_opt('landscape', ['-l', '--landscape'], default=False,
311
help=_("Don't split landscape images into two portrait images"))
312
c.add_opt('wide', ['-w', '--wide-aspect'], default=False,
313
help=_("Keep aspect ratio and scale image using screen height as image width for viewing in landscape mode."))
314
c.add_opt('right2left', ['--right2left'], default=False, action='store_true',
315
help=_('Used for right-to-left publications like manga. Causes landscape pages to be split into portrait pages from right to left.'))
316
c.add_opt('despeckle', ['-d', '--despeckle'], default=False,
317
help=_('Enable Despeckle. Reduces speckle noise. May greatly increase processing time.'))
318
c.add_opt('no_sort', ['--no-sort'], default=False,
319
help=_("Don't sort the files found in the comic alphabetically by name. Instead use the order they were added to the comic."))
320
c.add_opt('profile', ['-p', '--profile'], default='prs500', choices=PROFILES.keys(),
321
help=_('Choose a profile for the device you are generating this file for. The default is the SONY PRS-500 with a screen size of 584x754 pixels. This is suitable for any reader with the same screen size. Choices are %s')%PROFILES.keys())
322
c.add_opt('verbose', ['-v', '--verbose'], default=0, action='count',
323
help=_('Be verbose, useful for debugging. Can be specified multiple times for greater verbosity.'))
324
c.add_opt('no_progress_bar', ['--no-progress-bar'], default=False,
325
help=_("Don't show progress bar."))
326
if output_format == 'pdf':
327
c.add_opt('no_process',['--no_process'], default=False,
328
help=_("Apply no processing to the image"))
331
def option_parser(output_format='lrf'):
332
c = config(output_format=output_format)
333
return c.option_parser(usage=_('''\
334
%prog [options] comic.cb[z|r]
336
Convert a comic in a CBZ or CBR file to an ebook.
339
def create_epub(pages, profile, opts, thumbnail=None):
341
WRAPPER = textwrap.dedent('''\
344
<title>Page #%d</title>
345
<style type="text/css">@page {margin:0pt; padding: 0pt;}</style>
347
<body style="margin: 0pt; padding: 0pt">
348
<div style="text-align:center">
349
<img src="%s" alt="comic page #%d" />
354
dir = os.path.dirname(pages[0])
355
for i, page in enumerate(pages):
356
wrapper = WRAPPER%(i+1, os.path.basename(page), i+1)
357
page = os.path.join(dir, 'page_%d.html'%(i+1))
358
open(page, 'wb').write(wrapper)
359
wrappers.append(page)
361
mi = MetaInformation(opts.title, [opts.author])
362
opf = OPFCreator(dir, mi)
363
opf.create_manifest([(w, None) for w in wrappers])
364
opf.create_spine(wrappers)
365
metadata = os.path.join(dir, 'metadata.opf')
366
opf.render(open(metadata, 'wb'))
367
opts2 = html2epub_config('margin_left=0\nmargin_right=0\nmargin_top=0\nmargin_bottom=0').parse()
368
opts2.output = opts.output
369
html2epub(metadata, opts2)
371
def create_lrf(pages, profile, opts, thumbnail=None):
372
width, height = PROFILES[profile]
375
ps['evensidemargin'] = 0
376
ps['oddsidemargin'] = 0
377
ps['textwidth'] = width
378
ps['textheight'] = height
379
book = Book(title=opts.title, author=opts.author,
381
publisher='%s %s'%(__appname__, __version__), thumbnail=thumbnail,
382
category='Comic', pagestyledefault=ps,
383
booksetting=BookSetting(screenwidth=width, screenheight=height))
385
imageStream = ImageStream(page)
386
_page = book.create_page()
387
_page.append(ImageBlock(refstream=imageStream,
388
blockwidth=width, blockheight=height, xsize=width,
389
ysize=height, x1=width, y1=height))
392
book.renderLrf(open(opts.output, 'wb'))
393
print _('Output written to'), opts.output
396
def create_pdf(pages, profile, opts, thumbnail=None,toc=None):
397
width, height = PROFILES[profile]
399
from reportlab.pdfgen import canvas
412
letter=toc[0][0][base_cur]
413
for i in range(len(toc)):
414
if letter != toc[i][0][base_cur]:
421
toc.append(("Not seen",-1))
424
pdf = canvas.Canvas(filename=opts.output, pagesize=(width,height+15))
425
pdf.setAuthor(opts.author)
426
pdf.setTitle(opts.title)
430
if opts.keep_aspect_ratio:
431
img = NewMagickWand()
433
raise RuntimeError('Cannot create wand.')
434
if not MagickReadImage(img, page):
435
raise IOError('Failed to read image from: %'%page)
436
sizex = MagickGetImageWidth(img)
437
sizey = MagickGetImageHeight(img)
438
if opts.keep_aspect_ratio:
439
# Preserve the aspect ratio by adding border
440
aspect = float(sizex) / float(sizey)
441
if aspect <= (float(width) / float(height)):
443
newsizex = int(newsizey * aspect)
444
deltax = (width - newsizex) / 2
448
newsizey = int(newsizex / aspect)
450
deltay = (height - newsizey) / 2
451
pdf.drawImage(page, x=deltax,y=deltay,width=newsizex, height=newsizey)
453
pdf.drawImage(page, x=0,y=0,width=width, height=height)
455
if toc[toc_index][1] == cur_page:
456
tmp=toc[toc_index][0]
457
toc_current=tmp[rem:len(tmp)-4]
460
key = 'page%d-%d' % (cur_page, index)
461
pdf.bookmarkPage(key)
462
(head,dummy,list)=toc_current.partition(os.sep)
464
if heading[index] != head:
465
heading[index] = head
466
pdf.addOutlineEntry(title=head,key=key,level=index)
469
pdf.addOutlineEntry(title=head,key=key,level=index)
477
# Write the document to disk
481
def do_convert(path_to_file, opts, notification=lambda m, p: p, output_format='lrf'):
482
path_to_file = run_plugins_on_preprocess(path_to_file)
483
source = path_to_file
491
opts.title = os.path.splitext(os.path.basename(source))[0]
493
opts.output = os.path.abspath(os.path.splitext(os.path.basename(source))[0]+'.'+output_format)
494
if os.path.isdir(source):
495
for path in all_files( source , '*.cbr|*.cbz' ):
498
list= [ os.path.abspath(source) ]
501
tdir = extract_comic(source)
502
new_pages = find_pages(tdir, sort_on_mtime=opts.no_sort, verbose=opts.verbose)
505
raise ValueError('Could not find any pages in the comic: %s'%source)
506
if not getattr(opts, 'no_process', False):
507
new_pages, failures, tdir2 = process_pages(new_pages, opts, notification)
509
raise ValueError('Could not find any valid pages in the comic: %s'%source)
511
print 'Could not process the following pages (run with --verbose to see why):'
514
thumbnail = os.path.join(tdir2, 'thumbnail.png')
515
if not os.access(thumbnail, os.R_OK):
517
toc.append((source,len(pages)))
518
pages.extend(new_pages)
519
to_delete.append(tdir)
522
if output_format == 'lrf':
523
create_lrf(pages, opts.profile, opts, thumbnail=thumbnail)
524
if output_format == 'epub':
525
create_epub(pages, opts.profile, opts, thumbnail=thumbnail)
526
if output_format == 'pdf':
527
create_pdf(pages, opts.profile, opts, thumbnail=thumbnail,toc=toc)
528
for tdir in to_delete:
532
def all_files(root, patterns='*'):
533
# Expand patterns from semicolon-separated string to list
534
patterns = patterns.split('|')
535
for path, subdirs, files in os.walk(root):
538
for pattern in patterns:
539
if fnmatch.fnmatch(name, pattern):
540
yield os.path.join(path, name)
544
def main(args=sys.argv, notification=None, output_format='lrf'):
545
parser = option_parser(output_format=output_format)
546
opts, args = parser.parse_args(args)
549
print '\nYou must specify a file to convert'
552
if not callable(notification):
553
pb = ProgressBar(terminal_controller, _('Rendering comic pages...'),
554
no_progress_bar=opts.no_progress_bar or getattr(opts, 'no_process', False))
555
notification = pb.update
557
source = os.path.abspath(args[1])
558
do_convert(source, opts, notification, output_format=output_format)
561
if __name__ == '__main__':