Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ target/
.settings
*.iml
dependency-reduced-pom.xml
.venv

# pip
build/
dist/
.egg/
*.egg-info/
kernelspec/**/*.jar

# jupyter
.ipynb_checkpoints
311 changes: 311 additions & 0 deletions examples/static-image-rendering.ipynb

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,23 @@ public class RenderRegistration<T> {
private final Set<MIMEType> supported;
private final Set<MIMEType> preferred;
private final Set<Class<? extends T>> types;
private final Set<String> typeNames;

public RenderRegistration(Class<? extends T> 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<T> supporting(MIMEType... types) {
Expand Down Expand Up @@ -96,22 +107,36 @@ public RenderRegistration<T> onType(Class<? extends T> type) {
public void register(RenderFunction<T> function) {
Set<MIMEType> supported = this.supported.isEmpty() ? DisplayDataRenderable.ANY : this.supported;
Set<MIMEType> 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<Class, List<RenderFunctionProps>> renderFunctions;
private final Map<String, List<RenderFunctionProps>> renderFunctionsByName;
private final Map<String, MIMEType> suffixMappings;

public Renderer() {
this.renderFunctions = new HashMap<>();
this.renderFunctionsByName = new HashMap<>();
this.suffixMappings = new HashMap<>();
}

public <T> RenderRegistration<T> createRegistration(Class<T> 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<Object> createRegistration(String typeName) {
return new RenderRegistration<>(typeName);
}

public <T> void register(Set<MIMEType> supported, Set<MIMEType> preferred, Set<Class<? extends T>> types, RenderFunction<T> function) {
RenderFunctionProps props = new RenderFunctionProps(function, supported, preferred);

Expand All @@ -122,6 +147,21 @@ public <T> void register(Set<MIMEType> supported, Set<MIMEType> preferred, Set<C
}));
}

public <T> void registerByName(Set<MIMEType> supported, Set<MIMEType> preferred, Set<String> typeNames, RenderFunction<T> function) {
RenderFunctionProps props = new RenderFunctionProps(function, supported, preferred);

typeNames.forEach(name -> this.renderFunctionsByName.compute(name, (k, v) -> {
List<RenderFunctionProps> functions = v != null ? v : new ArrayList<>();
functions.add(props);
return functions;
}));
}

private List<RenderFunctionProps> lookup(Class type) {
List<RenderFunctionProps> 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));
Expand Down Expand Up @@ -171,7 +211,7 @@ public DisplayData render(Object value, Map<String, Object> params) {
while (inheritedTypes.hasNext()) {
Class type = inheritedTypes.next();

List<RenderFunctionProps> allRenderFunctionProps = this.renderFunctions.get(type);
List<RenderFunctionProps> allRenderFunctionProps = this.lookup(type);
if (allRenderFunctionProps != null && !allRenderFunctionProps.isEmpty()) {
for (RenderFunctionProps renderFunctionProps : allRenderFunctionProps) {
RenderRequestTypes.Builder requestTypes = new RenderRequestTypes.Builder(this.suffixMappings::get);
Expand Down Expand Up @@ -257,7 +297,7 @@ public DisplayData renderAs(Object value, Map<String, Object> params, String...
Iterator<Class> inheritedTypes = new InheritanceIterator(value.getClass());
while (inheritedTypes.hasNext() && !requestTypes.isEmpty()) {
Class type = inheritedTypes.next();
List<RenderFunctionProps> allRenderFunctionProps = this.renderFunctions.get(type);
List<RenderFunctionProps> allRenderFunctionProps = this.lookup(type);
if (allRenderFunctionProps != null) {
for (RenderFunctionProps renderFunctionProps : allRenderFunctionProps) {
if (requestTypes.anyRequestedIsSupported(renderFunctionProps.getSupportedTypes())) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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, ...).
*
* <p>Everything is done reflectively, so jjava-jupyter keeps <strong>no compile- or link-time dependency on
* JavaFX</strong>: JavaFX is optional and user-supplied (e.g. {@code %maven org.openjfx:javafx-controls:...}). The
* renderer is registered <strong>by class name</strong> so it matches user nodes regardless of which class loader loaded
* them.
*
* <p>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<BufferedImage> result = new AtomicReference<>();
AtomicReference<Throwable> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, () -> "<named/>"));

DisplayData data = this.renderer.render(new A());

assertEquals("<named/>", 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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading