Skip to content

Instantly share code, notes, and snippets.

@tresf
Last active January 15, 2020 22:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tresf/c3cffc12edfcc53b6a68acb9d1a15d1a to your computer and use it in GitHub Desktop.
Save tresf/c3cffc12edfcc53b6a68acb9d1a15d1a to your computer and use it in GitHub Desktop.
package qz.printer.action;
import javafx.animation.PauseTransition;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Worker;
import javafx.embed.swing.SwingFXUtils;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.image.WritableImage;
import javafx.scene.web.WebView;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.joor.Reflect;
import org.joor.ReflectException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import qz.ui.IconCache;
import qz.utils.SystemUtilities;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* 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 = LoggerFactory.getLogger(WebApp.class);
private static final int SLEEP = 250;
private static final int TIMEOUT = 60; //total paused seconds before failing
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 final AtomicBoolean started = new AtomicBoolean(false);
private static final AtomicBoolean complete = new AtomicBoolean(false);
private static final AtomicReference<Throwable> thrown = new AtomicReference<>();
private static final AtomicReference<BufferedImage> capture = new AtomicReference<>();
//listens for a Succeeded state to activate image capture
private static ChangeListener<Worker.State> stateListener = new ChangeListener<Worker.State>() {
@Override
public void changed(ObservableValue<? extends Worker.State> ov, Worker.State oldState, Worker.State newState) {
log.trace("New state: {} > {}", oldState, newState);
if (newState == Worker.State.SUCCEEDED) {
setWidth();
}
}
};
private static void setWidth() {
setStyle(webView);
log.trace("Setting HTML page width to {}", (pageWidth * pageZoom));
setWidthAndFire(pageWidth * pageZoom, "setHeight");
}
@SuppressWarnings("unused")
public static void setHeight() {
if (pageHeight <= 0) {
String heightText = webView.getEngine().executeScript("Math.max(document.body.offsetHeight, document.body.scrollHeight)").toString();
pageHeight = Double.parseDouble(heightText);
}
setHeightAndFire(pageHeight * pageZoom, "doCapture");
}
@SuppressWarnings("unused")
public static void doCapture() {
try {
WritableImage snapshot = webView.snapshot(new SnapshotParameters(), null);
capture.set(SwingFXUtils.fromFXImage(snapshot, null));
complete.set(true);
}
catch(Throwable t) {
thrown.set(t);
}
finally {
stage.hide(); //hide stage so users won't have to manually close it
}
}
//listens for load progress
private static ChangeListener<Number> workDoneListener = new ChangeListener<Number>() {
@Override
public void changed(ObservableValue<? extends Number> ov, Number oldWork, Number newWork) {
log.trace("Done: {} > {}", oldWork, newWork);
}
};
//listens for failures
private static ChangeListener<Throwable> exceptListener = new ChangeListener<Throwable>() {
@Override
public void changed(ObservableValue<? extends Throwable> obs, Throwable oldExc, Throwable newExc) {
log.info("Changed: {}, {}, {}", obs, oldExc, newExc);
if (newExc != null) { thrown.set(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) {
new Thread() {
public void run() {
Application.launch(WebApp.class);
}
}.start();
}
for(int i = 0; i < (TIMEOUT * 1000); i += SLEEP) {
if (started.get()) { break; }
log.trace("Waiting for JavaFX..");
try { Thread.sleep(SLEEP); } catch(Exception ignore) {}
}
if (!started.get()) {
throw new IOException("JavaFX did not start");
}
}
@Override
public void start(Stage st) throws Exception {
started.set(true);
log.debug("Started JavaFX");
webView = new WebView();
Scene sc = new Scene(webView);
stage = st;
stage.setScene(sc);
Worker<Void> worker = webView.getEngine().getLoadWorker();
worker.stateProperty().addListener(stateListener);
worker.workDoneProperty().addListener(workDoneListener);
worker.exceptionProperty().addListener(exceptListener);
//prevents JavaFX from shutting down when hiding window
Platform.setImplicitExit(false);
}
/**
* Sets up capture to run on JavaFX thread and returns snapshot of rendered page
*
* @param model Data about the html to be rendered for capture
* @return BufferedImage of the rendered html
*/
public static synchronized BufferedImage capture(final WebAppModel model) throws Throwable {
pageWidth = model.getWebWidth();
pageHeight = model.getWebHeight();
pageZoom = model.getZoom();
capture.set(null);
complete.set(false);
thrown.set(null);
//ensure JavaFX has started before we run
if (!started.get()) {
throw new IOException("JavaFX has not been started");
}
// run these actions on the JavaFX thread
Platform.runLater(new Thread() {
public void run() {
try {
webView.setPrefWidth(0);
webView.setPrefHeight(0);
//actually begin loading the html
if (model.isPlainText()) {
webView.getEngine().loadContent(model.getSource(), "text/html");
} else {
webView.getEngine().load(model.getSource());
}
if(!stage.isShowing()) {
stage.setAlwaysOnTop(false);
// Move HTML off-screen
stage.setX(Double.MAX_VALUE);
stage.setY(Double.MAX_VALUE);
stage.show(); //FIXME - will not capture without showing stage
stage.toBack();
}
}
catch(Throwable t) {
thrown.set(t);
}
}
});
Throwable t = null;
while(!complete.get() && (t = thrown.get()) == null) {
log.trace("Waiting on capture..");
try { Thread.sleep(10); } catch(Exception ignore) {}
}
if (t != null) { throw t; }
return capture.get();
}
private static void setWidthAndFire(double width, String actionWhenDone) {
webView.setPrefWidth(width);
webView.setMinWidth(width);
autosize(webView);
waitAndFire(true, width, actionWhenDone);
}
private static void setHeightAndFire(double height, String actionWhenDone) {
webView.setPrefHeight(height);
webView.setMinHeight(height);
autosize(webView);
waitAndFire(false, height, actionWhenDone);
}
private static void waitAndFire(final boolean usingWidth, final double desiredValue, final String actionWhenDone) {
//log.info("Starting looper...");
final PauseTransition pause = new PauseTransition(Duration.millis(10));
pause.setOnFinished(new EventHandler<ActionEvent>() {
@Override
public void handle(ActionEvent actionEvent) {
double currentValue = usingWidth ? webView.getWidth() : webView.getHeight();
if(currentValue == desiredValue) {
//log.info("SUCCESS: {} detected", propertyValue);
stage.sizeToScene();
try {
Method method = WebApp.class.getDeclaredMethod(actionWhenDone);
method.invoke(WebApp.class, null);
} catch(InvocationTargetException | NoSuchMethodException | IllegalAccessException e) {
log.error("Unable to call {}.{}", WebApp.class.getName(), actionWhenDone);
}
} else {
log.info("WAITING: {} != {}, we'll keep waiting", webView.getWidth(), desiredValue);
pause.playFromStart();
}
}
});
pause.play();
}
private static void setStyle(WebView webView) {
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);
}
try {
Reflect.on(webView).call("setZoom", pageZoom);
log.trace("Zooming in by x{} for increased quality", pageZoom);
}
catch(ReflectException e) {
log.warn("Unable zoom, using default quality");
pageZoom = 1; //only zoom affects webView scaling
}
}
public static void autosize(WebView webView) {
webView.autosize();
// 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);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment