diff --git a/src/calibre/ebooks/pdf/render/common.py b/src/calibre/ebooks/pdf/render/common.py new file mode 100644 index 0000000000..5abfe60a84 --- /dev/null +++ b/src/calibre/ebooks/pdf/render/common.py @@ -0,0 +1,152 @@ +#!/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 +from io import BytesIO + +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) +# }}} + +# Basic PDF datatypes {{{ + +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['Filter'] = 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')) +# }}} + diff --git a/src/calibre/ebooks/pdf/render/engine.py b/src/calibre/ebooks/pdf/render/engine.py index 0286244dbb..2336dab85c 100644 --- a/src/calibre/ebooks/pdf/render/engine.py +++ b/src/calibre/ebooks/pdf/render/engine.py @@ -11,13 +11,14 @@ import sys, traceback from math import sqrt from collections import namedtuple from future_builtins import map +from functools import wraps from PyQt4.Qt import (QPaintEngine, QPaintDevice, Qt, QApplication, QPainter, - QTransform, QPainterPath) + QTransform, QPainterPath, QFontMetricsF) from calibre.constants import DEBUG -from calibre.ebooks.pdf.render.serialize import (Color, inch, A4, PDFStream, - Path) +from calibre.ebooks.pdf.render.serialize import (Color, PDFStream, Path, Text) +from calibre.ebooks.pdf.render.common import inch, A4 XDPI = 1200 YDPI = 1200 @@ -25,6 +26,17 @@ YDPI = 1200 Point = namedtuple('Point', 'x y') ColorState = namedtuple('ColorState', 'color opacity do') +def store_error(func): + + @wraps(func) + def errh(self, *args, **kwargs): + try: + func(self, *args, **kwargs) + except: + self.errors.append(traceback.format_exc()) + + return errh + class GraphicsState(object): # {{{ def __init__(self): @@ -156,8 +168,9 @@ class GraphicsState(object): # {{{ # Now apply the new operations for op, val in ops.iteritems(): - self.apply(op, val, engine, pdf) - self.current_state[op] = val + if op != 'clip': + self.apply(op, val, engine, pdf) + self.current_state[op] = val def apply(self, op, val, engine, pdf): getattr(self, 'apply_'+op)(val, engine, pdf) @@ -219,8 +232,9 @@ class PdfEngine(QPaintEngine): self.do_stroke = True self.do_fill = False self.scale = sqrt(sy**2 + sx**2) - self.yscale = sy + self.xscale, self.yscale = sx, sy self.graphics_state = GraphicsState() + self.errors = [] def init_page(self): self.pdf.transform(self.pdf_system) @@ -246,7 +260,7 @@ class PdfEngine(QPaintEngine): compress=not DEBUG) self.init_page() except: - traceback.print_exc() + self.errors.append(traceback.format_exc()) return False return True @@ -261,7 +275,7 @@ class PdfEngine(QPaintEngine): self.end_page(start_new=False) self.pdf.end() except: - traceback.print_exc() + self.errors.append(traceback.format_exc()) return False finally: self.pdf = self.file_object = None @@ -270,12 +284,15 @@ class PdfEngine(QPaintEngine): def type(self): return QPaintEngine.Pdf + @store_error def drawPixmap(self, rect, pixmap, source_rect): print ('TODO: drawPixmap() currently unimplemented') + @store_error def drawImage(self, rect, image, source_rect, flags=Qt.AutoColor): print ('TODO: drawImage() currently unimplemented') + @store_error def updateState(self, state): self.graphics_state.read(state) self.graphics_state(self) @@ -299,6 +316,7 @@ class PdfEngine(QPaintEngine): p.curve_to(*(c1 + c2 + em)) return p + @store_error def drawPath(self, path): p = self.convert_path(path) fill_rule = {Qt.OddEvenFill:'evenodd', @@ -312,6 +330,7 @@ class PdfEngine(QPaintEngine): Qt.WindingFill:'winding'}[path.fillRule()] self.pdf.add_clip(p, fill_rule=fill_rule) + @store_error def drawPoints(self, points): p = Path() for point in points: @@ -319,14 +338,16 @@ class PdfEngine(QPaintEngine): p.line_to(point.x(), point.y() + 0.001) self.pdf.draw_path(p, stroke=self.do_stroke, fill=False) + @store_error def drawRects(self, rects): for rect in rects: bl = rect.topLeft() self.pdf.draw_rect(bl.x(), bl.y(), rect.width(), rect.height(), stroke=self.do_stroke, fill=self.do_fill) + @store_error def drawTextItem(self, point, text_item): - # super(PdfEngine, self).drawTextItem(point, text_item) + # super(PdfEngine, self).drawTextItem(point+QPoint(0, 300), text_item) f = text_item.font() px, pt = f.pixelSize(), f.pointSizeF() if px == -1: @@ -343,37 +364,46 @@ class PdfEngine(QPaintEngine): self.do_fill, self.do_stroke = f, s return - to = self.canvas.beginText() - # set_transform(QTransform(1, 0, 0, -1, point.x(), point.y()), to.setTextTransform) - fontname = 'Times-Roman' - to.setFont(fontname, sz) # TODO: Embed font + to = Text() + to.size = sz + to.set_transform(1, 0, 0, -1, point.x(), point.y()) stretch = f.stretch() if stretch != 100: - to.setHorizontalScale(stretch) + to.horizontal_scale = stretch ws = f.wordSpacing() if ws != 0: - to.setWordSpacing(self.map_dx(ws)) + to.word_spacing = ws spacing = f.letterSpacing() st = f.letterSpacingType() if st == f.AbsoluteSpacing and spacing != 0: - to.setCharSpace(spacing) - # TODO: Handle percentage letter spacing + to.char_space = spacing/self.scale + if st == f.PercentageSpacing and spacing not in {100, 0}: + # TODO: Implement this with the TJ operator + avg_char_width = QFontMetricsF(f).averageCharWidth() + to.char_space = (spacing - 100) * avg_char_width / 100 text = type(u'')(text_item.text()) - to.textOut(text) - # TODO: handle colors - self.canvas.drawText(to) + to.text = text + with self: + self.graphics_state.apply_fill(self.graphics_state.current_state['stroke'], + self, self.pdf) + self.pdf.draw_text(to) def draw_line(kind='underline'): - tw = self.canvas.stringWidth(text, fontname, sz) - p = self.canvas.beginPath() + m = QFontMetricsF(f) + tw = m.width(text) + p = Path() if kind == 'underline': - dy = -text_item.descent() + dy = m.underlinePos() elif kind == 'overline': - dy = text_item.ascent() + dy = -m.overlinePos() elif kind == 'strikeout': - dy = text_item.ascent()/2 - p.moveTo(point.x, point.y+dy) - p.lineTo(point.x+tw, point.y+dy) + dy = -m.strikeOutPos() + p.move_to(point.x(), point.y()+dy) + p.line_to(point.x()+tw, point.y()+dy) + with self: + self.graphics_state.apply_line_width(m.lineWidth(), + self, self.pdf) + self.pdf.draw_path(p, stroke=True, fill=False) if f.underline(): draw_line() @@ -382,6 +412,7 @@ class PdfEngine(QPaintEngine): if f.strikeOut(): draw_line('strikeout') + @store_error def drawPolygon(self, points, mode): if not points: return p = Path() @@ -397,8 +428,10 @@ class PdfEngine(QPaintEngine): def __enter__(self): self.pdf.save_stack() + self.saved_ps = (self.do_stroke, self.do_fill) def __exit__(self, *args): + self.do_stroke, self.do_fill = self.saved_ps self.pdf.restore_stack() class PdfDevice(QPaintDevice): # {{{ @@ -470,13 +503,18 @@ 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.setUnderline(True) + f.setFamily('Times New Roman') + p.setFont(f) + # p.scale(2, 2) + p.rotate(45) + p.setPen(QColor(0, 255, 0)) + p.drawText(QPoint(100, 300), 'Some text') finally: p.end() + if dev.engine.errors: + for err in dev.engine.errors: print (err) + raise SystemExit(1) diff --git a/src/calibre/ebooks/pdf/render/fonts.py b/src/calibre/ebooks/pdf/render/fonts.py new file mode 100644 index 0000000000..846d3fb144 --- /dev/null +++ b/src/calibre/ebooks/pdf/render/fonts.py @@ -0,0 +1,35 @@ +#!/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' + +from calibre.ebooks.pdf.render.common import ( + Dictionary, Name) + +STANDARD_FONTS = { + 'Times-Roman', 'Helvetica', 'Courier', 'Symbol', 'Times-Bold', + 'Helvetica-Bold', 'Courier-Bold', 'ZapfDingbats', 'Times-Italic', + 'Helvetica-Oblique', 'Courier-Oblique', 'Times-BoldItalic', + 'Helvetica-BoldOblique', 'Courier-BoldOblique', } + +class FontManager(object): + + def __init__(self, objects): + self.objects = objects + self.std_map = {} + + def add_standard_font(self, name): + if name not in STANDARD_FONTS: + raise ValueError('%s is not a standard font'%name) + if name not in self.std_map: + self.std_map[name] = self.objects.add(Dictionary({ + 'Type':Name('Font'), + 'Subtype':Name('Type1'), + 'BaseFont':Name(name) + })) + return self.std_map[name] + diff --git a/src/calibre/ebooks/pdf/render/serialize.py b/src/calibre/ebooks/pdf/render/serialize.py index 65bed5ca17..8d0b40eb49 100644 --- a/src/calibre/ebooks/pdf/render/serialize.py +++ b/src/calibre/ebooks/pdf/render/serialize.py @@ -7,156 +7,19 @@ __license__ = 'GPL v3' __copyright__ = '2012, Kovid Goyal ' __docformat__ = 'restructuredtext en' -import codecs, zlib, hashlib -from io import BytesIO +import hashlib from future_builtins import map from collections import namedtuple from calibre.constants import (__appname__, __version__) +from calibre.ebooks.pdf.render.common import ( + Reference, EOL, serialize, Stream, Dictionary, String, Name, Array) +from calibre.ebooks.pdf.render.fonts import FontManager PDFVER = b'%PDF-1.6' -EOL = b'\n' Color = namedtuple('Color', 'red green blue opacity') -# 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) -# }}} - -# Basic PDF datatypes {{{ - -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['Filter'] = 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): @@ -222,6 +85,7 @@ class Page(Stream): 'Parent': parentref, }) self.opacities = {} + self.fonts = {} def set_opacity(self, opref): if opref not in self.opacities: @@ -230,6 +94,11 @@ class Page(Stream): serialize(Name(name), self) self.write(b' gs ') + def add_font(self, fontref): + if fontref not in self.fonts: + self.fonts[fontref] = 'F%d'%len(self.fonts) + return self.fonts[fontref] + def add_resources(self): r = Dictionary() if self.opacities: @@ -237,6 +106,11 @@ class Page(Stream): for opref, name in self.opacities.iteritems(): extgs[name] = opref r['ExtGState'] = extgs + if self.fonts: + fonts = Dictionary() + for ref, name in self.fonts.iteritems(): + fonts[name] = ref + r['Font'] = fonts if r: self.page_dict['Resources'] = r @@ -263,6 +137,44 @@ class Path(object): def curve_to(self, x1, y1, x2, y2, x, y): self.ops.append((x1, y1, x2, y2, x, y, 'c')) +class Text(object): + + def __init__(self): + self.transform = self.default_transform = [1, 0, 0, 1, 0, 0] + self.font_name = 'Times-Roman' + self.font_path = None + self.horizontal_scale = self.default_horizontal_scale = 100 + self.word_spacing = self.default_word_spacing = 0 + self.char_space = self.default_char_space = 0 + self.size = 12 + self.text = '' + + def set_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 + self.transform = vals + + def pdf_serialize(self, stream, font_name): + if not self.text: return + stream.write_line('BT ') + serialize(Name(font_name), stream) + stream.write(' %g Tf '%self.size) + stream.write(' '.join(map(type(u''), self.transform)) + ' Tm ') + if self.horizontal_scale != self.default_horizontal_scale: + stream.write('%g Tz '%self.horizontal_scale) + if self.word_spacing != self.default_word_spacing: + stream.write('%g Tw '%self.word_spacing) + if self.char_space != self.default_char_space: + stream.write('%g Tc '%self.char_space) + stream.write_line() + serialize(String(self.text), stream) + stream.write(' Tj ') + stream.write_line('ET') + + class Catalog(Dictionary): def __init__(self, pagetree): @@ -325,6 +237,7 @@ class PDFStream(object): self.info = Dictionary({'Creator':String(creator), 'Producer':String(creator)}) self.stroke_opacities, self.fill_opacities = {}, {} + self.font_manager = FontManager(self.objects) @property def page_tree(self): @@ -377,8 +290,9 @@ class PDFStream(object): def add_clip(self, path, fill_rule='winding'): if not path.ops: return + self.write_path(path) op = 'W' if fill_rule == 'winding' else 'W*' - self.current_page.write(op + ' ' + 'n') + self.current_page.write_line(op + ' ' + 'n') def set_dash(self, array, phase=0): array = Array(array) @@ -421,6 +335,14 @@ class PDFStream(object): self.page_tree.obj.add_page(pageref) self.current_page = Page(self.page_tree, compress=self.compress) + def draw_text(self, text_object): + if text_object.font_path is None: + fontref = self.font_manager.add_standard_font(text_object.font_name) + else: + raise NotImplementedError() + name = self.current_page.add_font(fontref) + text_object.pdf_serialize(self.current_page, name) + def end(self): if self.current_page.getvalue(): self.end_page()