Files
LuaScript/src/main/java/org/openautonomousconnection/luascript/fx/FxImageHost.java

270 lines
8.4 KiB
Java
Raw Normal View History

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 <img>} element.
*
* <p>No JavaFX overlay panes are used. Positioning is done by setting CSS via {@code style} attribute.</p>
*
* <p>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.</p>
*/
public final class FxImageHost implements ImageHost {
private static final Set<String> 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 <body> 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;
}
}