Source code for schrodinger.application.report2d

"""
Utilities for generating PDF and HTML 2D report files.

Copyright Schrodinger, LLC. All rights reserved.

"""

import os
import shutil
import sys
from past.utils import old_div

from schrodinger import structure
from schrodinger.infra import canvas2d
from schrodinger.infra import mm
from schrodinger.Qt import QtCore
from schrodinger.Qt import QtGui
from schrodinger.Qt import QtPrintSupport
from schrodinger.Qt import QtWidgets
from schrodinger.Qt.QtCore import Qt
from schrodinger.ui.qt.structtable import draw_picture_into_rect
# Check whether SCHRODINGER_PYTHON_DEBUG is set for debugging:
from schrodinger.utils import fileutils
from schrodinger.utils import log

# For some reason switching to swidgets requires a Canvas license.
# from schrodinger.ui.qt.swidgets import draw_picture_into_rect
# FIXME make this change in the future.

DEBUG = (log.get_environ_log_level() <= log.DEBUG)

# FIXME Use the logger module

# Image size export (HTML, PDF, PNG, and EPS):
EXPORT_IMAGE_HEIGHT = 200
EXPORT_IMAGE_WIDTH = 400

# When exporting as un-scaled, make bonds 60 pixels long:
# (Bonds are 100 pixels in QPicture generated by Canvas)
EXPORT_NOSCALE_SCALE = 0.6
# This produces 0.2 inch bonds at 300dpi

# Whether to have the images in HTML & Excel appear a fraction of their original size:
HTML_IMAGE_SCALE = 0.5

# When running OUTSIDE of 2D Viewer, maximum number of atoms a structure must
# have in order to be exported:
MAX_ATOMS = 300

# Whether input structures are 2D or 3D
STEREO_3D = canvas2d.ChmMmctAdaptor.StereoFromGeometry_Safe
STEREO_2D = canvas2d.ChmMmctAdaptor.StereoFromAnnotation_Safe
STEREO_AUTO = canvas2d.ChmMmctAdaptor.StereoFromAnnotationAndGeometry_Safe


[docs]class StructInfo: """ Stores information that is needed to draw a structure to PDF/HTML. This includes the QPicture or image file path, picture dimentions, lables, and structure title. """
[docs] def __init__(self, pic, width, height, labels, title): self.pic = pic self.width = width self.height = height self.labels = labels self.title = title
[docs]class PicturePrinter(object): """ Class for drawing structure pictures to a QPrinter for generating PDFs. """ PAGE_MARGIN = 10 CELL_PADDING = 10
[docs] def __init__(self, printer, pictures, num_cols, max_scale_factor): """ :type printer: QPrinter instance :param printer: PDF printer to draw the pictures to. :type pictures: List of StructInfo instances. :param pictures: Pictures to draw for each structure. :type num_cols: int :param num_cols: Number of columns to create in the PDF. :type max_scale_factor: float :param max_scale_factor: This setting will set the limit on shrinking of smaller structures. So that, for example, benzens never take up the whole image. """ self.printer = printer self.pictures = pictures self.num_cols = num_cols self.max_scale_factor = max_scale_factor self.painter = QtGui.QPainter() self.painter.begin(self.printer) # may fail to open the file # FIXME raise a clean exception if the file can't be opened self.hw_ratio = old_div(float(EXPORT_IMAGE_HEIGHT), float(EXPORT_IMAGE_WIDTH)) self.column_width = old_div((printer.width() - (self.PAGE_MARGIN * 2)), num_cols)
# The column will have and additional margin to the left & right of it # # equal to CELL_PADDING / 2 def _calc_pic_size(self, width, height): """ Determine the size that the QPicture of the given width and heights should be drawn at. """ width = self.column_width - (self.CELL_PADDING // 2) height = int(width * self.hw_ratio) return width, height def _calc_row_height(self, cells): """ Calculate the height of the row, given a list of StrucInfo instances. """ # Determine the height of the row: cell_heights = [] for icol, st_info in enumerate(cells): pic_width, pic_height = self._calc_pic_size(st_info.width, st_info.height) cell_height = pic_height + 20 # Add the heights of all the properties: for label in st_info.labels: rect = QtCore.QRectF(0, 0, pic_width, 20) boundrect = self.painter.boundingRect(rect, label) cell_height += boundrect.height() cell_heights.append(cell_height) row_height = max(cell_heights) return row_height def _draw_cell(self, st_info, x, y): """ Draw the given StructInfo at the specified coordinate. """ # Left-most part of the cell: pic_width, pic_height = self._calc_pic_size(st_info.width, st_info.height) destrect = QtCore.QRect(x, y, pic_width, pic_height) draw_picture_into_rect(self.painter, st_info.pic, destrect, self.max_scale_factor) y += pic_height + 20 # Draw the labels: for label in st_info.labels: rect = QtCore.QRectF(x, y, pic_width, 20) # The text will be wrapped if it doesn't fit on one line... boundrect = self.painter.boundingRect(rect, label) # ...unless it has no white space, in which case we clip it. boundrect.setWidth(pic_width - self.CELL_PADDING) self.painter.setClipRect(boundrect) self.painter.drawText(boundrect, label) y += boundrect.height() # Turn clipping off self.painter.setClipping(False)
[docs] def run(self): """ Execute the drawing. """ # Separate the pictures by rows: rows = [] for i, st_info in enumerate(self.pictures): if i % self.num_cols == 0: rows.append([]) rows[-1].append(st_info) # Draw the pictures and labels: for irow, cells in enumerate(rows): row_height = self._calc_row_height(cells) if irow == 0: # First row rowtop = self.PAGE_MARGIN else: # Not the first row; determine if we should start a new page: if rowtop + row_height > self.printer.height(): self.printer.newPage() rowtop = self.PAGE_MARGIN # Now draw every cell of this row: for icol, st_info in enumerate(cells): sys.stdout.write(".") sys.stdout.flush() x = self.PAGE_MARGIN + (icol * self.column_width) + (self.CELL_PADDING) self._draw_cell(st_info, x, rowtop) # Draw an outline around each cell outline = QtCore.QRect(x - self.CELL_PADDING, rowtop, self.column_width, row_height + self.CELL_PADDING) self.painter.drawRect(outline) # TODO in the spec it is mentioned that grid lines should # be added between cells. # The bottom of this row will become the top of the next row: rowtop += row_height + self.CELL_PADDING self.painter.end()
[docs]class Report2D:
[docs] def __init__(self, adaptor, renderer, render_model): self.num_cols = 3 self.paper_size = "LETTER" self.adaptor = adaptor self.renderer = renderer self.render_model = render_model self.display_property_names = True # FIXME Add documentation for these 2 options: self.props = [] # Ev:102471 What precisions to round off the properties to: self.precisions = {} self.stereo = STEREO_AUTO # Whether to draw the structures the same size (bond lengths will vary) self.images_same_size = True # When images_same_size is True, this will set the limit on shrinking, so # that benzenes never take up the whole image: self.max_scale_factor = 0.2 # When images_same_size is False, this is used to determine how # long the bonds will be: # When exporting as un-scaled, make bonds 60 pixels long: # (Bonds are 100 pixels in QPicture generated by Canvas) # This produces 0.2 inch bonds at 300dpi self.bond_length_scale = 0.6 # FIXME combine with the global constant EXPORT_NOSCALE_SCALE # Whether to neutralize the structure first PYAPP-3710 self.neutralize = False
def _savePicToFile(self, pic, filename): """ Saves specified QPicture to specified file path, using the global model. Used by generateReport() and exportToPng() """ border = 15 # border to add around the QPicture if self.images_same_size: width = EXPORT_IMAGE_WIDTH height = EXPORT_IMAGE_HEIGHT else: scale = self.bond_length_scale picrect = pic.boundingRect() width = picrect.width() * scale + border + border height = picrect.height() * scale + border + border img = QtGui.QImage(QtCore.QSize(width, height), QtGui.QImage.Format_ARGB32_Premultiplied) # Required to avoid garbage in the background: img.fill(Qt.transparent) painter = QtGui.QPainter() painter.begin(img) if self.images_same_size: destrect = QtCore.QRect(border, border, width - border - border, height - border - border) draw_picture_into_rect(painter, pic, destrect, self.max_scale_factor) else: painter.translate(border, border) painter.scale(scale, scale) painter.drawPicture(-pic.boundingRect().left(), \ -pic.boundingRect().top(), pic) painter.scale(old_div(1.0, scale), old_div(1.0, scale)) painter.end() img.save(filename) return width, height def _generateHtml(self, outfile): """ Generates the HTML file from the saved images """ # Generate the image directory: image_dir = "%s_img" % fileutils.splitext(outfile)[0] if os.path.isfile(image_dir): try: os.remove(image_dir) except: raise RuntimeError("Failed to remove existing file: %s" % image_dir) elif os.path.isdir(image_dir): try: shutil.rmtree(image_dir) except: raise RuntimeError("Failed to remove existing directory: %s" % image_dir) try: os.mkdir(image_dir) except: raise RuntimeError("Failed to create directory: %s" % image_dir) base_name = os.path.join(image_dir, fileutils.get_basename(outfile)) print("\nGenerating structure images...") pictures = self._generateImages(base_name) print("\nGenerating XHTML file...") fh = QtCore.QFile(outfile) fh.open(QtCore.QIODevice.WriteOnly) xml = QtCore.QXmlStreamWriter(fh) xml.setAutoFormatting(True) xml.writeStartElement("html") xml.writeStartElement("body") xml.writeStartElement("table") xml.writeAttribute("border", "1") # Draw border lines # Spreadsheet style is one structure per row. First cell is the 2D image, # remaining cells are properties: if self.num_cols == 0: spreadsheet_style = True self.num_cols = 1 else: spreadsheet_style = False if spreadsheet_style: # Write the header row xml.writeStartElement("tr") xml.writeStartElement("td") xml.writeCharacters("Structure") xml.writeEndElement() # td for prop in self.props: username = prop.userName() xml.writeStartElement("td") xml.writeCharacters(username) xml.writeEndElement() # td xml.writeEndElement() # tr col = 0 for i, st_info in enumerate(pictures): image_fname = st_info.pic width = st_info.width height = st_info.height sys.stdout.write(".") sys.stdout.flush() col += 1 if col == 1 or spreadsheet_style: # Start a new row xml.writeStartElement("tr") # Image height and width strings: DISPLAY_HEIGHT = int(st_info.height * HTML_IMAGE_SCALE) DISPLAY_WIDTH = int(width * HTML_IMAGE_SCALE) # Ev:83257 Put relative image path into the HTML file: tmp_dirpath, filename = os.path.split(image_fname) dirname = os.path.split(tmp_dirpath)[1] # HTML uses forward slash as path separator: relative_image_fname = "%s/%s" % (dirname, filename) # Treat quotes right: st_title = st_info.title.replace('"', '&quot;') relative_image_fname.replace('"', '&quot;') # Start a new cell: xml.writeStartElement("td") # For some reason, if we don't add one pixel, then images get # offset by one pixel with each row: xml.writeAttribute("height", str(DISPLAY_HEIGHT + 1)) xml.writeAttribute("width", str(DISPLAY_WIDTH)) xml.writeStartElement("img") xml.writeAttribute("alt", st_title) # Ev:83259 xml.writeAttribute("src", relative_image_fname) xml.writeAttribute("height", str(DISPLAY_HEIGHT)) xml.writeAttribute("width", str(DISPLAY_WIDTH)) xml.writeEndElement() # img if spreadsheet_style: # Write labels to seperate cells: xml.writeEndElement() # td structure cell for label in st_info.labels: xml.writeStartElement("td") xml.writeCharacters(label) xml.writeEndElement() # td label cell else: # Write labels to the same cell: for label in st_info.labels: xml.writeEmptyElement("br") xml.writeCharacters(label) xml.writeEndElement() # td cell if col == self.num_cols or spreadsheet_style: col = 0 xml.writeEndElement() # tr if not spreadsheet_style and col != 0: # If the last row was not completed: while col != self.num_cols: # Will this row with cells until the end: xml.writeStartElement("td") xml.writeCharacters(" ") xml.writeEndElement() # td col += 1 xml.writeEndElement() # tr xml.writeEndElement() # table xml.writeEndElement() # body xml.writeEndElement() # HTML fh.close() def _generatePdf(self, outfile): """ Generates the PDF file from the saved images. :type outfile: str :param outfile: Path to the PDF file to generate. """ print("\nGenerating structure images...") pictures = self._generateImages(None, qpic=True) if len(pictures) == 0: return print("\nGenerating PDF file...") printer = QtPrintSupport.QPrinter() printer.setFullPage(True) paper_size = self.paper_size.upper() if paper_size == "A4": printer.setPageSize(QtPrintSupport.QPrinter.A4) elif paper_size == "LETTER": printer.setPageSize(QtPrintSupport.QPrinter.Letter) else: raise ValueError("Invalid paper size: '%s'" % self.paper_size) printer.setOrientation(QtPrintSupport.QPrinter.Portrait) printer.setOutputFileName(outfile) if sys.platform == 'win32': printer.setOutputFormat( QtPrintSupport.QPrinter.PdfFormat) # Fix for Ev:131693 elif sys.platform == 'darwin': printer.setOutputFormat( QtPrintSupport.QPrinter.NativeFormat) # Fix for Ev:124251 else: pass # Linux pp = PicturePrinter(printer, pictures, self.num_cols, self.max_scale_factor) pp.run() def _generateImages(self, base_name, qpic=False): """ Generates 2D structure images and saves them to files :param qpic: whether to return a list of QPicture objects instead of file paths. If qpic is True, base_name is ignored. """ images = [] bad_render = 0 if self.neutralize: # This will check out a LigPrep license: mm.mmneut_initialize_lic(mm.error_handler) st_num = 0 num_skipped = 0 for st in self.sts: st_num += 1 sys.stdout.write(".") sys.stdout.flush() if not qpic: # Determine what file to save the picture to: image_fname = "%s%d.png" % (base_name, st_num) # Ev:84855 if this cell has more than max_atoms # of atoms, # then st will be None. # If failed to render or too many atoms: if st.atom_total > MAX_ATOMS: # When running in generate_2d_report num_skipped += 1 else: if self.neutralize: try: new_handle = mm.mmneut_a_ct(st.handle) st = structure.Structure(new_handle) except mm.MmException as e: # We just skip neutralization step on error. print("WARNING: Failed to neutralize structure %i" % st_num) chmmol = self.adaptor.create(st.handle, self.stereo) # FIXME We should re-enable skipping of un-rendrered structures #try: pic = self.renderer.getQPicture(chmmol) #except Exception, err: # print 'WARNING:', err # output = base64.decodestring(failed_to_render_icon) # bad_render += 1 # raise # Generate a list of property label strings: st_labels = [] for prop in self.props: username = prop.userName() dataname = prop.dataName() value = st.property.get(dataname, 'None') # Ev:102471 Round off the real properties: if value != 'None' and self.precisions and dataname.startswith( "r_"): precision = self.precisions.get(dataname, 5) value = round(value, precision) if self.display_property_names: label = "%s: %s" % (username, value) else: label = str(value) st_labels.append(label) if qpic: # No need to save width & height when storing QPictures: info = StructInfo(pic, None, None, st_labels, st.title) else: width, height = self._savePicToFile(pic, image_fname) info = StructInfo(image_fname, width, height, st_labels, st.title) images.append(info) if self.neutralize: mm.mmneut_terminate() if num_skipped == self.num_sts: msg = "Failed to export, because every structure had too many atoms" print(msg) elif num_skipped > 0: msg = "%i structures will not be exported because they have too many atoms" % num_skipped print(msg) # FIXME Currently this code is not getting run because # bad structures are not getting skipped (program instead exits). if bad_render != 0: self.warning("WARNING: %d structures could not be rendered" % bad_render) return images def _generateReport(self, outfile, type): if type not in ["html", "pdf"]: raise ValueError("Invalid type: %s" % type) # Remove the previous output file (if any): if os.path.isfile(outfile): try: os.remove(outfile) except: raise RuntimeError("Failed to remove existing file: %s" % outfile) if type == 'html': self._generateHtml(outfile) elif type == 'pdf': self._generatePdf(outfile) if not os.path.isfile(outfile): self.warning("Failed to export (output file missing)")
[docs] def warning(self, msg): print(msg)
[docs] def generatePdf(self, sts, num_sts, outfile): """ Generates a PDF document from the structures returned by the specified Structures iterator. :param sts: Iterator that yields Structure objects. You can pass structure.StructureReader(<file>) :param num_sts: Number of structures :param outfile: Name of the file to generate (must end with .pdf) """ if self.num_cols <= 0: print("Warning: Cannot have %i columns...setting to 1." % self.num_cols) self.num_cols = 1 if self.num_cols > 10: self.num_cols = 10 print("Warning: Cannot have more than 10 columns...setting to 10.") # Always scale export images when exporting to PDF: self.sts = sts self.num_sts = num_sts self._generateReport(outfile, "pdf")
[docs] def generateHtml(self, sts, num_sts, outfile): """ Generates an HTML document from the structures returned by the specified Structures iterator. :param sts: Iterator that yields Structure objects. You can pass structure.StructureReader(<file>) :param num_sts: Number of structures :param outfile: Name of the file to generate (must end with .html) """ if self.num_cols < 0: print("Warning: Cannot have %i columns...setting to 1." % self.num_cols) self.num_cols = 1 if self.num_cols == 0: print('Writing HTML in spreadsheet style...') self.display_property_names = False self.sts = sts self.num_sts = num_sts self._generateReport(outfile, "html")
[docs] def generateXls(self, sts, num_sts, outfile): """ Generates an Excel spreadsheet, which is basically just like HTML file, but with a different exteion and only one structure per row. :param sts: Iterator that yields Structure objects. You can pass structure.StructureReader(<file>) :param num_sts: Number of structures :param outfile: Name of the file to generate (must end with .html) """ print('Writing spreadsheet style XHTML...') self.num_cols = 0 self.display_property_names = False self.sts = sts self.num_sts = num_sts self._generateReport(outfile, "html")
if __name__ == '__main__': # For testing this module if len(sys.argv) != 3: print("Please specify input file and output file.") sys.exit(1) infile = sys.argv[1] outfile = sys.argv[2] # Must construct a QApplication before a QPaintDevice: app = QtWidgets.QApplication(sys.argv) if not os.path.exists(infile): print("File does not exist: %s" % infile) sys.exit(1) if not outfile.endswith('.pdf') and not outfile.endswith( '.html') and not outfile.endswith('.xls'): print( "Invalid extension for output file. Must be *.pdf, *.html, or *.xls" ) sys.exit(1) # Initialize Chembios rendering engine: # <render_model> is a list of settings (color, etc) render_model = canvas2d.ChmRender2DModel() # Do not show hydrogens: render_model.setShowHydrogenPreference(0) # Do not use color: render_model.clearColorMap() renderer = canvas2d.Chm2DRenderer(render_model) adaptor = canvas2d.ChmMmctAdaptor() num_sts = structure.count_structures(infile) first_st = structure.Structure.read(infile) props = [structure.PropertyName(prop) for prop in first_st.property] st_iterator = structure.StructureReader(infile) r2d = Report2D(adaptor, renderer, render_model) r2d.props = props if outfile.endswith('.html'): r2d.num_cols = 3 r2d.generateHtml(st_iterator, num_sts, outfile) elif outfile.endswith('.xls'): r2d.images_same_size = True r2d.bond_length_scale = 0.3 r2d.generateXls(st_iterator, num_sts, outfile) elif outfile.endswith('.pdf'): # Always scale export images when exporting to PDF: r2d.images_same_size = True r2d.num_cols = 3 r2d.generatePdf(st_iterator, num_sts, outfile) print("\nReport file: %s" % outfile) #EOF