diff --git a/gradle.properties b/gradle.properties index 589ddb01..79ebfb21 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,6 +16,7 @@ me.champeau.jmh.version = 0.6.6 org.sonarqube.version = 5.1.0.4882 jacoco.version = 0.8.12 biz.aQute.bnd.lib.version = 7.1.0 +org.openjfx.javafxplugin.version = 0.1.0 #Dependencies # Libraries @@ -23,11 +24,12 @@ nullabilityAnnotations.version = 23.0.0 javaxAnnotations.version = 1.3.2 osgiAnnotations.version = 2.0.0 slf4jApi.version = 2.0.17 +javafx.version = 17.0.15 # Test libraries junit.version = 5.6.2 imageCompare.version = 1.0.0 -batik.version = 1.17 +batik.version = 1.19 svgSalamander.version = 1.1.3 darklaf.version = 3.1.0 swingExtensions.version = 0.1.4-SNAPSHOT diff --git a/jsvg-javafx/build.gradle.kts b/jsvg-javafx/build.gradle.kts new file mode 100644 index 00000000..46d17c89 --- /dev/null +++ b/jsvg-javafx/build.gradle.kts @@ -0,0 +1,78 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +plugins { + `java-library` + jacoco + id("biz.aQute.bnd.builder") + id("org.openjfx.javafxplugin") +} + +javafx { + version = rootProject.extra["javafx.version"].toString() + modules("javafx.controls", "javafx.fxml", "javafx.swing") +} + +dependencies { + compileOnly(projects.jsvg) + compileOnly(libs.nullabilityAnnotations) + compileOnly(toolLibs.errorprone.annotations) + compileOnly(libs.osgiAnnotations) + + testImplementation(projects.jsvg) + testImplementation(testLibs.junit.api) + testImplementation(testLibs.imageCompare) + testImplementation(gradleApi()) + + testRuntimeOnly(testLibs.junit.engine) + + testCompileOnly(libs.nullabilityAnnotations) + testCompileOnly(toolLibs.errorprone.annotations) +} +tasks { + + compileTestJava { + options.release.set(21) + } + + jar { + bundle { + bnd( + bndFile( + moduleName = "com.github.weisj.jsvg.javafx", + requiredModules = + listOf( + Requires("com.github.weisj.jsvg"), + Requires("org.jetbrains.annotations", static = true), + Requires("com.google.errorprone.annotations", static = true), + Requires("org.osgi.annotation.bundle", static = true), + ), + ), + ) + } + } + + withType { + environment("JAVAFX_TEST_SVG_PATH" to File(project.rootDir, "jsvg/src/test/resources").absolutePath) + } + + test { + dependsOn(jar) + doFirst { + workingDir = File(project.rootDir, "build/ref_test").also { it.mkdirs() } + } + useJUnitPlatform() + testLogging { + showStandardStreams = true + showExceptions = true + showStackTraces = true + exceptionFormat = TestExceptionFormat.FULL + } + } + + register("SVGViewerFX") { + group = "application" + description = "Runs the JavaFX SVG Viewer application." + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("com.github.weisj.jsvg.renderer.jfx.viewer.FXViewerMain") + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/FXOutput.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/FXOutput.java new file mode 100644 index 00000000..f6dfd78f --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/FXOutput.java @@ -0,0 +1,52 @@ +/* + * MIT License + * + * Copyright (c) 2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx; + +import javafx.scene.canvas.GraphicsContext; + +import org.jetbrains.annotations.NotNull; + +import com.github.weisj.jsvg.renderer.jfx.impl.FXOutputImpl; +import com.github.weisj.jsvg.renderer.jfx.impl.bridge.FXRenderingHintsUtil; +import com.github.weisj.jsvg.renderer.output.Output; + +/** + * A utility class for retrieving an {@link Output} implementation that uses a {@link GraphicsContext} to draw to. + */ +public final class FXOutput { + + private FXOutput() {} + + /** + * Example usage: + *

