Index: src/sas/qtgui/Calculators/media/sas_calculator_help.rst
===================================================================
--- src/sas/qtgui/Calculators/media/sas_calculator_help.rst	(revision 417c03f075685f86777838c8c717dec8e0ed8965)
+++ src/sas/qtgui/Calculators/media/sas_calculator_help.rst	(revision 2f539b22542f126c701aee99b72be9d49333aa03)
@@ -24,5 +24,5 @@
 
 Assuming that all the pixel sizes are the same, the elastic scattering
-intensity from the particle is
+intensity from the particle is defined as
 
 .. image:: gen_i.png
@@ -33,5 +33,5 @@
 the position of the $j^\text{th}$ pixel respectively.
 
-The total volume $V$
+The total volume $V$ is equal to
 
 .. math::
@@ -43,9 +43,11 @@
 density * Avogadro number) for the atomic structures).
 
-$V$ can be corrected by users. This correction is useful especially for an
-atomic structure (such as taken from a PDB file) to get the right normalization.
-
-*NOTE! $\beta_j$ displayed in the GUI may be incorrect but this will not
-affect the scattering computation if the correction of the total volume V is made.*
+$V$ can be corrected by users (input parameter `Total volume`). This correction
+is useful especially for an atomic structure (such as taken from a PDB file)
+to get the right normalization.
+
+*NOTE! $\beta_j$ displayed in the GUI may be incorrect (input parameter
+`solvent_SLD`) but this will not affect the scattering computation if the
+correction of the total volume V is made.*
 
 The scattering length density (SLD) of each pixel, where the SLD is uniform, is
@@ -99,14 +101,26 @@
 .. image:: mxp.png
 
+
+
 .. image:: myp.png
 
+
+
 .. image:: mzp.png
 
+
+
 .. image:: mqx.png
 
+
+
 .. image:: mqy.png
 
-Here the $M0_x$, $M0_y$ and $M0_z$ are the $x$, $y$ and $z$
+Here the $M_{0x}$, $M_{0y}$ and $M_{0z}$ are the $x$, $y$ and $z$
 components of the magnetisation vector in the laboratory $xyz$ frame.
+
+
+.. .. image:: Mxyzp.png
+
 
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
@@ -115,7 +129,23 @@
 --------------
 
-.. image:: gen_gui_help.png
-
-After computation the result will appear in the *Theory* box in the SasView
+.. figure:: gen_gui_help.png
+
+   ..
+
+   1) Load .sld, .txt, or .omf datafile
+   2) Select default shape of sample
+   3) Draw magnetization with arrows (not recommended for a large number of
+      pixels).
+   4) Ratio of (+/total) neutrons after analyser
+   5) Ratio of (+/total) neutrons before sample
+   6) Polarization angle in degrees
+   7) Default volume calculated from the pixel info
+      (or natural density of pdf file)
+   8) Compute the scattering pattern
+   9) Reset GUI to initial state
+   10) Display mean values or enter a new value if enabled
+   11) Save the sld data as sld format
+
+.. After computation the result will appear in the *Theory* box in the SasView
 *Data Explorer* panel.
 
@@ -126,5 +156,5 @@
 of neutrons before the sample and at the analyzer, respectively.
 
-*NOTE 1. The values of* Up_frac_in *and* Up_frac_out *must be in the range
+*NOTE 1. The values of Up_frac_in and Up_frac_out must be in the range
 0.0 to 1.0. Both values are 0.5 for unpolarized neutrons.*
 
@@ -133,5 +163,5 @@
 
 *NOTE 3. For the nuclear scattering length density, only the real component
-is taken account.*
+is taken into account.*
 
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
@@ -151,8 +181,19 @@
 
 where $v_j \beta_j \equiv b_j$ is the scattering
-length of the $j^\text{th}$ atom. The calculation output is passed to the *Data Explorer*
+length of the $j^\text{th}$ atom.
+.. The calculation output is passed to the *Data Explorer*
 for further use.
 
-.. image:: pdb_combo.jpg
+.. figure:: pdb_combo.png
+
+   ..
+
+   1) PDB file loaded
+   2) disabled input for *Up_frac_in*, *Up_frac_oupt*, *Up_theta*
+   3) option to perform the calculations using "Fixed orientations" (2D output)
+      or "Averaging over all orientations using Debye equation" (1D output).
+      This choice is only available for PDB files.
+
+
 
 .. ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ
Index: src/sas/qtgui/GUITests.py
===================================================================
--- src/sas/qtgui/GUITests.py	(revision 01ef3f797e8d4032a84fb98c0186de9326036df9)
+++ src/sas/qtgui/GUITests.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -49,4 +49,5 @@
 from Utilities.UnitTesting import TabbedModelEditorTest
 from Utilities.UnitTesting import AddMultEditorTest
+from Utilities.UnitTesting import ReportDialogTest
 
 # Unit Testing
@@ -109,4 +110,5 @@
         unittest.makeSuite(TabbedModelEditorTest.TabbedModelEditorTest,'test'),
         unittest.makeSuite(AddMultEditorTest.AddMultEditorTest, 'test'),
+        unittest.makeSuite(ReportDialogTest.ReportDialogTest,     'test'),
 
         # Calculators
Index: src/sas/qtgui/MainWindow/GuiManager.py
===================================================================
--- src/sas/qtgui/MainWindow/GuiManager.py	(revision fa05c6c1658784af4cd2c607f15d140eb7468310)
+++ src/sas/qtgui/MainWindow/GuiManager.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -23,4 +23,5 @@
 from sas.qtgui.Utilities.GridPanel import BatchOutputPanel
 
+from sas.qtgui.Utilities.ReportDialog import ReportDialog
 from sas.qtgui.MainWindow.UI.AcknowledgementsUI import Ui_Acknowledgements
 from sas.qtgui.MainWindow.AboutBox import AboutBox
@@ -65,4 +66,7 @@
         # Add signal callbacks
         self.addCallbacks()
+
+        # Assure model categories are available
+        self.addCategories()
 
         # Create the data manager
@@ -142,4 +146,17 @@
         self.ResolutionCalculator = ResolutionCalculatorPanel(self)
         self.DataOperation = DataOperationUtilityPanel(self)
+
+    def addCategories(self):
+        """
+        Make sure categories.json exists and if not compile it and install in ~/.sasview
+        """
+        try:
+            from sas.sascalc.fit.models import ModelManager
+            from sas.qtgui.Utilities.CategoryInstaller import CategoryInstaller
+            model_list = ModelManager().cat_model_list()
+            CategoryInstaller.check_install(model_list=model_list)
+        except Exception:
+            logger.error("%s: could not load SasView models")
+            logger.error(traceback.format_exc())
 
     def statusBarSetup(self):
@@ -472,8 +489,7 @@
     def actionSave_Analysis(self):
         """
-        """
-        print("actionSave_Analysis TRIGGERED")
-
-        pass
+        Menu File/Save Analysis
+        """
+        self.communicate.saveAnalysisSignal.emit()
 
     def actionQuit(self):
@@ -510,7 +526,16 @@
     def actionReport(self):
         """
-        """
-        print("actionReport TRIGGERED")
-        pass
+        Show the Fit Report dialog.
+        """
+        report_list = None
+        if getattr(self._current_perspective, "currentTab"):
+            try:
+                report_list = self._current_perspective.currentTab.getReport()
+            except Exception as ex:
+                logging.error("Report generation failed with: " + str(ex))
+
+        if report_list is not None:
+            self.report_dialog = ReportDialog(parent=self, report_list=report_list)
+            self.report_dialog.show()
 
     def actionReset(self):
Index: src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py	(revision 3b3b40bf9d5056d902b19728c621dd2a4661d65f)
+++ src/sas/qtgui/Perspectives/Fitting/FittingPerspective.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -37,6 +37,6 @@
         self.maxIndex = 0
 
-        # Index of the current tab
-        self.currentTab = 0
+        ## Index of the current tab
+        #self.currentTab = 0
 
         # The default optimizer
@@ -318,2 +318,10 @@
 
         pass
+
+    @property
+    def currentTab(self):
+        """
+        Returns the tab widget currently shown
+        """
+        return self.currentWidget()
+
Index: src/sas/qtgui/Perspectives/Fitting/FittingUtilities.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/FittingUtilities.py	(revision b5cc06e00e9fff66806563ef5663415de47997b8)
+++ src/sas/qtgui/Perspectives/Fitting/FittingUtilities.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -68,4 +68,5 @@
     multishell_parameters = getIterParams(parameters)
     multishell_param_name, _ = getMultiplicity(parameters)
+
     if is2D:
         params = [p for p in parameters.kernel_parameters if p.type != 'magnetic']
@@ -443,2 +444,60 @@
 
 
+def getStandardParam(model=None):
+    """
+    Returns a list with standard parameters for the current model
+    """
+    param = []
+    num_rows = model.rowCount()
+    if num_rows < 1:
+        return None
+
+    for row in range(num_rows):
+        param_name = model.item(row, 0).text()
+        checkbox_state = model.item(row,0).checkState() == QtCore.Qt.Checked
+        value= model.item(row, 1).text()
+        column_shift = 0
+        if model.columnCount() == 5: # no error column
+            error_state = False
+            error_value = 0.0
+        else:
+            error_state = True
+            error_value = model.item(row, 2).text()
+            column_shift = 1
+        min_state = True
+        max_state = True
+        min_value = model.item(row, 2+column_shift).text()
+        max_value = model.item(row, 3+column_shift).text()
+        unit = ""
+        if model.item(row, 4+column_shift) is not None:
+            unit = model.item(row, 4+column_shift).text()
+
+        param.append([checkbox_state, param_name, value, "",
+                        [error_state, error_value],
+                        [min_state, min_value],
+                        [max_state, max_value], unit])
+
+    return param
+
+def getOrientationParam(kernel_module=None):
+    """
+    Get the dictionary with orientation parameters
+    """
+    param = []
+    if kernel_module is None: 
+        return None
+    for param_name in list(kernel_module.params.keys()):
+        name = param_name
+        value = kernel_module.params[param_name]
+        min_state = True
+        max_state = True
+        error_state = False
+        error_value = 0.0
+        checkbox_state = True #??
+        details = kernel_module.details[param_name] #[unit, mix, max]
+        param.append([checkbox_state, name, value, "",
+                     [error_state, error_value],
+                     [min_state, details[1]],
+                     [max_state, details[2]], details[0]])
+
+    return param
Index: src/sas/qtgui/Perspectives/Fitting/FittingWidget.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/FittingWidget.py	(revision aed053275d5f293f1f6495be4c78e4b18fc52b41)
+++ src/sas/qtgui/Perspectives/Fitting/FittingWidget.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -1,4 +1,5 @@
 import json
 import os
+import copy
 from collections import defaultdict
 
@@ -21,4 +22,5 @@
 
 from sas.sascalc.fit.BumpsFitting import BumpsFit as Fit
+from sas.sascalc.fit.pagestate import PageState
 
 import sas.qtgui.Utilities.GuiUtils as GuiUtils
@@ -45,4 +47,6 @@
 from sas.qtgui.Perspectives.Fitting.Constraint import Constraint
 from sas.qtgui.Perspectives.Fitting.MultiConstraint import MultiConstraint
+from sas.qtgui.Perspectives.Fitting.ReportPageLogic import ReportPageLogic
+
 
 
@@ -506,4 +510,6 @@
         # Signals from other widgets
         self.communicate.customModelDirectoryChanged.connect(self.onCustomModelChange)
+        self.communicate.saveAnalysisSignal.connect(self.savePageState)
+        #self.communicate.saveReportSignal.connect(self.saveReport)
 
     def modelName(self):
@@ -2633,2 +2639,126 @@
             self.page_stack.pop()
 
+    def getReport(self):
+        """
+        Create and return HTML report with parameters and charts
+        """
+        index = None
+        if self.all_data:
+            index = self.all_data[self.data_index]
+        report_logic = ReportPageLogic(self,
+                                       kernel_module=self.kernel_module,
+                                       data=self.data,
+                                       index=index,
+                                       model=self._model_model)
+
+        return report_logic.reportList()
+
+    def savePageState(self):
+        """
+        Create and serialize local PageState
+        """
+        from sas.sascalc.fit.pagestate import Reader
+        model = self.kernel_module
+
+        # Old style PageState object
+        state = PageState(model=model, data=self.data)
+
+        # Add parameter data to the state
+        self.getCurrentFitState(state)
+
+        # Create the filewriter, aptly named 'Reader'
+        state_reader = Reader(self.loadPageStateCallback)
+        filepath = self.saveAsAnalysisFile()
+        if filepath is None:
+            return
+        state_reader.write(filename=filepath, fitstate=state)
+        pass
+
+    def saveAsAnalysisFile(self):
+        """
+        Show the save as... dialog and return the chosen filepath
+        """
+        default_name = "FitPage"+str(self.tab_id)+".fitv"
+
+        wildcard = "fitv files (*.fitv)"
+        kwargs = {
+            'caption'   : 'Save As',
+            'directory' : default_name,
+            'filter'    : wildcard,
+            'parent'    : None,
+        }
+        # Query user for filename.
+        filename_tuple = QtWidgets.QFileDialog.getSaveFileName(**kwargs)
+        filename = filename_tuple[0]
+        return filename
+
+    def loadPageStateCallback(self,state=None, datainfo=None, format=None):
+        """
+        This is a callback method called from the CANSAS reader.
+        We need the instance of this reader only for writing out a file,
+        so there's nothing here.
+        Until Load Analysis is implemented, that is.
+        """
+        pass
+
+    def loadPageState(self, pagestate=None):
+        """
+        Load the PageState object and update the current widget
+        """
+        pass
+
+    def getCurrentFitState(self, state=None):
+        """
+        Store current state for fit_page
+        """
+        # save model option
+        #if self.model is not None:
+        #    self.disp_list = self.getDispParamList()
+        #    state.disp_list = copy.deepcopy(self.disp_list)
+        #    #state.model = self.model.clone()
+
+        # Comboboxes
+        state.categorycombobox = self.cbCategory.currentText()
+        state.formfactorcombobox = self.cbModel.currentText()
+        if self.cbStructureFactor.isEnabled():
+            state.structureCombobox = self.cbStructureFactor.currentText()
+        state.tcChi = self.chi2
+
+        state.enable2D = self.is2D
+
+        #state.weights = copy.deepcopy(self.weights)
+        # save data
+        state.data = copy.deepcopy(self.data)
+
+        # save plotting range
+        state.qmin = self.q_range_min
+        state.qmax = self.q_range_max
+        state.npts = self.npts
+
+        #    self.state.enable_disp = self.enable_disp.GetValue()
+        #    self.state.disable_disp = self.disable_disp.GetValue()
+
+        #    self.state.enable_smearer = \
+        #                        copy.deepcopy(self.enable_smearer.GetValue())
+        #    self.state.disable_smearer = \
+        #                        copy.deepcopy(self.disable_smearer.GetValue())
+
+        #self.state.pinhole_smearer = \
+        #                        copy.deepcopy(self.pinhole_smearer.GetValue())
+        #self.state.slit_smearer = copy.deepcopy(self.slit_smearer.GetValue())
+        #self.state.dI_noweight = copy.deepcopy(self.dI_noweight.GetValue())
+        #self.state.dI_didata = copy.deepcopy(self.dI_didata.GetValue())
+        #self.state.dI_sqrdata = copy.deepcopy(self.dI_sqrdata.GetValue())
+        #self.state.dI_idata = copy.deepcopy(self.dI_idata.GetValue())
+
+        p = self.model_parameters
+        # save checkbutton state and txtcrtl values
+        state.parameters = FittingUtilities.getStandardParam()
+        state.orientation_params_disp = FittingUtilities.getOrientationParam()
+
+        #self._copy_parameters_state(self.orientation_params_disp, self.state.orientation_params_disp)
+        #self._copy_parameters_state(self.parameters, self.state.parameters)
+        #self._copy_parameters_state(self.fittable_param, self.state.fittable_param)
+        #self._copy_parameters_state(self.fixed_param, self.state.fixed_param)
+
+
Index: src/sas/qtgui/Perspectives/Fitting/OptionsWidget.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/OptionsWidget.py	(revision d6b8a1dfaf6521ae4a9054d5195def2810d6d8bc)
+++ src/sas/qtgui/Perspectives/Fitting/OptionsWidget.py	(revision 976978b3f089803c3bfea3ba57e611f9e52d2796)
@@ -116,6 +116,6 @@
         self.mapper.addMapping(self.txtNpts,     MODEL.index('NPTS'))
         self.mapper.addMapping(self.chkLogData,  MODEL.index('LOG_SPACED'))
-        # FIXME DOESNT WORK WITH QT5
-        #self.mapper.toFirst()
+
+        self.mapper.toFirst()
 
     def toggleLogData(self, isChecked):
Index: src/sas/qtgui/Perspectives/Fitting/ReportPageLogic.py
===================================================================
--- src/sas/qtgui/Perspectives/Fitting/ReportPageLogic.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
+++ src/sas/qtgui/Perspectives/Fitting/ReportPageLogic.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -0,0 +1,210 @@
+# -*- coding: utf-8 -*-
+import base64
+import datetime
+import re
+import logging
+from io import BytesIO
+import urllib.parse
+
+from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
+
+from sas.qtgui.Plotting.Plotter import Plotter
+from sas.qtgui.Plotting.Plotter2D import Plotter2D
+from sas.qtgui.Plotting.PlotterData import Data1D
+from sas.qtgui.Plotting.PlotterData import Data2D
+
+import sas.qtgui.Utilities.GuiUtils as GuiUtils
+from sas.qtgui.Perspectives.Fitting import FittingUtilities
+
+class ReportPageLogic(object):
+    """
+    Logic for the Report Page functionality. Refactored from FittingWidget.
+    """
+    def __init__(self, parent=None, kernel_module=None, data=None, index=None, model=None):
+
+        self.parent = parent
+        self.kernel_module = kernel_module
+        self.data = data
+        self._index = index
+        self.model = model
+
+    @staticmethod
+    def cleanhtml(raw_html):
+        """Remove html tags from a document"""
+        cleanr = re.compile('<.*?>')
+        cleantext = re.sub(cleanr, '', raw_html)
+        return cleantext
+
+    def reportList(self):
+        """
+        Return the HTML version of the full report
+        """
+        if self.kernel_module is None:
+            report_txt = "No model defined"
+            report_html = HEADER % report_txt
+            images = []
+            return [report_html, report_txt, images]
+
+        # Get plot image from plotpanel
+        images = self.getImages()
+
+        imagesHTML = ""
+        if images is not None:
+            imagesHTML = self.buildPlotsForReport(images)
+
+        report_header = self.reportHeader()
+
+        report_parameters = self.reportParams()
+
+        report_html = report_header + report_parameters + imagesHTML
+
+        report_txt = self.cleanhtml(report_html)
+
+        report_list = [report_html, report_txt, images]
+
+        return report_list
+
+    def reportHeader(self):
+        """
+        Look at widget state and extract report header info
+        """
+        report = ""
+
+        title = self.data.name
+        current_time = datetime.datetime.now().strftime("%I:%M%p, %B %d, %Y")
+        filename = self.data.filename
+        modelname = self.kernel_module.id
+        if hasattr(self.data, 'xmin'):
+            qrange_min = self.data.xmin
+            qrange_max = self.data.xmax
+        else:
+            qrange_min = min(self.data.x)
+            qrange_max = max(self.data.x)
+        qrange = "min = {}, max = {}".format(qrange_min, qrange_max)
+
+        title = title + " [" + current_time + "]"
+        title_name = HEADER % title
+        report = title_name
+        report = report + CENTRE % "File name:{}\n".format(filename)
+        report = report + CENTRE % "Model name:{}\n".format(modelname)
+        report = report + CENTRE % "Q Range: {}\n".format(qrange)
+        chi2_repr = GuiUtils.formatNumber(self.parent.chi2, high=True)
+        report = report + CENTRE % "Chi2/Npts:{}\n".format(chi2_repr)
+
+        return report
+
+    def buildPlotsForReport(self, images):
+        """ Convert Matplotlib figure 'fig' into a <img> tag for HTML use using base64 encoding. """
+        html = FEET_1 % self.data.filename
+
+        for fig in images:
+            canvas = FigureCanvas(fig)
+            png_output = BytesIO()
+            canvas.print_png(png_output)
+            data = png_output.getvalue()
+            data64 = base64.b64encode(data)
+            data_to_print = urllib.parse.quote(data64)
+            html += '<img src="data:image/png;base64,{}">'.format(data_to_print)
+
+        return html
+
+    def reportParams(self):
+        """
+        Look at widget state and extract parameters
+        """
+        pars = FittingUtilities.getStandardParam(self.model)
+        if pars is None:
+            return ""
+
+        report = ""
+        plus_minus = " &#177; "
+        for value in pars:
+            try:
+                par_name = value[1]
+                par_fixed = not value[0]
+                par_value = value[2]
+                par_unit = value[7]
+                # Convert units for nice display
+                par_unit = GuiUtils.convertUnitToHTML(par_unit.strip())
+                if par_fixed:
+                    error = " (fixed)"
+                else:
+                    error = plus_minus + str(value[4][1])
+                param = par_name + " = " + par_value + error + " " + par_unit
+            except IndexError as ex:
+                # corrupted model. Complain and skip the line
+                logging.error("Error in parsing parameters: "+str(ex))
+                continue
+            report += CENTRE % param + "\n"
+
+        return report
+
+    def getImages(self):
+        """
+        Create MPL figures for the current fit
+        """
+        graphs = []
+        modelname = self.kernel_module.name
+        if not modelname or self._index is None:
+            return None
+        plots = GuiUtils.plotsFromModel(modelname, self._index)
+        # Call show on requested plots
+        # All same-type charts in one plot
+        for plot_set in plots:
+            if isinstance(plot_set, Data1D):
+                if 'residuals' in plot_set.title.lower():
+                    res_plot = Plotter(self, quickplot=True)
+                    res_plot.plot(plot_set)
+                    graphs.append(res_plot.figure)
+                    continue
+                if not 'new_plot' in locals():
+                    new_plot = Plotter(self, quickplot=True)
+                new_plot.plot(plot_set)
+            elif isinstance(plot_set, Data2D):
+                plot2D = Plotter2D(self, quickplot=True)
+                plot2D.item = self._index
+                plot2D.plot(plot_set)
+                graphs.append(plot2D.figure)
+            else:
+                msg = "Incorrect data type passed to Plotting"
+                raise AttributeError(msg)
+
+        if 'new_plot' in locals() and isinstance(new_plot.data, Data1D):
+            graphs.append(new_plot.figure)
+
+        return graphs
+
+
+# Simple html report template
+HEADER = "<html>\n"
+HEADER += "<head>\n"
+HEADER += "<meta http-equiv=Content-Type content='text/html; "
+HEADER += "charset=utf-8'> \n"
+HEADER += "<meta name=Generator >\n"
+HEADER += "</head>\n"
+HEADER += "<body lang=EN-US>\n"
+HEADER += "<div class=WordSection1>\n"
+HEADER += "<p class=MsoNormal><b><span ><center><font size='4' >"
+HEADER += "%s</font></center></span></center></b></p>"
+HEADER += "<p class=MsoNormal>&nbsp;</p>"
+PARA = "<p class=MsoNormal><font size='4' > %s \n"
+PARA += "</font></p>"
+CENTRE = "<p class=MsoNormal><center><font size='4' > %s \n"
+CENTRE += "</font></center></p>"
+FEET_1 = \
+"""
+<p class=MsoNormal>&nbsp;</p>
+<br>
+<p class=MsoNormal><b><span ><center> <font size='4' > Graph
+</font></span></center></b></p>
+<p class=MsoNormal>&nbsp;</p>
+<center>
+<br><font size='4' >Model Computation</font>
+<br><font size='4' >Data: "%s"</font><br>
+"""
+FEET_2 = \
+"""<img src="%s" width="540"></img>
+"""
+ELINE = """<p class=MsoNormal>&nbsp;</p>
+"""
+
Index: src/sas/qtgui/Utilities/CategoryInstaller.py
===================================================================
--- src/sas/qtgui/Utilities/CategoryInstaller.py	(revision cee5c78929349ae10afc88cb8b95e0c907f1826a)
+++ src/sas/qtgui/Utilities/CategoryInstaller.py	(revision c889a3ee244db57ee9ac05ab5adc2ba5a33bbdba)
@@ -174,4 +174,4 @@
                                                           model_enabled_dict)
 
-            json.dump(master_category_dict, open(serialized_file, 'wb'))
+            json.dump(master_category_dict, open(serialized_file, "w", encoding="utf8"))
 
Index: src/sas/qtgui/Utilities/GuiUtils.py
===================================================================
--- src/sas/qtgui/Utilities/GuiUtils.py	(revision d4dac8028a51b020b8e01f0998d475e053206dea)
+++ src/sas/qtgui/Utilities/GuiUtils.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -1,2 +1,3 @@
+# -*- coding: utf-8 -*-
 """
 Global defaults and various utility functions usable by the general GUI
@@ -248,4 +249,6 @@
     sendDataToGridSignal = QtCore.pyqtSignal(list)
 
+    # Action Save Analysis triggered
+    saveAnalysisSignal = QtCore.pyqtSignal()
 
 def updateModelItemWithPlot(item, update_data, name=""):
@@ -378,4 +381,27 @@
                  if str(i.text()) == filename])
     return item[0] if len(item)>0 else None
+
+def plotsFromModel(model_name, model_item):
+    """
+    Returns the list of plots for the item with model name in the model
+    """
+    assert isinstance(model_item, QtGui.QStandardItem)
+    assert isinstance(model_name, str)
+
+    plot_data = []
+    # Iterate over model looking for named items
+    for index in range(model_item.rowCount()):
+        item = model_item.child(index)
+        if isinstance(item.data(), (Data1D, Data2D)):
+            plot_data.append(item.data())
+        if model_name in str(item.text()):
+            #plot_data.append(item.child(0).data())
+            # Going 1 level deeper only
+            for index_2 in range(item.rowCount()):
+                item_2 = item.child(index_2)
+                if item_2 and isinstance(item_2.data(), (Data1D, Data2D)):
+                    plot_data.append(item_2.data())
+
+    return plot_data
 
 def plotsFromFilename(filename, model_item):
@@ -841,4 +867,55 @@
     return output.lstrip().rstrip()
 
+def replaceHTMLwithUTF8(html):
+    """
+    Replace some important HTML-encoded characters
+    with their UTF-8 equivalents
+    """
+    # Angstrom
+    html_out = html.replace("&#x212B;", "Ã
+")
+    # infinity
+    html_out = html_out.replace("&#x221e;", "â")
+    # +/-
+    html_out = html_out.replace("&#177;", "Â±")
+
+    return html_out
+
+def replaceHTMLwithASCII(html):
+    """
+    Replace some important HTML-encoded characters
+    with their ASCII equivalents
+    """
+    # Angstrom
+    html_out = html.replace("&#x212B;", "Ang")
+    # infinity
+    html_out = html_out.replace("&#x221e;", "inf")
+    # +/-
+    html_out = html_out.replace("&#177;", "+/-")
+
+    return html_out
+
+def convertUnitToUTF8(unit):
+    """
+    Convert ASCII unit display into UTF-8 symbol
+    """
+    if unit == "1/A":
+        return "Ã
+<sup>-1</sup>"
+    elif unit == "1/cm":
+        return "cm<sup>-1</sup>"
+    elif unit == "Ang":
+        return "Ã
+"
+    elif unit == "1e-6/Ang^2":
+        return "10<sup>-6</sup>/Ã
+<sup>2</sup>"
+    elif unit == "inf":
+        return "â"
+    elif unit == "-inf":
+        return "-â"
+    else:
+        return unit
+
 def convertUnitToHTML(unit):
     """
Index: src/sas/qtgui/Utilities/ReportDialog.py
===================================================================
--- src/sas/qtgui/Utilities/ReportDialog.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
+++ src/sas/qtgui/Utilities/ReportDialog.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -0,0 +1,183 @@
+import os
+import sys
+import re
+import logging
+from xhtml2pdf import pisa
+
+from PyQt5 import QtWidgets
+from PyQt5 import QtPrintSupport
+
+import sas.qtgui.Utilities.GuiUtils as GuiUtils
+
+from sas.qtgui.Utilities.UI.ReportDialogUI import Ui_ReportDialogUI
+
+
+class ReportDialog(QtWidgets.QDialog, Ui_ReportDialogUI):
+    """
+    Class for stateless grid-like printout of model parameters for mutiple models
+    """
+    def __init__(self, parent=None, report_list=None):
+
+        super(ReportDialog, self).__init__(parent._parent)
+        self.setupUi(self)
+
+        assert isinstance(report_list, list)
+        assert len(report_list) == 3
+
+        self.data_html, self.data_txt, self.data_images = report_list
+
+        # Fill in the table from input data
+        self.setupDialog(self.data_html)
+
+        # Command buttons
+        self.cmdPrint.clicked.connect(self.onPrint)
+        self.cmdSave.clicked.connect(self.onSave)
+
+    def setupDialog(self, output=None):
+        """
+        Display the HTML content in the browser.
+        """
+        if output is not None:
+            self.txtBrowser.setHtml(output)
+
+    def onPrint(self):
+        """
+        Display the print dialog and send the report to printer
+        """
+        # Define the printer
+        printer = QtPrintSupport.QPrinter()
+
+        # Display the print dialog
+        dialog = QtPrintSupport.QPrintDialog(printer)
+        dialog.setModal(True)
+        dialog.setWindowTitle("Print")
+        if dialog.exec_() != QtWidgets.QDialog.Accepted:
+            return
+
+        document = self.txtBrowser.document()
+        try:
+            # pylint chokes on this line with syntax-error
+            # pylint: disable=syntax-error doesn't seem to help
+            document.print(printer)
+        except Exception as ex:
+            # Printing can return various exceptions, let's catch them all
+            logging.error("Print report failed with: " + str(ex))
+
+    def onSave(self):
+        """
+        Display the Save As... prompt and save the report if instructed so
+        """
+        # Choose user's home directory
+        location = os.path.expanduser('~')
+        # Use a sensible filename default
+        default_name = os.path.join(location, 'fit_report.pdf')
+        kwargs = {
+            'parent'   : self,
+            'caption'  : 'Save Report',
+            'directory': default_name,
+            'filter'   : 'PDF file (*.pdf);;HTML file (*.html);;Text file (*.txt)',
+            'options'  : QtWidgets.QFileDialog.DontUseNativeDialog}
+        # Query user for filename.
+        filename_tuple = QtWidgets.QFileDialog.getSaveFileName(**kwargs)
+        filename = filename_tuple[0]
+        if not filename:
+            return
+        extension = filename_tuple[1]
+
+        try:
+            # extract extension from filter
+            # e.g. "PDF file (*.pdf)" -> ".pdf"
+            ext = extension[extension.find("(")+2:extension.find(")")]
+        except IndexError as ex:
+            # (ext) not found...
+            logging.error("Error while saving report. " + str(ex))
+            return
+        basename, extension = os.path.splitext(filename)
+        if not extension:
+            filename = '.'.join((filename, ext))
+
+        # Create files with charts
+        pictures = self.getPictures(basename)
+
+        # translate png references into html from base64 string to on-disk name
+        cleanr = re.compile('<img src="data:image.*>')
+        replacement_name = ""
+        for picture in pictures:
+            replacement_name += '<img src="'+ picture + '"><p></p>'
+
+        # <img src="data:image/png;.*>  => <img src=filename>
+        html = re.sub(cleanr, replacement_name, self.data_html)
+
+        if ext.lower() == ".txt":
+            txt_ascii = GuiUtils.replaceHTMLwithASCII(self.data_txt)
+            self.onTXTSave(txt_ascii, filename)
+        if ext.lower() == ".html":
+            self.onHTMLSave(html, filename)
+        if ext.lower() == ".pdf":
+            html_utf = GuiUtils.replaceHTMLwithUTF8(html)
+            pdf_success = self.HTML2PDF(html_utf, filename)
+            # delete images used to create the pdf
+            for pic_name in pictures:
+                os.remove(pic_name)
+            #open pdf viewer
+            if pdf_success == 0:
+                try:
+                    if os.name == 'nt':  # Windows
+                        os.startfile(filename)
+                    elif sys.platform == "darwin":  # Mac
+                        os.system("open %s" % filename)
+                except Exception as ex:
+                    # cannot open pdf.
+                    # We don't know what happened in os.* , so broad Exception is required
+                    logging.error(str(ex))
+
+    def getPictures(self, basename):
+        """
+        Returns list of saved MPL figures
+        """
+        # save figures
+        pictures = []
+        for num, image in enumerate(self.data_images):
+            pic_name = basename + '_img%s.png' % num
+            # save the image for use with pdf writer
+            image.savefig(pic_name)
+            pictures.append(pic_name)
+        return pictures
+
+    @staticmethod
+    def onTXTSave(data, filename):
+        """
+        Simple txt file serializatio
+        """
+        with open(filename, 'w') as f:
+            f.write(data)
+
+    @staticmethod
+    def onHTMLSave(html, filename):
+        """
+        HTML file write
+        """
+        with open(filename, 'w') as f:
+            f.write(html)
+
+    @staticmethod
+    def HTML2PDF(data, filename):
+        """
+        Create a PDF file from html source string.
+        Returns True is the file creation was successful.
+        : data: html string
+        : filename: name of file to be saved
+        """
+        try:
+            # open output file for writing (truncated binary)
+            with open(filename, "w+b") as resultFile:
+                # convert HTML to PDF
+                pisaStatus = pisa.CreatePDF(data.encode("UTF-8"),
+                                            dest=resultFile,
+                                            encoding='UTF-8')
+                return pisaStatus.err
+        except Exception as ex:
+            logging.error("Error creating pdf: " + str(ex))
+        return False
+
+
Index: src/sas/qtgui/Utilities/UI/ReportDialogUI.ui
===================================================================
--- src/sas/qtgui/Utilities/UI/ReportDialogUI.ui	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
+++ src/sas/qtgui/Utilities/UI/ReportDialogUI.ui	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>ReportDialogUI</class>
+ <widget class="QDialog" name="ReportDialogUI">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>519</width>
+    <height>461</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Report: Fitting</string>
+  </property>
+  <layout class="QGridLayout" name="gridLayout">
+   <item row="0" column="0">
+    <widget class="QTextBrowser" name="txtBrowser"/>
+   </item>
+   <item row="1" column="0">
+    <layout class="QHBoxLayout" name="horizontalLayout">
+     <item>
+      <spacer name="horizontalSpacer">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+     <item>
+      <widget class="QPushButton" name="cmdPrint">
+       <property name="text">
+        <string>Print</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="cmdSave">
+       <property name="text">
+        <string>Save</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="cmdClose">
+       <property name="text">
+        <string>Close</string>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections>
+  <connection>
+   <sender>cmdClose</sender>
+   <signal>clicked()</signal>
+   <receiver>ReportDialogUI</receiver>
+   <slot>close()</slot>
+   <hints>
+    <hint type="sourcelabel">
+     <x>426</x>
+     <y>433</y>
+    </hint>
+    <hint type="destinationlabel">
+     <x>259</x>
+     <y>230</y>
+    </hint>
+   </hints>
+  </connection>
+ </connections>
+</ui>
Index: src/sas/qtgui/Utilities/UnitTesting/GuiUtilsTest.py
===================================================================
--- src/sas/qtgui/Utilities/UnitTesting/GuiUtilsTest.py	(revision 6cb305acde0f8a18b6580f6801bf0cd6613b4587)
+++ src/sas/qtgui/Utilities/UnitTesting/GuiUtilsTest.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -1,2 +1,3 @@
+# -*- coding: utf-8 -*-
 import sys
 import unittest
@@ -446,4 +447,93 @@
         self.assertEqual(yscale, "log")
 
+    def testReplaceHTMLwithUTF8(self):
+        ''' test single character replacement '''
+        s = None
+        with self.assertRaises(AttributeError):
+            result = replaceHTMLwithUTF8(s)
+
+        s = ""
+        self.assertEqual(replaceHTMLwithUTF8(s), s)
+
+        s = "aaaa"
+        self.assertEqual(replaceHTMLwithUTF8(s), s)
+
+        s = "&#x212B; &#x221e;      &#177;"
+        self.assertEqual(replaceHTMLwithUTF8(s), "Ã
+ â      Â±")
+
+    def testReplaceHTMLwithASCII(self):
+        ''' test single character replacement'''
+        s = None
+        with self.assertRaises(AttributeError):
+            result = replaceHTMLwithASCII(s)
+
+        s = ""
+        self.assertEqual(replaceHTMLwithASCII(s), s)
+
+        s = "aaaa"
+        self.assertEqual(replaceHTMLwithASCII(s), s)
+
+        s = "&#x212B; &#x221e;      &#177;"
+        self.assertEqual(replaceHTMLwithASCII(s), "Ang inf      +/-")
+
+    def testConvertUnitToUTF8(self):
+        ''' test unit string replacement'''
+        s = None
+        self.assertIsNone(convertUnitToUTF8(s))
+
+        s = ""
+        self.assertEqual(convertUnitToUTF8(s), s)
+
+        s = "aaaa"
+        self.assertEqual(convertUnitToUTF8(s), s)
+
+        s = "1/A"
+        self.assertEqual(convertUnitToUTF8(s), "Ã
+<sup>-1</sup>")
+
+        s = "Ang"
+        self.assertEqual(convertUnitToUTF8(s), "Ã
+")
+
+        s = "1e-6/Ang^2"
+        self.assertEqual(convertUnitToUTF8(s), "10<sup>-6</sup>/Ã
+<sup>2</sup>")
+
+        s = "inf"
+        self.assertEqual(convertUnitToUTF8(s), "â")
+
+        s = "1/cm"
+        self.assertEqual(convertUnitToUTF8(s), "cm<sup>-1</sup>")
+
+    def testConvertUnitToHTML(self):
+        ''' test unit string replacement'''
+        s = None
+        self.assertIsNone(convertUnitToHTML(s))
+
+        s = ""
+        self.assertEqual(convertUnitToHTML(s), s)
+
+        s = "aaaa"
+        self.assertEqual(convertUnitToHTML(s), s)
+
+        s = "1/A"
+        self.assertEqual(convertUnitToHTML(s), "&#x212B;<sup>-1</sup>")
+
+        s = "Ang"
+        self.assertEqual(convertUnitToHTML(s), "&#x212B;")
+
+        s = "1e-6/Ang^2"
+        self.assertEqual(convertUnitToHTML(s), "10<sup>-6</sup>/&#x212B;<sup>2</sup>")
+
+        s = "inf"
+        self.assertEqual(convertUnitToHTML(s), "&#x221e;")
+        s = "-inf"
+
+        self.assertEqual(convertUnitToHTML(s), "-&#x221e;")
+
+        s = "1/cm"
+        self.assertEqual(convertUnitToHTML(s), "cm<sup>-1</sup>")
+
     def testParseName(self):
         '''test parse out a string from the beinning of a string'''
Index: src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py
===================================================================
--- src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
+++ src/sas/qtgui/Utilities/UnitTesting/ReportDialogTest.py	(revision 57be490d989c9a52dd3d902191267a861890a9ef)
@@ -0,0 +1,150 @@
+import os
+import sys
+import unittest
+import logging
+from xhtml2pdf import pisa
+
+from unittest.mock import mock_open, patch
+
+from unittest.mock import MagicMock
+
+from PyQt5 import QtWidgets, QtPrintSupport
+
+# set up import paths
+import path_prepare
+
+import sas.qtgui.Utilities.GuiUtils as GuiUtils
+# Local
+from sas.qtgui.Utilities.ReportDialog import ReportDialog
+
+if not QtWidgets.QApplication.instance():
+    app = QtWidgets.QApplication(sys.argv)
+
+class ReportDialogTest(unittest.TestCase):
+    '''Test the report dialog'''
+    def setUp(self):
+        '''Create the dialog'''
+        class dummy_manager(object):
+            _parent = QtWidgets.QWidget()
+            def communicator(self):
+                return GuiUtils.Communicate()
+            def communicate(self):
+                return GuiUtils.Communicate()
+
+        test_html = "test_html"
+        test_txt = "test_txt"
+        test_images = []
+        self.test_list = [test_html, test_txt, test_images]
+        self.widget = ReportDialog(parent=dummy_manager(), report_list=self.test_list)
+
+    def tearDown(self):
+        '''Destroy the GUI'''
+        #self.widget.close()
+        self.widget = None
+
+    def testDefaults(self):
+        '''Look at the default state of the widget'''
+        self.assertIn(self.test_list[0], self.widget.txtBrowser.toHtml())
+        self.assertTrue(self.widget.txtBrowser.isReadOnly())
+
+    def testOnPrint(self):
+        ''' Printing the report '''
+        document = self.widget.txtBrowser.document()
+        document.print = MagicMock()
+
+        # test rejected dialog
+        QtPrintSupport.QPrintDialog.exec_ = MagicMock(return_value=QtWidgets.QDialog.Rejected)
+
+        # invoke the method
+        self.widget.onPrint()
+
+        # Assure printing was not done
+        self.assertFalse(document.print.called)
+
+        # test accepted dialog
+        QtPrintSupport.QPrintDialog.exec_ = MagicMock(return_value=QtWidgets.QDialog.Accepted)
+
+        # invoke the method
+        self.widget.onPrint()
+
+        # Assure printing was done
+        self.assertTrue(document.print.called)
+
+
+    def testOnSave(self):
+        ''' Saving the report to a file '''
+        # PDF save
+        QtWidgets.QFileDialog.getSaveFileName = MagicMock(return_value=["test.pdf", "(*.pdf)"])
+        os.startfile = MagicMock()
+        os.system = MagicMock()
+
+        # conversion failed
+        self.widget.HTML2PDF = MagicMock(return_value=1)
+
+        # invoke the method
+        self.widget.onSave()
+
+        # Check that the file wasn't saved
+        if os.name == 'nt':  # Windows
+            self.assertFalse(os.startfile.called)
+        elif sys.platform == "darwin":  # Mac
+            self.assertFalse(os.system.called)
+
+        # conversion succeeded
+        self.widget.HTML2PDF = MagicMock(return_value=0)
+
+        # invoke the method
+        self.widget.onSave()
+
+        # Check that the file was saved
+        if os.name == 'nt':  # Windows
+            self.assertTrue(os.startfile.called)
+        elif sys.platform == "darwin":  # Mac
+            self.assertTrue(os.system.called)
+
+        # TXT save
+        QtWidgets.QFileDialog.getSaveFileName = MagicMock(return_value=["test.txt", "(*.txt)"])
+        self.widget.onTXTSave = MagicMock()
+        # invoke the method
+        self.widget.onSave()
+
+        # Check that the file was saved
+        self.assertTrue(self.widget.onTXTSave)
+
+        # HTML save
+        QtWidgets.QFileDialog.getSaveFileName = MagicMock(return_value=["test.html", "(*.html)"])
+        self.widget.onHTMLSave = MagicMock()
+        # invoke the method
+        self.widget.onSave()
+
+        # Check that the file was saved
+        self.assertTrue(self.widget.onHTMLSave)
+
+    def testGetPictures(self):
+        ''' Saving MPL charts and returning filenames '''
+        pass
+
+    def testHTML2PDF(self):
+        ''' html to pdf conversion '''
+        class pisa_dummy(object):
+            err = 0
+        pisa.CreatePDF = MagicMock(return_value=pisa_dummy())
+        open = MagicMock(return_value="y")
+
+        data = self.widget.txtBrowser.toHtml()
+        return_value = self.widget.HTML2PDF(data, "b")
+
+        self.assertTrue(pisa.CreatePDF.called)
+        self.assertEqual(return_value, 0)
+
+        # Try to raise somewhere
+        pisa.CreatePDF.side_effect = Exception("Failed")
+
+        logging.error = MagicMock()
+
+        #run the method
+        return_value = self.widget.HTML2PDF(data, "c")
+
+        self.assertTrue(logging.error.called)
+        logging.error.assert_called_with("Error creating pdf: Failed")
+
Index: src/sas/qtgui/convertUI.py
===================================================================
--- src/sas/qtgui/convertUI.py	(revision 4992ff2626be19df7de9e5dfc0078b96bf47a75e)
+++ src/sas/qtgui/convertUI.py	(revision 2e27cdb6ce4390e566556a115bd7df99d9be58c0)
@@ -1,4 +1,5 @@
 # Convert all .ui files in all subdirectories of the current script
 import os
+import sys
 
 def pyrrc(in_file, out_file):
@@ -21,12 +22,14 @@
 
 # RC file in UI directory
-ui_root = 'UI'
+execute_root = os.path.split(sys.modules[__name__].__file__)[0]
+ui_root = os.path.join(execute_root, 'UI')
 rc_file = 'main_resources.qrc'
 out_file = 'main_resources_rc.py'
+
 pyrrc(os.path.join(ui_root, rc_file), os.path.join(ui_root, out_file))
 
 # Images
-images_root = 'images'
+images_root = os.path.join(execute_root, 'images')
 rc_file = 'images.qrc'
 out_file = 'images_rc.py'
-pyrrc(os.path.join(images_root, rc_file), os.path.join(ui_root, out_file))
+pyrrc(os.path.join(images_root, rc_file), os.path.join(images_root, out_file))
