package org.openautonomousconnection.luascript.fx; import javafx.scene.web.WebEngine; import org.openautonomousconnection.luascript.hosts.ImageHost; import org.w3c.dom.Document; import org.w3c.dom.Element; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.net.URL; import java.net.URLConnection; import java.nio.file.Files; import java.nio.file.Path; import java.util.Locale; import java.util.Objects; import java.util.Set; /** * ImageHost implementation that renders images inside the WebView DOM using an {@code } element. * *

No JavaFX overlay panes are used. Positioning is done by setting CSS via {@code style} attribute.

* *

For {@code web://} URLs this host downloads the resource via {@link URLConnection} (your installed handler), * stores it as a temporary file, then sets {@code src=file://...} because WebView won't reliably load custom schemes.

*/ public final class FxImageHost implements ImageHost { private static final Set IMAGE_EXTENSIONS = Set.of( "png", "jpg", "jpeg", "ico", "bmp", "avif", "heif", "heic", "webp" ); private static final String IMG_ID = "__oac_image"; private final WebEngine engine; private final FxDomHost dom; private volatile double x; private volatile double y; private volatile double w = 320; private volatile double h = 240; private volatile boolean preserveRatio = true; private volatile boolean smooth = true; private volatile double opacity = 1.0; private volatile Path lastTempFile; /** * Creates a new image host. * * @param engine web engine * @param dom fx dom host */ public FxImageHost(WebEngine engine, FxDomHost dom) { this.engine = Objects.requireNonNull(engine, "engine"); this.dom = Objects.requireNonNull(dom, "dom"); // Ensure element exists once document is present; if not yet loaded, calls will create on demand. } @Override public void showFile(File file) { Objects.requireNonNull(file, "file"); if (!file.isFile()) { throw new IllegalArgumentException("Image file not found: " + file.getAbsolutePath()); } String ext = MediaExtensions.extensionOf(file.getName()); ensureAllowed(ext, "image", file.getName()); URI uri = file.toURI(); setSource(uri.toString(), null); } @Override public void showUrl(String url) { Objects.requireNonNull(url, "url"); String u = url.trim(); if (u.isEmpty()) throw new IllegalArgumentException("URL is empty"); URI uri = URI.create(u); String ext = MediaExtensions.extensionOf(uri.getPath()); ensureAllowed(ext, "image", uri.getPath()); String scheme = (uri.getScheme() == null) ? "" : uri.getScheme().toLowerCase(Locale.ROOT); if ("http".equals(scheme) || "https".equals(scheme) || "file".equals(scheme)) { setSource(uri.toString(), null); return; } if ("web".equals(scheme)) { Path tmp = null; try { tmp = downloadToTempFile(u, ext, "oac-img-"); rememberTemp(tmp); setSource(tmp.toUri().toString(), tmp); return; } catch (IOException e) { safeDelete(tmp); throw new RuntimeException("Failed to load web image: " + e.getMessage(), e); } } throw new IllegalArgumentException("Unsupported scheme: " + scheme); } @Override public void hide() { FxThreadBridge.runAndWait(() -> { Element img = ensureImgElement(); img.setAttribute("style", mergeStyle(baseStyle(false), rectStyle(), visualStyle())); img.removeAttribute("src"); }); Path tmp = this.lastTempFile; this.lastTempFile = null; safeDelete(tmp); } @Override public void setRect(double x, double y, double w, double h) { this.x = x; this.y = y; this.w = Math.max(0.0, w); this.h = Math.max(0.0, h); applyStyle(true); } @Override public void setPreserveRatio(boolean preserve) { this.preserveRatio = preserve; applyStyle(true); } @Override public void setSmooth(boolean smooth) { this.smooth = smooth; applyStyle(true); } @Override public void setOpacity(double opacity) { this.opacity = clamp01(opacity); applyStyle(true); } private void setSource(String src, Path tempFileKept) { FxThreadBridge.runAndWait(() -> { Element img = ensureImgElement(); img.setAttribute("src", src); img.setAttribute("style", mergeStyle(baseStyle(true), rectStyle(), visualStyle())); }); // Keep temp file reference (so previous gets cleaned up) if (tempFileKept != null) { rememberTemp(tempFileKept); } } private void applyStyle(boolean visible) { FxThreadBridge.runAndWait(() -> { Element img = ensureImgElement(); String src = img.getAttribute("src"); boolean show = visible && src != null && !src.isBlank(); img.setAttribute("style", mergeStyle(baseStyle(show), rectStyle(), visualStyle())); }); } private Element ensureImgElement() { Document doc = dom.requireDocument(); Element img = doc.getElementById(IMG_ID); if (img != null) return img; Element body = (Element) doc.getElementsByTagName("body").item(0); if (body == null) throw new IllegalStateException("No element available"); img = doc.createElement("img"); img.setAttribute("id", IMG_ID); img.setAttribute("draggable", "false"); img.setAttribute("alt", ""); body.appendChild(img); return img; } private String baseStyle(boolean visible) { return "position:fixed;" + "left:0;top:0;" + "z-index:2147483647;" + "display:" + (visible ? "block" : "none") + ";"; } private String rectStyle() { return "left:" + x + "px;" + "top:" + y + "px;" + "width:" + w + "px;" + "height:" + h + "px;"; } private String visualStyle() { String fit; if (preserveRatio) { fit = "object-fit:contain;"; } else { fit = "object-fit:fill;"; } String smoothing = smooth ? "image-rendering:auto;" : "image-rendering:pixelated;"; return fit + smoothing + "opacity:" + opacity + ";"; } private static String mergeStyle(String... parts) { StringBuilder sb = new StringBuilder(256); for (String p : parts) { if (p == null || p.isBlank()) continue; String s = p.trim(); if (!s.endsWith(";")) s += ";"; sb.append(s); } return sb.toString(); } private static Path downloadToTempFile(String url, String ext, String prefix) throws IOException { URL u = new URL(url); URLConnection con = u.openConnection(); con.setUseCaches(false); String safeExt = (ext == null || ext.isBlank()) ? "bin" : ext.toLowerCase(Locale.ROOT); Path tmp = Files.createTempFile(prefix, "." + safeExt); try (InputStream in = con.getInputStream()) { Files.copy(in, tmp, java.nio.file.StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { safeDelete(tmp); throw e; } return tmp; } private void rememberTemp(Path tmp) { Path prev = this.lastTempFile; this.lastTempFile = tmp; safeDelete(prev); } private static void safeDelete(Path p) { if (p == null) return; try { Files.deleteIfExists(p); } catch (IOException ignored) { // best-effort } } private static void ensureAllowed(String ext, String kind, String source) { String e = (ext == null) ? "" : ext.toLowerCase(Locale.ROOT); if (e.isEmpty() || !IMAGE_EXTENSIONS.contains(e)) { throw new IllegalArgumentException("Unsupported " + kind + " format '" + e + "' for: " + source); } } private static double clamp01(double v) { if (v < 0.0) return 0.0; if (v > 1.0) return 1.0; return v; } }