001/* An Action that works with BasicGraphFrame to export HTML.
002
003 Copyright (c) 1998-2016 The Regents of the University of California.
004 All rights reserved.
005 Permission is hereby granted, without written agreement and without
006 license or royalty fees, to use, copy, modify, and distribute this
007 software and its documentation for any purpose, provided that the above
008 copyright notice and the following two paragraphs appear in all copies
009 of this software.
010
011 IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY
012 FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES
013 ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF
014 THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF
015 SUCH DAMAGE.
016
017 THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES,
018 INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
019 MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE
020 PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND THE UNIVERSITY OF
021 CALIFORNIA HAS NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES,
022 ENHANCEMENTS, OR MODIFICATIONS.
023
024 PT_COPYRIGHT_VERSION_2
025 COPYRIGHTENDKEY
026 */
027package ptolemy.vergil.basic.export.html;
028
029import java.awt.Color;
030import java.awt.event.ActionEvent;
031import java.awt.geom.AffineTransform;
032import java.awt.geom.Rectangle2D;
033import java.awt.print.PrinterException;
034import java.io.BufferedReader;
035import java.io.File;
036import java.io.FileOutputStream;
037import java.io.FileReader;
038import java.io.FileWriter;
039import java.io.IOException;
040import java.io.OutputStream;
041import java.io.PrintWriter;
042import java.io.Writer;
043import java.net.URL;
044import java.util.HashMap;
045import java.util.HashSet;
046import java.util.Iterator;
047import java.util.LinkedList;
048import java.util.List;
049import java.util.Locale;
050import java.util.Map;
051import java.util.Set;
052
053import javax.swing.AbstractAction;
054import javax.swing.SwingUtilities;
055
056import diva.canvas.Figure;
057import diva.canvas.JCanvas;
058import diva.graph.GraphController;
059import ptolemy.actor.CompositeActor;
060import ptolemy.actor.Manager;
061import ptolemy.actor.TypedActor;
062import ptolemy.actor.gui.BrowserEffigy;
063import ptolemy.actor.gui.Configuration;
064import ptolemy.actor.gui.EditParametersDialog;
065import ptolemy.actor.gui.Effigy;
066import ptolemy.actor.gui.PtolemyEffigy;
067import ptolemy.actor.gui.PtolemyFrame;
068import ptolemy.actor.gui.Tableau;
069import ptolemy.data.expr.StringParameter;
070import ptolemy.domains.modal.kernel.State;
071import ptolemy.kernel.CompositeEntity;
072import ptolemy.kernel.Entity;
073import ptolemy.kernel.attributes.URIAttribute;
074import ptolemy.kernel.attributes.VersionAttribute;
075import ptolemy.kernel.util.Attribute;
076import ptolemy.kernel.util.IllegalActionException;
077import ptolemy.kernel.util.InternalErrorException;
078import ptolemy.kernel.util.KernelException;
079import ptolemy.kernel.util.Locatable;
080import ptolemy.kernel.util.NameDuplicationException;
081import ptolemy.kernel.util.NamedObj;
082import ptolemy.util.CancelException;
083import ptolemy.util.FileUtilities;
084import ptolemy.util.MessageHandler;
085import ptolemy.util.StringUtilities;
086import ptolemy.vergil.actor.ActorGraphTableau;
087import ptolemy.vergil.basic.BasicGraphFrame;
088import ptolemy.vergil.basic.ExportParameters;
089import ptolemy.vergil.basic.HTMLExportable;
090import ptolemy.vergil.basic.export.web.DefaultIconLink;
091import ptolemy.vergil.basic.export.web.DefaultIconScript;
092import ptolemy.vergil.basic.export.web.WebAttribute;
093import ptolemy.vergil.basic.export.web.WebElement;
094import ptolemy.vergil.basic.export.web.WebExportParameters;
095import ptolemy.vergil.basic.export.web.WebExportable;
096import ptolemy.vergil.basic.export.web.WebExporter;
097
098/** An Action that works with BasicGraphFrame to export HTML.
099 *  Given a directory, this action creates an image of the
100 *  currently visible portion of the BasicGraphFrame and an
101 *  HTML page that displays that image. In addition, it
102 *  creates a map of the locations of actors in the image
103 *  and actions associated with each of the actors.
104 *  The default content of the web page and the actions
105 *  associated with the image map are defined by instances
106 *  of {@link WebExportable} that have been inserted at
107 *  the top level of the current {@link Configuration}.
108 *  The model may customize both the web page content and
109 *  the actions in the image map by inserting into the model
110 *  instances of {@link WebExportable}.
111 *  <p>
112 *  If the model contains an instance of
113 *  {@link WebExportParameters}, then that instance
114 *  defines parameters of the export. If not, but
115 *  the current configuration contains one, then that
116 *  instance defines the the parameters. Otherwise,
117 *  the defaults in {@link WebExportParameters}
118 *  are used.
119 *
120 * <p>The following JVM properties affect the output:</p>
121 * <dl>
122 * <dt>        -Dptolemy.ptII.exportHTML.usePtWebsite=true</dt>
123 * <dd> Include Ptolemy Website (<a href="http://ptolemy.org#in_browser" target="_top">http://ptolemy.org</a>)
124 * specific Side Includes (SSI) and use JavaScript libraries from the
125 * Ptolemy website.</dd>
126 * <dt> -Dptolemy.ptII.exportHTML.linkToJNLP=true</dt>
127 * <dd> Include a link to the a <code><i>sanitizedModelName</i>.jnlp</code> file.</dd>
128 * </dl>
129 *
130 * <p>Typically, JVM properties are set when Java is invoked.
131 * {@link ptolemy.vergil.basic.export.ExportModel} can be called with these
132 * properties set to create Ptolemy website specific web pages.</p>
133 *
134 * <p> See <a href="https://wiki.eecs.berkeley.edu/ptexternal/Main/Main/HTMLExport#in_browser" target="_top">https://wiki.eecs.berkeley.edu/ptexternal/Main/Main/HTMLExport</a>
135 * for detailed instructions about how to create web pages on the
136 * Ptolemy website for models.</p>
137 *
138 * @author Christopher Brooks and Edward A. Lee
139 * @version $Id$
140 * @since Ptolemy II 10.0
141 * @Pt.ProposedRating Yellow (eal)
142 * @Pt.AcceptedRating Red (eal)
143 */
144@SuppressWarnings("serial")
145public class ExportHTMLAction extends AbstractAction
146        implements HTMLExportable, WebExporter {
147
148    /** Create a new action to export HTML.
149     *  @param basicGraphFrame The Vergil window to export.
150     */
151    public ExportHTMLAction(BasicGraphFrame basicGraphFrame) {
152        super("Export to Web");
153        _basicGraphFrame = basicGraphFrame;
154        putValue("tooltip", "Export HTML and image files showing this model.");
155        // putValue(GUIUtilities.MNEMONIC_KEY, Integer.valueOf(KeyEvent.VK_G));
156    }
157
158    ///////////////////////////////////////////////////////////////////
159    ////                         public methods                    ////
160
161    /** Export a web page.
162     *  @param event The event that triggered this action.
163     */
164    @Override
165    public void actionPerformed(ActionEvent event) {
166        NamedObj model = _basicGraphFrame.getModel();
167        WebExportParameters defaultParameters = null;
168        try {
169            List<WebExportParameters> defaultParameterList = model
170                    .attributeList(WebExportParameters.class);
171            if (defaultParameterList == null
172                    || defaultParameterList.size() == 0) {
173                defaultParameterList = _basicGraphFrame.getConfiguration()
174                        .attributeList(WebExportParameters.class);
175                if (defaultParameterList == null
176                        || defaultParameterList.size() == 0) {
177                    defaultParameters = new WebExportParameters(model,
178                            model.uniqueName("_defaultWebExportParameters"));
179                    // We want this new attribute to look as if it were part of
180                    // its container's class definition so that it does not get
181                    // exported to MoML unless it changes in some way, e.g. one
182                    // of the parameter values it contains changes.
183                    defaultParameters.setDerivedLevel(1);
184                }
185            }
186            if (defaultParameters == null) {
187                defaultParameters = defaultParameterList.get(0);
188            }
189            EditParametersDialog dialog = new EditParametersDialog(
190                    _basicGraphFrame, defaultParameters,
191                    "Export to Web for " + model.getName());
192            if (!dialog.buttonPressed().equals("Commit")) {
193                return;
194            }
195
196            ExportParameters parameters = defaultParameters
197                    .getExportParameters();
198            // Set the copy directory target to null to indicate that no copying
199            // of files has happened.
200            parameters.setJSCopier(null);
201            exportToWeb(_basicGraphFrame, parameters);
202        } catch (KernelException ex) {
203            MessageHandler.error("Unable to export HTML.", ex);
204        }
205    }
206
207    /** If parameters.copyJavaScriptFiles is true and the Java
208     *  property ptolemy.ptII.exportHTML.usePtWebsite is false,
209     *  then copy the required JavaScript files into the target directory
210     *  given in the parameters argument.
211     *  @param graphFrame The frame being exported.
212     *  @param parameters The export parameters.
213     *  @return False if something went wrong and the user requested
214     *   canceling the export. True otherwise.
215     */
216    public static boolean copyJavaScriptFilesIfNeeded(
217            final BasicGraphFrame graphFrame,
218            final ExportParameters parameters) {
219        // First, if appropriate, copy needed files.
220        boolean usePtWebsite = Boolean.valueOf(StringUtilities
221                .getProperty("ptolemy.ptII.exportHTML.usePtWebsite"));
222        usePtWebsite = usePtWebsite || parameters.usePtWebsite;
223        if (parameters.copyJavaScriptFiles && !usePtWebsite) {
224            // Copy Javascript source files into destination directory,
225            // if they are available. The files are under an MIT license,
226            // which is compatible with the Ptolemy license.
227            // For jquery, we could use a CDS (content delivery service) instead
228            // of copying the file.
229            String jsDirectoryName = "$CLASSPATH/ptolemy/vergil/basic/export/html/javascript/";
230            File jsDirectory = FileUtilities.nameToFile(jsDirectoryName, null);
231            // We assume that if the directory exists, then the files exist.
232            if (jsDirectory.isDirectory()) {
233                // Copy files into the "javascript" directory.
234                File jsTargetDirectory = new File(
235                        parameters.directoryToExportTo, "javascript");
236                if (jsTargetDirectory.exists()
237                        && !jsTargetDirectory.isDirectory()) {
238                    File jsBackupDirectory = new File(
239                            parameters.directoryToExportTo, "javascript.bak");
240                    if (!jsTargetDirectory.renameTo(jsBackupDirectory)) {
241                        // It is ok to ignore this.
242                        System.out.println("Failed to rename \""
243                                + jsTargetDirectory + "\" to \""
244                                + jsBackupDirectory + "\"");
245                    }
246                }
247
248                if (!jsTargetDirectory.exists() && !jsTargetDirectory.mkdir()) {
249                    try {
250                        MessageHandler.warning(
251                                "Warning: Cannot find required JavaScript, CSS, and image files"
252                                        + " for lightbox effect implemented by the fancybox"
253                                        + " package. Perhaps your Ptolemy II"
254                                        + " installation does not include them."
255                                        + " Will use the files on ptolemy.org.");
256                    } catch (CancelException e) {
257                        // Cancel the action.
258                        return false;
259                    }
260                    parameters.copyJavaScriptFiles = false;
261                } else {
262                    // If deleteFilesOnExit is selected, mark the new
263                    // Javscript directory for deletion.  Mark it first so
264                    // that it will be deleted after its contained files have
265                    // been deleted.  Files/directories are deleted in the
266                    // reverse order that they are registered.
267                    if (parameters.deleteFilesOnExit) {
268                        jsTargetDirectory.deleteOnExit();
269                    }
270
271                    // Copy css, JavaScript, and image files.
272                    for (String filename : FILENAMES) {
273                        try {
274                            URL lightboxFile = FileUtilities.nameToURL(
275                                    jsDirectoryName + filename, null, null);
276                            File file = new File(jsTargetDirectory, filename);
277                            if (parameters.deleteFilesOnExit) {
278                                file.deleteOnExit();
279                            }
280                            FileUtilities.binaryCopyURLToFile(lightboxFile,
281                                    file);
282                        } catch (IOException e) {
283                            try {
284                                MessageHandler.warning(
285                                        "Warning: failed to copy required files."
286                                                + " Use the files on ptolemy.org? "
287                                                + e.getMessage());
288                            } catch (CancelException e1) {
289                                // Cancel the action.
290                                return false;
291                            }
292                            parameters.copyJavaScriptFiles = false;
293                        }
294                    }
295                    parameters.setJSCopier(graphFrame.getModel());
296                }
297            }
298        }
299        return true;
300    }
301
302    /** Define an attribute to be included in the HTML area element
303     *  corresponding to the region of the image map covered by
304     *  the specified object. For example, if an <i>attribute</i> "href"
305     *  is added, where the <i>value</i> is a URI, then the
306     *  area in the image map for the specified object will include
307     *  a hyperlink to the specified URI. If the specified object
308     *  already has a value for the specified attribute, then
309     *  the previous value is replaced by the new one.
310     *  If the specified attribute is "default", then all attributes
311     *  associated with the object are cleared.
312     *  <p>
313     *  This method is a callback method that may be performed
314     *  by attributes of class
315     *  {@link ptolemy.vergil.basic.export.web.WebExportable}
316     *  when their
317     *  {@link ptolemy.vergil.basic.export.web.WebExportable#provideContent(WebExporter)}
318     *  method is called by this exporter.</p>
319     *
320     *  @param webAttribute The attribute to be included.
321     *  @param overwrite If true, overwrite any previously defined value for
322     *   the specified attribute. If false, then do nothing if there is already
323     *   an attribute with the specified name.
324     *  @return True if the specified attribute and value was defined (i.e.,
325     *   if there was a previous value, it was overwritten).
326     */
327    @Override
328    public boolean defineAttribute(WebAttribute webAttribute,
329            boolean overwrite) {
330        if (webAttribute.getContainer() != null) {
331            NamedObj object = webAttribute.getContainer();
332            HashMap<String, String> areaTable = _areaAttributes.get(object);
333            if (areaTable == null) {
334                // No previously defined table. Add one.
335                areaTable = new HashMap<String, String>();
336                _areaAttributes.put(object, areaTable);
337            }
338            if (overwrite || areaTable.get(webAttribute.getWebName()) == null) {
339                areaTable.put(webAttribute.getWebName(),
340                        _escapeString(webAttribute.getExpression()));
341                return true;
342            }
343        }
344        return false;
345    }
346
347    /** Define an element.
348     *  If <i>onceOnly</i> is true, then if identical content has
349     *  already been added to the specified position, then it is not
350     *  added again.
351     *  @param webElement The element.
352     *  @param onceOnly True to prevent duplicate content.
353     */
354    @Override
355    public void defineElement(WebElement webElement, boolean onceOnly) {
356
357        List<StringBuffer> contents = _contents.get(webElement.getParent());
358        if (contents == null) {
359            contents = new LinkedList<StringBuffer>();
360            _contents.put(webElement.getParent(), contents);
361        }
362        StringBuffer webElementBuffer = new StringBuffer(
363                webElement.getExpression());
364        // Check to see whether contents are already present.
365        if (onceOnly) {
366            // FIXME: Will List.contains() work if two StringBuffers
367            // are constructed from the same String?
368            if (contents.contains(webElementBuffer)) {
369                return;
370            }
371        }
372        contents.add(new StringBuffer(webElementBuffer));
373    }
374
375    /** Export an HTML page and associated subpages for the specified
376     *  graph frame as given by the parameters. After setting everything
377     *  up, this method will delegate to the {@link BasicGraphFrame#writeHTML(ExportParameters, Writer)}
378     *  method, which in turn will delegate back to an instance of this class, ExportHTMLAction.
379     *  <p>
380     *  This method should be invoked in the swing thread.
381     *  It will invoke a separate thread to run the model (if so
382     *  specified in the parameters).
383     *  When that thread completes the run, it will delegate
384     *  back to the swing thread to do the export.
385     *  Note that this method will return before the export
386     *  is completed. If another thread needs to wait for
387     *  this complete, then it can call {@link #waitForExportToComplete()}.
388     *  This is synchronized to ensure that only one export can be in progress at a time.
389     *  </p>
390     *  @param graphFrame The frame containing a model to export.
391     *  @param parameters The parameters that control the export.
392     *   making the exported web page independent of the ptolemy.org site.
393     */
394    public static synchronized void exportToWeb(
395            final BasicGraphFrame graphFrame,
396            final ExportParameters parameters) {
397        try {
398
399            if (parameters.directoryToExportTo == null) {
400                MessageHandler.error("No directory specified.");
401                return;
402            }
403
404            // See whether the directory has a file called index.html.
405            final File indexFile = new File(parameters.directoryToExportTo,
406                    "index.html");
407            if (parameters.directoryToExportTo.exists()) {
408                // Previously, if directory existed and was a directory, we would always pop
409                // up a dialog stating that the directory existed and that the contents would
410                // be overwritten.  This seems excessive because the dialog will always
411                // be shown.
412                if (indexFile.exists()) {
413                    if (!MessageHandler.yesNoQuestion("\""
414                            + parameters.directoryToExportTo
415                            + "\" exists and contains an index.html file. Overwrite contents?")) {
416                        MessageHandler.message("HTML export canceled.");
417                        return;
418                    }
419                }
420                if (!parameters.directoryToExportTo.isDirectory()) {
421                    if (!MessageHandler.yesNoQuestion("\""
422                            + parameters.directoryToExportTo
423                            + "\" is a file, not a directory. Delete the file named \""
424                            + parameters.directoryToExportTo
425                            + "\" and create a directory with that name?")) {
426                        MessageHandler.message("HTML export canceled.");
427                        return;
428                    }
429                    if (!parameters.directoryToExportTo.delete()) {
430                        MessageHandler.message("Unable to delete file \""
431                                + parameters.directoryToExportTo + "\".");
432                        return;
433                    }
434                    if (!parameters.directoryToExportTo.mkdir()) {
435                        MessageHandler.message("Unable to create directory \""
436                                + parameters.directoryToExportTo + "\".");
437                        return;
438                    }
439                }
440            } else {
441                if (!parameters.directoryToExportTo.mkdir()) {
442                    MessageHandler.message("Unable to create directory \""
443                            + parameters.directoryToExportTo + "\".");
444                    return;
445                }
446            }
447            // We now have a directory and permission to write to it.
448            if (!copyJavaScriptFilesIfNeeded(graphFrame, parameters)) {
449                // Canceled the export.
450                return;
451            }
452
453            // Using a null here causes the write to occur to an index.html file.
454            final PrintWriter writer = null;
455
456            openRunAndWriteHTML(graphFrame, parameters, indexFile, writer,
457                    false);
458        } catch (Exception ex) {
459            MessageHandler.error("Unable to export to web.", ex);
460            throw new RuntimeException(ex);
461        }
462    }
463
464    /** During invocation of {@link #writeHTML(ExportParameters, Writer)},
465     *  return the parameters being used.
466     *  @return The parameters of the current export, or null if there
467     *   is not one in progress.
468     */
469    @Override
470    public ExportParameters getExportParameters() {
471        return _parameters;
472    }
473
474    /** The frame (window) being exported to HTML.
475     *  @return The frame provided to the constructor.
476     */
477    @Override
478    public PtolemyFrame getFrame() {
479        return _basicGraphFrame;
480    }
481
482    /** Depending on the export parameters (see {@link ExportParameters}),
483     *  open submodels, run the model, and export HTML.
484     *  @param graphFrame The frame being exported.
485     *  @param parameters The export parameters.
486     *  @param indexFile If you wish to show the exported page in a browser,
487     *   then this parameter must specify the file to which the write occurs
488     *   and parameters.showInBrowser must be true. Otherwise, this parameter
489     *   should be null.
490     *  @param writer The writer to write to, or null to write to the default
491     *   index.html file.
492     *  @param waitForCompletion If true, then do not return until the export
493     *   is complete. In this case, everything is run in the calling thread,
494     *   which is required to be the Swing event thread.
495     *  @exception IllegalActionException If something goes wrong.
496     */
497    public static void openRunAndWriteHTML(final BasicGraphFrame graphFrame,
498            final ExportParameters parameters, final File indexFile,
499            final Writer writer, final boolean waitForCompletion)
500            throws IllegalActionException {
501        if (graphFrame == null) {
502            throw new IllegalActionException(
503                    "Cannot export without a graphFrame.");
504        }
505        // Open submodels, if appropriate.
506        final Set<Tableau> tableauxToClose = new HashSet<Tableau>();
507        if (parameters.openCompositesBeforeExport) {
508            NamedObj model = graphFrame.getModel();
509            Effigy masterEffigy = Configuration
510                    .findEffigy(graphFrame.getModel());
511            if (model instanceof CompositeEntity) {
512                // graphFrame.getModel() might return a
513                // PteraController, which is not a CompositeActor.
514                List<Entity> entities = ((CompositeEntity) model).entityList();
515                for (Entity entity : entities) {
516                    _openEntity(entity, tableauxToClose, masterEffigy,
517                            graphFrame);
518                }
519            }
520        }
521        // Running the model has to occur in a new thread, or the whole
522        // process could hang (if the model doesn't return). So finish in a new thread.
523        // That thread will, in turn, have to again invoke the swing event thread
524        // to close any tableaux that were opened above.
525        // It does not wait for the close to complete before finishing itself.
526        Runnable exportAction = new Runnable() {
527            @Override
528            public void run() {
529                try {
530                    // graphFrame.getModel() might return a
531                    // PteraController, which is not a CompositeActor.
532                    NamedObj model = graphFrame.getModel();
533
534                    // If parameters are set to run the model, then do that.
535                    if (parameters.runBeforeExport
536                            && model instanceof CompositeActor) {
537                        // Run the model.
538                        Manager manager = ((CompositeActor) model).getManager();
539                        if (manager == null) {
540                            manager = new Manager(
541                                    ((CompositeActor) model).workspace(),
542                                    "MyManager");
543                            ((CompositeActor) model).setManager(manager);
544                        }
545                        manager.execute();
546                    }
547                } catch (Exception ex) {
548                    MessageHandler.error("Model execution failed.", ex);
549                    throw new RuntimeException(ex);
550                } finally {
551                    // The rest of the export has to occur in the
552                    // swing event thread. We do this whether the
553                    // run succeeded or not.
554                    Runnable finishExport = new Runnable() {
555                        @Override
556                        public void run() {
557                            try {
558                                // -------- Finally, actually export to web.
559                                graphFrame.writeHTML(parameters, writer);
560
561                                // Finally, if requested, show the exported page.
562                                if (parameters.showInBrowser
563                                        && indexFile != null) {
564                                    Configuration configuration = graphFrame
565                                            .getConfiguration();
566                                    try {
567                                        URL indexURL = new URL(indexFile.toURI()
568                                                .toURL().toString()
569                                                + "#in_browser");
570                                        configuration.openModel(indexURL,
571                                                indexURL,
572                                                indexURL.toExternalForm(),
573                                                BrowserEffigy.staticFactory);
574                                    } catch (Throwable throwable) {
575                                        MessageHandler.error("Failed to open \""
576                                                + indexFile + "\".", throwable);
577                                        throw new RuntimeException(throwable);
578                                    }
579                                }
580                            } catch (Exception ex) {
581                                MessageHandler.error("Unable to export to web.",
582                                        ex);
583                                throw new RuntimeException(ex);
584                            } finally {
585                                // Export is finally finished.
586                                _exportInProgress = false;
587                                synchronized (ExportHTMLAction.class) {
588                                    ExportHTMLAction.class.notifyAll();
589                                }
590                                for (Tableau tableau : tableauxToClose) {
591                                    tableau.close();
592                                }
593                            }
594
595                        }
596                    };
597                    if (waitForCompletion) {
598                        finishExport.run();
599                    } else {
600                        SwingUtilities.invokeLater(finishExport);
601                    }
602                }
603            }
604        };
605        // Invoke the new thread. First make sure the flag is set
606        // to indicate that an export is in progress.
607        _exportInProgress = true;
608        if (waitForCompletion) {
609            exportAction.run();
610        } else {
611            Thread result = new Thread(exportAction);
612            result.start();
613        }
614    }
615
616    /** Set the title to be used for the page being exported.
617     *  @param title The title.
618     *  @param showInHTML True to produce an HTML title prior to the model image.
619     */
620    // FIXME:  Replaced- a WebExportable will add the title, if any.  If it does not
621    // add a title, then there will be no title.
622
623    @Override
624    public void setTitle(String title, boolean showInHTML) {
625        _title = StringUtilities.escapeForXML(title);
626        _showTitleInHTML = showInHTML;
627    }
628
629    /** Wait for the current invocation of {@link #exportToWeb(BasicGraphFrame, ExportParameters)}
630     *  to complete. If there is not one in progress, return immediately.
631     */
632    public static synchronized void waitForExportToComplete() {
633        while (_exportInProgress) {
634            try {
635                ExportHTMLAction.class.wait();
636            } catch (InterruptedException e) {
637                // Ignore and return.
638                return;
639            }
640        }
641    }
642
643    /** Write an HTML page based on the current view of the model
644     *  to the specified destination directory. The file will be
645     *  named "index.html," and supporting files, including at
646     *  least an image showing the contents currently visible in
647     *  the graph frame, will be created. Any instances of
648     *  {@link WebExportable} in the configuration are first
649     *  cloned into the model, so these provide default behavior,
650     *  for example defining links to any open composite actors
651     *  or plot windows.
652     *  <p>
653     *  If the "ptolemy.ptII.exportHTML.usePtWebsite" property is set to true,
654     *  e.g. by invoking with -Dptolemy.ptII.usePtWebsite=true,
655     *  then the html files will have Ptolemy website specific Server Side Includes (SSI)
656     *  code and use the JavaScript and fancybox files from the Ptolemy website.
657     *  In addition, a toc.htm file will be created to aid in navigation.
658     *  This facility is not likely to be portable to other websites.
659     *  </p>
660     *
661     *  @param parameters The parameters that control the export.
662     *  @param writer The writer to use the write the HTML. If this is null,
663     *   then create an index.html file in the
664     *   directory given by the directoryToExportTo field of the parameters.
665     *  @exception IOException If unable to write associated files.
666     *  @exception PrinterException If unable to write associated files.
667     *  @exception IllegalActionException If reading parameters fails.
668     */
669    @Override
670    public void writeHTML(ExportParameters parameters, Writer writer)
671            throws PrinterException, IOException, IllegalActionException {
672        // Invoke with -Dptolemy.ptII.usePtWebsite=true to get Server
673        // Side Includes (SSI).  FIXME: this is a bit of a hack, we should
674        // use templates instead.
675        boolean usePtWebsite = Boolean.valueOf(StringUtilities
676                .getProperty("ptolemy.ptII.exportHTML.usePtWebsite"));
677        usePtWebsite = usePtWebsite || parameters.usePtWebsite;
678
679        File indexFile = null;
680        PrintWriter printWriter = null;
681
682        // The following try...finally block ensures that the index and toc files
683        // get closed even if an exception occurs. It also resets _parameters.
684        try {
685            _parameters = parameters;
686
687            // First, create the image file showing whatever the current
688            // view in this frame shows.
689            NamedObj model = _basicGraphFrame.getModel();
690
691            // $PTII/bin/ptinvoke ptolemy.vergil.basic.export.ExportModel -force htm -run -openComposites -timeOut 30000 -whiteBackground ptolemy/domains/ptera/demo/CarWash/CarWash.xml
692            // needs this.
693            if (model.getName().equals("_Controller")) {
694                model = model.getContainer();
695            }
696            // Use a sanitized model name and avoid problems with special characters in file names.
697            _sanitizedModelName = StringUtilities.sanitizeName(model.getName());
698            File imageFile = new File(parameters.directoryToExportTo,
699                    _sanitizedModelName + "." + _parameters.imageFormat);
700            if (parameters.deleteFilesOnExit) {
701                imageFile.deleteOnExit();
702            }
703            OutputStream out = new FileOutputStream(imageFile);
704            try {
705                _basicGraphFrame.writeImage(out, _parameters.imageFormat,
706                        parameters.backgroundColor);
707            } finally {
708                out.close();
709            }
710            // Initialize the data structures into which content is collected.
711            _areaAttributes = new HashMap<NamedObj, HashMap<String, String>>();
712            _contents = new HashMap<String, List<StringBuffer>>();
713            _end = new LinkedList<StringBuffer>();
714            _head = new LinkedList<StringBuffer>();
715            _start = new LinkedList<StringBuffer>();
716            _contents.put("head", _head);
717            _contents.put("start", _start);
718            _contents.put("end", _end);
719
720            // Clone instances of WebExportable from the Configuration
721            // into the model. These are removed in the finally clause
722            // of the try block.
723            _provideDefaultContent();
724
725            // Next, collect the web content specified by the instances
726            // of WebExportable contained by the model.
727            List<WebExportable> exportables = model
728                    .attributeList(WebExportable.class);
729
730            // Plus, collect the web content specified by the contained
731            // objects of the model.
732            Iterator<NamedObj> contentsIterator = model
733                    .containedObjectsIterator();
734            while (contentsIterator.hasNext()) {
735                NamedObj containedObject = contentsIterator.next();
736                exportables.addAll(
737                        containedObject.attributeList(WebExportable.class));
738            }
739
740            // Then, iterate through the list of exportables and extract
741            // content from each.
742            // Use the class of exportable to determine whether to insert
743            // content as an attribute or a seperate element
744            for (WebExportable exportable : exportables) {
745                exportable.provideContent(this);
746            }
747
748            // If a title has been specified and set to show, then
749            // add it to the start HTML section at the beginning.
750            if (_showTitleInHTML) {
751                _start.add(0, new StringBuffer("<h1>"));
752                _start.add(1, new StringBuffer(_title));
753                _start.add(2, new StringBuffer("</h1>\n"));
754            }
755
756            // System.out.println("Location of index.html: "+parameters.directoryToExportTo);
757
758            // Next, create an HTML file.
759            if (writer == null) {
760                indexFile = new File(parameters.directoryToExportTo,
761                        "index.html");
762                if (parameters.deleteFilesOnExit) {
763                    indexFile.deleteOnExit();
764                }
765                Writer indexWriter = new FileWriter(indexFile);
766                printWriter = new PrintWriter(indexWriter);
767            } else {
768                printWriter = new PrintWriter(writer);
769            }
770
771            // Generate a header that will pass the HTML validator at
772            // http://validator.w3.org/
773            // Use HTML5 tags.  Use charset utf-8 to support extended characters
774            // We use println so as to get the correct eol character for
775            // the local platform.
776            printWriter.println("<!DOCTYPE html>");
777            printWriter.println("<html>");
778            printWriter.println("<head>");
779            printWriter.println("<meta charset=utf-8>");
780
781            // Define the path to the SSI files on the ptolemy site.
782            // ssiRoot always has a trailing slash.
783            final String ssiRoot = "https://ptolemy.org/";
784
785            // Reference required script files.
786            // If the model contains an instance of CopyJavaScriptFiles, then
787            // the required files will have been copied into a directory called
788            // "javascript" in the top-level directory of the export.
789            // Otherwise, we want to reference these files at http://ptolemy.org/.
790            // If the usePtWebsite property is true, then reference the files
791            // at http://ptolemy.org/ whether the property is true or not.
792            String jsLibrary = ssiRoot;
793            if (!usePtWebsite) {
794                // If the model or a container above it in the hierarchy has
795                // copyJavaScriptFiles set to true, then set up the
796                // references to refer to the copied files rather than the
797                // website files.
798                // FIXME: This can fail if we export a submodel only but
799                // the enclosing model has its copyJavaScriptFiles parameter
800                // set to true!
801                String copiedLibrary = _findCopiedLibrary(model, "",
802                        parameters.getJSCopier());
803                if (copiedLibrary != null) {
804                    jsLibrary = copiedLibrary;
805                }
806            }
807
808            // In HTML5, can omit "type" attributes for scripts and stylesheets
809            printWriter.println("<link rel=\"stylesheet\"  href=\"" + jsLibrary
810                    + "js/" + FILENAMES[2] + "\" media=\"screen\"/>");
811            printWriter.println("<link rel=\"stylesheet\"  href=\"" + jsLibrary
812                    + "js/" + FILENAMES[4] + "\" media=\"screen\"/>");
813            if (usePtWebsite) {
814                // FIXME: this absolute path is not very safe.  The
815                // problem is that we don't know where $PTII is located on
816                // the website.
817                printWriter.println("<link href=\"" + ssiRoot
818                        + "ptolemyII/ptIIlatest/ptII/doc/default.css\" rel=\"stylesheet\" type=\"text/css\"/>");
819            }
820
821            // Title needed for the HTML validator.
822            printWriter.println("<title>" + _title + "</title>");
823
824            // In HTML5, can omit "type" attributes for scripts and stylesheets
825            // NOTE: Due to a bug somewhere (browser, Javascript, etc.), can't end this with />. Have to use </script>.
826            printWriter.println("<script src=\"" + jsLibrary + "js/"
827                    + FILENAMES[0] + "\"></script>");
828            printWriter.println("<script src=\"" + jsLibrary + "js/"
829                    + FILENAMES[1] + "\"></script>");
830
831            // FILENAMES[2] is a stylesheet <link, so it goes in the head, see above.
832
833            printWriter.println("<script src=\"" + jsLibrary + "js/"
834                    + FILENAMES[3] + "\"></script>");
835            printWriter.println("<script src=\"" + jsLibrary + "js/"
836                    + FILENAMES[5] + "\"></script>");
837            // Could alternatively use a CDS (Content Delivery Service) for the JavaScript library for jquery.
838            // index.println("<script type=\"text/js\" src=\"http://ajax.googleapis.com/ajax/libs/jquery/1.4/jquery.min.js\"></script>");
839
840            // Next, create the image map.
841            String map = _createImageMap(parameters.directoryToExportTo);
842
843            // Write the main part of the HTML file.
844            //
845            _printHTML(printWriter, "head");
846
847            //------ <head>...</head> above here
848            if (usePtWebsite) {
849                // Reference the server-side includes.
850                // toppremenu.htm includes </head>...<body>
851                printWriter.println(
852                        "<!--#include virtual=\"/ssi/toppremenu.htm\" -->");
853                printWriter.println("<!--#include virtual=\"toc.htm\" -->");
854                printWriter.println(
855                        "<!--#include virtual=\"/ssi/toppostmenu.htm\" -->");
856            } else {
857                // The Ptolemy website headers include the closing </head> and <body tag>
858                printWriter.println("</head>");
859                // Place </head> and <body> on separate lines so that
860                // tools like the TerraSwarm website can easily find
861                // them.
862                // Match the background color of the canvas, unless an explicit
863                // background color is given.
864                Color background = parameters.backgroundColor;
865                if (parameters.backgroundColor == null) {
866                    JCanvas canvas = _basicGraphFrame.getJGraph().getGraphPane()
867                            .getCanvas();
868                    background = canvas.getBackground();
869                }
870                String color = "#" + String.format("%02x", background.getRed())
871                        + String.format("%02x", background.getGreen())
872                        + String.format("%02x", background.getBlue());
873
874                printWriter.println("<body>");
875                printWriter.println(
876                        "<div style=\"background-color:" + color + "\">");
877            }
878
879            _printHTML(printWriter, "start");
880
881            boolean linkToJNLP = Boolean.valueOf(StringUtilities
882                    .getProperty("ptolemy.ptII.exportHTML.linkToJNLP"));
883            //System.out.println("ExportHTMLAction: model: " + model + " model name: " + model.getName() + " " + model.getContainer() + " isClassDef: " + ((ptolemy.kernel.InstantiableNamedObj)model).isClassDefinition());
884            //if (model.getContainer() != null) {
885            //    System.out.println("ExportHTMLAction: name: " + model.getContainer().getName() + model.getContainer().getContainer());
886            //}
887            if (linkToJNLP && ((model.getContainer() == null
888                    && model instanceof CompositeEntity
889                    // Don't include links to the .xml of class definitions
890                    && !((CompositeEntity) model).isClassDefinition())
891                    || (model.getContainer() != null
892                            && /* Ptera */model.getContainer()
893                                    .getContainer() == null
894                            && model.getName().equals("_Controller")))) {
895                String linkToHelp = "<a href=\"" + ssiRoot
896                        + "ptolemyII/ptIIlatest/ptII/doc/webStartHelp_index.htm\"><img src=\""
897                        + ssiRoot
898                        + "image/question.png\" alt=\"What is Web Start\"></a> (<i>Java Plug-in Required</i>)";
899
900                printWriter.println("<div id=\"inlineImg\">" // Defined in UCB.css
901                        + "<p>Below is a browsable image of the model.</p> "
902                        + "<ul>\n");
903
904                StringParameter noJNLPLinkParameter = (StringParameter) model
905                        .getAttribute("_noJNLPLink", StringParameter.class);
906                if (linkToJNLP && noJNLPLinkParameter != null) {
907                    System.out.println(
908                            "The ptolemy.ptII.exportHTML.linkToJNLP JVM property was set, "
909                                    + "but the _noJNLPLink parameter was set, so this model "
910                                    + model.getFullName()
911                                    + " will not have a link to the JNLP version.  Typically models that don't run well, "
912                                    + "like the BCVTB models have this parameter set.");
913                    printWriter.println(
914                            "<!-- The model had a _noJNLPLink parameter set, so we are not "
915                                    + "linking to the JNLP files. -->\n");
916                } else {
917                    printWriter.println("<li>For an executable version,"
918                            + "<!-- We use the deployJava.js script so that Java "
919                            + "will be installed if necessary -->\n"
920                            + "<script src=\"http://www.java.com/js/deployJava.js\"></script>\n"
921                            + "<script>\n"
922                            + "  var dir = location.href.substring(0,location.href.lastIndexOf('/'));\n"
923                            + "  var parentDir = dir.substring(0,dir.lastIndexOf('/')+1);\n"
924                            + "  var url = parentDir + \"" + _sanitizedModelName
925                            + ".jnlp\";\n"
926                            + "  deployJava.createWebStartLaunchButton(url);\n"
927                            + "  document.write(\" the WebStart version. "
928                            + linkToHelp.replace("\"", "\\\"") + "\");\n"
929                            + "</script>\n" + "<noscript>\n" + "<a href=\"../"
930                            + _sanitizedModelName
931                            + ".jnlp\">WebStart version</a>. \n" + linkToHelp
932                            + "</noscript>\n" + "</li>\n");
933                }
934                printWriter.println(
935                        "<li>To view or save the MoML file for this model, "
936                                + "<a href=\"../" + _sanitizedModelName
937                                + ".xml\">click here</a>.</li>");
938                if (usePtWebsite) {
939                    if (_isInDomains(model)) {
940                        printWriter.println("<li>For a domain overview, "
941                                + "<a href=\"../../../doc/\">click here</a>.</li>");
942                    }
943                }
944                printWriter.println("</ul>\n" + "</div> <!-- inlineImg -->\n");
945
946            }
947            // Put the image in.
948            printWriter.println("<img src=\"" + _sanitizedModelName + "."
949                    + _parameters.imageFormat + "\" usemap=\"#iconmap\" "
950                    // The HTML Validator at http://validator.w3.org/check wants an alt tag
951                    + "alt=\"" + _sanitizedModelName + "model\"/>");
952            printWriter.println(map);
953            _printHTML(printWriter, "end");
954
955            if (!usePtWebsite) {
956                printWriter.println("</div>");
957                printWriter.println("</body>");
958                printWriter.println("</html>");
959            } else {
960                printWriter.println("<!-- /body -->");
961                printWriter.println("<!-- /html -->");
962                printWriter.println(
963                        "<!--#include virtual=\"/ssi/bottom.htm\" -->");
964
965                ExportHTMLAction._findToc(model);
966                //if (tocContents != "") {
967                //    _addContent("toc.htm", false, tocContents);
968                //} else {
969                // Start the top of the toc.htm file.
970                _addContent("toc.htm", false, "<div id=\"menu\">");
971                _addContent("toc.htm", false, "<ul>");
972                _addContent("toc.htm", false,
973                        "<li><a href=\"/index.htm\">Ptolemy Home</a></li>");
974
975                // The URL of the current release.
976                String ptURL = (usePtWebsite ? "http://ptolemy.org" : "")
977                        + "/ptolemyII/ptII"
978                        + VersionAttribute.majorCurrentVersion() + "/ptII"
979                        + VersionAttribute.CURRENT_VERSION.getExpression()
980                        + "/";
981
982                _addContent("toc.htm", false,
983                        "<li><a href=\"" + ptURL + "doc/index.htm\">Ptolemy "
984                                + VersionAttribute.majorCurrentVersion()
985                                + "</a></li>");
986                _addContent("toc.htm", false, "</ul>");
987                _addContent("toc.htm", false, "");
988
989                String upHTML = null;
990                if (_isInDomains(model)) {
991                    upHTML = "<li><a href=\"../../../doc/\">Up</a></li>";
992                } else {
993                    // If there is a _upHTML parameter, use its value.
994                    StringParameter upHTMLParameter = (StringParameter) model
995                            .getAttribute("_upHTML", StringParameter.class);
996                    if (upHTMLParameter != null) {
997                        upHTML = upHTMLParameter.stringValue();
998                    } else {
999                        //if (!usePtWebsite) {
1000                        //    upHTML = " <li><a href=\"../index.html\">Up</a></li>";
1001                        //} else {
1002                        // Generate links to the domain docs.
1003                        String domains[] = { "Continuous", "DDF", "DE", "Modal",
1004                                "PN", "Rendezvous", "SDF", "SR", "Wireless" };
1005                        StringBuffer buffer = new StringBuffer();
1006                        for (int i = 0; i < domains.length; i++) {
1007                            buffer.append("<li><a href=\"" + ptURL
1008                                    + "ptolemy/domains/"
1009                                    + domains[i].toLowerCase()
1010                                    + "/doc/index.htm\">" + domains[i]
1011                                    + "</a></li>");
1012                        }
1013                        upHTML = buffer.toString();
1014                        //}
1015                    }
1016                }
1017
1018                // Only add <ul> if we have upHTML
1019                if (upHTML != null) {
1020                    _addContent("toc.htm", false, "<ul>");
1021                    _addContent("toc.htm", false, upHTML);
1022                    _addContent("toc.htm", false, "</ul>");
1023                }
1024
1025                // Get the toc contents and stuff it into toc.htm.
1026                List<StringBuffer> contents = _contents.get("tocContents");
1027                if (contents != null) {
1028                    _addContent("toc.htm", false, "<ul>");
1029                    for (StringBuffer line : contents) {
1030                        _addContent("toc.htm", false, line.toString());
1031                    }
1032                    _addContent("toc.htm", false, "</ul>");
1033                }
1034                _addContent("toc.htm", false, "</div><!-- /#menu -->");
1035                //}
1036            }
1037
1038            // If _contents contains any entry other than head, start, or end,
1039            // then interpret that entry as a file name to write to.
1040            for (String key : _contents.keySet()) {
1041                if (!key.equals("end") && !key.equals("head")
1042                        && !key.equals("start") && !key.equals("tocContents")) {
1043                    if (key.equals("")) {
1044                        // FIXME: I'm not sure why the key would be
1045                        // empty but the command below requires it:
1046
1047                        // (cd $PTII/doc/papers/y12/designContracts; $PTII/bin/ptinvoke -Dptolemy.ptII.exportHTML.linkToJNLP=true -Dptolemy.ptII.exportHTML.usePtWebsite=true ptolemy.vergil.basic.export.ExportModel -run -whiteBackground -openComposites htm DCMotorTol.xml)
1048
1049                        System.out.println(
1050                                "Warning, key of _contents was empty?");
1051                        continue;
1052                    }
1053                    // NOTE: A RESTful version of this would create a resource
1054                    // that could be addressed by a URL. For now, we just
1055                    // write to a file. Java documentation doesn't say
1056                    // whether the following overwrites a pre-existing file,
1057                    // but it does seem to do that, so I assume that's what it does.
1058                    Writer fileWriter = null;
1059                    try {
1060                        File file = new File(parameters.directoryToExportTo,
1061                                key);
1062                        if (parameters.deleteFilesOnExit) {
1063                            file.deleteOnExit();
1064                        }
1065                        fileWriter = new FileWriter(file);
1066                    } catch (IOException ex) {
1067                        throw new IllegalActionException(model, ex,
1068                                "Could not open a FileWriter "
1069                                        + "in directory \""
1070                                        + parameters.directoryToExportTo
1071                                        + "\" and file \"" + key + "\".");
1072
1073                    }
1074                    PrintWriter otherWriter = new PrintWriter(fileWriter);
1075                    List<StringBuffer> contents = _contents.get(key);
1076                    for (StringBuffer line : contents) {
1077                        otherWriter.println(line);
1078                    }
1079                    otherWriter.close();
1080                }
1081            }
1082        } finally {
1083            _parameters = null;
1084            _removeDefaultContent();
1085            if (printWriter != null) {
1086                printWriter.close(); // Without this, the output file may be empty
1087            }
1088            if (usePtWebsite && indexFile != null) {
1089                if (!indexFile.setExecutable(true, false /*ownerOnly*/)) {
1090                    System.err.println(
1091                            "Could not make " + indexFile + "executable.");
1092                }
1093            }
1094        }
1095    }
1096
1097    ///////////////////////////////////////////////////////////////////
1098    ////                         package protected methods         ////
1099
1100    /** List of filenames needed by jquery and fancybox.
1101     *  These are automatically provided to every exported web page
1102     *  either by referencing the ptolemy.org website (the default)
1103     *  or by copying the files into the target directory (if the
1104     *  model contains an instance of WebExportParameters with
1105     *  copyJavaScriptFiles set to true).
1106     *  The first three of these should be the JavaScript files to include,
1107     *  and the fourth should be the CSS file.
1108     *  The rest are image files to copy over.
1109     */
1110    // FIXME: I don't like the hardwired version numbers here.
1111    // Findbugs wants this package protected and final.
1112    final static String[] FILENAMES = { "jquery-1.7.2.min.js",
1113            "jquery.fancybox-1.3.4.pack.js", "jquery.fancybox-1.3.4.css",
1114            "pt-1.0.0.js", "tooltipster.css", "jquery.tooltipster.min.js",
1115            // The ones above this line must be in exactly the order given
1116            // They are referenced below by index.
1117            "blank.gif", "fancybox.png", "fancybox-y.png", "fancybox-x.png",
1118            "fancy_title_right.png", "fancy_title_over.png",
1119            "fancy_title_main.png", "fancy_title_left.png",
1120            "fancy_shadow_w.png", "fancy_shadow_sw.png", "fancy_shadow_se.png",
1121            "fancy_shadow_s.png", "fancy_shadow_nw.png", "fancy_shadow_ne.png",
1122            "fancy_shadow_n.png", "fancy_shadow_e.png", "fancy_nav_right.png",
1123            "fancy_nav_left.png", "fancy_loading.png", "fancy_close.png",
1124            "javascript-license.htm" };
1125
1126    ///////////////////////////////////////////////////////////////////
1127    ////                         protected methods                 ////
1128
1129    /** Create the image map. As a side effect, this may create other
1130     *  HTML files or subdirectories.
1131     *  @param directory The directory into which to write any HTML
1132     *   that is created as a side effect.
1133     *  @return HTML that describes the image map.
1134     *  @exception PrinterException If writing to the toc file fails.
1135     *  @exception IOException If IO fails.
1136     *  @exception IllegalActionException If reading parameters fails.
1137     */
1138    protected String _createImageMap(File directory)
1139            throws IllegalActionException, IOException, PrinterException {
1140        StringBuffer result = new StringBuffer();
1141        // The HTML Validator at http://validator.w3.org/check wants an id tag.
1142        // For HTML5, the name and id must match
1143        result.append("<map name=\"iconmap\" id=\"iconmap\">\n");
1144
1145        // Iterate over the icons.
1146        List<IconVisibleLocation> iconLocations = _getIconVisibleLocations();
1147        for (IconVisibleLocation location : iconLocations) {
1148            // This string will have at least one space at the start and the end.
1149            StringBuffer attributeString = new StringBuffer(" ");
1150            String title = "Actor"; // Default in case there is no title key.
1151            HashMap<String, String> areaAttributes = _areaAttributes
1152                    .get(location.object);
1153            // If areaAttributes is null, omit the entry, since an HTML area
1154            // element is required to have an href attribute
1155            if (areaAttributes != null) {
1156                for (Map.Entry<String, String> entry : areaAttributes
1157                        .entrySet()) {
1158                    String key = entry.getKey();
1159                    String value = entry.getValue();
1160                    // If the value is empty, omit the entry.
1161                    if (value != null && !value.trim().equals("")) {
1162                        if (key.equals("title")) {
1163                            title = StringUtilities.escapeString(value);
1164                        }
1165                        attributeString.append(key);
1166                        attributeString.append("=\"");
1167                        attributeString
1168                                .append(StringUtilities.escapeString(value));
1169                        attributeString.append("\" ");
1170                    }
1171                }
1172
1173                // Write the name of the actor followed by the table.
1174                result.append("<area shape=\"rect\" coords=\""
1175                        + (int) location.topLeftX + ","
1176                        + (int) location.topLeftY + ","
1177                        + (int) location.bottomRightX + ","
1178                        + (int) location.bottomRightY + "\"\n" + attributeString
1179                        + "alt=\"" + title + "\"/>\n");
1180            }
1181
1182        }
1183        result.append("</map>\n");
1184        return result.toString();
1185    }
1186
1187    /** Return a list of data structures with one entry for each visible
1188     *  entity and attribute. Each data structure contains
1189     *  a reference to the entity and the coordinates
1190     *  of the upper left corner and lower right corner of the main
1191     *  part of its icon (not including decorations like the name
1192     *  and any highlights it may have). The coordinates are relative
1193     *  to the current visible rectangle, where the upper left corner
1194     *  of the visible rectangle has coordinates (0,0), and the lower
1195     *  right corner has coordinates (w,h), where w is the width
1196     *  and h is the height (in pixels).
1197     *  @return A list representing the space occupied by each
1198     *   visible icon for the entities in the model, or an empty
1199     *   list if no icons are visible.
1200     */
1201    protected List<IconVisibleLocation> _getIconVisibleLocations() {
1202        List<IconVisibleLocation> result = new LinkedList<IconVisibleLocation>();
1203        Rectangle2D viewSize = _basicGraphFrame.getVisibleRectangle();
1204        JCanvas canvas = _basicGraphFrame.getJGraph().getGraphPane()
1205                .getCanvas();
1206        AffineTransform transform = canvas.getCanvasPane().getTransformContext()
1207                .getTransform();
1208        double scaleX = transform.getScaleX();
1209        double scaleY = transform.getScaleY();
1210        double translateX = transform.getTranslateX();
1211        double translateY = transform.getTranslateY();
1212
1213        NamedObj model = _basicGraphFrame.getModel();
1214        if (model instanceof CompositeEntity) {
1215            List<Entity> entities = ((CompositeEntity) model).entityList();
1216            for (Entity entity : entities) {
1217                _addRectangle(result, viewSize, scaleX, scaleY, translateX,
1218                        translateY, entity);
1219            }
1220        }
1221        List<Attribute> attributes = ((CompositeEntity) model).attributeList();
1222        for (Attribute attribute : attributes) {
1223            _addRectangle(result, viewSize, scaleX, scaleY, translateX,
1224                    translateY, attribute);
1225        }
1226        return result;
1227    }
1228
1229    /** Provide default HTML content by cloning any
1230     *  default WebExportable attributes provided by
1231     *  the configuration into the model. In the case
1232     *  of {@link DefaultIconScript} and {@link DefaultIconLink}
1233     *  objects, if the model contains one with the same event
1234     *  type, then the one from the configuration is not used.
1235     *  @exception IllegalActionException If cloning a configuration attribute fails.
1236     */
1237    protected void _provideDefaultContent() throws IllegalActionException {
1238        Configuration configuration = _basicGraphFrame.getConfiguration();
1239        if (configuration != null) {
1240            // Any instances of WebExportable contained by the
1241            // configuration are cloned into the model.
1242            NamedObj model = _basicGraphFrame.getModel();
1243            List<WebExportable> exportables = configuration
1244                    .attributeList(WebExportable.class);
1245            for (WebExportable exportable : exportables) {
1246                if (exportable instanceof Attribute) {
1247                    boolean foundOverride = false;
1248                    if (exportable instanceof DefaultIconScript) {
1249                        // Check whether the script provided by the model overrides the
1250                        // one given in the configurations. It does if the eventType matches
1251                        // and it either includes the same objects (Entities or Attributes) or
1252                        // it includes all objects, and the instancesOf that is specifies matches.
1253                        String eventType = ((DefaultIconScript) exportable).eventType
1254                                .stringValue();
1255                        String include = ((DefaultIconScript) exportable).include
1256                                .stringValue();
1257                        String instancesOf = ((DefaultIconScript) exportable).instancesOf
1258                                .stringValue();
1259                        List<DefaultIconScript> defaults = model
1260                                .attributeList(DefaultIconScript.class);
1261                        for (DefaultIconScript script : defaults) {
1262                            if (script.eventType.stringValue().equals(eventType)
1263                                    && (script.include.stringValue()
1264                                            .equals(include)
1265                                            || script.include.stringValue()
1266                                                    .toLowerCase(
1267                                                            Locale.getDefault())
1268                                                    .equals("all"))
1269                                    && script.instancesOf.stringValue()
1270                                            .equals(instancesOf)) {
1271                                // Skip this default from the configuration.
1272                                foundOverride = true;
1273                                break;
1274                            }
1275                        }
1276                    } else if (exportable instanceof DefaultIconLink) {
1277                        // Check whether the link default provided by the model overrides the
1278                        // one given in the configurations. It does if
1279                        // it either includes the same objects (Entities or Attributes) or
1280                        // it includes all objects, and the instancesOf that is specifies matches.
1281                        String include = ((DefaultIconLink) exportable).include
1282                                .stringValue();
1283                        String instancesOf = ((DefaultIconLink) exportable).instancesOf
1284                                .stringValue();
1285                        List<DefaultIconLink> defaults = model
1286                                .attributeList(DefaultIconLink.class);
1287                        for (DefaultIconLink script : defaults) {
1288                            if ((script.include.stringValue().equals(include)
1289                                    || script.include.stringValue()
1290                                            .toLowerCase(Locale.getDefault())
1291                                            .equals("all"))
1292                                    && script.instancesOf.stringValue()
1293                                            .equals(instancesOf)) {
1294                                // Skip this default from the configuration.
1295                                foundOverride = true;
1296                                break;
1297                            }
1298                        }
1299                    }
1300                    if (foundOverride) {
1301                        continue;
1302                    }
1303                    try {
1304                        Attribute clone = (Attribute) ((Attribute) exportable)
1305                                .clone(model.workspace());
1306                        clone.setName(model.uniqueName(clone.getName()));
1307                        clone.setContainer(model);
1308                        clone.setPersistent(false);
1309                        // Make sure this appears earlier in the list of attributes
1310                        // than any contained by the model. The ones in the model should
1311                        // override the ones provided by the configuration.
1312                        clone.moveToFirst();
1313                    } catch (CloneNotSupportedException e) {
1314                        throw new InternalErrorException(
1315                                "Can't clone WebExportable attribute in Configuration: "
1316                                        + ((Attribute) exportable).getName());
1317                    } catch (NameDuplicationException e) {
1318                        throw new InternalErrorException(
1319                                "Failed to generate unique name for attribute in Configuration: "
1320                                        + ((Attribute) exportable).getName());
1321                    }
1322                }
1323            }
1324        }
1325    }
1326
1327    /** Remove default HTML content, which includes all instances of
1328     *  WebExportable that are not persistent.
1329     *  @exception IllegalActionException If removing the attribute fails.
1330     */
1331    protected void _removeDefaultContent() throws IllegalActionException {
1332        NamedObj model = _basicGraphFrame.getModel();
1333        List<WebExportable> exportables = model
1334                .attributeList(WebExportable.class);
1335        for (WebExportable exportable : exportables) {
1336            if (exportable instanceof Attribute) {
1337                Attribute attribute = (Attribute) exportable;
1338                if (!attribute.isPersistent()) {
1339                    try {
1340                        attribute.setContainer(null);
1341                    } catch (NameDuplicationException e) {
1342                        throw new InternalErrorException(e);
1343                    }
1344                }
1345            }
1346        }
1347    }
1348
1349    ///////////////////////////////////////////////////////////////////
1350    ////                         protected methods                 ////
1351
1352    /** The associated Vergil frame. */
1353    protected final BasicGraphFrame _basicGraphFrame;
1354
1355    ///////////////////////////////////////////////////////////////////
1356    ////                         private methods                   ////
1357
1358    /** Add HTML content at the specified position.  This content is not
1359     *  associated with any NamedObj.
1360     *  The position is expected to be one of "head", "start", "end",
1361     *  or anything else. In the latter case, the value
1362     *  of the position attribute is a filename
1363     *  into which the content is written.
1364     *  If <i>onceOnly</i> is true, then if identical content has
1365     *  already been added to the specified position, then it is not
1366     *  added again.
1367     *  @param position The position for the content.
1368     *  @param onceOnly True to prevent duplicate content.
1369     *  @param content The content to add.
1370     */
1371
1372    private void _addContent(String position, boolean onceOnly,
1373            String content) {
1374        List<StringBuffer> contents = _contents.get(position);
1375        if (contents == null) {
1376            contents = new LinkedList<StringBuffer>();
1377            _contents.put(position, contents);
1378        }
1379        StringBuffer contentsBuffer = new StringBuffer(content);
1380        // Check to see whether contents are already present.
1381        if (onceOnly) {
1382            // FIXME: Will List.contains() work if two StringBuffers
1383            // are constructed from the same String?
1384            if (contents.contains(contentsBuffer)) {
1385                return;
1386            }
1387        }
1388        contents.add(contentsBuffer);
1389    }
1390
1391    /** Add to the specified result list the bounds of the icon
1392     *  for the specified object.
1393     *  @param result The list to add to.
1394     *  @param viewSize The view size.
1395     *  @param scaleX The x scaling factor.
1396     *  @param scaleY The y scaling factor.
1397     *  @param translateX The x translation.
1398     *  @param translateY The y translation.
1399     *  @param object The object to add.
1400     */
1401    private void _addRectangle(List<IconVisibleLocation> result,
1402            Rectangle2D viewSize, double scaleX, double scaleY,
1403            double translateX, double translateY, NamedObj object) {
1404        Locatable location = null;
1405        try {
1406            location = (Locatable) object.getAttribute("_location",
1407                    Locatable.class);
1408        } catch (IllegalActionException e1) {
1409            // NOTE: What to do here? For now, ignoring the node.
1410        }
1411        if (location != null) {
1412            GraphController controller = _basicGraphFrame.getJGraph()
1413                    .getGraphPane().getGraphController();
1414            Figure figure = controller.getFigure(location);
1415
1416            if (figure != null) {
1417                // NOTE: Calling getBounds() on the figure itself yields an
1418                // inaccurate bounds, for some reason.
1419                // Weirdly, to get the size right, we need to use the shape.
1420                // But to get the location right, we need the other!
1421                Rectangle2D figureBounds = figure.getShape().getBounds2D();
1422
1423                // If the figure is composite, use the background figure
1424                // for the bounds instead.  NOTE: This seems to be a mistake.
1425                // The size and position information yielded appears to have
1426                // no relationship to reality.
1427                /*
1428                if (figure instanceof CompositeFigure) {
1429                    figure = ((CompositeFigure) figure).getBackgroundFigure();
1430                    figureBounds = figure.getShape().getBounds2D();
1431                }
1432                 */
1433                // Populate the data structure with bound information
1434                // relative to the visible rectangle.
1435                // Sadly, neither the figureOrigin nor the figureBounds
1436                // tells us where the figure is.  So we have quite a bit
1437                // of work to do.
1438                // First, get the width of the figure.
1439                // This is the only variable that does not depend
1440                // on the anchor.
1441                double width = figureBounds.getWidth();
1442                double height = figureBounds.getHeight();
1443                IconVisibleLocation i = new IconVisibleLocation();
1444                i.object = object;
1445                i.topLeftX = figureBounds.getX() * scaleX + translateX
1446                        - _PADDING;
1447                i.topLeftY = figureBounds.getY() * scaleY + translateY
1448                        - _PADDING;
1449                i.bottomRightX = i.topLeftX + width * scaleX + 2 * _PADDING;
1450                i.bottomRightY = i.topLeftY + height * scaleY + 2 * _PADDING;
1451
1452                // If the rectangle is not visible, no more to do.
1453                if (i.bottomRightX < 0.0 || i.bottomRightY < 0.0
1454                        || i.topLeftX > viewSize.getWidth()
1455                        || i.topLeftY > viewSize.getHeight()) {
1456                    return;
1457                } else {
1458                    // Clip the rectangle so it does not include any portion
1459                    // that is not in the visible rectangle.
1460                    if (i.topLeftX < 0.0) {
1461                        i.topLeftX = 0.0;
1462                    }
1463                    if (i.topLeftY < 0.0) {
1464                        i.topLeftY = 0.0;
1465                    }
1466                    if (i.bottomRightX > viewSize.getWidth()) {
1467                        i.bottomRightX = viewSize.getWidth();
1468                    }
1469                    if (i.bottomRightY > viewSize.getHeight()) {
1470                        i.bottomRightY = viewSize.getHeight();
1471                    }
1472                    // Add the data to the result list.
1473                    // This is inserted at the start, not the end of the
1474                    // list so that in the image map, items in front appear
1475                    // earlier rather than later. This ensures that items
1476                    // in front take precedence.
1477                    result.add(0, i);
1478                }
1479            }
1480        }
1481    }
1482
1483    /** Escape strings for inclusion as the value of HTML attribute.
1484     *  @param string The string to escape.
1485     *  @return Escaped string.
1486     */
1487    private String _escapeString(String string) {
1488        // This method is abstracted because it's not really clear
1489        // what should be escaped.
1490        String result = StringUtilities.escapeForXML(string);
1491        // Bizarrely, escaping all characters except newlines work.
1492        // Newlines need to be converted to \n.
1493        // No idea why so many backslashes are required below.
1494        // result = result.replaceAll("&#10;", "\\\\\\n");
1495        return result;
1496    }
1497
1498    /** Construct a path the form "../../", for example, from the specified
1499     *  model to the specified copier, where the specified copier is either
1500     *  null or a any container above the specified model in the hierarchy.
1501     *  If the specified copier is null or is not a container above the
1502     *  specified model, then return null.
1503     *  @param model The model.
1504     *  @param path The path so far.
1505     *  @param copier The model responsible for the copying, which should be
1506     *   null if no copying is being done, equal to model if the model is
1507     *   responsible for copying, or a container of model if a container of model
1508     *   is doing the copying.
1509     */
1510    private String _findCopiedLibrary(NamedObj model, String path,
1511            NamedObj copier) {
1512        if (model == copier) {
1513            return path;
1514        }
1515        NamedObj container = model.getContainer();
1516        if (container == null) {
1517            // Got to the top level without finding an instance of CopyJavaScriptFiles.
1518            return null;
1519        }
1520        return _findCopiedLibrary(container, "../" + path, copier);
1521    }
1522
1523    /** Return the contents of a toc.htm file
1524     *  that is located in either the current directory
1525     *  or ../../doc/
1526     *  @param model The model to be checked
1527     *  @return the contents of the toc.htm file or the empty string.
1528     */
1529    private static String _findToc(NamedObj model) {
1530        try {
1531            URIAttribute modelURI = (URIAttribute) model.getAttribute("_uri",
1532                    URIAttribute.class);
1533            if (modelURI != null) {
1534                // If a model is remote, then don't look for toc.htm.
1535                if (!modelURI.getURI().toString().startsWith("file:")) {
1536                    System.out.println("Can't find toc: " + model.getFullName()
1537                            + ": _uri is " + modelURI.getURI()
1538                            + ", which does not start with \"file:\"");
1539                    return "";
1540                }
1541
1542                // Look in the current directory for toc.htm or toc.html, then
1543                // look for ../../doc/toc.htm and then ../../doc/toc.html.
1544                File modelFile = new File(modelURI.getURI());
1545                File tocFile = new File(modelFile.getParent(), "toc.htm");
1546                if (!tocFile.exists()) {
1547                    tocFile = new File(modelFile.getParent(), "toc.html");
1548                    if (!tocFile.exists()) {
1549                        File docDirectory = new File(modelFile.getParent(),
1550                                "../../doc/");
1551                        if (docDirectory.exists()
1552                                && docDirectory.isDirectory()) {
1553                            tocFile = new File(docDirectory, "toc.htm");
1554                            if (!tocFile.exists()) {
1555                                tocFile = new File(docDirectory, "toc.html");
1556                                if (!tocFile.exists()) {
1557                                    tocFile = null;
1558                                }
1559                            }
1560                        } else {
1561                            tocFile = null;
1562                        }
1563                    }
1564                }
1565                if (tocFile == null) {
1566                    return "";
1567                } else {
1568                    // Read the contents and return it.
1569                    System.out.println("Copying the contents of " + tocFile);
1570                    StringBuffer result = new StringBuffer();
1571                    FileReader fileReader = null;
1572                    BufferedReader bufferedReader = null;
1573                    try {
1574                        fileReader = new FileReader(tocFile);
1575                        bufferedReader = new BufferedReader(fileReader);
1576                        String line = null;
1577                        while ((line = bufferedReader.readLine()) != null) {
1578                            result.append(line + "\n");
1579                        }
1580                    } catch (IOException x) {
1581                        System.err.format("IOException: %s%n", x);
1582                    } finally {
1583                        if (bufferedReader != null) {
1584                            bufferedReader.close();
1585                        }
1586                    }
1587                    return result.toString();
1588                }
1589            }
1590        } catch (Throwable throwable) {
1591            System.out.println("Failed to find toc for " + model.getFullName()
1592                    + ": " + throwable);
1593            throwable.printStackTrace();
1594            return "";
1595        }
1596        return "";
1597    }
1598
1599    /** Return true if the model is in the domains demo directory
1600     *  and ../../../doc exists and is a directory and
1601     *  either ../../doc/index.htm or index.html exist
1602     *  @param model The model to be checked
1603     *  @return true if it is in the domains directory and doc exists.
1604     */
1605    private static boolean _isInDomains(NamedObj model) {
1606        try {
1607            URIAttribute modelURI = (URIAttribute) model.getAttribute("_uri",
1608                    URIAttribute.class);
1609            if (modelURI != null) {
1610                String modelURIString = modelURI.getURI().toString();
1611                if (modelURIString.contains("/domains")) {
1612                    try {
1613                        File modelFile = new File(modelURI.getURI());
1614                        File docDirectory = new File(modelFile,
1615                                "../../../doc/");
1616                        if (docDirectory.exists() && docDirectory.isDirectory()
1617                                && (new File(docDirectory, "index.htm").exists()
1618                                        || new File(docDirectory, "index.html")
1619                                                .exists())) {
1620                            return true;
1621                        }
1622                    } catch (Throwable throwable) {
1623                        return false;
1624                    }
1625                }
1626            }
1627        } catch (IllegalActionException ex) {
1628            return false;
1629        }
1630        return false;
1631    }
1632
1633    /** Open a composite entity, if it is not already open,
1634     *  and recursively open any composite
1635     *  entities or state refinements that it contains.
1636     *  @param entity The entity to open.
1637     *  @param tableauxToClose A list of tableaux are newly opened.
1638     *  @param masterEffigy The top-level effigy for the modeling being exported.
1639     *  @param graphFrame The graph frame.
1640     *  @exception IllegalActionException If opening fails.
1641     *  @exception NameDuplicationException Not thrown.
1642     */
1643    private static void _openComposite(CompositeEntity entity,
1644            Set<Tableau> tableauxToClose, Effigy masterEffigy,
1645            BasicGraphFrame graphFrame) throws IllegalActionException {
1646
1647        Configuration configuration = graphFrame.getConfiguration();
1648        Effigy effigy = configuration.getEffigy(entity);
1649
1650        Tableau tableau;
1651        if (effigy != null) {
1652            // Effigy exists. See whether it has an open tableau.
1653            List<Tableau> tableaux = effigy.entityList(Tableau.class);
1654            if (tableaux == null || tableaux.size() == 0) {
1655                // No open tableau. Open one.
1656                tableau = configuration.createPrimaryTableau(effigy);
1657                tableauxToClose.add(tableau);
1658            } else {
1659                // The first tablequ is sufficient to retrieve the model.
1660                tableau = tableaux.get(0);
1661            }
1662        } else {
1663            // No pre-existing effigy.
1664            try {
1665                tableau = configuration.openModel(entity);
1666                tableauxToClose.add(tableau);
1667            } catch (NameDuplicationException e) {
1668                // This should not occur.
1669                throw new InternalErrorException(e);
1670            }
1671        }
1672        // NOTE: The entity that was opened may not actually be entity
1673        // because if it was an instance of a class, then class definition
1674        // will have been opened.
1675        CompositeEntity actualEntity = entity;
1676        if (tableau instanceof ActorGraphTableau) {
1677            PtolemyEffigy actualEffigy = (PtolemyEffigy) tableau.getContainer();
1678            actualEntity = (CompositeEntity) actualEffigy.getModel();
1679        }
1680        List<Entity> entities = actualEntity.entityList();
1681        for (Entity inside : entities) {
1682            _openEntity(inside, tableauxToClose, masterEffigy, graphFrame);
1683        }
1684    }
1685
1686    /** Open the specified entity using the specified configuration.
1687     *  This method will recursively descend through the model, opening
1688     *  every composite actor and every state refinement.
1689     *  @param entity The entity to open.
1690     *  @param tableauxToClose A list of tableaux are newly opened.
1691     *  @param masterEffigy The top-level effigy for the modeling being exported.
1692     *  @param graphFrame The graph frame.
1693     */
1694    private static void _openEntity(Entity entity, Set<Tableau> tableauxToClose,
1695            Effigy masterEffigy, BasicGraphFrame graphFrame)
1696            throws IllegalActionException {
1697        if (entity instanceof CompositeEntity) {
1698            _openComposite((CompositeEntity) entity, tableauxToClose,
1699                    masterEffigy, graphFrame);
1700        } else if (entity instanceof State) {
1701            TypedActor[] refinements = ((State) entity).getRefinement();
1702            // refinements could be null, see ptolemy/domains/ptides/demo/PtidesBasicPowerPlant/PtidesBasicPowerPlant.xml
1703            if (refinements != null) {
1704                for (TypedActor refinement : refinements) {
1705                    _openComposite((CompositeEntity) refinement,
1706                            tableauxToClose, masterEffigy, graphFrame);
1707                }
1708            }
1709        }
1710    }
1711
1712    /** Print the HTML in the _contents structure corresponding to the
1713     *  specified position to the specified writer. Each item in the
1714     *  _contents structure is written on one line.
1715     *  @param writer The writer to print to.
1716     *  @param position The position.
1717     */
1718    private void _printHTML(PrintWriter writer, String position) {
1719        List<StringBuffer> contents = _contents.get(position);
1720        for (StringBuffer content : contents) {
1721            writer.println(content);
1722        }
1723    }
1724
1725    ///////////////////////////////////////////////////////////////////
1726    ////                         private fields                    ////
1727
1728    /** Data structure storing area attributes to for each Ptolemy II object. */
1729    private HashMap<NamedObj, HashMap<String, String>> _areaAttributes;
1730
1731    /** Content added by position. */
1732    private HashMap<String, List<StringBuffer>> _contents;
1733
1734    /** Content of the end section. */
1735    private LinkedList<StringBuffer> _end;
1736
1737    /** Indicator that an export is in progress. */
1738    private static boolean _exportInProgress = false;
1739
1740    /** Content of the head section. */
1741    private LinkedList<StringBuffer> _head;
1742
1743    /** Padding around figures for bounding box. */
1744    private static double _PADDING = 4.0;
1745
1746    /** The parameters of the current export, if there is one. */
1747    private ExportParameters _parameters;
1748
1749    /** Indicator of whether title should be shown in HTML. */
1750    private boolean _showTitleInHTML = false;
1751
1752    /** Content of the start section. */
1753    private LinkedList<StringBuffer> _start;
1754
1755    /** The sanitized modelName */
1756    private String _sanitizedModelName;
1757
1758    /** The title of the page. */
1759    private String _title = "Ptolemy II model";
1760
1761    ///////////////////////////////////////////////////////////////////
1762    //// IconVisibleLocation
1763
1764    /** A data structure consisting of a NamedObj and the coordinates
1765     *  of the upper left corner and lower right corner of the main
1766     *  part of its icon (not including decorations like the name
1767     *  and any highlights it may have). The coordinates are relative
1768     *  to the current visible rectangle, where the upper left corner
1769     *  of the visible rectangle has coordinates (0,0), and the lower
1770     *  right corner has coordinates (w,h), where w is the width
1771     *  and h is the height (in pixels).
1772     */
1773    static private class IconVisibleLocation {
1774
1775        /** The object with a visible icon. */
1776        public NamedObj object;
1777
1778        /** The top left X coordinate. */
1779        public double topLeftX;
1780
1781        /** The top left Y coordinate. */
1782        public double topLeftY;
1783
1784        /** The bottom right X coordinate. */
1785        public double bottomRightX;
1786
1787        /** The bottom right Y coordinate. */
1788        public double bottomRightY;
1789
1790        /** String representation. */
1791        @Override
1792        public String toString() {
1793            return object.getName() + " from (" + topLeftX + ", " + topLeftY
1794                    + ") to (" + bottomRightX + ", " + bottomRightY + ")";
1795        }
1796    }
1797}