+     *     Output output = FXOutput.createForGraphicsContext(graphics);
+     *     svgDocument.renderWithPlatform(NullPlatformSupport.INSTANCE, output, null, null);
+     *     output.dispose();
+     * 
+ */ + public static @NotNull Output createForGraphicsContext(@NotNull GraphicsContext context) { + FXOutputImpl output = new FXOutputImpl(context); + FXRenderingHintsUtil.setupDefaultJFXRenderingHints(output); + return output; + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/FXOutputImpl.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/FXOutputImpl.java new file mode 100644 index 00000000..e5f96ccf --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/FXOutputImpl.java @@ -0,0 +1,371 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.impl; + +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.geom.PathIterator; +import java.awt.geom.Rectangle2D; +import java.awt.image.*; +import java.util.*; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.transform.Affine; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import com.github.weisj.jsvg.geometry.util.GeometryUtil; +import com.github.weisj.jsvg.paint.SVGPaint; +import com.github.weisj.jsvg.paint.impl.AwtSVGPaint; +import com.github.weisj.jsvg.paint.impl.MaskedPaint; +import com.github.weisj.jsvg.renderer.jfx.impl.bridge.*; +import com.github.weisj.jsvg.renderer.output.Output; +import com.github.weisj.jsvg.renderer.output.impl.CurrentColorProvider; +import com.github.weisj.jsvg.renderer.output.impl.GraphicsUtil; +import com.github.weisj.jsvg.util.ImageUtil; + +/** + * An {@link Output} implementation that uses a {@link GraphicsContext} to draw to. + */ +public final class FXOutputImpl implements Output, CurrentColorProvider { + + private final GraphicsContext ctx; + private final RenderingHints renderingHints; + + private static final Color DEFAULT_PAINT = Color.BLACK; + private static final Stroke DEFAULT_STROKE = new BasicStroke(1.0f); + private static final float DEFAULT_OPACITY = 1F; + + private float currentOpacity = DEFAULT_OPACITY; + private Paint currentPaint = DEFAULT_PAINT; + private Stroke currentStroke = DEFAULT_STROKE; + private final SafeState originalState; + + public FXOutputImpl(@NotNull GraphicsContext context) { + ctx = context; + renderingHints = new RenderingHints(null); + setOpacity(DEFAULT_OPACITY); + setPaint(DEFAULT_PAINT); + setStroke(DEFAULT_STROKE); + originalState = new FXOutputState(this, SaveClipStack.YES); + } + + private FXOutputImpl(@NotNull FXOutputImpl parent) { + ctx = parent.ctx; + renderingHints = new RenderingHints(null); + renderingHints.putAll(parent.renderingHints); + currentOpacity = parent.currentOpacity; + currentPaint = parent.currentPaint; + currentStroke = parent.currentStroke; + originalState = new FXOutputState(this, SaveClipStack.YES); + } + + @Override + public @Nullable SVGPaint currentColor() { + javafx.scene.paint.Paint fill = ctx.getFill(); + if (fill instanceof javafx.scene.paint.Color) { + return new AwtSVGPaint(FXPaintBridge.convertColor((javafx.scene.paint.Color) fill)); + } + return null; + } + + @Override + public void fillShape(@NotNull Shape shape) { + if (FXPaintBridge.supportedPaint(currentPaint)) { + FXShapeBridge.fillShape(ctx, shape); + } else { + // Render incompatible / custom paints with PaintContext fallback + Rectangle2D userBounds = GeometryUtil.containingBoundsAfterTransform(transform(), shape.getBounds()); + Rectangle deviceBounds = userBounds.getBounds(); + PaintContext context = currentPaint.createContext(ColorModel.getRGBdefault(), deviceBounds, userBounds, + transform(), renderingHints); + Raster raster = context.getRaster(deviceBounds.x, deviceBounds.y, deviceBounds.width, deviceBounds.height); + BufferedImage image = FXImageBridge.convertRasterToBufferedImage(context.getColorModel(), raster); + ctx.save(); + applyClip(shape); + Affine transform = ctx.getTransform(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.drawImage(FXImageBridge.convertImage(image), userBounds.getX(), userBounds.getY(), + userBounds.getWidth(), + userBounds.getHeight()); + ctx.setTransform(transform); + ctx.restore(); + } + } + + @Override + public void drawShape(@NotNull Shape shape) { + if (FXPaintBridge.supportedPaint(currentPaint)) { + FXShapeBridge.strokeShape(ctx, shape); + } else { + fillShape(stroke().createStrokedShape(shape)); + } + } + + @Override + public void drawImage(@NotNull BufferedImage image) { + FXImageBridge.drawImage(ctx, image, currentOpacity); + } + + @Override + public void drawImage(@NotNull Image image, @Nullable ImageObserver observer) { + if (!FXPaintBridge.isWrappingPaint(currentPaint)) { + FXImageBridge.drawImage(ctx, image, currentOpacity); + } else { + // Handle rendering of wrapping paints + GraphicsUtil.WrappingPaint wrappingPaint = (GraphicsUtil.WrappingPaint) currentPaint; + Paint inner = wrappingPaint.innerPaint(); + + Rectangle r = new Rectangle(0, 0, image.getWidth(observer), image.getHeight(observer)); + BufferedImage img = image instanceof BufferedImage + ? (BufferedImage) image + : ImageUtil.toBufferedImage(image); + TexturePaint texturePaint = new TexturePaint(img, r); + + wrappingPaint.setPaint(GraphicsUtil.exchangePaint(this, wrappingPaint.paint(), texturePaint, false)); + fillShape(r); + wrappingPaint.setPaint(GraphicsUtil.exchangePaint(this, texturePaint, inner, false)); + } + } + + + @Override + public void drawImage(@NotNull Image image, @NotNull AffineTransform at, @Nullable ImageObserver observer) { + Affine originalTransform = ctx.getTransform(); + ctx.transform(at.getScaleX(), at.getShearY(), at.getShearX(), at.getScaleY(), at.getTranslateX(), + at.getTranslateY()); + FXImageBridge.drawImage(ctx, image, currentOpacity); + ctx.setTransform(originalTransform); + } + + @Override + public void setPaint(@NotNull Paint paint) { + paint = GraphicsUtil.exchangePaint(this, currentPaint, paint, true); + FXPaintBridge.applyPaint(ctx, paint, currentOpacity); + currentPaint = paint; + } + + @Override + public void setPaint(@NotNull Supplier paintProvider) { + setPaint(paintProvider.get()); + } + + @Override + public void setStroke(@NotNull Stroke stroke) { + FXStrokeBridge.applyStroke(ctx, stroke); + currentStroke = stroke; + } + + @Override + public @NotNull Stroke stroke() { + return currentStroke; + } + + @Override + public void applyClip(@NotNull Shape clipShape) { + PathIterator awtIterator = clipShape.getPathIterator(null); + FXShapeBridge.appendPathIterator(ctx, awtIterator); + FXShapeBridge.applyWindingRule(ctx, awtIterator.getWindingRule()); + ctx.clip(); + } + + @Override + public Optional contextFontSize() { + // TODO check this actually returns what we're after + return Optional.of((float) ctx.getFont().getSize()); + } + + @Override + public @NotNull Output createChild() { + return new FXOutputImpl(this); + } + + @Override + public void dispose() { + GraphicsUtil.cleanupPaint(this, currentPaint); + originalState.restore(); + } + + @Override + public void debugPaint(@NotNull Consumer painter) { + int width = (int) ctx.getCanvas().getWidth(); + int height = (int) ctx.getCanvas().getHeight(); + + BufferedImage debugImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D debugGraphics = debugImage.createGraphics(); + debugGraphics.setRenderingHints(renderingHints); + debugGraphics.setPaint(currentPaint); + debugGraphics.setTransform(transform()); + debugGraphics.setStroke(stroke()); + debugGraphics.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, currentOpacity)); + painter.accept(debugGraphics); + debugGraphics.dispose(); + + Affine originalTransform = ctx.getTransform(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + drawImage(debugImage); + ctx.setTransform(originalTransform); + } + + private Rectangle2D canvasBounds() { + return new Rectangle2D.Double(0, 0, ctx.getCanvas().getWidth(), ctx.getCanvas().getHeight()); + } + + @Override + public @NotNull Rectangle2D clipBounds() { + Rectangle2D bounds = canvasBounds();// clipStack.getClipBounds(); + return GeometryUtil.createInverse(transform()).createTransformedShape(bounds).getBounds2D(); + } + + @Override + public @Nullable RenderingHints renderingHints() { + return renderingHints; + } + + @Override + public @Nullable Object renderingHint(RenderingHints.@NotNull Key key) { + return renderingHints.get(key); + } + + @Override + public void setRenderingHint(RenderingHints.@NotNull Key key, @Nullable Object value) { + renderingHints.put(key, value); + } + + @Override + public @NotNull AffineTransform transform() { + return FXTransformBridge.convertAffine(ctx.getTransform()); + } + + @Override + public void setTransform(@NotNull AffineTransform awtTransform) { + FXTransformBridge.setTransform(ctx, awtTransform); + } + + @Override + public void applyTransform(@NotNull AffineTransform awtTransform) { + FXTransformBridge.applyTransform(ctx, awtTransform); + } + + @Override + public void rotate(double angle) { + ctx.rotate(Math.toDegrees(angle)); + } + + @Override + public void scale(double sx, double sy) { + ctx.scale(sx, sy); + } + + @Override + public void translate(double dx, double dy) { + ctx.translate(dx, dy); + } + + @Override + public float currentOpacity() { + return currentOpacity; + } + + @Override + public void applyOpacity(float opacity) { + if (GeometryUtil.approximatelyEqual(opacity, 1)) return; + setOpacity(opacity * currentOpacity); + } + + void setOpacity(float opacity) { + currentOpacity = opacity; + + // Re-apply paint with correct opacity + FXPaintBridge.applyPaint(ctx, currentPaint, currentOpacity); + } + + @Override + public @NotNull SafeState safeState() { + return new FXOutputState(this, SaveClipStack.YES); + } + + @Override + public boolean supportsFilters() { + return true; + } + + @Override + public boolean supportsColors() { + return true; + } + + @Override + public boolean isSoftClippingEnabled() { + // JavaFX performs soft clips by default + return false; + } + + @Override + public boolean hasMaskedPaint() { + return currentPaint instanceof MaskedPaint; + } + + private enum SaveClipStack { + YES, + NO + } + + private static final class FXOutputState implements Output.SafeState { + + private final FXOutputImpl fxOutput; + private final AffineTransform originalTransform; + private final Paint originalPaint; + private final Stroke originalStroke; + private final float originalOpacity; + private final SaveClipStack saveClip; + + FXOutputState(@NotNull FXOutputImpl fxOutput, SaveClipStack saveClip) { + this.fxOutput = fxOutput; + this.originalTransform = fxOutput.transform(); + this.originalPaint = fxOutput.currentPaint; + this.originalStroke = fxOutput.currentStroke; + this.originalOpacity = fxOutput.currentOpacity; + this.saveClip = saveClip; + if (saveClip == SaveClipStack.YES) { + fxOutput.ctx.save(); + } + } + + public @NotNull GraphicsContext context() { + return fxOutput.ctx; + } + + @Override + public void restore() { + if (saveClip == SaveClipStack.YES) { + fxOutput.ctx.restore(); + } + fxOutput.setOpacity(originalOpacity); + fxOutput.setTransform(originalTransform); + fxOutput.setPaint(originalPaint); + fxOutput.setStroke(originalStroke); + } + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXBlendModeBridge.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXBlendModeBridge.java new file mode 100644 index 00000000..33085111 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXBlendModeBridge.java @@ -0,0 +1,74 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.impl.bridge; + +import javafx.scene.effect.BlendMode; + +import com.github.weisj.jsvg.logging.Logger; +import com.github.weisj.jsvg.logging.Logger.Level; +import com.github.weisj.jsvg.logging.impl.LogFactory; + +public final class FXBlendModeBridge { + + static final Logger LOGGER = LogFactory.createLogger(FXBlendModeBridge.class); + + private FXBlendModeBridge() {} + + public static BlendMode toBlendMode(com.github.weisj.jsvg.attributes.filter.BlendMode jsvgBlendMode) { + switch (jsvgBlendMode) { + case Normal: + return BlendMode.SRC_OVER; + case Multiply: + return BlendMode.MULTIPLY; + case Screen: + return BlendMode.SCREEN; + case Overlay: + return BlendMode.OVERLAY; + case Darken: + return BlendMode.DARKEN; + case Lighten: + return BlendMode.LIGHTEN; + case ColorDodge: + return BlendMode.COLOR_DODGE; + case ColorBurn: + return BlendMode.COLOR_BURN; + case HardLight: + return BlendMode.HARD_LIGHT; + case SoftLight: + return BlendMode.SOFT_LIGHT; + case Difference: + return BlendMode.DIFFERENCE; + case Exclusion: + return BlendMode.EXCLUSION; + case Hue: + case Saturation: + case Color: + case Luminosity: + LOGGER.log(Level.WARNING, "Unsupported BlendMode, JavaFX doesn't support: " + jsvgBlendMode); + return BlendMode.SRC_OVER; + default: + return BlendMode.SRC_OVER; + } + } + + +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXImageBridge.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXImageBridge.java new file mode 100644 index 00000000..7bd310b9 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXImageBridge.java @@ -0,0 +1,75 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.impl.bridge; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; +import java.awt.image.Raster; +import javafx.embed.swing.SwingFXUtils; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.image.WritableImage; + +import org.jetbrains.annotations.NotNull; + +public final class FXImageBridge { + + private FXImageBridge() {} + + public static void drawImage(@NotNull GraphicsContext ctx, @NotNull BufferedImage awtImage, double currentOpacity) { + ctx.drawImage(convertImageWithOpacity(awtImage, currentOpacity), 0, 0); + } + + public static void drawImage(@NotNull GraphicsContext ctx, @NotNull Image awtImage, double currentOpacity) { + ctx.drawImage(convertImageWithOpacity(awtImage, currentOpacity), 0, 0); + } + + public static @NotNull WritableImage convertImage(@NotNull BufferedImage image) { + return SwingFXUtils.toFXImage(image, null); + } + + public static @NotNull WritableImage convertImageWithOpacity(@NotNull Image image, double globalOpacity) { + boolean hasOpacity = globalOpacity < 1.0; + if (image instanceof BufferedImage && !hasOpacity) { + return convertImage((BufferedImage) image); + } + int width = image.getWidth(null); + int height = image.getHeight(null); + BufferedImage dst = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = dst.createGraphics(); + if (hasOpacity) { + Composite alphaComposite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, (float) globalOpacity); + graphics.setComposite(alphaComposite); + } + graphics.drawImage(image, 0, 0, null); + graphics.dispose(); + return convertImage(dst); + } + + public static @NotNull BufferedImage convertRasterToBufferedImage(@NotNull ColorModel colorModel, + @NotNull Raster raster) { + BufferedImage image = new BufferedImage(colorModel, raster.createCompatibleWritableRaster(), + colorModel.isAlphaPremultiplied(), null); + image.setData(raster); + return image; + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXPaintBridge.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXPaintBridge.java new file mode 100644 index 00000000..ac946d59 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXPaintBridge.java @@ -0,0 +1,230 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.impl.bridge; + +import java.awt.*; +import java.awt.Color; +import java.awt.Paint; +import java.awt.geom.AffineTransform; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.*; + +import org.jetbrains.annotations.NotNull; + +import com.github.weisj.jsvg.logging.Logger; +import com.github.weisj.jsvg.logging.Logger.Level; +import com.github.weisj.jsvg.logging.impl.LogFactory; +import com.github.weisj.jsvg.paint.impl.RGBColor; +import com.github.weisj.jsvg.renderer.output.impl.GraphicsUtil; + +public final class FXPaintBridge { + + static final Logger LOGGER = LogFactory.createLogger(FXPaintBridge.class); + + private FXPaintBridge() {} + + public static void applyPaint(@NotNull GraphicsContext ctx, @NotNull Paint awtPaint, double globalOpacity) { + javafx.scene.paint.Paint jfxPaint = convertPaint(awtPaint, globalOpacity); + ctx.setFill(jfxPaint); + ctx.setStroke(jfxPaint); + } + + public static boolean isWrappingPaint(@NotNull Paint awtPaint) { + return awtPaint instanceof GraphicsUtil.WrappingPaint; + } + + public static boolean supportedPaint(@NotNull Paint awtPaint) { + if (awtPaint instanceof MultipleGradientPaint) { + return isGradientOpaque((MultipleGradientPaint) awtPaint); + } + return awtPaint instanceof Color + || awtPaint instanceof TexturePaint + || awtPaint instanceof RGBColor; + } + + public static javafx.scene.paint.Paint convertPaint(@NotNull Paint awtPaint, double globalOpacity) { + if (awtPaint instanceof Color) { + return convertColor((Color) awtPaint, globalOpacity); + } else if (awtPaint instanceof LinearGradientPaint) { + return convertLinearGradient((LinearGradientPaint) awtPaint, globalOpacity); + } else if (awtPaint instanceof RadialGradientPaint) { + return convertRadialGradient((RadialGradientPaint) awtPaint, globalOpacity); + } else if (awtPaint instanceof TexturePaint) { + return convertTexturePaint((TexturePaint) awtPaint, globalOpacity); + } else if (awtPaint instanceof RGBColor) { + return convertRGBColor((RGBColor) awtPaint, globalOpacity); + } else { + LOGGER.log(Level.WARNING, "Unsupported paint type, JavaFX doesn't support: " + awtPaint); + return javafx.scene.paint.Color.WHITE; + } + } + + public static @NotNull Color convertColor(@NotNull javafx.scene.paint.Color fxColor) { + return new Color( + (int) (fxColor.getRed() * 255), + (int) (fxColor.getGreen() * 255), + (int) (fxColor.getBlue() * 255), + (int) (fxColor.getOpacity() * 255)); + } + + public static javafx.scene.paint.Color convertColor(@NotNull Color awtColor, double globalOpacity) { + return javafx.scene.paint.Color.rgb( + awtColor.getRed(), + awtColor.getGreen(), + awtColor.getBlue(), + (awtColor.getAlpha() / 255.0) * globalOpacity); + } + + public static javafx.scene.paint.Color convertRGBColor(@NotNull RGBColor rgbColor, double globalOpacity) { + return convertColor(rgbColor.toColor(), globalOpacity); + } + + public static LinearGradient convertLinearGradient(@NotNull LinearGradientPaint awtGradient, + double globalOpacity) { + AffineTransform transform = awtGradient.getTransform(); + Point2D start = awtGradient.getStartPoint(); + Point2D end = awtGradient.getEndPoint(); + + start = transform.transform(start, start); + end = transform.transform(end, end); + + Color[] colors = awtGradient.getColors(); + float[] fractions = awtGradient.getFractions(); + MultipleGradientPaint.CycleMethod cycleMethod = awtGradient.getCycleMethod(); + + return new LinearGradient( + start.getX(), + start.getY(), + end.getX(), + end.getY(), + false, + toGradientCycleMethod(cycleMethod), + convertGradientStops(colors, fractions, globalOpacity)); + } + + public static RadialGradient convertRadialGradient(@NotNull RadialGradientPaint awtGradient, + double globalOpacity) { + AffineTransform transform = awtGradient.getTransform(); + Point2D centerPt = awtGradient.getCenterPoint(); + Point2D focusPt = awtGradient.getFocusPoint(); + double radius = awtGradient.getRadius(); + double focusAngle = calculateGradientFocusAngle(centerPt, focusPt); + double focusDistance = centerPt.distance(focusPt) / radius; + + centerPt = transform.transform(centerPt, centerPt); + + // Only uniform scaling is supported. + radius *= transform.getScaleX(); + + Color[] colors = awtGradient.getColors(); + float[] fractions = awtGradient.getFractions(); + MultipleGradientPaint.CycleMethod cycleMethod = awtGradient.getCycleMethod(); + + return new RadialGradient( + focusAngle, + focusDistance, + // Place the focus at the center of a pixel (matches AWT more accurately) + centerPt.getX() + 0.5D, + centerPt.getY() + 0.5D, + radius, + false, + toGradientCycleMethod(cycleMethod), + convertGradientStops(colors, fractions, globalOpacity)); + } + + public static CycleMethod toGradientCycleMethod(MultipleGradientPaint.CycleMethod awtCycleMethod) { + switch (awtCycleMethod) { + case NO_CYCLE: + return CycleMethod.NO_CYCLE; + case REFLECT: + return CycleMethod.REFLECT; + case REPEAT: + return CycleMethod.REPEAT; + default: + throw new IllegalStateException("Unknown cycle method " + awtCycleMethod); + } + } + + private static double calculateGradientFocusAngle(@NotNull Point2D center, @NotNull Point2D focus) { + if (center.equals(focus)) { + return 0.0; + } + + double dx = focus.getX() - center.getX(); + double dy = focus.getY() - center.getY(); + + return Math.toDegrees(Math.atan2(dy, dx)); + } + + private static @NotNull List<@NotNull Stop> convertGradientStops(@NotNull Color @NotNull [] colors, + float @NotNull [] offsets, double globalOpacity) { + List stops = new ArrayList<>(colors.length); + for (int i = 0; i < colors.length; i++) { + javafx.scene.paint.Color fxColor = convertColor(colors[i], globalOpacity); + stops.add(new Stop(offsets[i], fxColor)); + } + return stops; + } + + public static boolean isGradientOpaque(@NotNull MultipleGradientPaint awtGradient) { + for (Color color : awtGradient.getColors()) { + if (color.getAlpha() != 255) { + return false; + } + } + return true; + } + + public static @NotNull ImagePattern convertTexturePaint(@NotNull TexturePaint awtGradient, double globalOpacity) { + Rectangle2D rect = awtGradient.getAnchorRect(); + return new ImagePattern( + FXImageBridge.convertImageWithOpacity(awtGradient.getImage(), globalOpacity), + rect.getX(), + rect.getY(), + rect.getWidth(), + rect.getHeight(), + false); + } + + public static javafx.scene.paint.@NotNull Color toPreMultipliedColor(javafx.scene.paint.@NotNull Color c) { + return javafx.scene.paint.Color.color( + c.getRed() * c.getOpacity(), + c.getGreen() * c.getOpacity(), + c.getBlue() * c.getOpacity(), + c.getOpacity()); + } + + public static javafx.scene.paint.@NotNull Color toUnmultipliedColor(javafx.scene.paint.@NotNull Color c) { + if (c.getOpacity() == 0) { + return javafx.scene.paint.Color.color(0, 0, 0, 0); + } + return javafx.scene.paint.Color.color( + c.getRed() / c.getOpacity(), + c.getGreen() / c.getOpacity(), + c.getBlue() / c.getOpacity(), + c.getOpacity()); + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXRenderingHintsUtil.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXRenderingHintsUtil.java new file mode 100644 index 00000000..2a910c7e --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXRenderingHintsUtil.java @@ -0,0 +1,45 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.impl.bridge; + +import java.awt.*; + +import org.jetbrains.annotations.NotNull; + +import com.github.weisj.jsvg.renderer.SVGRenderingHints; +import com.github.weisj.jsvg.renderer.output.Output; + +public final class FXRenderingHintsUtil { + private FXRenderingHintsUtil() {} + + // JFX defaults to the highest render quality + public static void setupDefaultJFXRenderingHints(@NotNull Output output) { + output.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + output.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + output.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + output.setRenderingHint(SVGRenderingHints.KEY_SOFT_CLIPPING, SVGRenderingHints.VALUE_SOFT_CLIPPING_ON); + output.setRenderingHint(SVGRenderingHints.KEY_IMAGE_ANTIALIASING, + SVGRenderingHints.VALUE_IMAGE_ANTIALIASING_OFF); + output.setRenderingHint(SVGRenderingHints.KEY_MASK_CLIP_RENDERING, + SVGRenderingHints.VALUE_MASK_CLIP_RENDERING_ACCURACY); + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXShapeBridge.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXShapeBridge.java new file mode 100644 index 00000000..5419b02c --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXShapeBridge.java @@ -0,0 +1,219 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.impl.bridge; + +import java.awt.*; +import java.awt.geom.*; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.shape.ArcType; +import javafx.scene.shape.FillRule; + +import org.jetbrains.annotations.NotNull; + +public final class FXShapeBridge { + + private FXShapeBridge() {} + + public static void strokeShape(@NotNull GraphicsContext ctx, @NotNull Shape shape) { + if (shape instanceof Line2D) { + strokeLine(ctx, (Line2D) shape); + } else if (shape instanceof Rectangle2D) { + strokeRect(ctx, (Rectangle2D) shape); + } else if (shape instanceof RoundRectangle2D) { + strokeRoundRect(ctx, (RoundRectangle2D) shape); + } else if (shape instanceof Ellipse2D) { + strokeEllipse(ctx, (Ellipse2D) shape); + } else if (shape instanceof Arc2D) { + strokeArc(ctx, (Arc2D) shape); + } else if (shape instanceof QuadCurve2D) { + strokeQuadCurve(ctx, (QuadCurve2D) shape); + } else if (shape instanceof CubicCurve2D) { + strokeCubicCurve(ctx, (CubicCurve2D) shape); + } else { + strokeShapeAsPath(ctx, shape); + } + } + + public static void fillShape(@NotNull GraphicsContext ctx, @NotNull Shape shape) { + if (shape instanceof Line2D) { + // do nothing - lines can't be filled + } else if (shape instanceof Rectangle2D) { + fillRect(ctx, (Rectangle2D) shape); + } else if (shape instanceof RoundRectangle2D) { + fillRoundRect(ctx, (RoundRectangle2D) shape); + } else if (shape instanceof Ellipse2D) { + fillEllipse(ctx, (Ellipse2D) shape); + } else if (shape instanceof Arc2D) { + fillArc(ctx, (Arc2D) shape); + } else if (shape instanceof QuadCurve2D) { + fillQuadCurve(ctx, (QuadCurve2D) shape); + } else if (shape instanceof CubicCurve2D) { + fillCubicCurve(ctx, (CubicCurve2D) shape); + } else { + fillShapeAsPath(ctx, shape); + } + } + + public static void strokeLine(@NotNull GraphicsContext ctx, @NotNull Line2D line) { + ctx.strokeLine(line.getX1(), line.getY1(), line.getX2(), line.getY2()); + } + + public static void strokeRect(@NotNull GraphicsContext ctx, @NotNull Rectangle2D rect) { + ctx.strokeRect(rect.getX(), rect.getY(), rect.getWidth(), rect.getHeight()); + } + + public static void strokeRoundRect(@NotNull GraphicsContext ctx, @NotNull RoundRectangle2D roundRect) { + ctx.strokeRoundRect(roundRect.getX(), roundRect.getY(), roundRect.getWidth(), roundRect.getHeight(), + roundRect.getArcWidth(), roundRect.getArcHeight()); + } + + public static void strokeEllipse(@NotNull GraphicsContext ctx, @NotNull Ellipse2D ellipse) { + ctx.strokeOval(ellipse.getX(), ellipse.getY(), ellipse.getWidth(), ellipse.getHeight()); + } + + public static void strokeArc(@NotNull GraphicsContext ctx, @NotNull Arc2D arc2D) { + ctx.strokeArc(arc2D.getX(), arc2D.getY(), arc2D.getWidth(), arc2D.getHeight(), arc2D.getAngleStart(), + arc2D.getAngleExtent(), toArcType(arc2D.getArcType())); + } + + public static void strokeQuadCurve(@NotNull GraphicsContext ctx, @NotNull QuadCurve2D quad) { + ctx.beginPath(); + ctx.moveTo(quad.getX1(), quad.getY1()); + ctx.quadraticCurveTo(quad.getCtrlX(), quad.getCtrlY(), quad.getX2(), quad.getY2()); + ctx.stroke(); + } + + public static void strokeCubicCurve(@NotNull GraphicsContext ctx, @NotNull CubicCurve2D cubic) { + ctx.beginPath(); + ctx.moveTo(cubic.getX1(), cubic.getY1()); + ctx.bezierCurveTo(cubic.getCtrlX1(), cubic.getCtrlY1(), cubic.getCtrlX2(), cubic.getCtrlY2(), cubic.getX2(), + cubic.getY2()); + ctx.stroke(); + } + + public static void strokeShapeAsPath(@NotNull GraphicsContext ctx, @NotNull Shape shape) { + FillRule prevFillRule = ctx.getFillRule(); + PathIterator awtIterator = shape.getPathIterator(null); + appendPathIterator(ctx, awtIterator); + applyWindingRule(ctx, awtIterator.getWindingRule()); + ctx.stroke(); + ctx.setFillRule(prevFillRule); + } + + public static void fillRect(@NotNull GraphicsContext ctx, @NotNull Rectangle2D rect) { + ctx.fillRect(rect.getX(), rect.getY(), rect.getWidth(), rect.getHeight()); + } + + public static void fillRoundRect(@NotNull GraphicsContext ctx, @NotNull RoundRectangle2D roundRect) { + ctx.fillRoundRect(roundRect.getX(), roundRect.getY(), roundRect.getWidth(), roundRect.getHeight(), + roundRect.getArcWidth(), roundRect.getArcHeight()); + } + + public static void fillEllipse(@NotNull GraphicsContext ctx, @NotNull Ellipse2D ellipse) { + ctx.fillOval(ellipse.getX(), ellipse.getY(), ellipse.getWidth(), ellipse.getHeight()); + } + + public static void fillArc(@NotNull GraphicsContext ctx, @NotNull Arc2D arc2D) { + ctx.fillArc(arc2D.getX(), arc2D.getY(), arc2D.getWidth(), arc2D.getHeight(), arc2D.getAngleStart(), + arc2D.getAngleExtent(), toArcType(arc2D.getArcType())); + } + + public static void fillQuadCurve(@NotNull GraphicsContext ctx, @NotNull QuadCurve2D quad) { + ctx.beginPath(); + ctx.moveTo(quad.getX1(), quad.getY1()); + ctx.quadraticCurveTo(quad.getCtrlX(), quad.getCtrlY(), quad.getX2(), quad.getY2()); + ctx.fill(); + } + + public static void fillCubicCurve(@NotNull GraphicsContext ctx, @NotNull CubicCurve2D cubic) { + ctx.beginPath(); + ctx.moveTo(cubic.getX1(), cubic.getY1()); + ctx.bezierCurveTo(cubic.getCtrlX1(), cubic.getCtrlY1(), cubic.getCtrlX2(), cubic.getCtrlY2(), cubic.getX2(), + cubic.getY2()); + ctx.fill(); + } + + public static void fillShapeAsPath(@NotNull GraphicsContext ctx, @NotNull Shape shape) { + FillRule prevFillRule = ctx.getFillRule(); + PathIterator awtIterator = shape.getPathIterator(null); + appendPathIterator(ctx, awtIterator); + applyWindingRule(ctx, awtIterator.getWindingRule()); + ctx.fill(); + ctx.setFillRule(prevFillRule); + } + + public static void applyWindingRule(@NotNull GraphicsContext ctx, int awtWindingRule) { + ctx.setFillRule(toFillRule(awtWindingRule)); + } + + public static void appendPathIterator(@NotNull GraphicsContext ctx, @NotNull PathIterator awtIterator) { + ctx.beginPath(); + + float[] segment = new float[6]; + for (; !awtIterator.isDone(); awtIterator.next()) { + int type = awtIterator.currentSegment(segment); + switch (type) { + case PathIterator.SEG_MOVETO: + ctx.moveTo(segment[0], segment[1]); + break; + case PathIterator.SEG_LINETO: + ctx.lineTo(segment[0], segment[1]); + break; + case PathIterator.SEG_QUADTO: + ctx.quadraticCurveTo(segment[0], segment[1], segment[2], segment[3]); + break; + case PathIterator.SEG_CUBICTO: + ctx.bezierCurveTo(segment[0], segment[1], segment[2], segment[3], segment[4], segment[5]); + break; + case PathIterator.SEG_CLOSE: + ctx.closePath(); + break; + default: + throw new IllegalArgumentException("Unknown segment type: " + type); + } + } + } + + public static FillRule toFillRule(int awtWindingRule) { + switch (awtWindingRule) { + case PathIterator.WIND_EVEN_ODD: + return FillRule.EVEN_ODD; + case PathIterator.WIND_NON_ZERO: + return FillRule.NON_ZERO; + default: + throw new IllegalArgumentException("Unknown winding rule: " + awtWindingRule); + } + } + + public static ArcType toArcType(int awtArcType) { + switch (awtArcType) { + case Arc2D.OPEN: + return ArcType.OPEN; + case Arc2D.CHORD: + return ArcType.CHORD; + case Arc2D.PIE: + return ArcType.ROUND; + default: + throw new IllegalArgumentException("Unknown arc type: " + awtArcType); + } + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXStrokeBridge.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXStrokeBridge.java new file mode 100644 index 00000000..dc045742 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXStrokeBridge.java @@ -0,0 +1,92 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.impl.bridge; + +import java.awt.*; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.shape.StrokeLineCap; +import javafx.scene.shape.StrokeLineJoin; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import com.github.weisj.jsvg.logging.Logger; +import com.github.weisj.jsvg.logging.Logger.Level; +import com.github.weisj.jsvg.logging.impl.LogFactory; + +public final class FXStrokeBridge { + + static final Logger LOGGER = LogFactory.createLogger(FXStrokeBridge.class); + + private FXStrokeBridge() {} + + public static void applyStroke(@NotNull GraphicsContext ctx, @NotNull Stroke awtStroke) { + if (awtStroke instanceof BasicStroke) { + BasicStroke awtBasicStroke = (BasicStroke) awtStroke; + ctx.setLineWidth(awtBasicStroke.getLineWidth()); + ctx.setLineCap(toStrokeLineCap(awtBasicStroke.getEndCap())); + ctx.setLineJoin(toStrokeLineJoin(awtBasicStroke.getLineJoin())); + ctx.setMiterLimit(awtBasicStroke.getMiterLimit()); + ctx.setLineDashes(convertDashArray(awtBasicStroke.getDashArray())); + ctx.setLineDashOffset(awtBasicStroke.getDashPhase()); + } else { + LOGGER.log(Level.WARNING, "Unsupported stroke type, JavaFX doesn't support: " + awtStroke); + } + } + + public static StrokeLineCap toStrokeLineCap(int awtLineCap) { + switch (awtLineCap) { + case BasicStroke.CAP_BUTT: + return StrokeLineCap.BUTT; + case BasicStroke.CAP_ROUND: + return StrokeLineCap.ROUND; + case BasicStroke.CAP_SQUARE: + return StrokeLineCap.SQUARE; + default: + throw new IllegalArgumentException("Unknown stroke line cap: " + awtLineCap); + } + } + + public static StrokeLineJoin toStrokeLineJoin(int awtLineJoin) { + switch (awtLineJoin) { + case BasicStroke.JOIN_BEVEL: + return StrokeLineJoin.BEVEL; + case BasicStroke.JOIN_MITER: + return StrokeLineJoin.MITER; + case BasicStroke.JOIN_ROUND: + return StrokeLineJoin.ROUND; + default: + throw new IllegalArgumentException("Unknown stroke line join: " + awtLineJoin); + } + } + + public static double @Nullable [] convertDashArray(float @Nullable [] dashes) { + if (dashes == null) { + return null; + } + double[] doubles = new double[dashes.length]; + for (int i = 0; i < dashes.length; i++) { + doubles[i] = dashes[i]; + } + return doubles; + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXTransformBridge.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXTransformBridge.java new file mode 100644 index 00000000..62e9645e --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/impl/bridge/FXTransformBridge.java @@ -0,0 +1,57 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.impl.bridge; + +import java.awt.geom.*; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.transform.Affine; + +import org.jetbrains.annotations.NotNull; + +/** + * The bridge between JavaFX and AWT. + */ +public final class FXTransformBridge { + + private FXTransformBridge() {} + + public static void setTransform(@NotNull GraphicsContext ctx, @NotNull AffineTransform awtTransform) { + ctx.setTransform(awtTransform.getScaleX(), awtTransform.getShearY(), awtTransform.getShearX(), + awtTransform.getScaleY(), awtTransform.getTranslateX(), awtTransform.getTranslateY()); + } + + public static void applyTransform(@NotNull GraphicsContext ctx, @NotNull AffineTransform awtTransform) { + ctx.transform(awtTransform.getScaleX(), awtTransform.getShearY(), awtTransform.getShearX(), + awtTransform.getScaleY(), awtTransform.getTranslateX(), awtTransform.getTranslateY()); + } + + public static Affine convertAffineTransform(AffineTransform awtTransform) { + return new Affine(awtTransform.getScaleX(), awtTransform.getShearY(), awtTransform.getShearX(), + awtTransform.getScaleY(), awtTransform.getTranslateX(), awtTransform.getTranslateY()); + } + + public static AffineTransform convertAffine(Affine jfxAffine) { + return new AffineTransform(jfxAffine.getMxx(), jfxAffine.getMyx(), jfxAffine.getMxy(), jfxAffine.getMyy(), + jfxAffine.getTx(), jfxAffine.getTy()); + } + +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/package-info.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/package-info.java new file mode 100644 index 00000000..1a47c852 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/renderer/jfx/package-info.java @@ -0,0 +1,2 @@ +@org.osgi.annotation.bundle.Export +package com.github.weisj.jsvg.renderer.jfx; diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/FXSVGCanvas.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/FXSVGCanvas.java new file mode 100644 index 00000000..4f78af59 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/FXSVGCanvas.java @@ -0,0 +1,281 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.ui.jfx; + +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.beans.InvalidationListener; +import javafx.beans.property.*; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Control; +import javafx.scene.control.Skin; +import javafx.scene.control.SkinBase; +import javafx.util.Duration; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.animation.AnimationPeriod; +import com.github.weisj.jsvg.renderer.animation.Animation; +import com.github.weisj.jsvg.ui.jfx.skin.FXSVGCanvasSkin; +import com.github.weisj.jsvg.view.ViewBox; + +/** + * A JavaFX node for displaying a {@link SVGDocument}} + */ +public class FXSVGCanvas extends Control { + + public enum RenderBackend { + /** + * Renders directly to a {@link GraphicsContext} and benefits from some hardware acceleration. + * The majority of SVG's will render correctly, but some specific features e.g. Filters, Masks may not + */ + JavaFX, + /** + * Renders using JSVG AWT implementation, which may be slower to update but may display the SVG more accurately + */ + AWT + } + + private static final String STYLE_CLASS = "fx-svg-canvas"; + private static final String STYLE_CLASS_TRANSPARENT_PATTERN = "show-transparent-pattern"; + private static final RenderBackend DEFAULT_RENDER_BACKEND = RenderBackend.JavaFX; + private static final ViewBox DEFAULT_VIEW_BOX = null; + private static final AnimationPeriod DEFAULT_ANIMATION = new AnimationPeriod(0, 0, false); + private static final boolean DEFAULT_USE_SVG_VIEW_BOX = false; + private static final boolean DEFAULT_ANIMATED = true; + private static final boolean DEFAULT_SHOW_TRANSPARENT_PATTERN = false; + + private final Timeline timeline = new Timeline(); + + public FXSVGCanvas() { + getStyleClass().add(STYLE_CLASS); + if (getShowTransparentPattern()) getStyleClass().add(STYLE_CLASS_TRANSPARENT_PATTERN); + + InvalidationListener animationModificationListener = observable -> setupAnimation(); + animated.addListener(animationModificationListener); + document.addListener(animationModificationListener); + animation.addListener(animationModificationListener); + + showTransparentPatternProperty().addListener((observable, oldValue, newValue) -> { + if (oldValue) getStyleClass().remove(STYLE_CLASS_TRANSPARENT_PATTERN); + if (newValue) getStyleClass().add(STYLE_CLASS_TRANSPARENT_PATTERN); + }); + + } + + private Animation currentAnimation() { + if (!isAnimated()) { + return DEFAULT_ANIMATION; + } + Animation userAnimation = getAnimation(); + if (userAnimation != null) { + return userAnimation; + } + SVGDocument document = getDocument(); + if (document != null) { + return document.animation(); + } + return DEFAULT_ANIMATION; + } + + private void setupAnimation() { + Animation animation = currentAnimation(); + + timeline.getKeyFrames().clear(); + timeline.getKeyFrames() + .add(new KeyFrame(Duration.millis(animation.startTime()), new KeyValue(elapsedAnimationTime, 0))); + timeline.getKeyFrames().add(new KeyFrame(Duration.millis(animation.endTime()), + new KeyValue(elapsedAnimationTime, animation.endTime()))); + timeline.setCycleCount(Timeline.INDEFINITE); + timeline.playFromStart(); + } + + //////////////////////////////////////////////// + + /** + * Requests that the SVG Document be repainted + * In most cases this is not necessary, as the canvas will automatically be repainted when any properties (e.g. document, view-box, backend) are changed + */ + public void repaint() { + SkinBase skin = (SkinBase) getSkin(); + if (skin != null) { + FXSVGCanvasSkin fxSkin = (FXSVGCanvasSkin) skin; + fxSkin.markDirty(); + } + } + + public void playAnimation() { + timeline.play(); + } + + public void pauseAnimation() { + timeline.pause(); + } + + public void restartAnimation() { + timeline.playFromStart(); + } + + public void stopAnimation() { + timeline.stop(); + } + + //////////////////////////////////////////////// + + private final ObjectProperty<@NotNull RenderBackend> renderBackend = + new SimpleObjectProperty<>(DEFAULT_RENDER_BACKEND); + + public @NotNull RenderBackend getRenderBackend() { + return renderBackend.get(); + } + + public @NotNull ObjectProperty<@NotNull RenderBackend> renderBackendProperty() { + return renderBackend; + } + + public void setRenderBackend(@NotNull RenderBackend renderer) { + this.renderBackend.set(renderer); + } + + //////////////////////////////////////////////// + + private final ObjectProperty<@Nullable SVGDocument> document = new SimpleObjectProperty<>(); + + public @Nullable SVGDocument getDocument() { + return document.get(); + } + + public @NotNull ObjectProperty<@Nullable SVGDocument> documentProperty() { + return document; + } + + public void setDocument(@Nullable SVGDocument document) { + this.document.set(document); + } + + //////////////////////////////////////////////// + + private final BooleanProperty useSVGViewBox = new SimpleBooleanProperty(DEFAULT_USE_SVG_VIEW_BOX); + + public boolean isUsingSVGViewBox() { + return useSVGViewBox.get(); + } + + public BooleanProperty useSVGViewBoxProperty() { + return useSVGViewBox; + } + + public void setUseSVGViewBox(boolean useSVGViewBox) { + this.useSVGViewBox.set(useSVGViewBox); + } + + //////////////////////////////////////////////// + + private final ObjectProperty<@Nullable ViewBox> viewBox = new SimpleObjectProperty<>(DEFAULT_VIEW_BOX); + + public @Nullable ViewBox getViewBox() { + return viewBox.get(); + } + + public @NotNull ObjectProperty<@Nullable ViewBox> viewBoxProperty() { + return viewBox; + } + + public void setViewBox(@Nullable ViewBox viewBox) { + this.viewBox.set(viewBox); + } + + //////////////////////////////////////////////// + + private final BooleanProperty animated = new SimpleBooleanProperty(DEFAULT_ANIMATED); + + public boolean isAnimated() { + return animated.get(); + } + + public @NotNull BooleanProperty animatedProperty() { + return animated; + } + + public void setAnimated(boolean animated) { + this.animated.set(animated); + } + + //////////////////////////////////////////////// + + private final LongProperty elapsedAnimationTime = new SimpleLongProperty(); + + public long getElapsedAnimationTime() { + return elapsedAnimationTime.get(); + } + + public @NotNull ReadOnlyLongProperty elapsedAnimationTimeProperty() { + return elapsedAnimationTime; + } + + //////////////////////////////////////////////// + + private final ObjectProperty<@Nullable Animation> animation = new SimpleObjectProperty<>(); + + public @Nullable Animation getAnimation() { + return animation.get(); + } + + public @NotNull ObjectProperty<@Nullable Animation> animationProperty() { + return animation; + } + + public void setAnimation(@Nullable Animation animation) { + this.animation.set(animation); + } + + //////////////////////////////////////////////// + + private final BooleanProperty showTransparentPattern = new SimpleBooleanProperty(DEFAULT_SHOW_TRANSPARENT_PATTERN); + + public boolean getShowTransparentPattern() { + return showTransparentPattern.get(); + } + + public BooleanProperty showTransparentPatternProperty() { + return showTransparentPattern; + } + + public void setShowTransparentPattern(boolean showTransparentPattern) { + this.showTransparentPattern.set(showTransparentPattern); + } + + //////////////////////////////////////////////// + + @Override + public String getUserAgentStylesheet() { + return FXSVGCanvas.class.getResource("fx-svg-canvas.css").toExternalForm(); + } + + @Override + protected Skin createDefaultSkin() { + return new FXSVGCanvasSkin(this); + } +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/package-info.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/package-info.java new file mode 100644 index 00000000..e1b8ac8e --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/package-info.java @@ -0,0 +1,2 @@ +@org.osgi.annotation.bundle.Export +package com.github.weisj.jsvg.ui.jfx; diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRenderer.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRenderer.java new file mode 100644 index 00000000..c0e05ac3 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRenderer.java @@ -0,0 +1,47 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.ui.jfx.renderer; + +import javafx.scene.Node; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.renderer.animation.AnimationState; +import com.github.weisj.jsvg.ui.jfx.FXSVGCanvas; +import com.github.weisj.jsvg.view.ViewBox; + +public interface FXSVGRenderer { + + @NotNull + FXSVGCanvas.RenderBackend getBackend(); + + void render(@NotNull SVGDocument svgDocument, @Nullable ViewBox viewBox, + @Nullable AnimationState animationState); + + void dispose(); + + @NotNull + Node getFXNode(); + +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRendererAWT.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRendererAWT.java new file mode 100644 index 00000000..c4ba6b21 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRendererAWT.java @@ -0,0 +1,117 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.ui.jfx.renderer; + +import java.awt.*; +import java.awt.image.BufferedImage; +import javafx.embed.swing.SwingFXUtils; +import javafx.scene.Node; +import javafx.scene.image.ImageView; +import javafx.scene.image.WritableImage; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.renderer.NullPlatformSupport; +import com.github.weisj.jsvg.renderer.animation.AnimationState; +import com.github.weisj.jsvg.renderer.jfx.impl.bridge.FXRenderingHintsUtil; +import com.github.weisj.jsvg.renderer.output.Output; +import com.github.weisj.jsvg.ui.jfx.FXSVGCanvas; +import com.github.weisj.jsvg.view.FloatSize; +import com.github.weisj.jsvg.view.ViewBox; + +public final class FXSVGRendererAWT implements FXSVGRenderer { + + private ImageView fxImageView; + + private BufferedImage awtImage; + private WritableImage fxImage; + private int currentRTWidth = -1; + private int currentRTHeight = -1; + + public FXSVGRendererAWT() { + fxImageView = new ImageView(); + } + + private void setupRenderTargets(int width, int height) { + awtImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + fxImage = new WritableImage(width, height); + currentRTWidth = width; + currentRTHeight = height; + fxImageView.setImage(fxImage); + } + + private void disposeRenderTargets() { + awtImage = null; + fxImage = null; + currentRTWidth = -1; + currentRTHeight = -1; + } + + private void flush() { + fxImage = SwingFXUtils.toFXImage(awtImage, fxImage); + } + + @Override + public FXSVGCanvas.@NotNull RenderBackend getBackend() { + return FXSVGCanvas.RenderBackend.AWT; + } + + @Override + public void render(@NotNull SVGDocument svgDocument, @Nullable ViewBox viewBox, + @Nullable AnimationState animationState) { + FloatSize size = svgDocument.size(); + int width = (int) size.width; + int height = (int) size.height; + + if (currentRTWidth != width || currentRTHeight != height) { + disposeRenderTargets(); + setupRenderTargets(width, height); + } + Graphics2D g = awtImage.createGraphics(); + Output output = Output.createForGraphics(g); + FXRenderingHintsUtil.setupDefaultJFXRenderingHints(output); + g.setBackground(new Color(0, 0, 0, 0)); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + g.clearRect(0, 0, width, height); + svgDocument.renderWithPlatform(NullPlatformSupport.INSTANCE, output, viewBox, animationState); + } finally { + g.dispose(); + } + flush(); + } + + @Override + public void dispose() { + disposeRenderTargets(); + fxImageView = null; + } + + @Override + public @NotNull Node getFXNode() { + return fxImageView; + } + +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRendererJavaFX.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRendererJavaFX.java new file mode 100644 index 00000000..5d315872 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/renderer/FXSVGRendererJavaFX.java @@ -0,0 +1,92 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.ui.jfx.renderer; + +import javafx.scene.Node; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.effect.BlendMode; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.renderer.NullPlatformSupport; +import com.github.weisj.jsvg.renderer.animation.AnimationState; +import com.github.weisj.jsvg.renderer.jfx.FXOutput; +import com.github.weisj.jsvg.renderer.output.Output; +import com.github.weisj.jsvg.ui.jfx.FXSVGCanvas; +import com.github.weisj.jsvg.view.FloatSize; +import com.github.weisj.jsvg.view.ViewBox; + +public final class FXSVGRendererJavaFX implements FXSVGRenderer { + private final Canvas canvas; + private final GraphicsContext graphics; + + public FXSVGRendererJavaFX() { + canvas = new Canvas(); + graphics = canvas.getGraphicsContext2D(); + } + + @Override + public FXSVGCanvas.@NotNull RenderBackend getBackend() { + return FXSVGCanvas.RenderBackend.JavaFX; + } + + @Override + public void render(@NotNull SVGDocument svgDocument, @Nullable ViewBox viewBox, + @Nullable AnimationState animationState) { + FloatSize svgSize = svgDocument.size(); + double width = svgSize.getWidth(); + double height = svgSize.getHeight(); + + if (canvas.getWidth() != width || canvas.getHeight() != height) { + canvas.setWidth(svgSize.getWidth()); + canvas.setHeight(svgSize.getHeight()); + } + + graphics.save(); + try { + graphics.setTransform(1, 0, 0, 1, 0, 0); + graphics.setGlobalAlpha(1D); + graphics.setGlobalBlendMode(BlendMode.SRC_OVER); + graphics.clearRect(0, 0, width, height); + + Output output = FXOutput.createForGraphicsContext(graphics); + svgDocument.renderWithPlatform(NullPlatformSupport.INSTANCE, output, viewBox, animationState); + output.dispose(); + } finally { + graphics.restore(); + } + } + + @Override + public void dispose() { + // do nothing + } + + @Override + public @NotNull Node getFXNode() { + return canvas; + } + +} diff --git a/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/skin/FXSVGCanvasSkin.java b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/skin/FXSVGCanvasSkin.java new file mode 100644 index 00000000..aaf4a918 --- /dev/null +++ b/jsvg-javafx/src/main/java/com/github/weisj/jsvg/ui/jfx/skin/FXSVGCanvasSkin.java @@ -0,0 +1,142 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.ui.jfx.skin; + +import javafx.animation.*; +import javafx.scene.control.SkinBase; +import javafx.scene.layout.StackPane; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.renderer.animation.AnimationState; +import com.github.weisj.jsvg.ui.jfx.FXSVGCanvas; +import com.github.weisj.jsvg.ui.jfx.renderer.FXSVGRenderer; +import com.github.weisj.jsvg.ui.jfx.renderer.FXSVGRendererAWT; +import com.github.weisj.jsvg.ui.jfx.renderer.FXSVGRendererJavaFX; +import com.github.weisj.jsvg.view.ViewBox; + +/** + * Implementation of the {@link SkinBase} for {@link FXSVGCanvas}, internal use only + */ +public class FXSVGCanvasSkin extends SkinBase { + + private final FXSVGCanvas svgCanvas; + private final StackPane innerPane; + + private final AnimationTimer timer; + private FXSVGRenderer activeRenderer; + private boolean dirty = true; + + private final Timeline timeline = new Timeline(); + + public FXSVGCanvasSkin(FXSVGCanvas svgCanvas) { + super(svgCanvas); + this.consumeMouseEvents(false); + this.svgCanvas = svgCanvas; + + innerPane = new StackPane(); + innerPane.getStyleClass().add("inner-stack-pane"); + getChildren().add(innerPane); + + registerChangeListener(svgCanvas.documentProperty(), o -> markDirty()); + registerChangeListener(svgCanvas.renderBackendProperty(), o -> markDirty()); + registerChangeListener(svgCanvas.useSVGViewBoxProperty(), o -> markDirty()); + registerChangeListener(svgCanvas.viewBoxProperty(), o -> markDirty()); + registerChangeListener(svgCanvas.animationProperty(), o -> markDirty()); + registerChangeListener(svgCanvas.elapsedAnimationTimeProperty(), o -> markDirty()); + + timer = new AnimationTimer() { + @Override + public void handle(long now) { + tick(); + } + }; + timer.start(); + } + + public void markDirty() { + timer.start(); + dirty = true; + } + + public void tick() { + if (!dirty) { + timer.stop(); + return; + } + dirty = false; + + SVGDocument svgDocument = svgCanvas.getDocument(); + FXSVGCanvas.RenderBackend backend = svgCanvas.getRenderBackend(); + ViewBox viewBox = activeViewBox(); + AnimationState state = new AnimationState(0, svgCanvas.getElapsedAnimationTime()); + + if (activeRenderer != null && activeRenderer.getBackend() != backend) { + innerPane.getChildren().remove(activeRenderer.getFXNode()); + activeRenderer.dispose(); + activeRenderer = null; + } + + if (activeRenderer == null) { + activeRenderer = createRenderer(backend); + innerPane.getChildren().add(activeRenderer.getFXNode()); + } + + if (svgDocument != null) { + activeRenderer.render(svgDocument, viewBox, state); + activeRenderer.getFXNode().setVisible(true); + } else { + activeRenderer.getFXNode().setVisible(false); + } + } + + private @Nullable ViewBox activeViewBox() { + if (svgCanvas.getViewBox() != null) return svgCanvas.getViewBox(); + if (!svgCanvas.isUsingSVGViewBox()) return null; + SVGDocument document = svgCanvas.getDocument(); + return document == null ? null : document.viewBox(); + } + + @Override + public void dispose() { + super.dispose(); + if (activeRenderer != null) { + activeRenderer.dispose(); + activeRenderer = null; + } + timer.stop(); + } + + private static @NotNull FXSVGRenderer createRenderer(FXSVGCanvas.RenderBackend backend) { + switch (backend) { + case JavaFX: + return new FXSVGRendererJavaFX(); + case AWT: + return new FXSVGRendererAWT(); + default: + throw new IllegalArgumentException("Unknown render backend: " + backend); + } + } + +} diff --git a/jsvg-javafx/src/main/resources/com/github/weisj/jsvg/ui/jfx/fx-svg-canvas.css b/jsvg-javafx/src/main/resources/com/github/weisj/jsvg/ui/jfx/fx-svg-canvas.css new file mode 100644 index 00000000..db7ac428 --- /dev/null +++ b/jsvg-javafx/src/main/resources/com/github/weisj/jsvg/ui/jfx/fx-svg-canvas.css @@ -0,0 +1,5 @@ +.fx-svg-canvas.show-transparent-pattern > .inner-stack-pane { + -fx-background-image: url("pattern-transparent.png"); + -fx-background-repeat: repeat; + -fx-background-size: auto; +} diff --git a/jsvg-javafx/src/main/resources/com/github/weisj/jsvg/ui/jfx/pattern-transparent.png b/jsvg-javafx/src/main/resources/com/github/weisj/jsvg/ui/jfx/pattern-transparent.png new file mode 100644 index 00000000..fc495bd2 Binary files /dev/null and b/jsvg-javafx/src/main/resources/com/github/weisj/jsvg/ui/jfx/pattern-transparent.png differ diff --git a/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/FXTestSVGFiles.java b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/FXTestSVGFiles.java new file mode 100644 index 00000000..b99b7242 --- /dev/null +++ b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/FXTestSVGFiles.java @@ -0,0 +1,64 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +/** + * TODO Refactor test suite into testFixtures to allow different implementations to use the files more easily + */ +public class FXTestSVGFiles { + + private static final String TEST_PATH = System.getenv("JAVAFX_TEST_SVG_PATH"); + private static final String PACKAGE_NAME = "com/github/weisj/jsvg"; + + public static File getTestSVGDirectory() { + if (TEST_PATH != null) { + return new File(TEST_PATH).toPath().resolve(PACKAGE_NAME).toFile(); + } + // Fallback + String srcDir = System.getProperty("user.dir"); + String testDir = srcDir + "/jsvg/src/test/resources/" + PACKAGE_NAME; + return new File(testDir); + } + + public static List findTestSVGFiles() { + File dir = getTestSVGDirectory(); + if (dir.exists() && dir.isDirectory()) { + try (Stream stream = Files.walk(dir.toPath(), 2)) { + return stream.map(Path::toFile) + .filter(File::isFile) + .filter(f -> f.getName().toLowerCase().endsWith(".svg")) + .map(File::getAbsolutePath) + .toList(); + } catch (IOException e) { + return List.of(); + } + } + return List.of(); + } +} diff --git a/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/FXHeadlessApplication.java b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/FXHeadlessApplication.java new file mode 100644 index 00000000..b28d22ca --- /dev/null +++ b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/FXHeadlessApplication.java @@ -0,0 +1,73 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx; + +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; +import javafx.application.Application; +import javafx.stage.Stage; + +// Based on: http://awhite.blogspot.com/2013/04/javafx-junit-testing.html +public class FXHeadlessApplication extends Application { + + private static final ReentrantLock LOCK = new ReentrantLock(); + private static final CountDownLatch LATCH = new CountDownLatch(1); + + private static final AtomicBoolean init = new AtomicBoolean(false); + private static final AtomicBoolean started = new AtomicBoolean(false); + + private static final String THREAD_NAME = "JavaFX Init Thread"; + private static final int LAUNCH_TIMEOUT_SECONDS = 10; + + public static boolean checkJavaFXThread() { + LOCK.lock(); + try { + if (!init.get()) { + init.set(true); + + // JavaFX needs the launcher thread to stay open for its entire lifecycle + Thread jfxLauncherThread = new Thread(Application::launch); + jfxLauncherThread.setDaemon(true); + jfxLauncherThread.setName(THREAD_NAME); + jfxLauncherThread.start(); + + try { + if (!LATCH.await(LAUNCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + started.set(false); + } + } catch (InterruptedException e) { + started.set(false); + } + } + } finally { + LOCK.unlock(); + } + return started.get(); + } + + @Override + public void start(final Stage stage) { + started.set(true); + LATCH.countDown(); + } +} diff --git a/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/FXOutputTest.java b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/FXOutputTest.java new file mode 100644 index 00000000..a18f425f --- /dev/null +++ b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/FXOutputTest.java @@ -0,0 +1,182 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.nio.file.Files; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import javafx.application.Platform; +import javafx.embed.swing.SwingFXUtils; +import javafx.scene.SnapshotParameters; +import javafx.scene.canvas.Canvas; +import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.*; + +import com.github.romankh3.image.comparison.ImageComparison; +import com.github.romankh3.image.comparison.ImageComparisonUtil; +import com.github.romankh3.image.comparison.model.ImageComparisonResult; +import com.github.romankh3.image.comparison.model.ImageComparisonState; +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.parser.LoaderContext; +import com.github.weisj.jsvg.parser.SVGLoader; +import com.github.weisj.jsvg.parser.resources.ResourcePolicy; +import com.github.weisj.jsvg.renderer.FXTestSVGFiles; +import com.github.weisj.jsvg.renderer.NullPlatformSupport; +import com.github.weisj.jsvg.renderer.jfx.impl.bridge.FXRenderingHintsUtil; +import com.github.weisj.jsvg.renderer.output.Output; +import com.github.weisj.jsvg.view.FloatSize; + +class FXOutputTest { + + private static final double DEFAULT_TOLERANCE = 0.3; + private static final double DEFAULT_PIXEL_TOLERANCE = 0.1; + + @TestFactory + Collection generateSVGTests() { + Assumptions.assumeTrue(FXHeadlessApplication.checkJavaFXThread(), "Failed to initialize JavaFX"); + + List testFiles = FXTestSVGFiles.findTestSVGFiles(); + Assumptions.assumeTrue(!testFiles.isEmpty(), + "No SVG Test Files Found in: " + FXTestSVGFiles.getTestSVGDirectory().getAbsolutePath()); + + return testFiles.stream() + .map(File::new) + .map(file -> { + String testName = "test-jfx_" + file.getName().replace(".svg", ""); + return DynamicTest.dynamicTest(testName, () -> { + Assumptions.assumeTrue(!isExceptionTest(file.getName()), + "Skipping exception test: " + file.getAbsolutePath()); + compareSVGOutput(file); + }); + }) + .collect(Collectors.toList()); + } + + // Lazily filter out tests that are expected to fail. TODO refactor testing to allow testing JFX + // with the same unit tests as AWT implementation + private boolean isExceptionTest(String testName) { + return "manyImplicitPathsThroughUse.svg".equals(testName) + || "useCycle.svg".equals(testName) + || "useCycleSelfReference.svg".equals(testName) + || "useNesting.svg".equals(testName); + } + + private void compareSVGOutput(File file) throws MalformedURLException { + SVGLoader loader = new SVGLoader(); + LoaderContext loaderContext = LoaderContext.builder() + .externalResourcePolicy(ResourcePolicy.ALLOW_ALL) + .build(); + SVGDocument svgDocument = loader.load(file.toURI().toURL(), loaderContext); + + if (svgDocument == null) { + throw new IllegalStateException("Invalid SVG Test File: " + file.getAbsolutePath()); + } + + BufferedImage expected = renderJSVG(svgDocument); + BufferedImage actual = renderJavaFX(svgDocument); + + // TODO Move ReferenceTest to a testFixtures package and use that instead + ImageComparison comp = new ImageComparison(expected, actual); + comp.setAllowingPercentOfDifferentPixels(DEFAULT_TOLERANCE); + comp.setPixelToleranceLevel(DEFAULT_PIXEL_TOLERANCE); + ImageComparisonResult comparison = comp.compareImages(); + ImageComparisonState state = comparison.getImageComparisonState(); + + if (state == ImageComparisonState.MISMATCH && comparison.getDifferencePercent() <= DEFAULT_TOLERANCE) { + return; + } + + String baseName = file.getAbsolutePath().replaceAll("[- /]", "_"); + File diffFile = new File(baseName + "_jfx_diff.png"); + File expectedFile = new File(baseName + "_jfx_expected.png"); + File actualFile = new File(baseName + "_jfx_actual.png"); + + try { + Files.deleteIfExists(diffFile.toPath()); + Files.deleteIfExists(expectedFile.toPath()); + Files.deleteIfExists(actualFile.toPath()); + } catch (IOException ignore) { + } + + if (state != ImageComparisonState.MATCH) { + System.err.println("Image comparison failed"); + System.err.println("Expected: " + comparison.getExpected()); + System.err.println("Actual: " + comparison.getActual()); + System.err.println("Diff: " + comparison.getResult()); + + ImageComparisonUtil.saveImage(diffFile, comparison.getResult()); + ImageComparisonUtil.saveImage(expectedFile, comparison.getExpected()); + ImageComparisonUtil.saveImage(actualFile, comparison.getActual()); + } + Assumptions.assumeTrue(state == ImageComparisonState.MATCH, + "JFX/AWT Render Comparison Failed: " + file.getAbsolutePath()); + } + + private BufferedImage renderJSVG(@NotNull SVGDocument svgDocument) { + FloatSize size = svgDocument.size(); + BufferedImage image = new BufferedImage((int) size.width, (int) size.height, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + Output output = Output.createForGraphics(g); + FXRenderingHintsUtil.setupDefaultJFXRenderingHints(output); + svgDocument.renderWithPlatform(NullPlatformSupport.INSTANCE, output, null); + g.dispose(); + return image; + } + + private BufferedImage renderJavaFX(@NotNull SVGDocument svgDocument) { + FloatSize size = svgDocument.size(); + CompletableFuture result = new CompletableFuture<>(); + + Platform.runLater(() -> { + Canvas canvas = new Canvas((int) size.width, (int) size.height); + canvas.getGraphicsContext2D().clearRect(0, 0, (int) size.width, (int) size.height); + + Output output = FXOutput.createForGraphicsContext(canvas.getGraphicsContext2D()); + svgDocument.renderWithPlatform(NullPlatformSupport.INSTANCE, output, null, null); + output.dispose(); + + SnapshotParameters snapshotParameters = new SnapshotParameters(); + snapshotParameters.setFill(Color.TRANSPARENT); + WritableImage snapshot = canvas.snapshot(snapshotParameters, null); + result.complete(SwingFXUtils.fromFXImage(snapshot, null)); + }); + + try { + return result.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + + } + +} diff --git a/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerApplication.java b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerApplication.java new file mode 100644 index 00000000..ee33da7c --- /dev/null +++ b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerApplication.java @@ -0,0 +1,46 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.viewer; + +import javafx.application.Application; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.stage.Stage; + +/** + * Start with {@link FXTestViewerLauncher} + */ +public class FXTestViewerApplication extends Application { + + @Override + public void start(Stage primaryStage) throws Exception { + Thread.setDefaultUncaughtExceptionHandler((t, e) -> e.printStackTrace(System.err)); + + FXMLLoader loader = new FXMLLoader(getClass().getResource("svg-viewer.fxml")); + Parent root = loader.load(); + + Scene scene = new Scene(root); + primaryStage.setScene(scene); + primaryStage.show(); + } +} diff --git a/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerController.java b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerController.java new file mode 100644 index 00000000..e13114c8 --- /dev/null +++ b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerController.java @@ -0,0 +1,125 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.viewer; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.event.ActionEvent; +import javafx.scene.control.*; +import javafx.util.Callback; + +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.parser.LoaderContext; +import com.github.weisj.jsvg.parser.SVGLoader; +import com.github.weisj.jsvg.parser.resources.ResourcePolicy; +import com.github.weisj.jsvg.renderer.FXTestSVGFiles; +import com.github.weisj.jsvg.ui.jfx.FXSVGCanvas; + + +public class FXTestViewerController { + + private final ObjectProperty currentSVG = new SimpleObjectProperty<>(); + + public ComboBox comboBoxSVGDocument; + public ScrollPane scrollPaneJFX; + public FXSVGCanvas svgCanvasJFX; + + public ScrollPane scrollPaneAWT; + public FXSVGCanvas svgCanvasAWT; + public CheckBox checkBoxShowTransparentPattern; + + public void initialize() { + String svgDirectoryPrefix = FXTestSVGFiles.getTestSVGDirectory().toPath().toString(); + List testSVGFiles = FXTestSVGFiles.findTestSVGFiles(); + comboBoxSVGDocument.getItems().addAll(testSVGFiles); + if (!testSVGFiles.isEmpty()) { + comboBoxSVGDocument.setValue(testSVGFiles.getFirst()); + Callback, ListCell> cellFactory = view -> new ListCell<>() { + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (item != null && item.startsWith(svgDirectoryPrefix)) { + setText(item.substring(svgDirectoryPrefix.length() + 1)); + } + } + }; + comboBoxSVGDocument.setCellFactory(cellFactory); + comboBoxSVGDocument.setButtonCell(cellFactory.call(null)); + } + + svgCanvasJFX.setRenderBackend(FXSVGCanvas.RenderBackend.JavaFX); + svgCanvasJFX.documentProperty().bind(currentSVG); + svgCanvasJFX.showTransparentPatternProperty().bind(checkBoxShowTransparentPattern.selectedProperty()); + scrollPaneJFX.setPannable(true); + + svgCanvasAWT.setRenderBackend(FXSVGCanvas.RenderBackend.AWT); + svgCanvasAWT.documentProperty().bind(currentSVG); + svgCanvasAWT.showTransparentPatternProperty().bind(checkBoxShowTransparentPattern.selectedProperty()); + scrollPaneAWT.setPannable(true); + + // Bind viewport positions + scrollPaneJFX.hvalueProperty().bindBidirectional(scrollPaneAWT.hvalueProperty()); + scrollPaneJFX.vvalueProperty().bindBidirectional(scrollPaneAWT.vvalueProperty()); + + currentSVG.bind(Bindings.createObjectBinding(() -> { + String file = comboBoxSVGDocument.getValue(); + if (file == null || !file.endsWith("svg")) { + return null; + } + URL url; + try { + url = new File(file).toURI().toURL(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + SVGLoader loader = new SVGLoader(); + LoaderContext loaderContext = LoaderContext.builder() + .externalResourcePolicy(ResourcePolicy.ALLOW_ALL) + .build(); + return loader.load(url, loaderContext); + }, comboBoxSVGDocument.valueProperty())); + } + + public void refreshCanvas() { + svgCanvasAWT.repaint(); + svgCanvasJFX.repaint(); + } + + public void previousSVG() { + int index = comboBoxSVGDocument.getSelectionModel().getSelectedIndex(); + if (index > 0) { + comboBoxSVGDocument.getSelectionModel().select(index - 1); + } + } + + public void nextSVG(ActionEvent actionEvent) { + int index = comboBoxSVGDocument.getSelectionModel().getSelectedIndex(); + if (index < comboBoxSVGDocument.getItems().size() - 1) { + comboBoxSVGDocument.getSelectionModel().select(index + 1); + } + } +} diff --git a/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerLauncher.java b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerLauncher.java new file mode 100644 index 00000000..d966e22e --- /dev/null +++ b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXTestViewerLauncher.java @@ -0,0 +1,30 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.viewer; + +public class FXTestViewerLauncher { + + public static void main(String[] args) { + FXTestViewerApplication.launch(FXTestViewerApplication.class, args); + } + +} diff --git a/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXViewerMain.java b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXViewerMain.java new file mode 100644 index 00000000..b17dff9d --- /dev/null +++ b/jsvg-javafx/src/test/java/com/github/weisj/jsvg/renderer/jfx/viewer/FXViewerMain.java @@ -0,0 +1,31 @@ +/* + * MIT License + * + * Copyright (c) 2025-2026 Jannis Weis + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and + * associated documentation files (the "Software"), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, + * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT + * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ +package com.github.weisj.jsvg.renderer.jfx.viewer; + +import javafx.application.Application; + +public class FXViewerMain { + + public static void main(String[] args) { + Application.launch(FXTestViewerApplication.class, args); + } +} diff --git a/jsvg-javafx/src/test/resources/com/github/weisj/jsvg/renderer/jfx/viewer/svg-viewer.fxml b/jsvg-javafx/src/test/resources/com/github/weisj/jsvg/renderer/jfx/viewer/svg-viewer.fxml new file mode 100644 index 00000000..d526c487 --- /dev/null +++ b/jsvg-javafx/src/test/resources/com/github/weisj/jsvg/renderer/jfx/viewer/svg-viewer.fxml @@ -0,0 +1,52 @@ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + +