""" Provides a class to render a PDF (incompletely) using tkinter. This is for testing only! The PDF spec has many, many corner cases that this viewer does not handle separately. The purpose of this module is to allow seeing PDFs as minecart sees them. ***DO NOT USE THIS MODULE FOR PRODUCTION CODE. IT IS ENTIRELY UNTESTED.*** """ import ctypes import Tkinter as tkinter import tkFont import ttk import six import minecart.content import pdfminer.pdftypes # Constants for AddFontResource in windll: FR_PRIVATE = 0x10 FR_NOT_ENUM = 0x20 class AutoScrollbar(ttk.Scrollbar): #pylint: disable=R0901 """ A scrollbar that hides itself if it's not needed. Only works if you use the grid geometry manager. """ def set(self, lo_val, hi_val): #pylint: disable=W0221 if float(lo_val) <= 0.0 and float(hi_val) >= 1.0: self.grid_remove() else: self.grid() ttk.Scrollbar.set(self, lo_val, hi_val) def pack(self, **kw): raise tkinter.TclError("Cannot use pack with AutoScrollbar") def place(self, **kw): raise tkinter.TclError("Cannot use place with AutoScrollbar") class ScrollingCanvas(tkinter.Canvas, object): #pylint: disable=R0904,R0901 """ A canvas packed in a frame with scrollbars that appear when needed. """ def __init__(self, master=None, cnf=None, **kwargs): self.frame = ttk.Frame(master) self.frame.grid_rowconfigure(0, weight=1) self.frame.grid_columnconfigure(0, weight=1) self.xbar = AutoScrollbar(self.frame, orient=tkinter.HORIZONTAL) self.xbar.grid(row=1, column=0, sticky=tkinter.E + tkinter.W) self.ybar = AutoScrollbar(self.frame) self.ybar.grid(row=0, column=1, sticky=tkinter.S + tkinter.N) tkinter.Canvas.__init__(self, self.frame, cnf or {}, xscrollcommand=self.xbar.set, yscrollcommand=self.ybar.set, **kwargs) tkinter.Canvas.grid(self, row=0, column=0, sticky=tkinter.E + tkinter.W + tkinter.N + tkinter.S) self.xbar.config(command=self.xview) self.ybar.config(command=self.yview) self.bind("<MouseWheel>", self.on_mousewheel) def pack(self, cnf=None, **kw): """Pack the parent frame.""" self.frame.pack(cnf or {}, **kw) def grid(self, cnf=None, **kw): """Grid the parent frame.""" self.frame.grid(cnf or {}, **kw) def on_mousewheel(self, event): "Called when the user tries to scroll with the mousewheel." self.yview_scroll(-1 * (event.delta / 120) ** 3, "units") def create_window(self, *args, **kw): "Make sure the mouse wheel is bound in children windows." widget = kw['window'] widget.bind("<MouseWheel>", self.on_mousewheel) return tkinter.Canvas.create_window(self, *args, **kw) def loadfont(fontpath, private=True, enumerable=False): ''' Makes fonts located in file `fontpath` available to the font system. `private` if True, other processes cannot see this font, and this font will be unloaded when the process dies `enumerable` if True, this font will appear when enumerating fonts See https://msdn.microsoft.com/en-us/library/dd183327(VS.85).aspx ''' # This function was taken from digsby: # https://github.com/ifwe/digsby/blob/f5fe00244744aa131e07f09348d10563f3d8fa99/digsby/src/gui/native/win/winfonts.py#L15 if isinstance(fontpath, six.binary_type): pathbuf = ctypes.create_string_buffer(fontpath) AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExA elif isinstance(fontpath, six.text_type): pathbuf = ctypes.create_unicode_buffer(fontpath) AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExW else: raise TypeError('fontpath must be of type {} or {}'.format(six.binary_type, six.text_type)) flags = ((FR_PRIVATE if private else 0) | (FR_NOT_ENUM if not enumerable else 0)) return AddFontResourceEx(ctypes.byref(pathbuf), flags, 0) def loadfont_memory(data): "Load a given font stored in memory." num = ctypes.c_uint32() ctypes.windll.gdi32.AddFontMemResourceEx(data, len(data), 0, ctypes.byref(num)) return num.value def get_font_program(font_dict): """ Extracts the font program from a PDF font dictionary. Returns a tuple (family_name, font_program_data, attrs), where family_name is the unicode name of the family that can be passed to `tkFont.Font` to create the font. `font_program_data` is the raw binary data of the embedded font, if any, that can be passed to `loadfont_memory` to load the font into the central repository, or `None` if the font isn't emebedded. `attrs` is a dictionary of kwargs that can be passed to `tkFont.Font`. """ subtype = str(pdfminer.pdftypes.resolve1(font_dict['Subtype'])) if subtype == '/Type1': return get_type1_font(font_dict) elif subtype == '/TrueType': return get_truetype_font(font_dict) elif subtype == '/Type0': return get_type0_font(font_dict) elif subtype == '/Type3': raise pdfminer.pdftypes.PDFNotImplementedError( "Type 3 Fonts are not supported by this viewer") elif subtype == '/MMType1': raise pdfminer.pdftypes.PDFNotImplementedError( "Multiple Master Fonts are not supported by this viewer") raise pdfminer.pdftypes.PDFValueError( "Unknown font subtype: '%s'" % subtype) def extract_font_stream(font_dict, file_names): """ Try to extract the embedded font program data from a font_dict. Returns (stream, data) or (None, None) if there is no embedded font. file_names is a sequence of keys to try looking in under the font descriptor. (E.g., ('/FontFile2', '/FontFile3') for TrueType fonts) """ desc = pdfminer.pdftypes.resolve1(font_dict['FontDescriptor']) for name in file_names: try: stream = desc[name] except KeyError: pass else: stream = pdfminer.pdftypes.resolve1(stream) return stream, stream.get_data() return None, None def get_type1_font(font_dict): # Convert base name to regular string without the leading backslash base_name = str(pdfminer.pdftypes.resolve1(font_dict.get('BaseFont')))[1:] if base_name in ('Times-Roman', 'Times-Bold', 'Times-Italic', 'Times-BoldItalic', 'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique', 'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique'): root_name = base_name.split("-", 1)[0] args = {} if 'Bold' in base_name: args['weight'] = 'bold' if 'Italic' in base_name or 'Oblique' in base_name: args['slant'] = 'italic' return root_name, None, args elif base_name in ('Symbol', 'ZapfDingbats'): raise pdfminer.pdftypes.PDFNotImplementedError( "The '%s' builtin font is not supported by this viewer." % base_name) if (base_name[6] == '+' and set(base_name[:6]) < set('ABCDEFGHIJKLMNOPQRSTUVWXYZ')): base_name = base_name[7:] data = extract_font_stream(font_dict, ('FontFile', 'FontFile3'))[1] return base_name, data, {} def get_truetype_font(font_dict): base_name = str(pdfminer.pdftypes.resolve1(font_dict.get('BaseFont')))[1:] if (base_name[6] == '+' and set(base_name[:6]) < set('ABCDEFGHIJKLMNOPQRSTUVWXYZ')): base_name = base_name[7:] args = {} if ',' in base_name: base_name, attrs = base_name.split(',', 1) if 'Bold' in attrs: args['weight'] = 'bold' if 'Italic' in attrs: args['slant'] = 'italic' data = extract_font_stream(font_dict, ('FontFile2', 'FontFile3'))[1] return base_name, data, args def get_type0_font(font_dict): subfont = pdfminer.pdftypes.resolve1(font_dict['DescendantFonts'])[0] subfont = pdfminer.pdftypes.resolve1(subfont) subtype = str(pdfminer.pdftypes.resolve1(subfont['Subtype'])) if subtype == '/CIDFontType0': name, data, args = get_type1_font(subfont) elif subtype == '/CIDFontType2': return get_truetype_font(subfont) else: raise pdfminer.pdftypes.PDFValueError( "Unknown subtype of CID font: '%s'" % subtype) class TkPage(ScrollingCanvas): """ The canvas displays the contents from one page only. """ def __init__(self, master, page, zoom=1, **kwargs): self.res = zoom * master.winfo_fpixels('1i') / 72.0 # pixels per point if 'width' not in kwargs: kwargs['width'] = page.width * self.res if 'height' not in kwargs: kwargs['height'] = page.height * self.res kwargs['scrollregion'] = (0, 0, kwargs['width'], kwargs['height']) super(TkPage, self).__init__(master, **kwargs) self.page = page # A minecart.Page object self.postscript_names = {} # A mapping of PDF font names to PS names self.font_cache = {} # To prevent loading duplicate fonts def render(self): "Render all shapes and text onto the canvas" elems = sorted(self.page.shapes + self.page.letterings + self.page.images, key=lambda obj: obj.z_index) for elem in elems: if isinstance(elem, minecart.content.Shape): self.render_shape(elem) elif isinstance(elem, minecart.content.Image): self.render_image(elem) else: self.render_lettering(elem) def render_shape(self, shape): "Render the given shape onto the canvas." subpaths = [] filled = shape.fill is not None closed_path = True curpath = None for segment in shape.path: kind = segment[0] if kind == 'm': if filled and not closed_path: curpath.extend(curpath[:2] * 3) curpath = list(segment[1:]) subpaths.append(curpath) elif kind == 'l': curpath.extend(curpath[-2:]) curpath.extend(segment[1:] * 2) elif kind == 'c': curpath.extend(segment[1:]) elif kind == 'h': curpath.extend(curpath[:2] * 3) elif kind == 'v': curpath.extend(curpath[-2:]) curpath.extend(segment[1:]) elif kind == 'y': curpath.extend(segment[1:]) curpath.extend(curpath[-2:]) else: raise ValueError("Invalid path operator '%s'" % kind) if filled: drawer = self.create_polygon args = {'fill': "#%02X%02X%02X" % tuple(int(255 * c + .5) for c in shape.fill.color.as_rgb())} else: drawer = self.create_line args = {} if shape.stroke is None: args['width'] = 0 else: args['width'] = shape.stroke.linewidth or 2 args['joinstyle'] = \ ('miter', 'round', 'bevel')[shape.stroke.linejoin or 0] if shape.stroke.dash and shape.stroke.dash[0]: args['dash'] = shape.stroke.dash[0] # ignore the phase args['smooth'] = 'raw' for subpath in subpaths: xvals = [xval * self.res for xval in subpath[::2]] yvals = [(self.page.height - yval) * self.res for yval in subpath[1::2]] subpath = sum(zip(xvals, yvals), tuple()) drawer(subpath, **args) def render_image(self, image): "Render the given image onto the canvas." from PIL import ImageTk size = (image.bbox[2] - image.bbox[0], image.bbox[3] - image.bbox[1]) size = tuple(int(c * self.res + .5) for c in size) tkim = ImageTk.PhotoImage(image.as_pil().resize(size)) pos = (image.bbox[0] * self.res, (self.page.height - image.bbox[3]) * self.res) image.tkim = tkim self.create_image(pos, anchor='nw', image=tkim) def render_lettering(self, lettering): "Render the given lettering onto the canvas." font = self.get_font(lettering.font, lettering.bbox[3] - lettering.bbox[1]) top = (self.page.height - lettering.bbox[3]) * self.res left = lettering.bbox[0] * self.res self.create_text((left, top), anchor='nw', text=lettering, font=font) def get_font(self, m_font, size): "Return a font matching the given specs, loading it if necessary." try: family, attrs = self.font_cache[m_font] except KeyError: family, data, attrs = get_font_program(m_font) self.font_cache[m_font] = family, attrs loadfont_memory(data) desc = pdfminer.pdftypes.resolve1(m_font.descriptor) if 'FontWeight' in desc: attrs['weight'] = 'normal' if desc['FontWeight'] < 450 else 'bold' if 'ItalicAngle' in desc: attrs['slant'] = 'roman' if desc['ItalicAngle'] ==0 else 'italic' return tkFont.Font(family=family, size=-int(size * self.res), **attrs)