513 lines
20 KiB
Java
Executable File
513 lines
20 KiB
Java
Executable File
package qz.printer.action.html;
|
|
|
|
import com.github.zafarkhaja.semver.Version;
|
|
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.print.PageLayout;
|
|
import javafx.print.PrinterJob;
|
|
import javafx.scene.Scene;
|
|
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 org.apache.logging.log4j.LogManager;
|
|
import org.apache.logging.log4j.Logger;
|
|
import org.w3c.dom.Attr;
|
|
import org.w3c.dom.Document;
|
|
import org.w3c.dom.Node;
|
|
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.IOException;
|
|
import java.lang.reflect.Method;
|
|
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.
|
|
* Used by PrintHTML to generate printable images.
|
|
* <p/>
|
|
* Do not use constructor (used by JavaFX), instead call {@code WebApp.initialize()}
|
|
*/
|
|
public class WebApp extends Application {
|
|
|
|
private static final Logger log = LogManager.getLogger(WebApp.class);
|
|
|
|
private static WebApp instance = null;
|
|
private static Version webkitVersion = null;
|
|
private static int CAPTURE_FRAMES = 2;
|
|
private static int VECTOR_FRAMES = 1;
|
|
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 CountDownLatch startupLatch;
|
|
private static CountDownLatch captureLatch;
|
|
|
|
private static IntPredicate printAction;
|
|
private static final AtomicReference<Throwable> thrown = new AtomicReference<>();
|
|
|
|
// JDK-8283686: Printing WebView may results in empty page
|
|
private static final Version JDK_8283686_START = Version.valueOf(/* WebKit */ "609.1.0");
|
|
private static final Version JDK_8283686_END = Version.valueOf(/* WebKit */ "612.1.0");
|
|
private static final int JDK_8283686_VECTOR_FRAMES = 30;
|
|
|
|
|
|
//listens for a Succeeded state to activate image capture
|
|
private static ChangeListener<Worker.State> stateListener = (ov, oldState, newState) -> {
|
|
log.trace("New state: {} > {}", oldState, newState);
|
|
|
|
// Cancelled should probably throw exception listener, but does not
|
|
if (newState == Worker.State.CANCELLED) {
|
|
// This can happen for file downloads, e.g. "response-content-disposition=attachment"
|
|
// See https://github.com/qzind/tray/issues/1183
|
|
unlatch(new IOException("Page load was cancelled for an unknown reason"));
|
|
}
|
|
if (newState == Worker.State.SUCCEEDED) {
|
|
boolean hasBody = (boolean)webView.getEngine().executeScript("document.body != null");
|
|
if (!hasBody) {
|
|
log.warn("Loaded page has no body - likely a redirect, skipping state");
|
|
return;
|
|
}
|
|
|
|
//ensure html tag doesn't use scrollbars, clipping page instead
|
|
Document doc = webView.getEngine().getDocument();
|
|
NodeList tags = doc.getElementsByTagName("html");
|
|
if (tags != null && tags.getLength() > 0) {
|
|
Node base = tags.item(0);
|
|
Attr applied = (Attr)base.getAttributes().getNamedItem("style");
|
|
if (applied == null) {
|
|
applied = doc.createAttribute("style");
|
|
}
|
|
applied.setValue(applied.getValue() + "; overflow: hidden;");
|
|
base.getAttributes().setNamedItem(applied);
|
|
}
|
|
|
|
//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
|
|
double fittedHeight = findHeight();
|
|
boolean heightNeeded = pageHeight <= 0;
|
|
|
|
if (heightNeeded) {
|
|
pageHeight = fittedHeight;
|
|
}
|
|
|
|
// 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);
|
|
|
|
adjustSize(pageWidth * pageZoom, pageHeight * pageZoom);
|
|
|
|
//need to check for height again as resizing can cause partial results
|
|
if (heightNeeded) {
|
|
fittedHeight = findHeight();
|
|
if (fittedHeight != pageHeight) {
|
|
adjustSize(pageWidth * pageZoom, fittedHeight * pageZoom);
|
|
}
|
|
}
|
|
|
|
log.trace("Set HTML page height to {}", pageHeight);
|
|
|
|
autosize(webView);
|
|
|
|
Platform.runLater(() -> new AnimationTimer() {
|
|
int frames = 0;
|
|
|
|
@Override
|
|
public void handle(long l) {
|
|
if (printAction.test(++frames)) {
|
|
stop();
|
|
}
|
|
}
|
|
}.start());
|
|
}
|
|
};
|
|
|
|
//listens for load progress
|
|
private static ChangeListener<Number> workDoneListener = (ov, oldWork, newWork) -> log.trace("Done: {} > {}", oldWork, newWork);
|
|
|
|
private static ChangeListener<String> msgListener = (ov, oldMsg, newMsg) -> log.trace("New status: {}", newMsg);
|
|
|
|
//listens for failures
|
|
private static ChangeListener<Throwable> exceptListener = (obs, oldExc, newExc) -> {
|
|
if (newExc != null) { unlatch(newExc); }
|
|
};
|
|
|
|
|
|
/** Called by JavaFX thread */
|
|
public WebApp() {
|
|
instance = this;
|
|
}
|
|
|
|
/** Starts JavaFX thread if not already running */
|
|
public static synchronized void initialize() throws IOException {
|
|
if (instance == null) {
|
|
startupLatch = new CountDownLatch(1);
|
|
// For JDK8 compat
|
|
headless = false;
|
|
|
|
// JDK11+ depends bundled javafx
|
|
if (Constants.JAVA_VERSION.getMajorVersion() >= 11) {
|
|
// 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 && SystemUtilities.hasMonocle()) {
|
|
log.trace("Initializing monocle platform");
|
|
System.setProperty("javafx.platform", "monocle");
|
|
// Don't set glass.platform on Linux per https://github.com/qzind/tray/issues/702
|
|
switch(SystemUtilities.getOs()) {
|
|
case WINDOWS:
|
|
case MAC:
|
|
System.setProperty("glass.platform", "Monocle");
|
|
break;
|
|
default:
|
|
// don't set "glass.platform"
|
|
}
|
|
|
|
//software rendering required headless environments
|
|
if (headless) {
|
|
System.setProperty("prism.order", "sw");
|
|
}
|
|
} else {
|
|
log.warn("Monocle platform will not be used");
|
|
}
|
|
}
|
|
|
|
new Thread(() -> Application.launch(WebApp.class)).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("<h1>startup</h1>", true, 0, 0, true, 2));
|
|
log.trace("JFX initialized successfully");
|
|
}
|
|
catch(Throwable t) {
|
|
throw new IOException(t);
|
|
}
|
|
}
|
|
}
|
|
catch(InterruptedException ignore) {}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void start(Stage st) throws Exception {
|
|
startupLatch.countDown();
|
|
log.debug("Started JavaFX");
|
|
|
|
webView = new WebView();
|
|
|
|
// JDK-8283686: Printing WebView may results in empty page
|
|
// See also https://github.com/qzind/tray/issues/778
|
|
if(getWebkitVersion() == null ||
|
|
(getWebkitVersion().greaterThan(JDK_8283686_START) &&
|
|
getWebkitVersion().lessThan(JDK_8283686_END))) {
|
|
VECTOR_FRAMES = JDK_8283686_VECTOR_FRAMES; // Additional pulses needed for vector graphics
|
|
}
|
|
|
|
st.setScene(new Scene(webView));
|
|
stage = st;
|
|
stage.setWidth(1);
|
|
stage.setHeight(1);
|
|
|
|
Worker<Void> worker = webView.getEngine().getLoadWorker();
|
|
worker.stateProperty().addListener(stateListener);
|
|
worker.workDoneProperty().addListener(workDoneListener);
|
|
worker.exceptionProperty().addListener(exceptListener);
|
|
worker.messageProperty().addListener(msgListener);
|
|
|
|
//prevents JavaFX from shutting down when hiding window
|
|
Platform.setImplicitExit(false);
|
|
}
|
|
|
|
/**
|
|
* Prints the loaded source specified in the passed {@code model}.
|
|
*
|
|
* @param job A setup JavaFx {@code PrinterJob}
|
|
* @param model The model specifying the web page parameters
|
|
* @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 {
|
|
model.setZoom(1); //vector prints do not need to use zoom
|
|
raster = false;
|
|
|
|
load(model, (int frames) -> {
|
|
if(frames == VECTOR_FRAMES) {
|
|
try {
|
|
double printScale = 72d / 96d;
|
|
webView.getTransforms().add(new Scale(printScale, printScale));
|
|
|
|
PageLayout layout = job.getJobSettings().getPageLayout();
|
|
if (model.isScaled()) {
|
|
double viewWidth = webView.getWidth() * printScale;
|
|
double viewHeight = webView.getHeight() * printScale;
|
|
|
|
double scale;
|
|
if ((viewWidth / viewHeight) >= (layout.getPrintableWidth() / layout.getPrintableHeight())) {
|
|
scale = (layout.getPrintableWidth() / viewWidth);
|
|
} else {
|
|
scale = (layout.getPrintableHeight() / viewHeight);
|
|
}
|
|
webView.getTransforms().add(new Scale(scale, scale));
|
|
}
|
|
|
|
Platform.runLater(() -> {
|
|
double useScale = 1;
|
|
for(Transform t : webView.getTransforms()) {
|
|
if (t instanceof Scale) { useScale *= ((Scale)t).getX(); }
|
|
}
|
|
|
|
PageLayout page = job.getJobSettings().getPageLayout();
|
|
Rectangle printBounds = new Rectangle(0, 0, page.getPrintableWidth(), page.getPrintableHeight());
|
|
log.debug("Paper area: {},{}:{},{}", (int)page.getLeftMargin(), (int)page.getTopMargin(),
|
|
(int)page.getPrintableWidth(), (int)page.getPrintableHeight());
|
|
|
|
Translate activePage = new Translate();
|
|
webView.getTransforms().add(activePage);
|
|
|
|
int columnsNeed = Math.max(1, (int)Math.ceil(webView.getWidth() / printBounds.getWidth() * useScale - 0.1));
|
|
int rowsNeed = Math.max(1, (int)Math.ceil(webView.getHeight() / printBounds.getHeight() * useScale - 0.1));
|
|
log.debug("Document will be printed across {} pages", columnsNeed * rowsNeed);
|
|
|
|
try {
|
|
for(int row = 0; row < rowsNeed; row++) {
|
|
for(int col = 0; col < columnsNeed; col++) {
|
|
activePage.setX((-col * printBounds.getWidth()) / useScale);
|
|
activePage.setY((-row * printBounds.getHeight()) / useScale);
|
|
|
|
job.printPage(webView);
|
|
}
|
|
}
|
|
|
|
unlatch(null);
|
|
}
|
|
catch(Exception e) {
|
|
unlatch(e);
|
|
}
|
|
finally {
|
|
//reset state
|
|
webView.getTransforms().clear();
|
|
}
|
|
});
|
|
}
|
|
catch(Exception e) { unlatch(e); }
|
|
}
|
|
return frames >= VECTOR_FRAMES;
|
|
});
|
|
|
|
log.trace("Waiting on print..");
|
|
captureLatch.await(); //released when unlatch is called
|
|
|
|
if (thrown.get() != null) { throw thrown.get(); }
|
|
}
|
|
|
|
public static synchronized BufferedImage raster(final WebAppModel model) throws Throwable {
|
|
AtomicReference<BufferedImage> capture = new AtomicReference<>();
|
|
|
|
//ensure JavaFX has started before we run
|
|
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();
|
|
});
|
|
|
|
raster = true;
|
|
|
|
load(model, (int frames) -> {
|
|
if (frames == CAPTURE_FRAMES) {
|
|
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) {
|
|
unlatch(e);
|
|
}
|
|
finally {
|
|
Toolkit.getToolkit().removePostSceneTkPulseListener(this);
|
|
}
|
|
}
|
|
});
|
|
Toolkit.getToolkit().requestNextPulse();
|
|
}
|
|
|
|
return frames >= CAPTURE_FRAMES;
|
|
});
|
|
|
|
log.trace("Waiting on capture..");
|
|
captureLatch.await(); //released when unlatch is called
|
|
|
|
if (thrown.get() != null) { throw thrown.get(); }
|
|
|
|
return capture.get();
|
|
}
|
|
|
|
/**
|
|
* Prints the loaded source specified in the passed {@code model}.
|
|
*
|
|
* @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, IntPredicate action) {
|
|
captureLatch = new CountDownLatch(1);
|
|
thrown.set(null);
|
|
|
|
Platform.runLater(() -> {
|
|
//zoom should only be factored on raster prints
|
|
pageZoom = model.getZoom();
|
|
pageWidth = model.getWebWidth();
|
|
pageHeight = model.getWebHeight();
|
|
|
|
log.trace("Setting starting size {}:{}", pageWidth, pageHeight);
|
|
adjustSize(pageWidth * pageZoom, pageHeight * pageZoom);
|
|
|
|
if (pageHeight == 0) {
|
|
webView.setMinHeight(1);
|
|
webView.setPrefHeight(1);
|
|
webView.setMaxHeight(1);
|
|
}
|
|
|
|
autosize(webView);
|
|
|
|
printAction = action;
|
|
|
|
if (model.isPlainText()) {
|
|
webView.getEngine().loadContent(model.getSource(), "text/html");
|
|
} else {
|
|
webView.getEngine().load(model.getSource());
|
|
}
|
|
});
|
|
}
|
|
|
|
private static double findHeight() {
|
|
String heightText = webView.getEngine().executeScript("Math.max(document.body.offsetHeight, document.body.scrollHeight)").toString();
|
|
return Double.parseDouble(heightText);
|
|
}
|
|
|
|
private static void adjustSize(double toWidth, double toHeight) {
|
|
webView.setMinSize(toWidth, toHeight);
|
|
webView.setPrefSize(toWidth, toHeight);
|
|
webView.setMaxSize(toWidth, toHeight);
|
|
}
|
|
|
|
/**
|
|
* Fix blank page after autosize is called
|
|
*/
|
|
public static void autosize(WebView webView) {
|
|
webView.autosize();
|
|
|
|
if (!raster) {
|
|
// Call updatePeer; fixes a bug with webView resizing
|
|
// Can be avoided by calling stage.show() but breaks headless environments
|
|
// See: https://github.com/qzind/tray/issues/513
|
|
String[] methods = {"impl_updatePeer" /*jfx8*/, "doUpdatePeer" /*jfx11*/};
|
|
try {
|
|
for(Method m : webView.getClass().getDeclaredMethods()) {
|
|
for(String method : methods) {
|
|
if (m.getName().equals(method)) {
|
|
m.setAccessible(true);
|
|
m.invoke(webView);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch(SecurityException | ReflectiveOperationException e) {
|
|
log.warn("Unable to update peer; Blank pages may occur.", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static double calculateSupportedZoom(double width, double height) {
|
|
long memory = Runtime.getRuntime().maxMemory();
|
|
int allowance = (memory / 1048576L) > 1024? 3:2;
|
|
if (headless) { allowance--; }
|
|
long availSpace = memory << allowance;
|
|
|
|
// Memory needed for print is roughly estimated as
|
|
// (width * height) [pixels needed] * (pageZoom * 72d) [print density used] * 3 [rgb channels]
|
|
return Math.sqrt(availSpace / ((width * height) * (pageZoom * 72d) * 3));
|
|
}
|
|
|
|
/**
|
|
* Final cleanup when no longer capturing
|
|
*/
|
|
public static void unlatch(Throwable t) {
|
|
if (t != null) {
|
|
thrown.set(t);
|
|
}
|
|
|
|
captureLatch.countDown();
|
|
stage.hide();
|
|
}
|
|
|
|
public static Version getWebkitVersion() {
|
|
if(webkitVersion == null) {
|
|
if(webView != null) {
|
|
String userAgent = webView.getEngine().getUserAgent();
|
|
String[] parts = userAgent.split("WebKit/");
|
|
if (parts.length > 1) {
|
|
String[] split = parts[1].split(" ");
|
|
if (split.length > 0) {
|
|
try {
|
|
webkitVersion = Version.valueOf(split[0]);
|
|
log.info("WebKit version {} detected", webkitVersion);
|
|
} catch(Exception ignore) {}
|
|
}
|
|
}
|
|
if(webkitVersion == null) {
|
|
log.warn("WebKit version couldn't be parsed from UserAgent: {}", userAgent);
|
|
}
|
|
} else {
|
|
log.warn("Can't get WebKit version, JavaFX hasn't started yet.");
|
|
}
|
|
}
|
|
return webkitVersion;
|
|
}
|
|
}
|