diff --git a/.gitignore b/.gitignore index 42c698c..33b61ca 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ target/ .settings *.iml dependency-reduced-pom.xml +.venv # pip build/ @@ -12,3 +13,6 @@ dist/ .egg/ *.egg-info/ kernelspec/**/*.jar + +# jupyter +.ipynb_checkpoints \ No newline at end of file diff --git a/examples/static-image-rendering.ipynb b/examples/static-image-rendering.ipynb new file mode 100644 index 0000000..f77a26e --- /dev/null +++ b/examples/static-image-rendering.ipynb @@ -0,0 +1,311 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1088f6b5", + "metadata": {}, + "source": [ + "# Static image rendering (Swing & JavaFX)\n", + "\n", + "This exercises PR #123: `display(component)` / `display(node)` — and a bare last-expression value —\n", + "render a GUI component to a **still PNG**. No JupyterLab extension, no comms; the image shows in any\n", + "frontend and on GitHub/nbviewer.\n", + "\n", + "- **Swing/AWT** is headless-native (no display needed).\n", + "- **JavaFX** is optional (pull it with `%maven`) and needs a display to snapshot — on a headless host run\n", + " the kernel under **Xvfb** (`xvfb-run -a jupyter lab`). Build FX nodes on the JavaFX thread (helper below).\n", + "\n", + "Rebuild the kernel from the `static-image-rendering` branch and reinstall the kernelspec before running." + ] + }, + { + "cell_type": "markdown", + "id": "bf167ff9", + "metadata": {}, + "source": [ + "## Swing / AWT" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "ad3f7721", + "metadata": { + "vscode": { + "languageId": "java" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "javax.swing.JButton[,0,0,220x48,invalid,alignmentX=0.0,alignmentY=0.5,border=javax.swing.plaf.BorderUIResource$CompoundBorderUIResource@31703f41,flags=296,maximumSize=,minimumSize=,preferredSize=,defaultIcon=,disabledIcon=,disabledSelectedIcon=,margin=javax.swing.plaf.InsetsUIResource[top=2,left=14,bottom=2,right=14],paintBorder=true,paintFocus=true,pressedIcon=,rolloverEnabled=true,rolloverIcon=,rolloverSelectedIcon=,selectedIcon=,text=Hello from Swing,defaultCapable=true]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "d1df19a6-9b08-42a7-86fd-5559d87814a0" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import javax.swing.*;\n", + "import java.awt.*;\n", + "\n", + "// A laid-out Swing component renders as a static image.\n", + "JButton button = new JButton(\"Hello from Swing\");\n", + "button.setSize(220, 48);\n", + "display(button);" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e4dcab6c", + "metadata": { + "vscode": { + "languageId": "java" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAWgAAAC0CAYAAAC5brY1AAAM50lEQVR4Xu3b14skZR/Fcf80EyZMmHcdA+qFYr4RwQCKGFARFC+80ztBxYQBMYDZ1YXVVVAwIo6YMIu6ar38Hqh5Z8/u+ox7rK3i+P3Ageme7q7Wi6811e1+AwBgkfbTOwAAy0CgAWChCDQALBSBBoCFItAAsFCTB/qvv/4azj///OHnn3/WX+21Aw88cPjuu+/07sns6+MBQNlQoF966aVhZWVlOPLII4cTTjhhuOeee/Qh+9S+Dua/ebzXX3/9X3stANm6gV5dXR0OP/zw4aOPPmq3v/jii+Htt9/e+UH7WAXz+++/17sn828Guv6a+OSTT/RuANhFN9Dbt28fTjzxRL27ufjii4fnnnuu/fz7778Phx566HD//fev/f64444bPv74450Cd9RRRw2PP/74cPLJJ7efb7nllnYZpLz77rvD2Wef3e6/9tprh9tuu2146qmn1l5vVK/38MMPt/dVx7j77rvXfvfkk08Op59+enuNc889d/j000/XfnfssccOb7zxxrBp06bh6quvXrt/9OGHHw4XXnhh+0vhpJNOGj744IN2fx3vwQcfbO/56KOP3uk9/5Pj3XjjjcMBBxzQXr9e588//1x7LACobqB//fXXFqsbbrhh+PLLL3f6XYXx9ttvbz9v2bJlOOOMM4bLLrus3a5QVaDK+kDXzzfddNOwY8eO4Ycffhg2b97c/uz/448/2nGefvrp9rgK20EHHbTHQN96663tOV9//XV7jZdffrn9riL/1VdftZ/vvPPO4frrr1973sEHHzxcfvnlu70ePh7/sccea/H99ttv23sse3rP5Z8ery4RcQYNYCO6gS51WeOaa64ZDjnkkHYWWPEq27ZtG84555z2c4X6oYceGo455pjht99+a6Gr5xQN9HhmWip8dXb6zjvvtDPU9c4777w9Bnr9a9Q18Qq2eu2114aLLrpo7XY9r/4i2J233nprl+OP9Hjje1YbOR6BBrBRGwr0qM6gr7rqqnYdtdRljcMOO2z46aef2oeIn3/+eTtjrLPpOuN+5JFH2uM00GPgS4X1vvvuG1544YW11x1dccUVewx0nTmPHn300fbYOvOtWJ922mntEsIRRxwxXHDBBTs9b/2x13v++ed3Of5Inze+5705HoEGsFH/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", + "text/plain": [ + "REPL.$JShell$16$1[,0,0,360x180,invalid,layout=java.awt.FlowLayout,alignmentX=0.0,alignmentY=0.0,border=,flags=9,maximumSize=,minimumSize=,preferredSize=]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "3428dfbe-5159-4364-bd28-aafe00e93858" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import javax.swing.*;\n", + "import java.awt.*;\n", + "\n", + "// Custom painting also works - here a tiny bar chart.\n", + "JPanel chart = new JPanel() {\n", + " protected void paintComponent(Graphics g) {\n", + " super.paintComponent(g);\n", + " Graphics2D g2 = (Graphics2D) g;\n", + " g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);\n", + " int[] vals = {40, 90, 60, 120, 80};\n", + " int w = getWidth() / vals.length;\n", + " for (int i = 0; i < vals.length; i++) {\n", + " g2.setColor(Color.getHSBColor(i / (float) vals.length, 0.6f, 0.9f));\n", + " g2.fillRect(i * w + 8, getHeight() - vals[i] - 10, w - 16, vals[i]);\n", + " }\n", + " g2.setColor(Color.DARK_GRAY);\n", + " g2.drawString(\"Swing bar chart\", 8, 16);\n", + " }\n", + "};\n", + "chart.setBackground(Color.WHITE);\n", + "chart.setSize(360, 180);\n", + "display(chart);" + ] + }, + { + "cell_type": "markdown", + "id": "a9b0dacc", + "metadata": {}, + "source": [ + "## JavaFX (optional)\n", + "\n", + "Pull OpenJFX (edit the classifier for your OS: `linux` | `mac` | `mac-aarch64` | `win`). JavaFX needs a\n", + "display; on a headless host run the kernel under Xvfb. The `fx(...)` helper builds a node on the JavaFX\n", + "Application Thread, which is the correct place to construct scene-graph objects." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f29b4158", + "metadata": { + "vscode": { + "languageId": "java" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "JavaFX 21.0.2+5 ready\n" + ] + } + ], + "source": [ + "%maven org.openjfx:javafx-base:jar:linux:21.0.2\n", + "%maven org.openjfx:javafx-graphics:jar:linux:21.0.2\n", + "%maven org.openjfx:javafx-controls:jar:linux:21.0.2\n", + "%maven org.openjfx:javafx-swing:jar:linux:21.0.2\n", + "\n", + "import javafx.application.Platform;\n", + "import java.util.concurrent.CountDownLatch;\n", + "import java.util.function.Supplier;\n", + "\n", + "// Boots the JavaFX toolkit (needs a display / Xvfb).\n", + "new javafx.embed.swing.JFXPanel();\n", + "\n", + "// Build a JavaFX node on the FX thread and return it.\n", + " T fx(Supplier builder) throws Exception {\n", + " Object[] holder = new Object[1];\n", + " CountDownLatch latch = new CountDownLatch(1);\n", + " Platform.runLater(() -> { try { holder[0] = builder.get(); } finally { latch.countDown(); } });\n", + " latch.await();\n", + " return (T) holder[0];\n", + "}\n", + "System.out.println(\"JavaFX \" + System.getProperty(\"javafx.runtime.version\") + \" ready\");" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "89c60519", + "metadata": { + "vscode": { + "languageId": "java" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "Pane@4b2b8782" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "13ca5010-69c2-41e6-8a42-ed5f850e0c56" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import javafx.scene.layout.Pane;\n", + "import javafx.scene.shape.Circle;\n", + "import javafx.scene.shape.Rectangle;\n", + "import javafx.scene.paint.Color;\n", + "\n", + "var pane = fx(() -> {\n", + " Pane p = new Pane();\n", + " p.setPrefSize(300, 200);\n", + " Rectangle r = new Rectangle(40, 50, 90, 90);\n", + " r.setFill(Color.ORANGE);\n", + " Circle c = new Circle(170, 100, 70, Color.CORNFLOWERBLUE);\n", + " p.getChildren().addAll(r, c);\n", + " return p;\n", + "});\n", + "display(pane);" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f4127c55", + "metadata": { + "vscode": { + "languageId": "java" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAACQAAAAaCAYAAADfcP5FAAABMklEQVR4Xu2RQaqCUBSG3YWgc6mJ0twlBBauQEQnpbUA15DQAoK24AKySbgAZ4IDh0bQwIFIxP94F97FHg/ulYJ6cD+4cP7Df+CDK+HDkH4v3s3/FsrzHIvFguau6xCGIVzXRZqmaJoGhmFA0zTEcdy75Idb6Hw+Y7VaYTqd0t1ut8N+v8f9fid7x3Hg+z5utxtkWSbCQ+EW+sGyLDoHQYCyLMm8XC4xHo9xPB5J1nUdp9OJdnl5SuhboqoqMq/Xa/JVWZaRPJlMkCQJ7UqSxHykRy846Qttt1scDgcyz+dzzGYzbDYbkhVFQV3XtMvLU0KXywW2bcPzPERRhKIooKoqRqMRTNPsXfEzWOgv2rZ9yNfr9SEP4SVCr0QIsRBCLIQQCyHEQgixEEIshBCLjxP6AsxLJoRlqujfAAAAAElFTkSuQmCC", + "text/plain": [ + "BarChart@6607a754[styleClass=chart bar-chart]" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "453e94d8-1358-46bf-91dc-be209de20e9a" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import javafx.scene.chart.BarChart;\n", + "import javafx.scene.chart.CategoryAxis;\n", + "import javafx.scene.chart.NumberAxis;\n", + "import javafx.scene.chart.XYChart;\n", + "\n", + "var chart = fx(() -> {\n", + " BarChart bc = new BarChart<>(new CategoryAxis(), new NumberAxis());\n", + " bc.setTitle(\"JavaFX BarChart\");\n", + " XYChart.Series s = new XYChart.Series<>();\n", + " s.setName(\"demo\");\n", + " s.getData().add(new XYChart.Data<>(\"A\", 40));\n", + " s.getData().add(new XYChart.Data<>(\"B\", 90));\n", + " s.getData().add(new XYChart.Data<>(\"C\", 60));\n", + " bc.getData().add(s);\n", + " bc.setPrefSize(440, 300);\n", + " return bc;\n", + "});\n", + "display(chart);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6b290d2f-fdfa-4ea4-9f09-a0aefe93b3dd", + "metadata": { + "vscode": { + "languageId": "java" + } + }, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Java (jjava)", + "language": "java", + "name": "java" + }, + "language_info": { + "codemirror_mode": "java", + "file_extension": ".jshell", + "mimetype": "text/x-java-source", + "name": "Java", + "pygments_lexer": "java", + "version": "21.0.11+10-1-24.04.2-Ubuntu" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/BaseKernel.java b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/BaseKernel.java index 0b50d0b..ab3856b 100644 --- a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/BaseKernel.java +++ b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/BaseKernel.java @@ -7,6 +7,8 @@ import org.dflib.jjava.jupyter.kernel.display.DisplayData; import org.dflib.jjava.jupyter.kernel.display.Renderer; import org.dflib.jjava.jupyter.kernel.display.common.Image; +import org.dflib.jjava.jupyter.kernel.display.common.JavaFx; +import org.dflib.jjava.jupyter.kernel.display.common.Swing; import org.dflib.jjava.jupyter.kernel.display.common.Text; import org.dflib.jjava.jupyter.kernel.display.common.Url; import org.dflib.jjava.jupyter.kernel.history.HistoryEntry; @@ -136,6 +138,8 @@ protected BaseKernel( this.executionCount = new AtomicInteger(1); Image.registerAll(this.renderer); + Swing.registerAll(this.renderer); + JavaFx.registerAll(this.renderer); Url.registerAll(this.renderer); Text.registerAll(this.renderer); } diff --git a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/Renderer.java b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/Renderer.java index ab7f2cd..4333301 100644 --- a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/Renderer.java +++ b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/Renderer.java @@ -56,12 +56,23 @@ public class RenderRegistration { private final Set supported; private final Set preferred; private final Set> types; + private final Set typeNames; public RenderRegistration(Class type) { + this(); + this.types.add(type); + } + + public RenderRegistration(String typeName) { + this(); + this.typeNames.add(typeName); + } + + private RenderRegistration() { this.supported = new LinkedHashSet<>(); this.preferred = new LinkedHashSet<>(); this.types = new LinkedHashSet<>(); - this.types.add(type); + this.typeNames = new LinkedHashSet<>(); } public RenderRegistration supporting(MIMEType... types) { @@ -96,15 +107,20 @@ public RenderRegistration onType(Class type) { public void register(RenderFunction function) { Set supported = this.supported.isEmpty() ? DisplayDataRenderable.ANY : this.supported; Set preferred = this.preferred.isEmpty() ? supported : this.preferred; - Renderer.this.register(supported, preferred, types, function); + if (!this.types.isEmpty()) + Renderer.this.register(supported, preferred, types, function); + if (!this.typeNames.isEmpty()) + Renderer.this.registerByName(supported, preferred, typeNames, function); } } private final Map> renderFunctions; + private final Map> renderFunctionsByName; private final Map suffixMappings; public Renderer() { this.renderFunctions = new HashMap<>(); + this.renderFunctionsByName = new HashMap<>(); this.suffixMappings = new HashMap<>(); } @@ -112,6 +128,15 @@ public RenderRegistration createRegistration(Class type) { return new RenderRegistration<>(type); } + /** + * Create a registration keyed by a fully-qualified class name rather than a {@link Class} instance. This matches a + * rendered value when any class or interface in its hierarchy has this name, regardless of which class loader loaded + * it — useful for optional, runtime-supplied types (e.g. JavaFX) that are not on the kernel's own class path. + */ + public RenderRegistration createRegistration(String typeName) { + return new RenderRegistration<>(typeName); + } + public void register(Set supported, Set preferred, Set> types, RenderFunction function) { RenderFunctionProps props = new RenderFunctionProps(function, supported, preferred); @@ -122,6 +147,21 @@ public void register(Set supported, Set preferred, Set void registerByName(Set supported, Set preferred, Set typeNames, RenderFunction function) { + RenderFunctionProps props = new RenderFunctionProps(function, supported, preferred); + + typeNames.forEach(name -> this.renderFunctionsByName.compute(name, (k, v) -> { + List functions = v != null ? v : new ArrayList<>(); + functions.add(props); + return functions; + })); + } + + private List lookup(Class type) { + List byClass = this.renderFunctions.get(type); + return byClass != null ? byClass : this.renderFunctionsByName.get(type.getName()); + } + protected DisplayData finalizeDisplayData(DisplayData data, Object value) { if (!data.hasDataForType(MIMEType.TEXT_PLAIN)) data.putText(String.valueOf(value)); @@ -171,7 +211,7 @@ public DisplayData render(Object value, Map params) { while (inheritedTypes.hasNext()) { Class type = inheritedTypes.next(); - List allRenderFunctionProps = this.renderFunctions.get(type); + List allRenderFunctionProps = this.lookup(type); if (allRenderFunctionProps != null && !allRenderFunctionProps.isEmpty()) { for (RenderFunctionProps renderFunctionProps : allRenderFunctionProps) { RenderRequestTypes.Builder requestTypes = new RenderRequestTypes.Builder(this.suffixMappings::get); @@ -257,7 +297,7 @@ public DisplayData renderAs(Object value, Map params, String... Iterator inheritedTypes = new InheritanceIterator(value.getClass()); while (inheritedTypes.hasNext() && !requestTypes.isEmpty()) { Class type = inheritedTypes.next(); - List allRenderFunctionProps = this.renderFunctions.get(type); + List allRenderFunctionProps = this.lookup(type); if (allRenderFunctionProps != null) { for (RenderFunctionProps renderFunctionProps : allRenderFunctionProps) { if (requestTypes.anyRequestedIsSupported(renderFunctionProps.getSupportedTypes())) { diff --git a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/common/JavaFx.java b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/common/JavaFx.java new file mode 100644 index 0000000..ad66266 --- /dev/null +++ b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/common/JavaFx.java @@ -0,0 +1,108 @@ +package org.dflib.jjava.jupyter.kernel.display.common; + +import org.dflib.jjava.jupyter.kernel.display.RenderContext; +import org.dflib.jjava.jupyter.kernel.display.Renderer; +import org.dflib.jjava.jupyter.kernel.display.mime.MIMEType; + +import java.awt.image.BufferedImage; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Renders a JavaFX {@code javafx.scene.Node} as a static image: it snapshots the node on the JavaFX Application Thread + * and converts the result to a {@link BufferedImage}, which is then encoded by {@link Image}. So {@code display(node)} + * (or a bare node expression) yields a still PNG that works anywhere a static image does (GitHub, nbviewer, ...). + * + *

Everything is done reflectively, so jjava-jupyter keeps no compile- or link-time dependency on + * JavaFX: JavaFX is optional and user-supplied (e.g. {@code %maven org.openjfx:javafx-controls:...}). The + * renderer is registered by class name so it matches user nodes regardless of which class loader loaded + * them. + * + *

Producing a snapshot starts the JavaFX toolkit, which needs a display; on a headless host run under Xvfb. If the + * toolkit can't start, rendering degrades to a short text message instead of failing the cell. + */ +public class JavaFx { + + public static final String NODE_CLASS = "javafx.scene.Node"; + + private static final long SNAPSHOT_TIMEOUT_SECONDS = 15; + + public static void registerAll(Renderer renderer) { + renderer.createRegistration(NODE_CLASS) + .preferring(Image.PNG) + .supporting(Image.JPEG) + .register(JavaFx::renderNode); + } + + public static void renderNode(Object node, RenderContext context) { + try { + Image.renderImage(snapshot(node), context); + } catch (Throwable t) { + context.renderIfRequested(MIMEType.TEXT_PLAIN, + () -> "Could not render JavaFX node as an image: " + rootMessage(t) + + " (JavaFX needs a display; on a headless host run the kernel under Xvfb)."); + } + } + + private static BufferedImage snapshot(Object node) throws Exception { + ClassLoader cl = node.getClass().getClassLoader(); + Class platformClass = Class.forName("javafx.application.Platform", true, cl); + Class snapshotParamsClass = Class.forName("javafx.scene.SnapshotParameters", true, cl); + Class writableImageClass = Class.forName("javafx.scene.image.WritableImage", true, cl); + Class imageClass = Class.forName("javafx.scene.image.Image", true, cl); + Class swingFxUtilsClass = Class.forName("javafx.embed.swing.SwingFXUtils", true, cl); + + startToolkit(platformClass); + + Method runLater = platformClass.getMethod("runLater", Runnable.class); + Method snapshotMethod = node.getClass().getMethod("snapshot", snapshotParamsClass, writableImageClass); + Method fromFXImage = swingFxUtilsClass.getMethod("fromFXImage", imageClass, BufferedImage.class); + + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + + // snapshot() must run on the JavaFX Application Thread. + runLater.invoke(null, (Runnable) () -> { + try { + Object params = snapshotParamsClass.getConstructor().newInstance(); + Object fxImage = snapshotMethod.invoke(node, params, null); + result.set((BufferedImage) fromFXImage.invoke(null, fxImage, null)); + } catch (Throwable t) { + failure.set(t); + } finally { + done.countDown(); + } + }); + + if (!done.await(SNAPSHOT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for the JavaFX snapshot (is a display available?)"); + } + if (failure.get() != null) { + throw new RuntimeException(failure.get()); + } + return result.get(); + } + + private static void startToolkit(Class platformClass) throws Exception { + try { + platformClass.getMethod("startup", Runnable.class).invoke(null, (Runnable) () -> { }); + } catch (InvocationTargetException e) { + // IllegalStateException means the toolkit is already running, which is fine; anything else is fatal. + if (!(e.getCause() instanceof IllegalStateException)) { + throw e; + } + } + } + + private static String rootMessage(Throwable t) { + Throwable cause = t; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName(); + } +} diff --git a/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/common/Swing.java b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/common/Swing.java new file mode 100644 index 0000000..729d0c0 --- /dev/null +++ b/jjava-jupyter/src/main/java/org/dflib/jjava/jupyter/kernel/display/common/Swing.java @@ -0,0 +1,82 @@ +package org.dflib.jjava.jupyter.kernel.display.common; + +import org.dflib.jjava.jupyter.kernel.display.RenderContext; +import org.dflib.jjava.jupyter.kernel.display.Renderer; +import org.dflib.jjava.jupyter.kernel.display.mime.MIMEType; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; + +/** + * Renders an {@link java.awt.Component AWT/Swing component} as an image by painting it off-screen into a + * {@link BufferedImage}, which is then encoded by {@link Image}. This works without a display (headless) since no native + * window is ever created. + */ +public class Swing { + public static final MIMEType PNG = MIMEType.IMAGE_PNG; + public static final MIMEType JPEG = MIMEType.IMAGE_JPEG; + public static final MIMEType GIF = MIMEType.IMAGE_GIF; + + public static void registerAll(Renderer renderer) { + renderer.createRegistration(Component.class) + .preferring(PNG) + .supporting(JPEG, GIF) + .register(Swing::renderComponent); + } + + public static void renderComponent(Component component, RenderContext context) { + Image.renderImage(snapshot(component), context); + } + + /** + * Paint a component using its current size, falling back to its {@link Component#getPreferredSize() preferred size} + * when it has not been laid out yet (which is the usual case for a component that was never added to a window). + */ + public static BufferedImage snapshot(Component component) { + Dimension size = component.getSize(); + if (size.width <= 0 || size.height <= 0) { + size = component.getPreferredSize(); + } + return snapshot(component, size.width, size.height); + } + + public static BufferedImage snapshot(Component component, int width, int height) { + width = Math.max(width, 1); + height = Math.max(height, 1); + + component.setSize(width, height); + layoutTree(component); + + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + // print() rather than paint(): it bypasses Swing's double-buffering, which is the right behaviour for + // off-screen rendering of a component that has no native peer. + component.print(g); + } finally { + g.dispose(); + } + return image; + } + + /** + * A component that was never realized in a window is not laid out, so its children have zero bounds and would not + * paint (nor hit-test). Forcing a layout pass over the whole subtree gives every child a position and size. + */ + public static void layoutTree(Component component) { + synchronized (component.getTreeLock()) { + component.doLayout(); + if (component instanceof Container) { + for (Component child : ((Container) component).getComponents()) { + layoutTree(child); + } + } + } + } +} diff --git a/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/RendererTest.java b/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/RendererTest.java index 56da88a..b8c1ff1 100644 --- a/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/RendererTest.java +++ b/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/RendererTest.java @@ -88,6 +88,18 @@ public void rendersExternal() { assertNull(data.getData(MIMEType.TEXT_LATEX)); } + @Test + public void rendersByClassName() { + this.renderer.createRegistration(A.class.getName()) + .preferring(MIMEType.TEXT_HTML) + .register((Object o, RenderContext ctx) -> ctx.renderIfRequested(MIMEType.TEXT_HTML, () -> "")); + + DisplayData data = this.renderer.render(new A()); + + assertEquals("", data.getData(MIMEType.TEXT_HTML)); + assertEquals("A", data.getData(MIMEType.TEXT_PLAIN)); + } + @Test public void rendersAs() { DisplayData data = this.renderer.renderAs(new C(), "text/markdown"); diff --git a/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/common/JavaFxTest.java b/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/common/JavaFxTest.java new file mode 100644 index 0000000..dd394df --- /dev/null +++ b/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/common/JavaFxTest.java @@ -0,0 +1,32 @@ +package org.dflib.jjava.jupyter.kernel.display.common; + +import org.dflib.jjava.jupyter.kernel.display.DisplayData; +import org.dflib.jjava.jupyter.kernel.display.Renderer; +import org.dflib.jjava.jupyter.kernel.display.mime.MIMEType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class JavaFxTest { + + /** A stand-in for a JavaFX node so the by-name registration dispatches without JavaFX on the test classpath. */ + static class FakeNode { + } + + @Test + public void degradesGracefullyWhenJavaFxUnavailable() { + Renderer renderer = new Renderer(); + // Exercise the JavaFX renderer against the stand-in's name (no JavaFX on the classpath, so it must not throw). + renderer.createRegistration(FakeNode.class.getName()) + .preferring(MIMEType.IMAGE_PNG) + .register(JavaFx::renderNode); + + DisplayData data = renderer.render(new FakeNode()); + + Object text = data.getData(MIMEType.TEXT_PLAIN); + assertNotNull(text, "a text/plain fallback should be present"); + assertTrue(text.toString().contains("Could not render JavaFX"), + "fallback should explain the JavaFX node could not be rendered"); + } +} diff --git a/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/common/SwingTest.java b/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/common/SwingTest.java new file mode 100644 index 0000000..a3fbbe3 --- /dev/null +++ b/jjava-jupyter/src/test/java/org/dflib/jjava/jupyter/kernel/display/common/SwingTest.java @@ -0,0 +1,94 @@ +package org.dflib.jjava.jupyter.kernel.display.common; + +import org.dflib.jjava.jupyter.kernel.display.DisplayData; +import org.dflib.jjava.jupyter.kernel.display.Renderer; +import org.dflib.jjava.jupyter.kernel.display.mime.MIMEType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SwingTest { + + static { + // Snapshotting works with or without a display, but force headless so the suite behaves identically on CI + // servers and developer machines. + System.setProperty("java.awt.headless", "true"); + } + + private Renderer renderer; + + @BeforeEach + public void setUp() { + this.renderer = new Renderer(); + Swing.registerAll(this.renderer); + } + + @Test + public void rendersComponentAsPng() throws Exception { + JButton button = new JButton("Click me"); + button.setSize(120, 40); + + DisplayData data = this.renderer.render(button); + + BufferedImage image = decodePng(data); + assertEquals(120, image.getWidth()); + assertEquals(40, image.getHeight()); + } + + @Test + public void fallsBackToPreferredSizeWhenNotSized() throws Exception { + JLabel label = new JLabel("hello"); + Dimension preferred = label.getPreferredSize(); + + DisplayData data = this.renderer.render(label); + + BufferedImage image = decodePng(data); + assertEquals(preferred.width, image.getWidth()); + assertEquals(preferred.height, image.getHeight()); + } + + @Test + public void snapshotHonoursRequestedSize() { + JPanel panel = new JPanel(); + + BufferedImage image = Swing.snapshot(panel, 200, 100); + + assertEquals(200, image.getWidth()); + assertEquals(100, image.getHeight()); + } + + @Test + public void laysOutChildrenBeforePainting() { + // A nested component that was never realized in a window: layout() must give the child non-zero bounds. + JPanel panel = new JPanel(new BorderLayout()); + JButton child = new JButton("child"); + panel.add(child, BorderLayout.CENTER); + + Swing.snapshot(panel, 300, 80); + + assertTrue(child.getWidth() > 0, "child should have been laid out with a non-zero width"); + assertTrue(child.getHeight() > 0, "child should have been laid out with a non-zero height"); + } + + private static BufferedImage decodePng(DisplayData data) throws Exception { + Object png = data.getData(MIMEType.IMAGE_PNG); + assertNotNull(png, "expected an image/png rendering"); + byte[] bytes = Base64.getDecoder().decode(png.toString()); + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + assertNotNull(image, "image/png payload should decode to an image"); + return image; + } +}