From 409be26edc0ad17e101de10bfc7ba0e1eea7d46b Mon Sep 17 00:00:00 2001 From: Tres Finocchiaro Date: Tue, 26 May 2020 17:05:49 -0400 Subject: [PATCH] Add Monocle support (#632) Add Monocle support, performance improvements This is a 2.1 refactor of #580 Co-authored-by: Berenz --- ant/javafx.xml | 10 +- ant/project.properties | 4 +- sample.html | 2 +- src/qz/common/Constants.java | 1 + src/qz/common/PropertyHelper.java | 7 +- src/qz/common/TrayManager.java | 31 ++- src/qz/printer/action/PrintHTML.java | 2 +- src/qz/printer/action/PrintImage.java | 42 ++-- src/qz/printer/action/WebApp.java | 308 +++++++++++++++++-------- src/qz/printer/action/WebAppModel.java | 38 ++- src/qz/ui/tray/TaskbarTrayIcon.java | 19 +- src/qz/ws/PrintSocketServer.java | 6 +- test/qz/printer/action/WebAppTest.java | 246 ++++++++++++++++++++ 13 files changed, 580 insertions(+), 136 deletions(-) create mode 100644 test/qz/printer/action/WebAppTest.java diff --git a/ant/javafx.xml b/ant/javafx.xml index 7e0dfb8c0..a7d328b59 100644 --- a/ant/javafx.xml +++ b/ant/javafx.xml @@ -103,7 +103,7 @@ - + @@ -116,8 +116,8 @@ - - + + @@ -126,7 +126,7 @@ - + @@ -223,4 +223,4 @@ - \ No newline at end of file + diff --git a/ant/project.properties b/ant/project.properties index 819b577f8..6f8d4b57a 100644 --- a/ant/project.properties +++ b/ant/project.properties @@ -22,6 +22,6 @@ jar.index=true # See also qz.common.Constants.java javac.source=1.8 javac.target=1.8 -javafx.version=11.0.2 -javafx.mirror=https://gluonhq.com/download +javafx.version=15.ea+3_monocle +javafx.mirror=https://download2.gluonhq.com/openjfx/15 java.download=https://adoptopenjdk.net/?variant=openjdk11 diff --git a/sample.html b/sample.html index b142a6759..b2004ab39 100755 --- a/sample.html +++ b/sample.html @@ -1793,7 +1793,7 @@

Options

/// Pixel Printers /// function printHTML() { - var config = getUpdatedConfig({ 'pxlScale': false }); + var config = getUpdatedConfig(); var opts = getUpdatedOptions(true); var printData = [ diff --git a/src/qz/common/Constants.java b/src/qz/common/Constants.java index d44562f6c..307199ce2 100644 --- a/src/qz/common/Constants.java +++ b/src/qz/common/Constants.java @@ -57,6 +57,7 @@ public class Constants { public static final String PREFS_NOTIFICATIONS = "tray.notifications"; public static final String PREFS_HEADLESS = "tray.headless"; + public static final String PREFS_MONOCLE = "tray.monocle"; public static final String WHITE_LIST = "Permanently allowed \"%s\" to access local resources"; public static final String BLACK_LIST = "Permanently blocked \"%s\" from accessing local resources"; diff --git a/src/qz/common/PropertyHelper.java b/src/qz/common/PropertyHelper.java index 1c5559077..ee58dc902 100644 --- a/src/qz/common/PropertyHelper.java +++ b/src/qz/common/PropertyHelper.java @@ -40,9 +40,10 @@ public PropertyHelper(String file) { } public boolean getBoolean(String key, boolean defaultVal) { - try { - return Boolean.parseBoolean(getProperty(key)); - } catch (Throwable t) { + String prop = getProperty(key); + if (prop != null) { + return Boolean.parseBoolean(prop); + } else { return defaultVal; } } diff --git a/src/qz/common/TrayManager.java b/src/qz/common/TrayManager.java index fc83b0459..695e1760b 100644 --- a/src/qz/common/TrayManager.java +++ b/src/qz/common/TrayManager.java @@ -10,6 +10,7 @@ package qz.common; +import com.github.zafarkhaja.semver.Version; import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; @@ -242,6 +243,16 @@ private void addMenuItems() { notificationsItem.addActionListener(notificationsListener); diagnosticMenu.add(notificationsItem); + JCheckBoxMenuItem monocleItem = new JCheckBoxMenuItem("Use Monocle for HTML"); + monocleItem.setToolTipText("Use monocle platform for HTML printing (restart required)"); + monocleItem.setMnemonic(KeyEvent.VK_U); + monocleItem.setState(prefs.getBoolean(Constants.PREFS_MONOCLE, true)); + monocleItem.addActionListener(monocleListener); + + if (Constants.JAVA_VERSION.greaterThanOrEqualTo(Version.valueOf("11.0.0"))) { //only include if it can be used + diagnosticMenu.add(monocleItem); + } + diagnosticMenu.add(new JSeparator()); JMenuItem logItem = new JMenuItem("View logs (live feed)...", iconCache.getIcon(IconCache.Icon.LOG_ICON)); @@ -326,6 +337,16 @@ public void actionPerformed(ActionEvent e) { } }; + private final ActionListener monocleListener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + JCheckBoxMenuItem j = (JCheckBoxMenuItem)e.getSource(); + prefs.setProperty(Constants.PREFS_MONOCLE, j.getState()); + displayWarningMessage(String.format("A restart of %s is required to ensure this feature is %sabled.", + Constants.ABOUT_TITLE, j.getState()? "en":"dis")); + } + }; + private final ActionListener desktopListener() { return e -> { shortcutCreator.createDesktopShortcut(); @@ -577,7 +598,7 @@ private void displayMessage(final String caption, final String text, final TrayI if (tray != null) { SwingUtilities.invokeLater(() -> { boolean showAllNotifications = prefs.getBoolean(Constants.PREFS_NOTIFICATIONS, false); - if (showAllNotifications || level == TrayIcon.MessageType.ERROR) { + if (showAllNotifications || level != TrayIcon.MessageType.INFO) { tray.displayMessage(caption, text, level); } }); @@ -595,4 +616,12 @@ public void singleInstanceCheck(java.util.List insecurePorts, Integer i } } + public boolean isMonoclePreferred() { + return prefs.getBoolean(Constants.PREFS_MONOCLE, true); + } + + public boolean isHeadless() { + return headless; + } + } diff --git a/src/qz/printer/action/PrintHTML.java b/src/qz/printer/action/PrintHTML.java index cb2bf902b..3662f4243 100644 --- a/src/qz/printer/action/PrintHTML.java +++ b/src/qz/printer/action/PrintHTML.java @@ -76,7 +76,7 @@ public void parseData(JSONArray printData, PrintOptions options) throws JSONExce PrintingUtilities.Flavor flavor = PrintingUtilities.Flavor.valueOf(data.optString("flavor", "FILE").toUpperCase(Locale.ENGLISH)); double pageZoom = (pxlOpts.getDensity() * pxlOpts.getUnits().as1Inch()) / 72.0; - if (pageZoom <= 1 || data.optBoolean("forceOriginal")) { pageZoom = 1; } + if (pageZoom <= 1) { pageZoom = 1; } double pageWidth = 0; double pageHeight = 0; diff --git a/src/qz/printer/action/PrintImage.java b/src/qz/printer/action/PrintImage.java index 45a6253ea..3d770c763 100644 --- a/src/qz/printer/action/PrintImage.java +++ b/src/qz/printer/action/PrintImage.java @@ -195,20 +195,9 @@ public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) throws double imgH = imgToPrint.getHeight() / dpiScale; if (scaleImage) { - //scale up to print density (using less of a stretch if image is already larger than page) - double upScale = dpiScale * Math.min((pageFormat.getImageableWidth() / imgToPrint.getWidth()), (pageFormat.getImageableHeight() / imgToPrint.getHeight())); - if (upScale > dpiScale) { upScale = dpiScale; } else if (upScale < 1) { upScale = 1; } - log.debug("Scaling image up by x{}", upScale); - - BufferedImage scaled = new BufferedImage((int)(imgToPrint.getWidth() * upScale), (int)(imgToPrint.getHeight() * upScale), BufferedImage.TYPE_INT_ARGB); - Graphics2D g2d = scaled.createGraphics(); - g2d.setRenderingHints(buildRenderingHints(dithering, interpolation)); - g2d.drawImage(imgToPrint, 0, 0, (int)(imgToPrint.getWidth() * upScale), (int)(imgToPrint.getHeight() * upScale), null); - g2d.dispose(); + imgToPrint = scale(imgToPrint, pageFormat); - imgToPrint = scaled; - - // scale image to smallest edge, keeping size ratio + // adjust dimensions to smallest edge, keeping size ratio if (((float)imgToPrint.getWidth() / (float)imgToPrint.getHeight()) >= (boundW / boundH)) { imgW = boundW; imgH = (imgToPrint.getHeight() / (imgToPrint.getWidth() / boundW)); @@ -243,6 +232,33 @@ public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) throws return PAGE_EXISTS; } + /** + * + * @param image + * @param pageFormat + * @return + */ + private BufferedImage scale(BufferedImage image, PageFormat pageFormat) { + //scale up to print density (using less of a stretch if image is already larger than page) + double upScale = dpiScale * Math.min((pageFormat.getImageableWidth() / image.getWidth()), (pageFormat.getImageableHeight() / image.getHeight())); + if (upScale > dpiScale) { upScale = dpiScale; } else if (upScale < 1) { upScale = 1; } + + if (upScale > 1) { + log.debug("Scaling image up by x{}", upScale); + + BufferedImage scaled = new BufferedImage((int)(image.getWidth() * upScale), (int)(image.getHeight() * upScale), BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = scaled.createGraphics(); + g2d.setRenderingHints(buildRenderingHints(dithering, interpolation)); + g2d.drawImage(image, 0, 0, (int)(image.getWidth() * upScale), (int)(image.getHeight() * upScale), null); + g2d.dispose(); + + return scaled; + } else { + log.debug("No need to upscale image"); + return image; + } + } + /** * Rotates {@code image} by the specified {@code angle}. * diff --git a/src/qz/printer/action/WebApp.java b/src/qz/printer/action/WebApp.java index c9b4dfd91..d246977eb 100644 --- a/src/qz/printer/action/WebApp.java +++ b/src/qz/printer/action/WebApp.java @@ -1,26 +1,23 @@ package qz.printer.action; import com.github.zafarkhaja.semver.Version; -import javafx.animation.PauseTransition; +import com.sun.javafx.tk.TKPulseListener; +import com.sun.javafx.tk.Toolkit; +import javafx.animation.AnimationTimer; import javafx.application.Application; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.concurrent.Worker; import javafx.embed.swing.SwingFXUtils; -import javafx.event.ActionEvent; -import javafx.event.EventHandler; import javafx.print.PageLayout; import javafx.print.PrinterJob; import javafx.scene.Scene; -import javafx.scene.SnapshotParameters; -import javafx.scene.image.WritableImage; import javafx.scene.shape.Rectangle; import javafx.scene.transform.Scale; import javafx.scene.transform.Transform; import javafx.scene.transform.Translate; import javafx.scene.web.WebView; import javafx.stage.Stage; -import javafx.util.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Attr; @@ -29,13 +26,17 @@ import org.w3c.dom.NodeList; import qz.common.Constants; import qz.utils.SystemUtilities; +import qz.ws.PrintSocketServer; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.lang.reflect.Method; -import java.util.concurrent.atomic.AtomicBoolean; +import java.net.URL; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.IntPredicate; /** * JavaFX container for taking HTML snapshots. @@ -47,22 +48,22 @@ public class WebApp extends Application { private static final Logger log = LoggerFactory.getLogger(WebApp.class); - private static final int SLEEP = 250; - private static final int TIMEOUT = 60; //total paused seconds before failing - - private static final double WEB_SCALE = 72d / 96d; - private static WebApp instance = null; private static Stage stage; private static WebView webView; + private static double pageWidth; private static double pageHeight; + private static double pageZoom; + private static boolean raster; + private static boolean headless; - private static final AtomicBoolean startup = new AtomicBoolean(false); - private static final AtomicBoolean complete = new AtomicBoolean(false); + private static CountDownLatch startupLatch; + private static CountDownLatch captureLatch; + + private static IntPredicate printAction; private static final AtomicReference thrown = new AtomicReference<>(); - private static PauseTransition snap; //listens for a Succeeded state to activate image capture private static ChangeListener stateListener = (ov, oldState, newState) -> { @@ -83,21 +84,38 @@ public class WebApp extends Application { } //width was resized earlier (for responsive html), then calculate the best fit height + // FIXME: Should only be needed when height is unknown but fixes blank vector prints + String heightText = webView.getEngine().executeScript("Math.max(document.body.offsetHeight, document.body.scrollHeight)").toString(); if (pageHeight <= 0) { - String heightText = webView.getEngine().executeScript("Math.max(document.body.offsetHeight, document.body.scrollHeight)").toString(); pageHeight = Double.parseDouble(heightText); - log.trace("Setting HTML page height to {}", pageHeight); - webView.setMinHeight(pageHeight); - webView.setPrefHeight(pageHeight); - webView.setMaxHeight(pageHeight); - autosize(webView); } - //scale web dimensions down to print dpi - webView.getTransforms().add(new Scale(WEB_SCALE, WEB_SCALE)); + // find and set page zoom for increased quality + double usableZoom = calculateSupportedZoom(pageWidth, pageHeight); + if (usableZoom < pageZoom) { + log.warn("Zoom level {} decreased to {} due to physical memory limitations", pageZoom, usableZoom); + pageZoom = usableZoom; + } + webView.setZoom(pageZoom); + log.trace("Zooming in by x{} for increased quality", pageZoom); + + webView.setMinSize(pageWidth * pageZoom, pageHeight * pageZoom); + webView.setPrefSize(pageWidth * pageZoom, pageHeight * pageZoom); + webView.setMaxSize(pageWidth * pageZoom, pageHeight * pageZoom); + + autosize(webView); - snap.playFromStart(); + Platform.runLater(() -> new AnimationTimer() { + int frames = 0; + + @Override + public void handle(long l) { + if (printAction.test(++frames)) { + stop(); + } + } + }.start()); } }; @@ -106,7 +124,7 @@ public class WebApp extends Application { //listens for failures private static ChangeListener exceptListener = (obs, oldExc, newExc) -> { - if (newExc != null) { thrown.set(newExc); } + if (newExc != null) { unlatch(newExc); } }; @@ -117,36 +135,75 @@ public WebApp() { /** Starts JavaFX thread if not already running */ public static synchronized void initialize() throws IOException { - //JavaFX native libs - if (SystemUtilities.isJar() && Constants.JAVA_VERSION.greaterThanOrEqualTo(Version.valueOf("11.0.0"))) { - SystemUtilities.appendProperty("java.library.path", new File(SystemUtilities.detectJarPath()).getParent() + "/libs/"); - } - if (instance == null) { - new Thread(() -> Application.launch(WebApp.class)).start(); - startup.set(false); - } + startupLatch = new CountDownLatch(1); + // For JDK8 compat + headless = false; + + // JDK11+ depends bundled javafx + if (Constants.JAVA_VERSION.greaterThanOrEqualTo(Version.valueOf("11.0.0"))) { + // JavaFX native libs + if (SystemUtilities.isJar()) { + SystemUtilities.appendProperty("java.library.path", new File(SystemUtilities.detectJarPath()).getParent() + "/libs/"); + } else if(hasConflictingLib()) { + // IDE helper for "no suitable pipeline found" errors + System.err.println("\n=== WARNING ===\nWrong javafx platform detected. Delete lib/javafx/ to correct this.\n"); + } + + // Monocle default for unit tests + boolean useMonocle = true; + if (PrintSocketServer.getTrayManager() != null) { + // Honor user monocle override + useMonocle = PrintSocketServer.getTrayManager().isMonoclePreferred(); + // Trust TrayManager's headless detection + headless = PrintSocketServer.getTrayManager().isHeadless(); + } else { + // Fallback for JDK11+ + headless = true; + } + if (useMonocle) { + log.trace("Initializing monocle platform"); + System.setProperty("javafx.platform", "monocle"); - for(int i = 0; i < (TIMEOUT * 1000); i += SLEEP) { - if (startup.get()) { break; } + //software rendering required headless environments + if (headless) { + System.setProperty("prism.order", "sw"); + } + } + } - log.trace("Waiting for JavaFX.."); - try { Thread.sleep(SLEEP); } catch(Exception ignore) {} + new Thread(() -> Application.launch(WebApp.class)).start(); } - if (!startup.get()) { - throw new IOException("JavaFX did not start"); + if (startupLatch.getCount() > 0) { + try { + log.trace("Waiting for JavaFX.."); + if (!startupLatch.await(60, TimeUnit.SECONDS)) { + throw new IOException("JavaFX did not start"); + } else { + log.trace("Running a test snapshot to size the stage..."); + try { + raster(new WebAppModel("

startup

", true, 0, 0, true, 2)); + } + catch(Throwable t) { + throw new IOException(t); + } + } + } + catch(InterruptedException ignore) {} } } @Override public void start(Stage st) throws Exception { - startup.set(true); + startupLatch.countDown(); log.debug("Started JavaFX"); webView = new WebView(); st.setScene(new Scene(webView)); stage = st; + stage.setWidth(1); + stage.setHeight(1); Worker worker = webView.getEngine().getLoadWorker(); worker.stateProperty().addListener(stateListener); @@ -165,15 +222,18 @@ public void start(Stage st) throws Exception { * @throws Throwable JavaFx will throw a generic {@code Throwable} class for any issues */ public static synchronized void print(final PrinterJob job, final WebAppModel model) throws Throwable { - load(model, (event) -> { + model.setZoom(1); //vector prints do not need to use zoom + raster = false; + + load(model, (int frames) -> { try { PageLayout layout = job.getJobSettings().getPageLayout(); if (model.isScaled()) { double scale; - if ((webView.getWidth() / webView.getHeight()) / WEB_SCALE >= (layout.getPrintableWidth() / layout.getPrintableHeight())) { - scale = (layout.getPrintableWidth() / webView.getWidth()) / WEB_SCALE; + if ((webView.getWidth() / webView.getHeight()) >= (layout.getPrintableWidth() / layout.getPrintableHeight())) { + scale = (layout.getPrintableWidth() / webView.getWidth()); } else { - scale = (layout.getPrintableHeight() / webView.getHeight()) / WEB_SCALE; + scale = (layout.getPrintableHeight() / webView.getHeight()); } webView.getTransforms().add(new Scale(scale, scale)); } @@ -206,56 +266,73 @@ public static synchronized void print(final PrinterJob job, final WebAppModel mo } //reset state - webView.getTransforms().remove(activePage); + webView.getTransforms().clear(); - complete.set(true); + unlatch(null); }); } - catch(Exception e) { thrown.set(e); } - finally { stage.hide(); } + catch(Exception e) { unlatch(e); } + + return true; //only runs on first frame }); - Throwable t = null; - while((job.getJobStatus() == PrinterJob.JobStatus.NOT_STARTED || job.getJobStatus() == PrinterJob.JobStatus.PRINTING) - && !complete.get() && (t = thrown.get()) == null) { - log.trace("Waiting on print.."); - try { Thread.sleep(1000); } catch(Exception ignore) {} - } + log.trace("Waiting on print.."); + captureLatch.await(); //released when unlatch is called - if (t != null) { throw t; } + if (thrown.get() != null) { throw thrown.get(); } } public static synchronized BufferedImage raster(final WebAppModel model) throws Throwable { AtomicReference capture = new AtomicReference<>(); //ensure JavaFX has started before we run - if (!startup.get()) { + if (startupLatch.getCount() > 0) { throw new IOException("JavaFX has not been started"); } + //raster still needs to show stage for valid capture Platform.runLater(() -> { stage.show(); stage.toBack(); }); - load(model, (event) -> { - try { - WritableImage snapshot = webView.snapshot(new SnapshotParameters(), null); - capture.set(SwingFXUtils.fromFXImage(snapshot, null)); + //adjust raster prints to web dpi + double increase = 96d / 72d; + model.setWebWidth(model.getWebWidth() * increase); + model.setWebHeight(model.getWebHeight() * increase); + + raster = true; - complete.set(true); + load(model, (int frames) -> { + if (frames == 2) { + log.debug("Attempting image capture"); + + Toolkit.getToolkit().addPostSceneTkPulseListener(new TKPulseListener() { + @Override + public void pulse() { + try { + // TODO: Revert to Callback once JDK-8244588/SUPQZ-5 is avail (JDK11+ only) + capture.set(SwingFXUtils.fromFXImage(webView.snapshot(null, null), null)); + unlatch(null); + } + catch(Exception e) { + log.error("Caught during snapshot"); + unlatch(e); + } + finally { + Toolkit.getToolkit().removePostSceneTkPulseListener(this); + } + } + }); } - catch(Throwable t) { thrown.set(t); } - finally { stage.hide(); } + + return frames >= 2; }); - Throwable t = null; - while(!complete.get() && (t = thrown.get()) == null) { - log.trace("Waiting on capture.."); - try { Thread.sleep(1000); } catch(Exception ignore) {} - } + log.trace("Waiting on capture.."); + captureLatch.await(); //released when unlatch is called - if (t != null) { throw t; } + if (thrown.get() != null) { throw thrown.get(); } return capture.get(); } @@ -266,25 +343,30 @@ public static synchronized BufferedImage raster(final WebAppModel model) throws * @param model The model specifying the web page parameters. * @param action EventHandler that will be ran when the WebView completes loading. */ - private static synchronized void load(WebAppModel model, EventHandler action) { - complete.set(false); + private static synchronized void load(WebAppModel model, IntPredicate action) { + captureLatch = new CountDownLatch(1); thrown.set(null); Platform.runLater(() -> { - log.trace("Setting starting size {}:{}", model.getWebWidth(), model.getWebHeight()); - webView.setMinSize(model.getWebWidth(), model.getWebHeight()); - webView.setPrefSize(model.getWebWidth(), model.getWebHeight()); - webView.setMaxSize(model.getWebWidth(), model.getWebHeight()); - autosize(webView); - + //zoom should only be factored on raster prints + pageZoom = model.getZoom(); + pageWidth = model.getWebWidth(); pageHeight = model.getWebHeight(); - //reset additive properties - webView.getTransforms().clear(); - webView.setZoom(1.0); + log.trace("Setting starting size {}:{}", pageWidth, pageHeight); + webView.setMinSize(pageWidth * pageZoom, pageHeight * pageZoom); + webView.setPrefSize(pageWidth * pageZoom, pageHeight * pageZoom); + webView.setMaxSize(pageWidth * pageZoom, pageHeight * pageZoom); + + if (pageHeight == 0) { + webView.setMinHeight(1); + webView.setPrefHeight(1); + webView.setMaxHeight(1); + } + + autosize(webView); - snap = new PauseTransition(Duration.millis(100)); - snap.setOnFinished(action); + printAction = action; if (model.isPlainText()) { webView.getEngine().loadContent(model.getSource(), "text/html"); @@ -300,23 +382,59 @@ private static synchronized void load(WebAppModel model, EventHandler 1024? 3:2; + if (headless) { allowance--; } + long availSpace = (long)((memory << allowance) / 72d); + + return Math.sqrt(availSpace / (width * height)); + } + + /** + * Final cleanup when no longer capturing + */ + public static void unlatch(Throwable t) { + if (t != null) { + thrown.set(t); + } + + captureLatch.countDown(); + stage.hide(); + } + + public static boolean hasConflictingLib() { + // If running from the IDE, make sure we're not using the wrong libs + URL url = Application.class.getResource("/" + Application.class.getName().replace('.', '/') + ".class"); + String graphicsJar = url.toString().replaceAll("file:/|jar:", "").replaceAll("!.*", ""); + log.trace("JavaFX will startup using {}", graphicsJar); + if(SystemUtilities.isWindows()) { + return !graphicsJar.contains("windows"); + } else if(SystemUtilities.isMac()) { + return !graphicsJar.contains("osx") && !graphicsJar.contains("mac"); + } + return !graphicsJar.contains("linux"); + } } diff --git a/src/qz/printer/action/WebAppModel.java b/src/qz/printer/action/WebAppModel.java index e690811e3..6af349882 100644 --- a/src/qz/printer/action/WebAppModel.java +++ b/src/qz/printer/action/WebAppModel.java @@ -3,21 +3,18 @@ public class WebAppModel { private String source; - private boolean plainText = false; + private boolean plainText; - private double webWidth = 0.0; - private double webHeight = 0.0; - private boolean isScaled = true; - private double zoom = 1.0; + private double webWidth; + private double webHeight; + private boolean isScaled; + private double zoom; public WebAppModel(String source, boolean plainText, double webWidth, double webHeight, boolean isScaled, double zoom) { - //values supplied are at print dpi, scale up to web dpi here - double increase = 96d / 72d; - this.source = source; this.plainText = plainText; - this.webWidth = webWidth * increase; - this.webHeight = webHeight * increase; + this.webWidth = webWidth; + this.webHeight = webHeight; this.isScaled = isScaled; this.zoom = zoom; } @@ -26,22 +23,42 @@ public String getSource() { return source; } + public void setSource(String source) { + this.source = source; + } + public boolean isPlainText() { return plainText; } + public void setPlainText(boolean plainText) { + this.plainText = plainText; + } + public double getWebWidth() { return webWidth; } + public void setWebWidth(double webWidth) { + this.webWidth = webWidth; + } + public double getWebHeight() { return webHeight; } + public void setWebHeight(double webHeight) { + this.webHeight = webHeight; + } + public boolean isScaled() { return isScaled; } + public void setScaled(boolean scaled) { + isScaled = scaled; + } + public double getZoom() { return zoom; } @@ -49,5 +66,4 @@ public double getZoom() { public void setZoom(double zoom) { this.zoom = zoom; } - } diff --git a/src/qz/ui/tray/TaskbarTrayIcon.java b/src/qz/ui/tray/TaskbarTrayIcon.java index 44b625112..c1755c8b4 100644 --- a/src/qz/ui/tray/TaskbarTrayIcon.java +++ b/src/qz/ui/tray/TaskbarTrayIcon.java @@ -97,7 +97,24 @@ public void keyReleased(KeyEvent keyEvent) {} }); } - public void displayMessage(String caption, String text, TrayIcon.MessageType level) { /* noop */ } + public void displayMessage(String caption, String text, TrayIcon.MessageType level) { + int messageType; + switch(level) { + case WARNING: + messageType = JOptionPane.WARNING_MESSAGE; + break; + case ERROR: + messageType = JOptionPane.ERROR_MESSAGE; + break; + case INFO: + messageType = JOptionPane.INFORMATION_MESSAGE; + break; + case NONE: + default: + messageType = JOptionPane.PLAIN_MESSAGE; + } + JOptionPane.showMessageDialog(null, text, caption, messageType); + } @Override public void windowDeiconified(WindowEvent e) { diff --git a/src/qz/ws/PrintSocketServer.java b/src/qz/ws/PrintSocketServer.java index 288f357c9..5a8555d91 100644 --- a/src/qz/ws/PrintSocketServer.java +++ b/src/qz/ws/PrintSocketServer.java @@ -60,7 +60,7 @@ public class PrintSocketServer { private static TrayManager trayManager; private static CertificateManager certificateManager; - private static boolean headless; + private static boolean forceHeadless; public static void main(String[] args) { @@ -68,7 +68,7 @@ public static void main(String[] args) { if(parser.intercept()) { System.exit(parser.getExitCode()); } - headless = parser.hasFlag("-h", "--headless"); + forceHeadless = parser.hasFlag("-h", "--headless"); log.info(Constants.ABOUT_TITLE + " version: {}", Constants.VERSION); log.info(Constants.ABOUT_TITLE + " vendor: {}", Constants.ABOUT_COMPANY); log.info("Java version: {}", Constants.JAVA_VERSION.toString()); @@ -92,7 +92,7 @@ public static void main(String[] args) { try { log.info("Starting {} {}", Constants.ABOUT_TITLE, Constants.VERSION); - SwingUtilities.invokeAndWait(() -> trayManager = new TrayManager(headless)); + SwingUtilities.invokeAndWait(() -> trayManager = new TrayManager(forceHeadless)); runServer(); } catch(Exception e) { diff --git a/test/qz/printer/action/WebAppTest.java b/test/qz/printer/action/WebAppTest.java new file mode 100644 index 000000000..080daf9b9 --- /dev/null +++ b/test/qz/printer/action/WebAppTest.java @@ -0,0 +1,246 @@ +package qz.printer.action; + +import javafx.print.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.Arrays; + +public class WebAppTest { + + private static final Logger log = LoggerFactory.getLogger(WebAppTest.class); + private static final int SPOOLER_WAIT = 2000; // millis + + public static void main(String[] args) throws Throwable { + WebApp.initialize(); + + // RASTER// + + boolean audit = false; + if (args.length > 0) { audit = Boolean.parseBoolean(args[0]) || "1".equals(args[0]); } + int knownHeightTests = 1000; + if (args.length > 1) { knownHeightTests = Integer.parseInt(args[1]); } + int fitToHeightTests = 1000; + if (args.length > 2) { fitToHeightTests = Integer.parseInt(args[2]); } + + if (!testKnownSize(knownHeightTests, audit)) { + log.error("Testing well defined sizes failed"); + } else if (!testFittedSize(fitToHeightTests, audit)) { + log.error("Testing fit to height sizing failed"); + } else { + log.info("All raster tests passed"); + } + + + // VECTOR // + + int vectorKnownHeightPrints = 100; + if (args.length > 3) { vectorKnownHeightPrints = Integer.parseInt(args[3]); } + int vectorFittedHeightPrints = 100; + if (args.length > 4) { vectorFittedHeightPrints = Integer.parseInt(args[4]); } + + if (!testVectorKnownPrints(vectorKnownHeightPrints)) { + log.error("Failed vector prints with defined heights"); + } else if (!testVectorFittedPrints(vectorFittedHeightPrints)) { + log.error("Failed vector prints with fit to height sizing"); + } else { + log.info("All vector prints completed"); + } + + + System.exit(0); //explicit exit since jfx is running in background + } + + + public static boolean testKnownSize(int trials, boolean enableAuditing) throws Throwable { + for(int i = 0; i < trials; i++) { + //new size every run + double printW = Math.max(2, (int)(Math.random() * 110) / 10d) * 72d; + double printH = Math.max(3, (int)(Math.random() * 110) / 10d) * 72d; + double zoom = Math.max(0.5d, (int)(Math.random() * 30) / 10d); + + String id = "known-" + i; + WebAppModel model = buildModel(id, printW, printH, zoom, true, (int)(Math.random() * 360)); + BufferedImage sample = WebApp.raster(model); + + if (sample == null) { + log.error("Failed to create capture"); + return false; + } + + //TODO - check bottom right matches expected color + //check capture for dimensional accuracy within 1 pixel of expected (due to int rounding) + int expectedWidth = (int)Math.round(printW * (96d / 72d) * zoom); + int expectedHeight = (int)Math.round(printH * (96d / 72d) * zoom); + boolean audit = enableAuditing && Math.random() < 0.1; + boolean passed = true; + + if (!Arrays.asList(expectedWidth, expectedWidth + 1, expectedWidth - 1).contains(sample.getWidth())) { + log.error("Expected width to be {} but got {}", expectedWidth, sample.getWidth()); + audit = true; + passed = false; + } + if (!Arrays.asList(expectedHeight, expectedHeight + 1, expectedHeight - 1).contains(sample.getHeight())) { + log.error("Expected height to be {} but got {}", expectedHeight, sample.getHeight()); + audit = true; + passed = false; + } + + if (audit) { + saveAudit(passed? id:"invalid", sample); + } + if (!passed) { + return false; + } + } + + return true; + } + + public static boolean testFittedSize(int trials, boolean enableAuditing) throws Throwable { + for(int i = 0; i < trials; i++) { + //new size every run (height always starts at 0) + double printW = Math.max(2, (int)(Math.random() * 110) / 10d) * 72d; + double zoom = Math.max(0.5d, (int)(Math.random() * 30) / 10d); + + String id = "fitted-" + i; + WebAppModel model = buildModel(id, printW, 0, zoom, true, (int)(Math.random() * 360)); + BufferedImage sample = WebApp.raster(model); + + if (sample == null) { + log.error("Failed to create capture"); + return false; + } + + //TODO - check bottom right matches expected color + //check capture for dimensional accuracy within 1 pixel of expected (due to int rounding) + //expected height is not known for these tests + int expectedWidth = (int)Math.round(printW * (96d / 72d) * zoom); + boolean audit = enableAuditing && Math.random() < 0.1; + boolean passed = true; + + if (!Arrays.asList(expectedWidth, expectedWidth + 1, expectedWidth - 1).contains(sample.getWidth())) { + log.error("Expected width to be {} but got {}", expectedWidth, sample.getWidth()); + audit = true; + passed = false; + } + + if (audit) { + saveAudit(passed? id:"invalid", sample); + } + if (!passed) { + return false; + } + } + + return true; + } + + public static boolean testVectorKnownPrints(int trials) throws Throwable { + PrinterJob job = buildVectorJob("vector-test-known"); + for(int i = 0; i < trials; i++) { + //new size every run + double printW = Math.max(2, (int)(Math.random() * 85) / 10d) * 72d; + double printH = Math.max(3, (int)(Math.random() * 110) / 10d) * 72d; + + String id = "known-" + i; + WebAppModel model = buildModel(id, printW, printH, 1, false, (int)(Math.random() * 360)); + + WebApp.print(job, model); + } + job.endJob(); + + try { + log.info("Waiting {} seconds for the spooler to catch up.", SPOOLER_WAIT/1000); + Thread.sleep(SPOOLER_WAIT); + } + catch(InterruptedException ignore) {} + + return job.getJobStatus() != PrinterJob.JobStatus.ERROR; + } + + public static boolean testVectorFittedPrints(int trials) throws Throwable { + PrinterJob job = buildVectorJob("vector-test-fitted"); + for(int i = 0; i < trials; i++) { + //new size every run + double printW = Math.max(2, (int)(Math.random() * 85) / 10d) * 72d; + + String id = "fitted-" + i; + WebAppModel model = buildModel(id, printW, 0, 1, false, (int)(Math.random() * 360)); + + WebApp.print(job, model); + } + job.endJob(); + + try { + log.info("Waiting {} seconds for the spooler to catch up.", SPOOLER_WAIT/1000); + Thread.sleep(SPOOLER_WAIT); + } + catch(InterruptedException ignore) {} + + return job.getJobStatus() != PrinterJob.JobStatus.ERROR; + } + + private static WebAppModel buildModel(String index, double width, double height, double zoom, boolean scale, int hue) { + int level = (int)(Math.random() * 50) + 25; + WebAppModel model = new WebAppModel("" + + "" + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + " " + + "
Generated content:" + index + "
Content size:" + width + "x" + height + "
Zoomed tox " + zoom + "
" + + "" + + "", + true, width, height, scale, zoom); + + log.trace("Generating #{} = [({},{}), x{}]", index, model.getWebWidth(), model.getWebHeight(), model.getZoom()); + + return model; + } + + private static PrinterJob buildVectorJob(String name) throws Throwable { + Printer defaultPrinter = Printer.getDefaultPrinter(); + PrinterJob job = PrinterJob.createPrinterJob(defaultPrinter); + + // All this to remove margins + Constructor plCon = PageLayout.class.getDeclaredConstructor(Paper.class, PageOrientation.class, double.class, double.class, double.class, double.class); + plCon.setAccessible(true); + + Paper paper = defaultPrinter.getDefaultPageLayout().getPaper(); + PageLayout layout = plCon.newInstance(paper, PageOrientation.PORTRAIT, 0, 0, 0, 0); + + Field field = defaultPrinter.getClass().getDeclaredField("defPageLayout"); + field.setAccessible(true); + field.set(defaultPrinter, layout); + + JobSettings settings = job.getJobSettings(); + settings.setPageLayout(layout); + settings.setJobName(name); + + return job; + } + + private static void saveAudit(String id, BufferedImage capture) throws IOException { + File temp = File.createTempFile("qz-" + id, ".png"); + ImageIO.write(capture, "png", temp); + + log.info("Sampled {}: {}", id, temp.getName()); + } + +}