diff --git a/src/calibre/ebooks/pdf/render/__init__.py b/src/calibre/ebooks/pdf/render/__init__.py new file mode 100644 index 0000000000..743a9d0561 --- /dev/null +++ b/src/calibre/ebooks/pdf/render/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:fdm=marker:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2012, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + + + diff --git a/src/calibre/ebooks/pdf/render/engine.py b/src/calibre/ebooks/pdf/render/engine.py index 23b2ddeb09..59609825fc 100644 --- a/src/calibre/ebooks/pdf/render/engine.py +++ b/src/calibre/ebooks/pdf/render/engine.py @@ -15,20 +15,14 @@ from future_builtins import map from PyQt4.Qt import (QPaintEngine, QPaintDevice, Qt, QApplication, QPainter, QTransform, QPoint, QPainterPath) -from reportlab.lib.units import inch -from reportlab.lib.pagesizes import A4 -from reportlab.pdfgen.canvas import FILL_NON_ZERO, FILL_EVEN_ODD, Canvas -from reportlab.lib.colors import Color - from calibre.constants import DEBUG +from calibre.ebooks.pdf.render.serialize import inch, A4, PDFStream, Path XDPI = 1200 YDPI = 1200 Point = namedtuple('Point', 'x y') - -def set_transform(transform, func): - func(transform.m11(), transform.m12(), transform.m21(), transform.m22(), transform.dx(), transform.dy()) +Color = namedtuple('Color', 'red green blue opacity') class GraphicsState(object): # {{{ @@ -51,30 +45,24 @@ class GraphicsState(object): # {{{ if flags & QPaintEngine.DirtyBrush: brush = state.brush() color = brush.color() - alpha = color.alphaF() - if alpha == 1.0: alpha = None - self.ops['do_fill'] = 0 if (alpha == 0.0 or brush.style() == Qt.NoBrush) else 1 - self.ops['fill_color'] = Color(color.red(), color.green(), color.blue(), - alpha=alpha) + self.ops['do_fill'] = 0 if (color.alpha() == 0 or brush.style() == Qt.NoBrush) else 1 + self.ops['fill_color'] = Color(*color.getRgbF()) if flags & QPaintEngine.DirtyPen: pen = state.pen() brush = pen.brush() color = pen.color() - alpha = color.alphaF() - if alpha == 1.0: alpha = None self.ops['do_stroke'] = 0 if (pen.style() == Qt.NoPen or brush.style() == - Qt.NoBrush or alpha == 0.0) else 1 + Qt.NoBrush or color.alpha() == 0) else 1 ps = {Qt.DashLine:[3], Qt.DotLine:[1,2], Qt.DashDotLine:[3,2,1,2], Qt.DashDotDotLine:[3, 2, 1, 2, 1, 2]}.get(pen.style(), []) self.ops['dash'] = ps self.ops['line_width'] = pen.widthF() - self.ops['stroke_color'] = Color(color.red(), color.green(), - color.blue(), alpha=alpha) - self.ops['line_cap'] = {Qt.FlatCap:0, Qt.RoundCap:1, - Qt.SquareCap:2}.get(pen.capStyle(), 0) - self.ops['line_join'] = {Qt.MiterJoin:0, Qt.RoundJoin:1, - Qt.BevelJoin:2}.get(pen.joinStyle(), 0) + self.ops['stroke_color'] = Color(*color.getRgbF()) + self.ops['line_cap'] = {Qt.FlatCap:'flat', Qt.RoundCap:'round', + Qt.SquareCap:'square'}.get(pen.capStyle(), 'flat') + self.ops['line_join'] = {Qt.MiterJoin:'miter', Qt.RoundJoin:'round', + Qt.BevelJoin:'bevel'}.get(pen.joinStyle(), 'miter') if flags & QPaintEngine.DirtyClipPath: self.ops['clip'] = (state.clipOperation(), state.clipPath()) @@ -87,14 +75,14 @@ class GraphicsState(object): # {{{ # TODO: Add support for opacity def __call__(self, engine): - canvas = engine.canvas + pdf = engine.pdf ops = self.ops current_transform = ops.get('transform', None) srn = self.stack_reset_needed if srn: - canvas.restoreState() - canvas.saveState() + pdf.restore_stack() + pdf.save_stack() # Since we have reset the stack we need to re-apply all previous # operations ops = engine.graphics_state.ops.copy() @@ -125,40 +113,40 @@ class GraphicsState(object): # {{{ ops['clip'] = (Qt.NoClip, None) path = ops['clip'][1] if path is not None: - engine.set_clip(path) + engine.add_clip(path) elif prev_clip_path is not None: # Re-apply the previous clip path since no clipping operation was # specified - engine.set_clip(prev_clip_path) + engine.add_clip(prev_clip_path) ops['clip'] = (Qt.ReplaceClip, prev_clip_path) # Apply transform if current_transform is not None: engine.qt_system = current_transform - set_transform(current_transform, canvas.transform) + pdf.transform(current_transform) - if 'fill_color' in ops: - canvas.setFillColor(ops['fill_color']) - if 'stroke_color' in ops: - canvas.setStrokeColor(ops['stroke_color']) + # if 'fill_color' in ops: + # canvas.setFillColor(ops['fill_color']) + # if 'stroke_color' in ops: + # canvas.setStrokeColor(ops['stroke_color']) for x in ('fill', 'stroke'): x = 'do_'+x if x in ops: - setattr(canvas, x, ops[x]) + setattr(engine, x, ops[x]) if 'dash' in ops: - canvas.setDash(ops['dash']) + pdf.set_dash(ops['dash']) if 'line_width' in ops: - canvas.setLineWidth(ops['line_width']) + pdf.set_line_width(ops['line_width']) if 'line_cap' in ops: - canvas.setLineCap(ops['line_cap']) + pdf.set_line_cap(ops['line_cap']) if 'line_join' in ops: - canvas.setLineJoin(ops['line_join']) + pdf.set_line_join(ops['line_join']) if not srn: # Add the operations from the previous state object that were not # updated in this state object. This is needed to allow stack # resetting to work. - ops = canvas.graphics_state.ops.copy() + ops = engine.graphics_state.ops.copy() ops.update(self.ops) self.ops = ops @@ -197,8 +185,11 @@ class PdfEngine(QPaintEngine): self.graphics_state = GraphicsState() def init_page(self): - set_transform(self.pdf_system, self.canvas.transform) - self.canvas.saveState() + self.pdf.transform(self.pdf_system) + self.pdf.set_rgb_colorspace() + width = self.painter.pen().widthF() if self.isActive() else 0 + self.pdf.set_line_width(width) + self.pdf.save_stack() @property def features(self): @@ -208,9 +199,9 @@ class PdfEngine(QPaintEngine): def begin(self, device): try: - self.canvas = Canvas(self.file_object, - pageCompression=0 if DEBUG else 1, - pagesize=(self.page_width, self.page_height)) + self.pdf = PDFStream(self.file_object, (self.page_width, + self.page_height), + compress=0 if DEBUG else 1) self.init_page() except: traceback.print_exc() @@ -218,20 +209,20 @@ class PdfEngine(QPaintEngine): return True def end_page(self, start_new=True): - self.canvas.restoreState() - self.canvas.showPage() + self.pdf.restore_stack() + self.pdf.end_page() if start_new: self.init_page() def end(self): try: self.end_page(start_new=False) - self.canvas.save() + self.pdf.end() except: traceback.print_exc() return False finally: - self.canvas = self.file_object = None + self.pdf = self.file_object = None return True def type(self): @@ -248,41 +239,36 @@ class PdfEngine(QPaintEngine): self.graphics_state = state(self) def convert_path(self, path): - p = self.canvas.beginPath() - path = path.simplified() + p = Path() i = 0 while i < path.elementCount(): elem = path.elementAt(i) em = (elem.x, elem.y) i += 1 if elem.isMoveTo(): - p.moveTo(*em) + p.move_to(*em) elif elem.isLineTo(): - p.lineTo(*em) + p.line_to(*em) elif elem.isCurveTo(): if path.elementCount() > i+1: c1, c2 = map(lambda j:( - path.elementAt(j).x, path.elementAt(j)), (i, i+1)) + path.elementAt(j).x, path.elementAt(j).y), (i, i+1)) i += 2 - p.curveTo(*(c1 + c2 + em)) + p.curve_to(*(c1 + c2 + em)) return p def drawPath(self, path): p = self.convert_path(path) - old = self.canvas._fillMode - self.canvas._fillMode = {Qt.OddEvenFill:FILL_EVEN_ODD, - Qt.WindingFill:FILL_NON_ZERO}[path.fillRule()] - self.canvas.drawPath(p, stroke=self.do_stroke, - fill=self.do_fill) - self.canvas._fillMode = old + fill_rule = {Qt.OddEvenFill:'evenodd', + Qt.WindingFill:'winding'}[path.fillRule()] + self.pdf.draw_path(p, stroke=self.do_stroke, + fill=self.do_fill, fill_rule=fill_rule) - def set_clip(self, path): + def add_clip(self, path): p = self.convert_path(path) - old = self.canvas._fillMode - self.canvas._fillMode = {Qt.OddEvenFill:FILL_EVEN_ODD, - Qt.WindingFill:FILL_NON_ZERO}[path.fillRule()] - self.canvas.clipPath(p, fill=0, stroke=0) - self.canvas._fillMode = old + fill_rule = {Qt.OddEvenFill:'evenodd', + Qt.WindingFill:'winding'}[path.fillRule()] + self.pdf.add_clip(p, fill_rule=fill_rule) def drawPoints(self, points): for point in points: @@ -293,7 +279,7 @@ class PdfEngine(QPaintEngine): def drawRects(self, rects): for rect in rects: bl = rect.topLeft() - self.canvas.rect(bl.x(), bl.y(), rect.width(), rect.height(), + self.pdf.draw_rect(bl.x(), bl.y(), rect.width(), rect.height(), stroke=self.do_stroke, fill=self.do_fill) def drawTextItem(self, point, text_item): @@ -315,7 +301,7 @@ class PdfEngine(QPaintEngine): return to = self.canvas.beginText() - set_transform(QTransform(1, 0, 0, -1, point.x(), point.y()), to.setTextTransform) + # set_transform(QTransform(1, 0, 0, -1, point.x(), point.y()), to.setTextTransform) fontname = 'Times-Roman' to.setFont(fontname, sz) # TODO: Embed font stretch = f.stretch() @@ -354,25 +340,23 @@ class PdfEngine(QPaintEngine): draw_line('strikeout') def drawPolygon(self, points, mode): - points = [Point(p.x(), p.y()) for p in points] - p = self.canvas.beginPath() - p.moveTo(*points[0]) + if not points: return + p = Path() + p.move_to(points[0].x(), points[0].y()) for point in points[1:]: - p.lineTo(*point) - p.close() - old = self.canvas._fillMode - self.canvas._fillMode = {self.OddEvenMode:FILL_EVEN_ODD, - self.WindingMode:FILL_NON_ZERO}.get(mode, - FILL_EVEN_ODD) - self.canvas.drawPath(p, fill=(mode in (self.OddEvenMode, - self.WindingMode, self.ConvexMode))) - self.canvas._fillMode = old + p.line_to(point.x(), point.y()) + if points[-1] != points[0]: + p.line_to(points[0].x(), points[0].y()) + fill_rule = {self.OddEvenMode:'evenodd', + self.WindingMode:'winding'}.get(mode, 'evenodd') + self.pdf.draw_path(p, stroke=True, fill_rule=fill_rule, + fill=(mode in (self.OddEvenMode, self.WindingMode, self.ConvexMode))) def __enter__(self): - self.canvas.saveState() + self.pdf.save_stack() def __exit__(self, *args): - self.canvas.restoreState() + self.pdf.restore_stack() class PdfDevice(QPaintDevice): # {{{ @@ -438,14 +422,13 @@ if __name__ == '__main__': p.drawLine(0, 0, 5000, 0) p.restore() - - f = p.font() - f.setPointSize(24) - f.setFamily('Times New Roman') - p.setFont(f) - # p.scale(2, 2) - p.rotate(45) - p.drawText(QPoint(100, 300), 'Some text') + # f = p.font() + # f.setPointSize(24) + # f.setFamily('Times New Roman') + # p.setFont(f) + # # p.scale(2, 2) + # p.rotate(45) + # p.drawText(QPoint(100, 300), 'Some text') finally: p.end() diff --git a/src/calibre/ebooks/pdf/render/serialize.py b/src/calibre/ebooks/pdf/render/serialize.py new file mode 100644 index 0000000000..b232d008f5 --- /dev/null +++ b/src/calibre/ebooks/pdf/render/serialize.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:fdm=marker:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2012, Kovid Goyal ' +__docformat__ = 'restructuredtext en' + +import codecs, zlib, hashlib +from io import BytesIO +from future_builtins import map + +from calibre.constants import (__appname__, __version__) + +PDFVER = b'%PDF-1.6' +EOL = b'\n' + +# Sizes {{{ +inch = 72.0 +cm = inch / 2.54 +mm = cm * 0.1 +pica = 12.0 + +_W, _H = (21*cm, 29.7*cm) + +A6 = (_W*.5, _H*.5) +A5 = (_H*.5, _W) +A4 = (_W, _H) +A3 = (_H, _W*2) +A2 = (_W*2, _H*2) +A1 = (_H*2, _W*4) +A0 = (_W*4, _H*4) + +LETTER = (8.5*inch, 11*inch) +LEGAL = (8.5*inch, 14*inch) +ELEVENSEVENTEEN = (11*inch, 17*inch) + +_BW, _BH = (25*cm, 35.3*cm) +B6 = (_BW*.5, _BH*.5) +B5 = (_BH*.5, _BW) +B4 = (_BW, _BH) +B3 = (_BH*2, _BW) +B2 = (_BW*2, _BH*2) +B1 = (_BH*4, _BW*2) +B0 = (_BW*4, _BH*4) +# }}} + +def serialize(o, stream): + if hasattr(o, 'pdf_serialize'): + o.pdf_serialize(stream) + elif isinstance(o, bool): + stream.write(b'true' if o else b'false') + elif isinstance(o, (int, float)): + stream.write(type(u'')(o).encode('ascii')) + elif o is None: + stream.write(b'null') + else: + raise ValueError('Unknown object: %r'%o) + +class Name(unicode): + + def pdf_serialize(self, stream): + raw = self.encode('ascii') + if len(raw) > 126: + raise ValueError('Name too long: %r'%self) + buf = [x if 33 < ord(x) < 126 and x != b'#' else b'#'+hex(ord(x)) for x + in raw] + stream.write(b'/'+b''.join(buf)) + +class String(unicode): + + def pdf_serialize(self, stream): + s = self.replace('\\', '\\\\').replace('(', r'\(').replace(')', r'\)') + try: + raw = s.encode('latin1') + if raw.startswith(codecs.BOM_UTF16_BE): + raise UnicodeEncodeError('') + except UnicodeEncodeError: + raw = codecs.BOM_UTF16_BE + s.encode('utf-16-be') + stream.write(b'('+raw+b')') + +class Dictionary(dict): + + def pdf_serialize(self, stream): + stream.write(b'<<' + EOL) + for k, v in self.iteritems(): + serialize(Name(k), stream) + stream.write(b' ') + serialize(v, stream) + stream.write(EOL) + stream.write(b'>>' + EOL) + +class InlineDictionary(Dictionary): + + def pdf_serialize(self, stream): + stream.write(b'<< ') + for k, v in self.iteritems(): + serialize(Name(k), stream) + stream.write(b' ') + serialize(v, stream) + stream.write(b' ') + stream.write(b'>>') + +class Array(list): + + def pdf_serialize(self, stream): + stream.write(b'[') + for i, o in enumerate(self): + if i != 0: + stream.write(b' ') + serialize(o, stream) + stream.write(b']') + +class Stream(BytesIO): + + def __init__(self, compress=False): + BytesIO.__init__(self) + self.compress = compress + + def pdf_serialize(self, stream): + raw = self.getvalue() + dl = len(raw) + filters = Array() + if self.compress: + filters.append(Name('FlateDecode')) + raw = zlib.compress(raw) + + d = InlineDictionary({'Length':len(raw), 'DL':dl}) + if filters: + d['Filters'] = filters + serialize(d, stream) + stream.write(EOL+b'stream'+EOL) + stream.write(raw) + stream.write(EOL+b'endstream'+EOL) + + def write_line(self, raw=b''): + self.write(raw if isinstance(raw, bytes) else raw.encode('ascii')) + self.write(EOL) + + def write(self, raw): + super(Stream, self).write(raw if isinstance(raw, bytes) else + raw.encode('ascii')) + +class Reference(object): + + def __init__(self, num, obj): + self.num, self.obj = num, obj + + def pdf_serialize(self, stream): + raw = '%d 0 R'%self.num + stream.write(raw.encode('ascii')) + +class IndirectObjects(object): + + def __init__(self): + self._list = [] + self._map = {} + self._offsets = [] + + def __len__(self): + return len(self._list) + + def add(self, o): + self._list.append(o) + ref = Reference(len(self._list), o) + self._map[id(o)] = ref + self._offsets.append(None) + return ref + + def commit(self, ref, stream): + self.write_obj(stream, ref.num, ref.obj) + + def write_obj(self, stream, num, obj): + stream.write(EOL) + self._offsets[num-1] = stream.tell() + stream.write('%d 0 obj'%num) + stream.write(EOL) + serialize(obj, stream) + if stream.last_char != EOL: + stream.write(EOL) + stream.write('endobj') + stream.write(EOL) + + def __getitem__(self, o): + try: + return self._map[id(self._list[o] if isinstance(o, int) else o)] + except (KeyError, IndexError): + raise KeyError('The object %r was not found'%o) + + def pdf_serialize(self, stream): + for i, obj in enumerate(self._list): + offset = self._offsets[i] + if offset is None: + self.write_obj(stream, i+1, obj) + + def write_xref(self, stream): + self.xref_offset = stream.tell() + stream.write(b'xref'+EOL) + stream.write('0 %d'%(1+len(self._offsets))) + stream.write(EOL) + stream.write('%010d 65535 f '%0) + stream.write(EOL) + + for offset in self._offsets: + line = '%010d 00000 n '%offset + stream.write(line.encode('ascii') + EOL) + return self.xref_offset + +class Page(Stream): + + def __init__(self, parentref, *args, **kwargs): + super(Page, self).__init__(*args, **kwargs) + self.page_dict = Dictionary({ + 'Type': Name('Page'), + 'Parent': parentref, + }) + + def end(self, objects, stream): + contents = objects.add(self) + objects.commit(contents, stream) + self.page_dict['Contents'] = contents + ret = objects.add(self.page_dict) + objects.commit(ret, stream) + return ret + +class Path(object): + + def __init__(self): + self.ops = [] + + def move_to(self, x, y): + self.ops.append((x, y, 'm')) + + def line_to(self, x, y): + self.ops.append((x, y, 'l')) + + def curve_to(self, x1, y1, x2, y2, x, y): + self.ops.append((x1, y1, x2, y2, x, y, 'c')) + +class Catalog(Dictionary): + + def __init__(self, pagetree): + super(Catalog, self).__init__({'Type':Name('Catalog'), + 'Pages': pagetree}) + +class PageTree(Dictionary): + + def __init__(self, page_size): + super(PageTree, self).__init__({'Type':Name('Pages'), + 'MediaBox':Array([0, 0, page_size[0], page_size[1]]), + 'Kids':Array(), 'Count':0, + }) + + def add_page(self, pageref): + self['Kids'].append(pageref) + self['Count'] += 1 + +class HashingStream(object): + + def __init__(self, f): + self.f = f + self.tell = f.tell + self.hashobj = hashlib.sha256() + self.last_char = b'' + + def write(self, raw): + raw = raw if isinstance(raw, bytes) else raw.encode('ascii') + self.f.write(raw) + self.hashobj.update(raw) + if raw: + self.last_char = raw[-1] + +class PDFStream(object): + + PATH_OPS = { + # stroke fill fill-rule + ( False, False, 'winding') : 'n', + ( False, False, 'evenodd') : 'n', + ( False, True, 'winding') : 'f', + ( False, True, 'evenodd') : 'f*', + ( True, False, 'winding') : 'S', + ( True, False, 'evenodd') : 'S', + ( True, True, 'winding') : 'B', + ( True, True, 'evenodd') : 'B*', + } + + def __init__(self, stream, page_size, compress=False): + self.stream = HashingStream(stream) + self.compress = compress + self.write_line(PDFVER) + self.write_line(b'%íì¦"') + creator = ('%s %s [http://calibre-ebook.com]'%(__appname__, + __version__)) + self.write_line('%% Created by %s'%creator) + self.objects = IndirectObjects() + self.objects.add(PageTree(page_size)) + self.objects.add(Catalog(self.page_tree)) + self.current_page = Page(self.page_tree, compress=self.compress) + self.info = Dictionary({'Creator':String(creator), + 'Producer':String(creator)}) + + @property + def page_tree(self): + return self.objects[0] + + @property + def catalog(self): + return self.objects[1] + + def write_line(self, byts=b''): + byts = byts if isinstance(byts, bytes) else byts.encode('ascii') + self.stream.write(byts + EOL) + + def transform(self, *args): + if len(args) == 1: + m = args[0] + vals = [m.m11(), m.m12(), m.m21(), m.m22(), m.dx(), m.dy()] + else: + vals = args + cm = ' '.join(map(type(u''), vals)) + self.current_page.write_line(cm + ' cm') + + def set_rgb_colorspace(self): + self.current_page.write_line('/DeviceRGB CS /DeviceRGB cs') + + def save_stack(self): + self.current_page.write_line('q') + + def restore_stack(self): + self.current_page.write_line('Q') + + def reset_stack(self): + self.current_page.write_line('Q q') + + def draw_rect(self, x, y, width, height, stroke=True, fill=False): + self.current_page.write('%g %g %g %g re '%(x, y, width, height)) + self.current_page.write_line(self.PATH_OPS[(stroke, fill, 'winding')]) + + def write_path(self, path): + for i, op in enumerate(path.ops): + if i != 0: + self.current_page.write_line() + for x in op: + self.current_page.write(type(u'')(x) + ' ') + + def draw_path(self, path, stroke=True, fill=False, fill_rule='winding'): + if not path.ops: return + self.write_path(path) + self.current_page.write_line(self.PATH_OPS[(stroke, fill, fill_rule)]) + + def add_clip(self, path, fill_rule='winding'): + if not path.ops: return + op = 'W' if fill_rule == 'winding' else 'W*' + self.current_page.write(op + ' ' + 'n') + + def set_dash(self, array, phase=0): + array = Array(array) + serialize(array, self.current_page) + self.current_page.write(b' ') + serialize(phase, self.current_page) + self.current_page.write_line(' d') + + def set_line_width(self, width): + serialize(width, self.current_page) + self.current_page.write_line(' w') + + def set_line_cap(self, style): + serialize({'flat':0, 'round':1, 'square':2}.get(style), + self.current_page) + self.current_page.write_line(' J') + + def set_line_join(self, style): + serialize({'miter':0, 'round':1, 'bevel':2}[style], self.current_page) + self.current_page.write_line(' j') + + def end_page(self): + pageref = self.current_page.end(self.objects, self.stream) + self.page_tree.obj.add_page(pageref) + self.current_page = Page(self.page_tree, compress=self.compress) + + def end(self): + if self.current_page.getvalue(): + self.end_page() + inforef = self.objects.add(self.info) + self.objects.pdf_serialize(self.stream) + self.write_line() + startxref = self.objects.write_xref(self.stream) + file_id = String(self.stream.hashobj.hexdigest().decode('ascii')) + self.write_line('trailer') + trailer = Dictionary({'Root':self.catalog, 'Size':len(self.objects)+1, + 'ID':Array([file_id, file_id]), 'Info':inforef}) + serialize(trailer, self.stream) + self.write_line('startxref') + self.write_line('%d'%startxref) + self.stream.write('%%EOF') +