mars
2012
Note : la solution présentée ici permet de convertir des instances de Chart en BufferedImage, car il s’agissait spécifiquement du problème que j’essayais de résoudre mais, en fait, elle peut être facilement étendue pour imprimer des Control, Layout ou Node. Également cette solution repose sur Swing, il devrait être possible d’utiliser SWT aussi (mais je n’ai pas assez pratique pour savoir si ça marcherait ou pas).
L’un des épineux problèmes qui me taraudent depuis la sortie de JavaFX c’est qu’il n’y a pas pour le moment de support qui permette d’obtenir aisément et rapidement une version bitmap d’un nœud. On peut faire plein de chose quand on a une bitmap sous les mains : on peut la sauvegarder dans un fichier, l’imprimer, la mettre dans un PDF, l’utiliser pour créer un thumbnail/aperçu ou encore un fantôme d’un nœud lors qu’on fait des opérations de drag’n drop sans pour autant trainer un objet vectoriel hautement complexe à dessiner à l’écran avec soit tout au long du traitement (non, souvent juste mettre cache = true ne suffit pas pour obtenir de meilleures performances).
En JavaFX 1.x, 1.1.x, ça permettait de gagner pas mal de performances sur des petites configs et c’était très sympa de la part de Rakesh Menon (qui travaillait alors pour Oracle) d’avoir partage cette astuce avec nous. En JavaFX 1.2.x et 1.3.x, Rakesh Menon nous avait une fois de plus fourni le correctif (il y avait eut des changements dans les API privées) à utiliser pour que l’export fonctionne a nouveau mais c’était déjà devenu un peu plus compliqué : souvent, un Control n’avait pas de taille tant qu’il n’était pas affiché une première fois à l’écran, mais en plus souvent il ne se dessinait pas non-plus ce qui empêchait toute composition offscreen. Donc du coup il fallait jongler avec des stages auxiliaires (la classe XDialog de JFXtras) pour faire popup une boite de dialogue a l’écran quelques secondes pour que le nœud apparaisse et si on allait trop vite, on avait une image toute vide…
Le problème est encore différent en 2.x et 2.1.x car personne n’est venu cette fois nous révéler les secrets de l’API privée et donc on se retrouve un peu sans savoir quoi faire mise a part une brève mention est faite sur le JIRA qu’il existe un moyen mais sans plus de précision quant a sa nature. Et puis le support de l’export en bitmap ne sera pas prévu avant au moins la 2.2 si ce n’est la 3.0 (en croisant fortement les doigts). Le sujet revient de temps a autre sur les forums OTN, c’est donc qu’il y a un besoin. Et puis même j’ajouterai, ici, nous nous en avons besoin (bonjour la tête de mon boss quand je lui ait annoncé qu’il ne pourrait pas exporter ses graphes dans la prochaine version du soft…).
Mais ouf il existe une manière détournée, qui a défaut d’être parfaite, semble fonctionner pour le moment : utiliser ce bon vieux Java2D. En effet, depuis la 2.0, il est possible d’inclure une Scene JavaFX dans une interface Swing grâce au composant JFXPanel qui est fourni dans l’API JavaFX.
*TILT* !
Or si on a un Component sous la main… on peut très bien lui demander de se dessiner dans un Graphics2D en appelant sa méthode paint() comme au bon vieux temps ! Ce qui peut nous permettre d’avoir l’affichage du contenu du JFXPanel dans une BufferedImage ! Ainsi il est possible de se créer une Task qui génère des images d’un nœud !
Cela va demander de se retrouver un peu les manches pour pouvoir imprimer ces Chart (dans mon cas précis) :
- Il va falloir utiliser pas moins de 3 threads:
- La thread de la Task.
- La thread javaFX (puisqu’une Scene doit être crée sur la thread de JavaFX).
- L’EDT (pour les composants Swing).
et synchroniser le tout sur la fin (je préférerai éviter que ma tache ne se finisse avant que la conversion ne soit entièrement effectuée et puis comme cela, ça permet de faire remonter les erreurs).
- Il va quand même falloir faire popup un JDialog à l’écran et ce pour les mêmes raisons que précédemment.
- Dans mon cas, je vais parfois avoir besoin de générer plusieurs dizaines d’images et donc ça m’ennuierai un peu que ma tache retourne juste une List<BufferedImage> que je vais conserver en mémoire pendant des lustres. j’ai donc opté pour une approche dans laquelle je fourni à la tache une interface ImageHandler qui permet d’effectuer une action sur chaque image nouvellement générée.
* Allows to provide an action to the {@code Chart2ImageTask} class.
* @author Fabrice Bouyé (fabriceb@spc.int)
*/
public interface ImageHandler {
/**
* Call to handle the chart image we've just created.
* @param chart The source chart.
* @param image The image of the chart.
* @param chartIndex The index of the chart.
* @param chartNumber The number of charts.
* @throws Exception In case of errors.
*/
public void handle(Chart chart, BufferedImage image, int chartIndex, int chartNumber) throws Exception;
}
Note : la classe java.lang.Void qui rappellera des souvenirs a ceux ayant fait du JavaFX 1.x est une classe de l’API standard dont la seule et unique valeur valide est null.
* A task that helps exporting a chart to an image.
* <br />Note: as of JavaFX 2.1, there is still no support for image export so we have to use a hack in which we display the chart in a {@code JFXPanel} and render this panel in a {@code Graphics2D}.
* @author Fabrice Bouyé (fabriceb@spc.int)
*/
public class Chart2ImageTask extends Task {
/**
* The charts to export.
*/
private List charts;
/**
* Target image width.
*/
private int imageWidth;
/**
* Target image height.
*/
private int imageHeight;
/**
* Map chart -> processed flag.
* <br />Indicates whether a chart has already been processed.
*/
private final Map processedMap = new HashMap();
/**
* Map chart -> error.
* <br />Indicates whether a chart export has produced an error.
*/
private final Map errorMap = new HashMap();
/**
* Creates a new instance.
* @param charts The charts to export.
*/
public Chart2ImageTask(Chart... charts) {
this(0, 0, charts);
}
/**
* Creates a new instance.
* @param charts The charts to export.
* @param panelWidth Target image width.
* @param panelHeight Target image height.
*/
public Chart2ImageTask(int imageWidth, int imageHeight, Chart... charts) {
super();
this.charts = Arrays.asList(charts);
this.imageWidth = imageWidth;
this.imageHeight = imageHeight;
for (Chart chart : charts) {
processedMap.put(chart, false);
}
}
/**
* Test if all charts have been converted.
* @return {@code True} if all charts have been converted, {@code false} otherwise.
*/
private boolean testConversionDone() {
boolean result = true;
synchronized (processedMap) {
for (Boolean processed : processedMap.values()) {
if (!processed) {
result = false;
break;
}
}
}
return result;
}
/**
* {@inheritDoc}
*/
@Override
protected synchronized Void call() throws Exception {
for (Chart chart : charts) {
final Chart currentChart = chart;
// Scenes need to be creates on FX thread.
Platform.runLater(new Runnable() {
@Override
public void run() {
initializeScene(currentChart);
}
});
}
while (!testConversionDone()) {
wait();
}
// Generate a new global error for this task (if needed).
if (!errorMap.isEmpty()) {
IllegalStateException ise = new IllegalStateException(MessageFormat.format("Task failed with {0} error(s).", errorMap.size()));
for (Throwable error : errorMap.values()) {
ise.addSuppressed(error);
}
throw ise;
}
return null;
}
/**
* Returns a list of all errors that happened during this task.
* @return A non-modifiable {@code List}, this list will be empty is the task succeeded.
*/
public List getExceptions() {
return Collections.unmodifiableList(new ArrayList(errorMap.values()));
}
/**
* Initialize the scene to which the chart is attached.
* <br />This method is called on FX thread.
* @param chart The chart to export.
*/
private void initializeScene(final Chart chart) {
// Set default width.
int width = imageWidth;
if (width <= 0) {
width = (int) Math.ceil(chart.getWidth());
}
if (width <= 0) {
width = (int) Math.ceil(chart.getPrefWidth());
}
if (width <= 0) {
width = (int) Math.ceil(chart.getMinWidth());
}
if (width <= 0) {
width = 500;
}
// Set default height.
int height = imageHeight;
if (height <= 0) {
height = (int) Math.ceil(chart.getHeight());
}
if (height <= 0) {
height = (int) Math.ceil(chart.getPrefHeight());
}
if (height <= 0) {
height = (int) Math.ceil(chart.getMinHeight());
}
if (height <= 0) {
height = 500;
}
final Scene scene = new Scene(chart, width, height);
// Dialog needs to be created on EDT.
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
initializeDialog(chart, scene);
}
});
}
/**
* Initialize, display and export the {@code JFXPanel} renderer.
* <br />This method is called on EDT.
* @param chart The chart to export.
* @param scene The scene in which the chart is attached.
*/
private void initializeDialog(final Chart chart, Scene scene) {
final int chartIndex = charts.indexOf(chart);
final JFXPanel jfxPanel = new JFXPanel();
int panelWidth = (int) Math.ceil(scene.getWidth());
int panelHeight = (int) Math.ceil(scene.getHeight());
Dimension size = new Dimension(panelWidth, panelHeight);
jfxPanel.setSize(size);
jfxPanel.setPreferredSize(size);
jfxPanel.setMinimumSize(size);
jfxPanel.setMaximumSize(size);
jfxPanel.setScene(scene);
final JDialog dialog = new JDialog();
dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
dialog.addComponentListener(new ComponentAdapter() {
@Override
public void componentShown(ComponentEvent e) {
Timer pause = new Timer(200, new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
exportAsImage(chart, jfxPanel, chartIndex);
dialog.dispose();
}
});
pause.setRepeats(false);
pause.start();
}
});
dialog.setContentPane(jfxPanel);
dialog.pack();
dialog.setVisible(true);
}
/**
* The taskProgress of the task.
*/
private int taskProgress = 0;
/**
* Export the {@code JFXPanel} renderer as an image.
* @param chart The source chart.
* @param delegated The {@code JFXPanel} renderer.
* @param chartIndex The index of the current chart.
*/
private void exportAsImage(Chart chart, JFXPanel delegated, int chartIndex) {
int chartNumber = charts.size();
try {
Dimension size = delegated.getSize();
BufferedImage image = new BufferedImage(size.width, size.height, BufferedImage.TYPE_INT_ARGB);
Graphics2D graphics = image.createGraphics();
try {
delegated.paint(graphics);
} finally {
graphics.dispose();
}
// Provide image to handler.
ImageHandler imageHandler = getImageHandler();
if (imageHandler != null) {
imageHandler.handle(chart, image, chartIndex, chartNumber);
}
} catch (Throwable t) {
errorMap.put(chart, t);
}
synchronized (processedMap) {
processedMap.put(chart, true);
}
synchronized (this) {
taskProgress++;
updateProgress(taskProgress, chartNumber);
notify();
}
}
//
/**
* The object that will handle the image after its creation.
*/
private ImageHandler imageHandler;
/**
* Sets the image handler.
* @param value The new value.
* @see #getImageHandler()
*/
public final void setImageHandler(ImageHandler value) {
imageHandler = value;
}
/**
* Gets the image handler.
* @return A {@code ImageHandler} instance, may be {@code null}.
* @see #setImageHandler(ImageHandler)
*/
public final ImageHandler getImageHandler() {
return imageHandler;
}
}
Je ne garanti pas que c’est sans bug ou deadlock car il y a trop longtemps que je n’ai pas manipulé des threads à un niveau aussi bas mais pour le moment, chez moi cela marche plutôt correctement.
Voici le programme de test utilisé ; ici le ImageHandler créé permet de sauvegarder les images produites sur le disque dans le répertoire home de l’utilisateur.
* Test program for the {@code Chart2ImageTask} class.
* @author Fabrice Bouyé (fabriceb@spc.int)
*/
public class Test_Chart2ImageTask extends Application {
/**
* Create test line chart.
* @param base The base number.
* @return A {@code LineChart} instance, never {@code null}.
*/
public static LineChart createTestChart(int base) {
double maxX = 10;
double maxY = Math.pow(base, maxX);
NumberAxis xAxis = new NumberAxis(0, maxX, maxX / 5);
NumberAxis yAxis = new NumberAxis(0, maxY, maxY / 5);
yAxis.setLabel("y = " + base + "^x;");
LineChart.Series lineSeries = new LineChart.Series();
lineSeries.setName("Power of " + base);
for (int x = 0; x <= maxX; x++) {
LineChart.Data lineData = new LineChart.Data(x, Math.pow(base, x));
lineSeries.getData().add(lineData);
}
LineChart lineChart = new LineChart(xAxis, yAxis, FXCollections.observableArrayList(lineSeries));
return lineChart;
}
/**
* @param args the command line arguments
*/
public static void main(String[] args) {
launch(args);
}
/**
* {@inheritDoc}
*/
@Override
public void start(Stage primaryStage) {
VBox chartVBox = VBoxBuilder.create().build();
for (int base = 0; base < 4; base++) {
LineChart lineChart = createTestChart(base);
VBox.setVgrow(lineChart, Priority.ALWAYS);
chartVBox.getChildren().add(lineChart);
}
//
EventHandler exportHandler = new EventHandler() {
@Override
public void handle(ActionEvent event) {
// In the real program here, we should make copy of existing charts:
// - a same node cannot be addded in 2 different scenes/parent.
// If we keep the same object, we would need to remove it from parent and re-add it after export.
// - we probably want to change to a more simple CSS when printing and exporting chart.
final List chartList = new ArrayList();
for (int base = 0; base < 4; base++) {
LineChart lineChart = createTestChart(base);
chartList.add(lineChart);
}
exportCharts(chartList);
}
};
Button exportButton = ButtonBuilder.create().text("Export").onAction(exportHandler).build();
ToolBar toolBar = ToolBarBuilder.create().items(exportButton).build();
BorderPane root = BorderPaneBuilder.create().top(toolBar).center(chartVBox).build();
//
primaryStage.setTitle("Test_Chart2ImageTask");
// Sometimes, AWT thread continue running after the window has been closed.
// May be due to uncaught exceptions in earlier developments.
// @todo Check if this still happens.
primaryStage.setOnCloseRequest(new EventHandler() {
@Override
public void handle(WindowEvent event) {
Platform.exit();
}
});
primaryStage.setScene(new Scene(root, 800, 800));
primaryStage.show();
}
private void exportCharts(final List charts) {
// Handler that allow to save produced image as a PNG file.
final ImageHandler imageHandler = new ImageHandler() {
@Override
public void handle(Chart chart, BufferedImage image, int chartIndex, int chartNumber) throws Exception {
System.out.println(MessageFormat.format("Handling chart #{0}/{1}.", chartIndex, chartNumber));
File home = new File(System.getProperty("user.home"));
String format = "png";
File file = new File(home, MessageFormat.format("Test_{0}.{1}", chartIndex, format));
ImageIO.write(image, format, file);
}
};
// The image generator task.
final Chart2ImageTask exportTask = new Chart2ImageTask(charts.toArray(new Chart[0]));
exportTask.setImageHandler(imageHandler);
// And finally the service.
final Service exportService = new Service() {
@Override
protected Task createTask() {
return exportTask;
}
};
exportService.setOnSucceeded(new EventHandler() {
@Override
public void handle(WorkerStateEvent arg0) {
System.out.println("Task succeeded");
}
});
exportService.setOnFailed(new EventHandler() {
@Override
public void handle(WorkerStateEvent arg0) {
System.out.println("Task failed with " + exportService.getException());
System.out.println(exportTask.getExceptions());
}
});
exportService.start();
}
}
À partir de là, on pourra fournir des ImageHandler appropriés pour faire telle ou telle tache spécifique comme exporter chacun des graphes dans un fichier PDF separé ou les imprimer (next step : finally trying to figure out how the hell Java print service work… yeah, more fun!). Attention cependant, par exemple dans le cas où tous les graphes doivent être inclus dans le même PDF, comme c’est du multi-threads, je ne sais pas encore si l’ordre d’appel de la méthode du ImageHandler est garanti ou pas.
4 Commentaires + Ajouter un commentaire
Commentaires récents
- Back from the future… dans
- Back from the future… dans
- Static linking = does not Compute dans
- Paquetage x 2 dans
- Why you little… dans
C’est tout à fait ça.
Par exemple j’avais fait un test dans lequel j’affichais des graphes dans un display shelf (un peu comme ça : http://learnjavafx.typepad.com/.a/6a00e54f133d6988340120a4d00bcb970b-800wi) mais ce faisant les performances étaient exécrables (tout est vectoriel) et en plus on perd un espace mémoire monstre (puisqu’on ne peut pas partager les séries et les données entre plusieurs graphes).
Par contre si on prend un snapshot bitmap d’un graphe et qu’on l’affiche dans le display shelf c’est beaucoup plus rapide et plutôt économique aussi en mémoire occupée ! Bien sur si le graphe contient des données dynamiques il faudra faire qq chose qui mette à jour le snapshot de temps à autres.
En FX 1.x j’avais fait des effets de drag’n drop assez sympa en rasterisant des nœuds et en affichant l’image ainsi produite sous le curseur. Là en 2.2, j’ai fait des icônes qui affichent le contenu des chart pour mettre dans les ListCell de mes ComboBox.
L’autre truc bien c’est que désormais c’est hyper simple de faire des sorties en image, en PDF (avec contenu bitmap) et surtout imprimer même si ce n’est pas 100% parfait (on peut faire mieux au niveau qualité).
Ah oui merci beaucoup et maintenant je pense que c’est une bonne chose pour FX 2.2 . Je pense aussi que ça peu servir à gagner sur les performance dans le moment d’on fait des truc staique donc les transformer avec ce snapshopt() on peux passer du vectoriel vers l’image ce qui réduira le coût sur le rendu graphique si je ne me trompe pas, ou bien je me trompe?
Non, ça marcherai avec n’importe quel type de noeud (mais dans mon cas j’avais besoin de charts. De toute manière c’est devenu obsolète car c’était surtout utile pour FX 2.0.x et FX 2.1.x. Avec FX 2.2 ce n’est plus nécessaire puisqu’on peut désormais utiliser Node.snapshot().
l’algo devient :
– faire une copie du noeud OU retirer le noeud existant de son parent dans la scène / sauvegarder ses préférence.
– redimensionner la copie / le noeud pour lui donner les dimensions de l’image.
– créer une scène.
– mettre la copie / le noeud dans la scène.
– appliquer la CSS.
– appeler Node.snapshot() ou Scene.snapshot() pour créer une WritableImage.
– convertir la WritableImage en BufferedImage en appelant SwingFXUtils.toFXImage().
– imprimer la BufferedImage / la sauvegarder / la coller dans un PDF / etc.
– supprimer la copie / restaurer le noeud dans son parent initial avec son layout et ses dimensions d’origine.
Ce faisant, on a donc plus trop besoin de jongler entre 3 threads (a la rigueur juste s’assurer qu’on manipule la scène dans la FX App thread – puisqu’en général on déportera ce genre de bout de code vers un Service.
Avec snapshot() on peut faire des trucs marrant comme générer des aperçus de charts pour les afficher dans une cell pour une ListView, CombBox, TreeView ou TableView par exemple.
Ma question est que ce que vous venez de proposer c’est juste pour des Chart, est ce pour les Chart que le truc marche ou bien on peut l’utiliser pour n’importe quel node. Car si j’ai bien si j’ai bien compris le principe en passant par JFXPanle ne va-t-il pas marcher pour le reste des neud?