From 59ac24966a319c507d101387640d7fc316490f9a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 22:18:03 +0300 Subject: [PATCH 01/29] Add build-time SVG transcoder with SMIL animation support New maven/svg-transcoder module parses SVG files with the JDK's StAX reader (no Batik dependency) and emits Codename One Image subclasses that render via the Graphics shape API. Covers shapes (rect, circle, ellipse, line, polyline, polygon, path including arcs), groups with affine transforms, linear gradients via LinearGradientPaint, and SMIL animations (animate, animateTransform, set) interpolated against wall-clock time. A new TranscodeSVGMojo runs in generate-sources, scans src/main/svg, and emits one class per SVG into target/generated-sources/svg plus an SVGRegistry class. The runtime base class lives at com.codename1.svg. GeneratedSVGImage; Resources.getImage now falls back to a global registry populated reflectively from the generated SVGRegistry so transcoded images appear under their source filename for any Resources opened in the VM. The hellocodenameone module includes five SVG fixtures (static shapes, gradient, path, two animated) and two screenshot tests; animated images expose setAnimationTimeMillis so tests can pin the frame deterministically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/svg/GeneratedSVGImage.java | 355 +++++++++++ .../src/com/codename1/svg/package-info.java | 12 + .../src/com/codename1/ui/util/Resources.java | 66 +- maven/codenameone-maven-plugin/pom.xml | 5 + .../com/codename1/maven/TranscodeSVGMojo.java | 163 +++++ maven/pom.xml | 6 + maven/svg-transcoder/pom.xml | 55 ++ .../svg/transcoder/SVGTranscoder.java | 148 +++++ .../svg/transcoder/animation/SMILParser.java | 86 +++ .../transcoder/codegen/JavaCodeGenerator.java | 597 ++++++++++++++++++ .../svg/transcoder/model/SVGAnimation.java | 70 ++ .../svg/transcoder/model/SVGCircle.java | 12 + .../svg/transcoder/model/SVGDocument.java | 35 + .../svg/transcoder/model/SVGEllipse.java | 14 + .../svg/transcoder/model/SVGGradientStop.java | 14 + .../svg/transcoder/model/SVGGroup.java | 12 + .../svg/transcoder/model/SVGLine.java | 14 + .../transcoder/model/SVGLinearGradient.java | 28 + .../svg/transcoder/model/SVGNode.java | 27 + .../svg/transcoder/model/SVGPath.java | 12 + .../svg/transcoder/model/SVGPolygon.java | 6 + .../svg/transcoder/model/SVGPolyline.java | 12 + .../transcoder/model/SVGRadialGradient.java | 26 + .../svg/transcoder/model/SVGRect.java | 18 + .../svg/transcoder/model/SVGShape.java | 5 + .../svg/transcoder/package-info.java | 6 + .../svg/transcoder/parser/ColorParser.java | 276 ++++++++ .../svg/transcoder/parser/NumberParser.java | 82 +++ .../svg/transcoder/parser/PathCommand.java | 25 + .../svg/transcoder/parser/PathDataParser.java | 238 +++++++ .../svg/transcoder/parser/SVGPaint.java | 51 ++ .../svg/transcoder/parser/SVGParser.java | 417 ++++++++++++ .../svg/transcoder/parser/SVGStyle.java | 60 ++ .../svg/transcoder/parser/SVGTransform.java | 65 ++ .../svg/transcoder/parser/StyleParser.java | 83 +++ .../transcoder/parser/TransformParser.java | 79 +++ .../transcoder/animation/SMILParserTest.java | 61 ++ .../codegen/CompileGeneratedSourceTest.java | 148 +++++ .../codegen/JavaCodeGeneratorTest.java | 129 ++++ .../transcoder/parser/ColorParserTest.java | 76 +++ .../transcoder/parser/PathDataParserTest.java | 108 ++++ .../svg/transcoder/parser/SVGParserTest.java | 110 ++++ .../parser/TransformParserTest.java | 73 +++ scripts/hellocodenameone/common/pom.xml | 7 + .../tests/SVGAnimatedScreenshotTest.java | 35 + .../tests/SVGStaticScreenshotTest.java | 37 ++ .../common/src/main/svg/gradient_circle.svg | 10 + .../common/src/main/svg/path_arrow.svg | 6 + .../common/src/main/svg/pulsing_circle.svg | 9 + .../common/src/main/svg/spinner_animated.svg | 14 + .../common/src/main/svg/star.svg | 5 + 51 files changed, 4007 insertions(+), 1 deletion(-) create mode 100644 CodenameOne/src/com/codename1/svg/GeneratedSVGImage.java create mode 100644 CodenameOne/src/com/codename1/svg/package-info.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java create mode 100644 maven/svg-transcoder/pom.xml create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/SVGTranscoder.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/animation/SMILParser.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/codegen/JavaCodeGenerator.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGAnimation.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGCircle.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGDocument.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGEllipse.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGGradientStop.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGGroup.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGLine.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGLinearGradient.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGNode.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPath.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPolygon.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPolyline.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGRadialGradient.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGRect.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGShape.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/package-info.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/ColorParser.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/NumberParser.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/PathCommand.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/PathDataParser.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGPaint.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGParser.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGStyle.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGTransform.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/StyleParser.java create mode 100644 maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/TransformParser.java create mode 100644 maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/animation/SMILParserTest.java create mode 100644 maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/codegen/CompileGeneratedSourceTest.java create mode 100644 maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/codegen/JavaCodeGeneratorTest.java create mode 100644 maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/ColorParserTest.java create mode 100644 maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/PathDataParserTest.java create mode 100644 maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/SVGParserTest.java create mode 100644 maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/TransformParserTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SVGAnimatedScreenshotTest.java create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SVGStaticScreenshotTest.java create mode 100644 scripts/hellocodenameone/common/src/main/svg/gradient_circle.svg create mode 100644 scripts/hellocodenameone/common/src/main/svg/path_arrow.svg create mode 100644 scripts/hellocodenameone/common/src/main/svg/pulsing_circle.svg create mode 100644 scripts/hellocodenameone/common/src/main/svg/spinner_animated.svg create mode 100644 scripts/hellocodenameone/common/src/main/svg/star.svg diff --git a/CodenameOne/src/com/codename1/svg/GeneratedSVGImage.java b/CodenameOne/src/com/codename1/svg/GeneratedSVGImage.java new file mode 100644 index 0000000000..98e0db81d9 --- /dev/null +++ b/CodenameOne/src/com/codename1/svg/GeneratedSVGImage.java @@ -0,0 +1,355 @@ +package com.codename1.svg; + +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.Transform; + +/// Base class for SVG images emitted by the build-time transcoder. +/// +/// A subclass is generated per source SVG file. The subclass overrides +/// [#paintSVG(Graphics, long)] to issue the actual drawing commands and +/// passes its intrinsic size and viewBox to the constructor. This class +/// handles viewport mapping (scaling the viewBox into the requested +/// destination rectangle) and animation time tracking so generated code +/// stays declarative. +/// +/// The generated classes are normally registered with a [com.codename1.ui.util.Resources] +/// object via the auto-generated `com.codename1.generated.svg.SVGRegistry` +/// so they appear under their original filename when calling +/// [com.codename1.ui.util.Resources#getImage(String)]. +public abstract class GeneratedSVGImage extends Image { + + /// Sentinel value used by [#progress] when an animation declared + /// `repeatCount="indefinite"`. + public static final int REPEAT_INDEFINITE = -1; + + private final int width; + private final int height; + private final float viewBoxX; + private final float viewBoxY; + private final float viewBoxWidth; + private final float viewBoxHeight; + private final boolean animated; + private long animationStartMs = -1L; + private long animationOverrideMs = -1L; + + /// Construct a generated SVG image with intrinsic size and viewBox metadata. + /// + /// #### Parameters + /// + /// - `width`: intrinsic pixel width (defaults to the viewBox width) + /// + /// - `height`: intrinsic pixel height + /// + /// - `viewBoxX`: x origin of the viewBox in SVG user units + /// + /// - `viewBoxY`: y origin of the viewBox in SVG user units + /// + /// - `viewBoxWidth`: width of the viewBox; falls back to `width` if `<= 0` + /// + /// - `viewBoxHeight`: height of the viewBox; falls back to `height` if `<= 0` + /// + /// - `animated`: true if any SMIL animation was found inside the SVG + protected GeneratedSVGImage(int width, int height, + float viewBoxX, float viewBoxY, + float viewBoxWidth, float viewBoxHeight, + boolean animated) { + super(null); + this.width = width; + this.height = height; + this.viewBoxX = viewBoxX; + this.viewBoxY = viewBoxY; + this.viewBoxWidth = viewBoxWidth <= 0 ? width : viewBoxWidth; + this.viewBoxHeight = viewBoxHeight <= 0 ? height : viewBoxHeight; + this.animated = animated; + } + + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + + @Override + public boolean isAnimation() { + return animated; + } + + /// Always returns whether this image is animated; the animation state is + /// re-derived from wall-clock time on each paint, so there is nothing to + /// advance here. Returning `true` requests that the embedding component + /// be repainted. + @Override + public boolean animate() { + return animated; + } + + /// Generated implementations render the SVG content using the supplied + /// graphics context. `elapsedMs` is the number of milliseconds since the + /// first paint of this image (or since the most recent [#resetAnimation]). + /// + /// #### Parameters + /// + /// - `g`: the graphics context, already transformed so SVG user-space + /// coordinates map onto the destination rectangle + /// + /// - `elapsedMs`: animation time in milliseconds, `0` for non-animated SVGs + protected abstract void paintSVG(Graphics g, long elapsedMs); + + @Override + protected void drawImage(Graphics g, Object nativeGraphics, int x, int y) { + drawImage(g, nativeGraphics, x, y, width, height); + } + + @Override + protected void drawImage(Graphics g, Object nativeGraphics, int x, int y, int w, int h) { + if (!g.isShapeSupported()) { + return; + } + long elapsed = 0L; + if (animated) { + if (animationOverrideMs >= 0L) { + elapsed = animationOverrideMs; + } else { + if (animationStartMs < 0L) { + animationStartMs = System.currentTimeMillis(); + } + elapsed = System.currentTimeMillis() - animationStartMs; + } + } + + Transform saved = null; + try { + saved = g.getTransform(); + } catch (Throwable ignored) { + saved = null; + } + int savedColor = g.getColor(); + int savedAlpha = g.getAlpha(); + try { + float sx = (float) w / viewBoxWidth; + float sy = (float) h / viewBoxHeight; + Transform t; + if (saved != null) { + t = saved.copy(); + } else { + t = Transform.makeIdentity(); + } + t.translate((float) x, (float) y); + t.scale(sx, sy); + t.translate(-viewBoxX, -viewBoxY); + g.setTransform(t); + paintSVG(g, elapsed); + } finally { + if (saved != null) { + g.setTransform(saved); + } else { + g.setTransform(Transform.makeIdentity()); + } + g.setColor(savedColor); + g.setAlpha(savedAlpha); + } + } + + /// We render to any requested size on the fly, so a "scaled" instance is + /// the same instance. Returning `this` avoids allocating a wrapper for + /// every layout pass. + @Override + public Image scaled(int width, int height) { + return this; + } + + /// Reset the animation clock so the next paint begins at `t = 0`. Useful + /// for screenshot tests that want a deterministic frame. + public void resetAnimation() { + animationStartMs = -1L; + } + + /// Pin the animation clock to a fixed elapsed time. Pass `-1` to release + /// the override and resume wall-clock time. Used by screenshot tests to + /// capture specific frames. + public void setAnimationTimeMillis(long elapsedMs) { + this.animationOverrideMs = elapsedMs; + } + + // --------------------------------------------------------------------- + // SMIL helpers — referenced by generated code; keep signatures stable. + // --------------------------------------------------------------------- + + /// Compute the active progress through an animation cycle, in the range + /// `[0, 1]`. Honors begin offsets, repeat counts and the SMIL fill="freeze" + /// behavior. Generated code calls this per animated attribute per paint. + public static float progress(long elapsedMs, long beginMs, long durMs, + int repeatCount, boolean freeze) { + if (durMs <= 0L) return freeze ? 1f : 0f; + long t = elapsedMs - beginMs; + if (t < 0L) return 0f; + if (repeatCount == REPEAT_INDEFINITE) { + long cycle = t % durMs; + return (float) cycle / (float) durMs; + } + long total = durMs * (long) repeatCount; + if (t >= total) { + return freeze ? 1f : 0f; + } + long cycle = t % durMs; + return (float) cycle / (float) durMs; + } + + public static float lerp(float from, float to, float t) { + return from + (to - from) * t; + } + + /// Lerp between two ARGB colors. Each channel is linearly interpolated. + public static int lerpColor(int fromArgb, int toArgb, float t) { + int fa = (fromArgb >>> 24) & 0xFF; + int fr = (fromArgb >>> 16) & 0xFF; + int fg = (fromArgb >>> 8) & 0xFF; + int fb = fromArgb & 0xFF; + int ta = (toArgb >>> 24) & 0xFF; + int tr = (toArgb >>> 16) & 0xFF; + int tg = (toArgb >>> 8) & 0xFF; + int tb = toArgb & 0xFF; + int a = round(fa + (ta - fa) * t); + int r = round(fr + (tr - fr) * t); + int g = round(fg + (tg - fg) * t); + int b = round(fb + (tb - fb) * t); + return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } + + /// Multi-stop floating point lerp. Stops are evenly spaced in `[0, 1]`. + public static float lerpValues(float[] values, float t) { + if (values == null || values.length == 0) return 0f; + if (values.length == 1) return values[0]; + if (t <= 0f) return values[0]; + if (t >= 1f) return values[values.length - 1]; + float seg = 1f / (values.length - 1); + int i = (int) Math.floor(t / seg); + if (i >= values.length - 1) i = values.length - 2; + float local = (t - i * seg) / seg; + return values[i] + (values[i + 1] - values[i]) * local; + } + + private static int round(float v) { + int r = (int) (v + 0.5f); + if (r < 0) return 0; + if (r > 255) return 255; + return r; + } + + /// Append an SVG elliptical arc segment to the given path using the + /// endpoint parameterization defined by the SVG 1.1 spec (appendix F.6). + /// The current point of `p` is treated as the arc's start; on return the + /// current point is the end of the arc. Decomposes into up to four + /// cubic Beziers — a single quadrant per Bezier — for accuracy. + public static void svgArc(com.codename1.ui.geom.GeneralPath p, + float x1, float y1, + float rx, float ry, + float xAxisRotationDeg, + boolean largeArc, boolean sweep, + float x2, float y2) { + if (rx == 0f || ry == 0f) { + p.lineTo(x2, y2); + return; + } + float arx = Math.abs(rx); + float ary = Math.abs(ry); + double phi = Math.toRadians(xAxisRotationDeg); + double cosPhi = Math.cos(phi); + double sinPhi = Math.sin(phi); + + // F.6.5.1 — compute (x1', y1') + double dx2 = (x1 - x2) / 2.0; + double dy2 = (y1 - y2) / 2.0; + double x1p = cosPhi * dx2 + sinPhi * dy2; + double y1p = -sinPhi * dx2 + cosPhi * dy2; + + // F.6.6.2 — ensure radii are large enough + double rx2 = arx * arx; + double ry2 = ary * ary; + double x1p2 = x1p * x1p; + double y1p2 = y1p * y1p; + double radiiCheck = x1p2 / rx2 + y1p2 / ry2; + if (radiiCheck > 1.0) { + double s = Math.sqrt(radiiCheck); + arx = (float) (s * arx); + ary = (float) (s * ary); + rx2 = arx * arx; + ry2 = ary * ary; + } + + // F.6.5.2 — compute (cx', cy') + double sign = (largeArc == sweep) ? -1.0 : 1.0; + double sq = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2) / (rx2 * y1p2 + ry2 * x1p2); + if (sq < 0.0) sq = 0.0; + double coef = sign * Math.sqrt(sq); + double cxp = coef * (arx * y1p / ary); + double cyp = coef * -(ary * x1p / arx); + + // F.6.5.3 — compute (cx, cy) + double sx2 = (x1 + x2) / 2.0; + double sy2 = (y1 + y2) / 2.0; + double cx = sx2 + (cosPhi * cxp - sinPhi * cyp); + double cy = sy2 + (sinPhi * cxp + cosPhi * cyp); + + // F.6.5.4 — start angle and sweep + double ux = (x1p - cxp) / arx; + double uy = (y1p - cyp) / ary; + double vx = (-x1p - cxp) / arx; + double vy = (-y1p - cyp) / ary; + double theta1 = vectorAngle(1.0, 0.0, ux, uy); + double deltaTheta = vectorAngle(ux, uy, vx, vy); + if (!sweep && deltaTheta > 0.0) deltaTheta -= 2.0 * Math.PI; + else if (sweep && deltaTheta < 0.0) deltaTheta += 2.0 * Math.PI; + + // Split into segments small enough that the cubic approximation stays accurate. + int segments = (int) Math.ceil(Math.abs(deltaTheta) / (Math.PI / 2.0)); + if (segments < 1) segments = 1; + double dt = deltaTheta / segments; + double t = (4.0 / 3.0) * Math.tan(dt / 4.0); + + double cosTheta1 = Math.cos(theta1); + double sinTheta1 = Math.sin(theta1); + double px = x1, py = y1; + for (int i = 0; i < segments; i++) { + double theta2 = theta1 + dt; + double cosTheta2 = Math.cos(theta2); + double sinTheta2 = Math.sin(theta2); + // end of this segment in (cx, cy, arx, ary, phi) frame + double ex = cx + arx * (cosPhi * cosTheta2 - sinPhi * sinTheta2); + double ey = cy + ary * (sinPhi * cosTheta2 + cosPhi * sinTheta2); + // control point offsets in ellipse-local frame, then rotate + double dx1L = -arx * sinTheta1; + double dy1L = ary * cosTheta1; + double dx2L = -arx * sinTheta2; + double dy2L = ary * cosTheta2; + double c1xL = px + t * (cosPhi * dx1L - sinPhi * dy1L); + double c1yL = py + t * (sinPhi * dx1L + cosPhi * dy1L); + double c2xL = ex - t * (cosPhi * dx2L - sinPhi * dy2L); + double c2yL = ey - t * (sinPhi * dx2L + cosPhi * dy2L); + p.curveTo((float) c1xL, (float) c1yL, + (float) c2xL, (float) c2yL, + (float) ex, (float) ey); + theta1 = theta2; + cosTheta1 = cosTheta2; + sinTheta1 = sinTheta2; + px = ex; + py = ey; + } + } + + private static double vectorAngle(double ux, double uy, double vx, double vy) { + double dot = ux * vx + uy * vy; + double len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)); + double cos = dot / len; + if (cos < -1.0) cos = -1.0; + if (cos > 1.0) cos = 1.0; + double a = Math.acos(cos); + if ((ux * vy - uy * vx) < 0.0) a = -a; + return a; + } +} diff --git a/CodenameOne/src/com/codename1/svg/package-info.java b/CodenameOne/src/com/codename1/svg/package-info.java new file mode 100644 index 0000000000..55a6d2530c --- /dev/null +++ b/CodenameOne/src/com/codename1/svg/package-info.java @@ -0,0 +1,12 @@ +/// Runtime support for build-time-transcoded SVG images. +/// +/// The Codename One SVG transcoder (in `maven/svg-transcoder`) parses SVG +/// files at build time and emits an [Image] subclass per file that renders +/// the SVG via the [Graphics] shape API. Those generated classes extend +/// [GeneratedSVGImage], which lives in this package together with the small +/// runtime helpers needed for SMIL animation interpolation. +/// +/// User code never references these classes directly — generated SVGs appear +/// under their source filename in any [com.codename1.ui.util.Resources] that +/// has been wired up through the transcoder's generated registry. +package com.codename1.svg; diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index 3a2e5b5257..ce6c716361 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -117,6 +117,13 @@ public class Resources { private static int lastLoadedDPI; private static boolean runtimeMultiImages; private static boolean failOnMissingTruetype = true; + + /// Global image registry populated by the build-time SVG transcoder. Keyed by + /// the source filename ("home.svg") and also under the filename stem ("home") + /// so CSS-style `url(home.svg)` references and direct `getImage("home")` calls + /// both resolve. Lazily probed via [#ensureGeneratedSVGsInstalled]. + private static final Map generatedImages = new HashMap(); + private static volatile boolean generatedSVGProbed; /// Hashtable containing the mapping between element types and their names in the /// resource hashtable private final HashMap resourceTypes = new HashMap(); @@ -880,7 +887,64 @@ public boolean isImage(String name) { /// /// cached image instance public Image getImage(String id) { - return (Image) resources.get(id); + Image local = (Image) resources.get(id); + if (local != null) return local; + ensureGeneratedSVGsInstalled(); + Image gen; + synchronized (generatedImages) { + gen = generatedImages.get(id); + } + return gen; + } + + /// Install an [Image] into this resources bundle under the given name so a + /// subsequent [#getImage(String)] returns it. Used by the build-time SVG + /// transcoder registry to inject generated images alongside the resources + /// loaded from the `.res` file. + public void setImage(String id, Image image) { + if (id == null || image == null) return; + resources.put(id, image); + resourceTypes.put(id, Byte.valueOf(MAGIC_IMAGE)); + } + + /// Add an [Image] to the global registry consulted by every + /// [#getImage(String)] call as a fallback. Intended for the + /// auto-generated `com.codename1.generated.svg.SVGRegistry` produced by + /// the SVG transcoder mojo — application code should not normally call + /// this directly. + /// + /// Registers the image both under the supplied `id` and (if `id` ends with + /// `.svg`) under the bare filename stem so a CSS reference like + /// `url(home.svg)` and a code reference like `getImage("home")` both + /// resolve to the same instance. + public static void registerGeneratedImage(String id, Image image) { + if (id == null || image == null) return; + synchronized (generatedImages) { + generatedImages.put(id, image); + if (id.endsWith(".svg")) { + generatedImages.put(id.substring(0, id.length() - 4), image); + } + } + } + + /// Lazily probe for `com.codename1.generated.svg.SVGRegistry` and ask it + /// to populate the global registry. If the application has no transcoded + /// SVGs, the class will not exist and the probe is silently skipped. + /// Marked package-private so {@link com.codename1.svg} tests can re-probe. + static void ensureGeneratedSVGsInstalled() { + if (generatedSVGProbed) return; + synchronized (generatedImages) { + if (generatedSVGProbed) return; + generatedSVGProbed = true; + try { + Class registry = Class.forName("com.codename1.generated.svg.SVGRegistry"); + registry.getMethod("installGlobal").invoke(null); + } catch (ClassNotFoundException notPresent) { + // No SVGs — nothing to install. + } catch (Throwable t) { + Log.e(t); + } + } } /// Returns the data resource from the file diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index 0c0719bcbd..11153bccbb 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -49,6 +49,11 @@ codenameone-designer jar-with-dependencies + + ${project.groupId} + codenameone-svg-transcoder + ${project.version} + org.apache.maven maven-plugin-api diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java new file mode 100644 index 0000000000..615da94e8d --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java @@ -0,0 +1,163 @@ +package com.codename1.maven; + +import com.codename1.svg.transcoder.SVGTranscoder; +import com.codename1.svg.transcoder.SVGTranscoder.GeneratedClass; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Scans an application module for SVG files, transcodes each to a Codename One + * {@code Image} subclass under {@code target/generated-sources/svg}, and emits + * a registry class that auto-installs the generated images into any + * {@code Resources} object opened at runtime. + * + *

SVG sources are picked up from {@code src/main/svg/} by default. The generated + * sources directory is automatically added to the project's compile source roots + * so the resulting Java classes participate in the normal compilation pass.

+ * + *

This mojo intentionally does not require a network or any external tool — + * SVG parsing is done in-process by {@code codenameone-svg-transcoder} using + * the JDK's StAX implementation.

+ */ +@Mojo(name = "transcode-svg", defaultPhase = LifecyclePhase.GENERATE_SOURCES, + requiresDependencyResolution = ResolutionScope.NONE, + requiresDependencyCollection = ResolutionScope.NONE) +public class TranscodeSVGMojo extends AbstractCN1Mojo { + + /** Default location for SVG sources, relative to the project base. */ + private static final String DEFAULT_SVG_DIR = "src/main/svg"; + + /** Default package for generated SVG image classes. */ + private static final String DEFAULT_PACKAGE = "com.codename1.generated.svg"; + + /** Class name used for the auto-generated registry — must match the + * class looked up reflectively by {@code Resources.ensureGeneratedSVGsInstalled}. */ + private static final String REGISTRY_CLASS_NAME = "SVGRegistry"; + + @Parameter(property = "cn1.svg.sourceDir") + private File svgSourceDir; + + @Parameter(property = "cn1.svg.outputDir", + defaultValue = "${project.build.directory}/generated-sources/svg") + private File svgOutputDir; + + @Parameter(property = "cn1.svg.package", defaultValue = DEFAULT_PACKAGE) + private String svgPackage; + + @Override + protected void executeImpl() throws MojoExecutionException, MojoFailureException { + File srcDir = svgSourceDir != null ? svgSourceDir : new File(project.getBasedir(), DEFAULT_SVG_DIR); + if (!srcDir.isDirectory()) { + getLog().debug("No SVG source directory at " + srcDir + " — skipping SVG transcoding."); + registerSourceRoot(); + return; + } + + List svgs = new ArrayList(); + collect(srcDir, svgs); + if (svgs.isEmpty()) { + getLog().debug("No .svg files found under " + srcDir); + registerSourceRoot(); + return; + } + // sort so generated output is deterministic + svgs.sort(new Comparator() { + @Override + public int compare(File a, File b) { return a.getAbsolutePath().compareTo(b.getAbsolutePath()); } + }); + + File packageDir = new File(svgOutputDir, svgPackage.replace('.', '/')); + packageDir.mkdirs(); + long registrySrcMtime = lastModified(svgs); + List generated = new ArrayList(); + Set usedClassNames = new HashSet(); + + for (File svg : svgs) { + String resourceName = svg.getName(); + String className = uniqueClassName(SVGTranscoder.classNameFor(resourceName), usedClassNames); + usedClassNames.add(className); + File outFile = new File(packageDir, className + ".java"); + if (outFile.exists() && outFile.lastModified() >= svg.lastModified()) { + getLog().debug("SVG transcoder up-to-date for " + svg.getName()); + } else { + getLog().info("Transcoding SVG " + svg.getName() + " → " + className + ".java"); + try { + SVGTranscoder.transcode(svg, svgPackage, className, outFile); + } catch (IOException ex) { + throw new MojoExecutionException("Failed to transcode " + svg, ex); + } + } + generated.add(new GeneratedClass(svgPackage, className, resourceName)); + } + + File registryFile = new File(packageDir, REGISTRY_CLASS_NAME + ".java"); + if (!registryFile.exists() || registryFile.lastModified() < registrySrcMtime) { + try { + Writer w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(registryFile), "UTF-8")); + try { + SVGTranscoder.writeRegistry(svgPackage, REGISTRY_CLASS_NAME, generated, w); + } finally { + w.close(); + } + } catch (IOException ex) { + throw new MojoExecutionException("Failed to write SVG registry", ex); + } + getLog().info("Wrote SVG registry " + registryFile.getName() + " with " + generated.size() + " image(s)"); + } else { + getLog().debug("SVG registry up-to-date."); + } + + registerSourceRoot(); + } + + private static String uniqueClassName(String base, Set taken) { + if (!taken.contains(base)) return base; + int n = 2; + while (taken.contains(base + n)) n++; + return base + n; + } + + private void registerSourceRoot() { + String path = svgOutputDir.getAbsolutePath(); + if (!project.getCompileSourceRoots().contains(path)) { + project.addCompileSourceRoot(path); + getLog().debug("Added compile source root " + path); + } + } + + private static void collect(File dir, List out) { + File[] entries = dir.listFiles(); + if (entries == null) return; + Arrays.sort(entries, new Comparator() { + @Override public int compare(File a, File b) { return a.getName().compareTo(b.getName()); } + }); + for (File f : entries) { + if (f.isDirectory()) collect(f, out); + else if (f.getName().toLowerCase().endsWith(".svg")) out.add(f); + } + } + + private static long lastModified(List files) { + long max = 0; + for (File f : files) if (f.lastModified() > max) max = f.lastModified(); + return max; + } +} diff --git a/maven/pom.xml b/maven/pom.xml index 0dca49fedd..081e6bf4f5 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -61,6 +61,7 @@ core factory css-compiler + svg-transcoder sqlite-jdbc javase javase-svg @@ -102,6 +103,11 @@ codenameone-css-compiler ${project.version}
+ + com.codenameone + codenameone-svg-transcoder + ${project.version} + com.codenameone sqlite-jdbc diff --git a/maven/svg-transcoder/pom.xml b/maven/svg-transcoder/pom.xml new file mode 100644 index 0000000000..970563cbd1 --- /dev/null +++ b/maven/svg-transcoder/pom.xml @@ -0,0 +1,55 @@ + + + + + com.codenameone + codenameone + 8.0-SNAPSHOT + + 4.0.0 + + codenameone-svg-transcoder + 8.0-SNAPSHOT + jar + codenameone-svg-transcoder + + Build-time tool that parses SVG files and emits Codename One Image + subclasses that render them via the Graphics API. Supports a useful + subset of the SVG 1.1 static-shape vocabulary and SMIL animations + (animate, animateTransform, set). No Batik dependency: SVG is parsed + with the built-in javax.xml StAX/SAX stack. + + + + UTF-8 + 1.8 + 1.8 + + + + + + maven-compiler-plugin + + 1.8 + 1.8 + + + + + + + + junit + junit + test + + + com.codenameone + codenameone-core + test + + + diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/SVGTranscoder.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/SVGTranscoder.java new file mode 100644 index 0000000000..ce8874c33e --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/SVGTranscoder.java @@ -0,0 +1,148 @@ +package com.codename1.svg.transcoder; + +import com.codename1.svg.transcoder.codegen.JavaCodeGenerator; +import com.codename1.svg.transcoder.model.SVGDocument; +import com.codename1.svg.transcoder.parser.SVGParser; + +import java.io.*; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Top-level entry point: parse an SVG file and emit a Codename One + * {@code GeneratedSVGImage} subclass. Also generates a small registry class + * that wires the emitted images into a {@code Resources} instance at runtime + * via {@code Resources.registerGeneratedImage(String, Image)}. + */ +public final class SVGTranscoder { + + /** Per-image entry — used both by the per-file transcode and the registry emitter. */ + public static final class GeneratedClass { + public final String packageName; + public final String className; + /** Logical resource name used at runtime (e.g. "home" for "home.svg"). */ + public final String resourceName; + + public GeneratedClass(String packageName, String className, String resourceName) { + this.packageName = packageName; + this.className = className; + this.resourceName = resourceName; + } + + public String fullyQualified() { + return packageName == null || packageName.isEmpty() ? className : packageName + "." + className; + } + } + + private SVGTranscoder() { } + + /** Parse {@code svg} and write a Java source file to {@code out}. */ + public static void transcode(InputStream svg, String packageName, String className, Writer out) throws IOException { + SVGDocument doc = new SVGParser().parse(svg); + new JavaCodeGenerator(doc, packageName, className).generate(out); + } + + public static void transcode(File svgFile, String packageName, String className, File outFile) throws IOException { + if (outFile.getParentFile() != null) outFile.getParentFile().mkdirs(); + InputStream in = new BufferedInputStream(new FileInputStream(svgFile)); + try { + Writer w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8")); + try { + transcode(in, packageName, className, w); + } finally { + w.close(); + } + } finally { + in.close(); + } + } + + /** + * Emit a {@code SVGRegistry} class with a single public static method + * {@code install(Resources)}, which instantiates each generated SVG image + * and registers it under its source filename. The runtime + * {@code Resources} class also looks up this same registry class via + * reflection so generated SVGs appear under {@code getImage(name)} for + * any resources bundle that has been opened in this VM. + */ + public static void writeRegistry(String packageName, String className, java.util.List classes, Writer out) throws IOException { + // dedupe by resource name; last wins + Map unique = new LinkedHashMap(); + for (GeneratedClass c : classes) unique.put(c.resourceName, c); + + if (packageName != null && !packageName.isEmpty()) { + out.write("package " + packageName + ";\n\n"); + } + out.write("import com.codename1.ui.Image;\n"); + out.write("import com.codename1.ui.util.Resources;\n\n"); + out.write("/** Generated by codenameone-svg-transcoder. DO NOT EDIT.\n"); + out.write(" * Registers transcoded SVG images so they are returned by\n"); + out.write(" * {@link Resources#getImage(String)}.\n"); + out.write(" */\n"); + out.write("public final class " + className + " {\n\n"); + out.write(" private static volatile boolean __installed;\n\n"); + out.write(" private " + className + "() { }\n\n"); + out.write(" /** Idempotent. Registers each generated SVG image both globally\n"); + out.write(" * (so any Resources opened in this VM can resolve them) and on\n"); + out.write(" * the provided instance for direct lookup. */\n"); + out.write(" public static void install(Resources r) {\n"); + out.write(" installGlobal();\n"); + out.write(" if (r != null) {\n"); + for (GeneratedClass c : unique.values()) { + out.write(" r.setImage(\"" + escapeJavaString(c.resourceName) + "\", new " + c.fullyQualified() + "());\n"); + } + out.write(" }\n"); + out.write(" }\n\n"); + out.write(" /** Called via reflection by Resources lazily — see Resources#getImage. */\n"); + out.write(" public static void installGlobal() {\n"); + out.write(" if (__installed) return;\n"); + out.write(" synchronized (" + className + ".class) {\n"); + out.write(" if (__installed) return;\n"); + for (GeneratedClass c : unique.values()) { + out.write(" Resources.registerGeneratedImage(\"" + escapeJavaString(c.resourceName) + "\", new " + c.fullyQualified() + "());\n"); + } + out.write(" __installed = true;\n"); + out.write(" }\n"); + out.write(" }\n"); + out.write("}\n"); + } + + /** "home.svg" → "HomeSvg". Used to derive a class name from a filename. */ + public static String classNameFor(String fileName) { + String stem = fileName; + int dot = stem.lastIndexOf('.'); + if (dot > 0) stem = stem.substring(0, dot); + StringBuilder sb = new StringBuilder(); + boolean upper = true; + for (int i = 0; i < stem.length(); i++) { + char c = stem.charAt(i); + if (c == '_' || c == '-' || c == ' ' || c == '.') { upper = true; continue; } + if (sb.length() == 0 && Character.isDigit(c)) { + sb.append('_').append(c); + upper = true; + continue; + } + if (!Character.isJavaIdentifierPart(c)) c = '_'; + sb.append(upper ? Character.toUpperCase(c) : c); + upper = false; + } + if (sb.length() == 0) sb.append("Svg"); + return sb.toString(); + } + + private static String escapeJavaString(String s) { + StringBuilder sb = new StringBuilder(s.length() + 4); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '\\': sb.append("\\\\"); break; + case '"': sb.append("\\\""); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: sb.append(c); + } + } + return sb.toString(); + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/animation/SMILParser.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/animation/SMILParser.java new file mode 100644 index 0000000000..ffb6c1aea5 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/animation/SMILParser.java @@ -0,0 +1,86 @@ +package com.codename1.svg.transcoder.animation; + +import com.codename1.svg.transcoder.model.SVGAnimation; + +import java.util.ArrayList; +import java.util.List; + +/** Parses SMIL clock values ("1s", "250ms", "1:30", "indefinite", etc.). */ +public final class SMILParser { + + private SMILParser() { } + + public static long parseClock(String s, long fallback) { + if (s == null) return fallback; + String v = s.trim(); + if (v.isEmpty()) return fallback; + if ("indefinite".equalsIgnoreCase(v)) return SVGAnimation.REPEAT_INDEFINITE; + try { + // h:m:s or m:s + if (v.indexOf(':') >= 0) { + String[] parts = v.split(":"); + double total = 0; + for (String p : parts) total = total * 60 + Double.parseDouble(p.trim()); + return Math.round(total * 1000.0); + } + // unit suffix + if (v.endsWith("ms")) { + return Math.round(Double.parseDouble(v.substring(0, v.length() - 2).trim())); + } + if (v.endsWith("s")) { + return Math.round(Double.parseDouble(v.substring(0, v.length() - 1).trim()) * 1000.0); + } + if (v.endsWith("min")) { + return Math.round(Double.parseDouble(v.substring(0, v.length() - 3).trim()) * 60000.0); + } + if (v.endsWith("h")) { + return Math.round(Double.parseDouble(v.substring(0, v.length() - 1).trim()) * 3600000.0); + } + // raw number = seconds + return Math.round(Double.parseDouble(v) * 1000.0); + } catch (RuntimeException e) { + return fallback; + } + } + + public static int parseRepeatCount(String s) { + if (s == null) return 1; + String v = s.trim(); + if (v.isEmpty()) return 1; + if ("indefinite".equalsIgnoreCase(v)) return SVGAnimation.REPEAT_INDEFINITE; + try { + float f = Float.parseFloat(v); + int i = (int) Math.max(1, Math.round(f)); + return i; + } catch (RuntimeException e) { + return 1; + } + } + + public static SVGAnimation.CalcMode parseCalcMode(String s) { + if (s == null) return SVGAnimation.CalcMode.LINEAR; + String v = s.trim().toLowerCase(); + if ("discrete".equals(v)) return SVGAnimation.CalcMode.DISCRETE; + if ("paced".equals(v)) return SVGAnimation.CalcMode.PACED; + return SVGAnimation.CalcMode.LINEAR; + } + + public static SVGAnimation.TransformType parseTransformType(String s) { + if (s == null) return SVGAnimation.TransformType.TRANSLATE; + String v = s.trim(); + if ("rotate".equals(v)) return SVGAnimation.TransformType.ROTATE; + if ("scale".equals(v)) return SVGAnimation.TransformType.SCALE; + if ("skewX".equals(v)) return SVGAnimation.TransformType.SKEW_X; + if ("skewY".equals(v)) return SVGAnimation.TransformType.SKEW_Y; + return SVGAnimation.TransformType.TRANSLATE; + } + + public static List parseValues(String s) { + if (s == null) return null; + String t = s.trim(); + if (t.isEmpty()) return null; + List out = new ArrayList(); + for (String p : t.split(";")) out.add(p.trim()); + return out; + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/codegen/JavaCodeGenerator.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/codegen/JavaCodeGenerator.java new file mode 100644 index 0000000000..3e87e728b7 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/codegen/JavaCodeGenerator.java @@ -0,0 +1,597 @@ +package com.codename1.svg.transcoder.codegen; + +import com.codename1.svg.transcoder.model.*; +import com.codename1.svg.transcoder.parser.*; + +import java.io.IOException; +import java.io.Writer; +import java.util.List; +import java.util.Locale; + +/** + * Walks a parsed {@link SVGDocument} and emits a Java source file that + * extends {@code com.codename1.svg.GeneratedSVGImage} and renders the SVG + * using the Codename One {@code Graphics} shape API. SMIL animations on a + * node become inline calls to the animation helpers on the runtime base + * class so the emitted code stays self-contained. + * + *

The generator is intentionally direct — one shape per inline block, + * no method extraction — both to keep the emitted code readable and to + * avoid surprising the bytecode translator with large method counts.

+ */ +public final class JavaCodeGenerator { + + private final SVGDocument doc; + private final String packageName; + private final String className; + private final StringBuilder body = new StringBuilder(); + private int indent = 2; + private int idSeq; + + public JavaCodeGenerator(SVGDocument doc, String packageName, String className) { + this.doc = doc; + this.packageName = packageName; + this.className = className; + } + + public void generate(Writer out) throws IOException { + boolean animated = containsAnimation(doc); + emitPaintBody(doc); + + StringBuilder src = new StringBuilder(); + if (packageName != null && !packageName.isEmpty()) { + src.append("package ").append(packageName).append(";\n\n"); + } + src.append("import com.codename1.svg.GeneratedSVGImage;\n"); + src.append("import com.codename1.ui.Graphics;\n"); + src.append("import com.codename1.ui.LinearGradientPaint;\n"); + src.append("import com.codename1.ui.MultipleGradientPaint;\n"); + src.append("import com.codename1.ui.Stroke;\n"); + src.append("import com.codename1.ui.Transform;\n"); + src.append("import com.codename1.ui.geom.GeneralPath;\n\n"); + src.append("/** Generated by codenameone-svg-transcoder. DO NOT EDIT. */\n"); + src.append("public final class ").append(className).append(" extends GeneratedSVGImage {\n\n"); + src.append(" public ").append(className).append("() {\n"); + src.append(" super(").append(intLit((int) Math.ceil(doc.getWidth()))) + .append(", ").append(intLit((int) Math.ceil(doc.getHeight()))) + .append(", ").append(floatLit(doc.getViewBoxX())) + .append(", ").append(floatLit(doc.getViewBoxY())) + .append(", ").append(floatLit(doc.getViewBoxWidth())) + .append(", ").append(floatLit(doc.getViewBoxHeight())) + .append(", ").append(animated).append(");\n"); + src.append(" }\n\n"); + src.append(" @Override\n"); + src.append(" protected void paintSVG(Graphics g, long __t) {\n"); + src.append(body); + src.append(" }\n"); + src.append("}\n"); + + out.write(src.toString()); + } + + private void emitPaintBody(SVGGroup root) { + // Shared scratch slots — reassigned per shape; not redeclared inside + // nested blocks since Java forbids local-variable shadowing. + line("GeneralPath __p = null;"); + line("Stroke __s = null;"); + line("if (__p != null || __s != null) { /* keep javac happy */ }"); + for (SVGNode child : root.getChildren()) { + emitNode(child, null); + } + } + + private void emitNode(SVGNode n, SVGStyle parentStyle) { + SVGStyle inherited = mergedStyle(n, parentStyle); + SVGTransform tr = n.getTransform(); + List anims = n.getAnimations(); + boolean hasTransformAnim = hasTransformAnimation(anims); + boolean needsTransform = tr != null || hasTransformAnim; + + String savedVar = null; + String newVar = null; + if (needsTransform) { + // Fresh names per block so sibling transformed elements compile + // (Java does not allow shadowing of enclosing locals). + int id = idSeq++; + savedVar = "__tsave" + id; + newVar = "__tnew" + id; + line("{"); + indent++; + line("Transform " + savedVar + " = g.getTransform();"); + line("Transform " + newVar + " = " + savedVar + ".copy();"); + if (tr != null) { + emitApplyMatrix(newVar, tr); + } + for (SVGAnimation a : anims) { + if (a.getKind() == SVGAnimation.Kind.ANIMATE_TRANSFORM) { + emitApplyAnimatedTransform(newVar, a); + } + } + line("g.setTransform(" + newVar + ");"); + line("try {"); + indent++; + } + + try { + if (n instanceof SVGGroup) { + SVGGroup g = (SVGGroup) n; + for (SVGNode child : g.getChildren()) { + emitNode(child, inherited); + } + } else if (n instanceof SVGRect) { + emitRect((SVGRect) n, inherited, anims); + } else if (n instanceof SVGCircle) { + emitCircle((SVGCircle) n, inherited, anims); + } else if (n instanceof SVGEllipse) { + emitEllipse((SVGEllipse) n, inherited, anims); + } else if (n instanceof SVGLine) { + emitLine((SVGLine) n, inherited, anims); + } else if (n instanceof SVGPolyline) { + emitPolyline((SVGPolyline) n, inherited, anims); + } else if (n instanceof SVGPath) { + emitPath((SVGPath) n, inherited, anims); + } + } finally { + if (needsTransform) { + indent--; + line("} finally {"); + indent++; + line("g.setTransform(" + savedVar + ");"); + indent--; + line("}"); + indent--; + line("}"); + } + } + } + + // ---- shape emitters ---------------------------------------------------- + + private void emitRect(SVGRect r, SVGStyle style, List anims) { + AnimatedFloat x = animFloat("x", r.getX(), anims); + AnimatedFloat y = animFloat("y", r.getY(), anims); + AnimatedFloat w = animFloat("width", r.getWidth(), anims); + AnimatedFloat h = animFloat("height", r.getHeight(), anims); + float rx = r.getRx(); + float ry = r.getRy(); + if (rx == 0 && ry > 0) rx = ry; + if (ry == 0 && rx > 0) ry = rx; + + line("__p = new GeneralPath();"); + if (rx <= 0f && ry <= 0f) { + line("__p.moveTo(" + x.expr + ", " + y.expr + ");"); + line("__p.lineTo(" + plus(x.expr, w.expr) + ", " + y.expr + ");"); + line("__p.lineTo(" + plus(x.expr, w.expr) + ", " + plus(y.expr, h.expr) + ");"); + line("__p.lineTo(" + x.expr + ", " + plus(y.expr, h.expr) + ");"); + line("__p.closePath();"); + } else { + String xs = x.expr, ys = y.expr, ws = w.expr, hs = h.expr; + String rxs = floatLit(rx), rys = floatLit(ry); + line("__p.moveTo(" + plus(xs, rxs) + ", " + ys + ");"); + line("__p.lineTo(" + plus(xs, w.expr) + " - " + rxs + ", " + ys + ");"); + line("__p.quadTo(" + plus(xs, ws) + ", " + ys + ", " + + plus(xs, ws) + ", " + plus(ys, rys) + ");"); + line("__p.lineTo(" + plus(xs, ws) + ", " + plus(ys, hs) + " - " + rys + ");"); + line("__p.quadTo(" + plus(xs, ws) + ", " + plus(ys, hs) + ", " + + plus(xs, ws) + " - " + rxs + ", " + plus(ys, hs) + ");"); + line("__p.lineTo(" + plus(xs, rxs) + ", " + plus(ys, hs) + ");"); + line("__p.quadTo(" + xs + ", " + plus(ys, hs) + ", " + + xs + ", " + plus(ys, hs) + " - " + rys + ");"); + line("__p.lineTo(" + xs + ", " + plus(ys, rys) + ");"); + line("__p.quadTo(" + xs + ", " + ys + ", " + plus(xs, rxs) + ", " + ys + ");"); + line("__p.closePath();"); + } + emitFillAndStroke(style, anims); + } + + private void emitCircle(SVGCircle c, SVGStyle style, List anims) { + AnimatedFloat cx = animFloat("cx", c.getCx(), anims); + AnimatedFloat cy = animFloat("cy", c.getCy(), anims); + AnimatedFloat rad = animFloat("r", c.getR(), anims); + line("__p = new GeneralPath();"); + line("__p.arc(" + cx.expr + " - " + rad.expr + ", " + + cy.expr + " - " + rad.expr + ", " + + "2f * " + rad.expr + ", 2f * " + rad.expr + + ", 0f, 6.2831855f);"); + emitFillAndStroke(style, anims); + } + + private void emitEllipse(SVGEllipse e, SVGStyle style, List anims) { + AnimatedFloat cx = animFloat("cx", e.getCx(), anims); + AnimatedFloat cy = animFloat("cy", e.getCy(), anims); + AnimatedFloat rx = animFloat("rx", e.getRx(), anims); + AnimatedFloat ry = animFloat("ry", e.getRy(), anims); + line("__p = new GeneralPath();"); + line("__p.arc(" + cx.expr + " - " + rx.expr + ", " + + cy.expr + " - " + ry.expr + ", " + + "2f * " + rx.expr + ", 2f * " + ry.expr + + ", 0f, 6.2831855f);"); + emitFillAndStroke(style, anims); + } + + private void emitLine(SVGLine l, SVGStyle style, List anims) { + AnimatedFloat x1 = animFloat("x1", l.getX1(), anims); + AnimatedFloat y1 = animFloat("y1", l.getY1(), anims); + AnimatedFloat x2 = animFloat("x2", l.getX2(), anims); + AnimatedFloat y2 = animFloat("y2", l.getY2(), anims); + line("__p = new GeneralPath();"); + line("__p.moveTo(" + x1.expr + ", " + y1.expr + ");"); + line("__p.lineTo(" + x2.expr + ", " + y2.expr + ");"); + emitFillAndStroke(style, anims); + } + + private void emitPolyline(SVGPolyline pl, SVGStyle style, List anims) { + float[] pts = pl.getPoints(); + if (pts.length < 4) return; + line("__p = new GeneralPath();"); + line("__p.moveTo(" + floatLit(pts[0]) + ", " + floatLit(pts[1]) + ");"); + for (int i = 2; i + 1 < pts.length; i += 2) { + line("__p.lineTo(" + floatLit(pts[i]) + ", " + floatLit(pts[i + 1]) + ");"); + } + if (pl.isClosed()) line("__p.closePath();"); + emitFillAndStroke(style, anims); + } + + private void emitPath(SVGPath path, SVGStyle style, List anims) { + if (path.getCommands() == null || path.getCommands().isEmpty()) return; + line("__p = new GeneralPath();"); + float curX = 0, curY = 0; + for (PathCommand pc : path.getCommands()) { + float[] a = pc.getArgs(); + switch (pc.getType()) { + case MOVE: + line("__p.moveTo(" + floatLit(a[0]) + ", " + floatLit(a[1]) + ");"); + curX = a[0]; curY = a[1]; + break; + case LINE: + line("__p.lineTo(" + floatLit(a[0]) + ", " + floatLit(a[1]) + ");"); + curX = a[0]; curY = a[1]; + break; + case CUBIC: + line("__p.curveTo(" + floatLit(a[0]) + ", " + floatLit(a[1]) + ", " + + floatLit(a[2]) + ", " + floatLit(a[3]) + ", " + + floatLit(a[4]) + ", " + floatLit(a[5]) + ");"); + curX = a[4]; curY = a[5]; + break; + case QUAD: + line("__p.quadTo(" + floatLit(a[0]) + ", " + floatLit(a[1]) + ", " + + floatLit(a[2]) + ", " + floatLit(a[3]) + ");"); + curX = a[2]; curY = a[3]; + break; + case ARC: + // args = curX, curY, rx, ry, xRot, largeArc, sweep, x, y + line("GeneratedSVGImage.svgArc(__p, " + + floatLit(a[0]) + ", " + floatLit(a[1]) + ", " + + floatLit(a[2]) + ", " + floatLit(a[3]) + ", " + + floatLit(a[4]) + ", " + (a[5] != 0f) + ", " + (a[6] != 0f) + ", " + + floatLit(a[7]) + ", " + floatLit(a[8]) + ");"); + curX = a[7]; curY = a[8]; + break; + case CLOSE: + line("__p.closePath();"); + break; + } + } + emitFillAndStroke(style, anims); + } + + // ---- fill / stroke ----------------------------------------------------- + + private void emitFillAndStroke(SVGStyle style, List anims) { + SVGPaint fill = style.getFill(); + SVGPaint stroke = style.getStroke(); + // default: fill black unless explicitly set + if (fill == null) fill = SVGPaint.BLACK; + + if (!fill.isNone()) { + emitPaintSet(fill, animFloat("fill-opacity", + style.getFillOpacity() == null ? 1f : style.getFillOpacity(), anims), + style.getOpacity()); + line("g.fillShape(__p);"); + } + if (stroke != null && !stroke.isNone()) { + Float sw = style.getStrokeWidth(); + float widthVal = sw == null ? 1f : sw; + int cap = style.getStrokeLineCap() == null ? SVGStyle.LINECAP_BUTT : style.getStrokeLineCap(); + int join = style.getStrokeLineJoin() == null ? SVGStyle.LINEJOIN_MITER : style.getStrokeLineJoin(); + float miter = style.getStrokeMiterLimit() == null ? 4f : style.getStrokeMiterLimit(); + emitPaintSet(stroke, animFloat("stroke-opacity", + style.getStrokeOpacity() == null ? 1f : style.getStrokeOpacity(), anims), + style.getOpacity()); + line("__s = new Stroke(" + floatLit(widthVal) + + ", " + capConst(cap) + ", " + joinConst(join) + + ", " + floatLit(miter) + ");"); + line("g.drawShape(__p, __s);"); + } + } + + private void emitPaintSet(SVGPaint paint, AnimatedFloat opacity, Float groupOpacity) { + // group opacity multiplies attribute opacity + String alphaExpr; + if (groupOpacity != null) { + alphaExpr = "(int)(255f * Math.max(0f, Math.min(1f, " + opacity.expr + " * " + + floatLit(groupOpacity) + ")))"; + } else { + alphaExpr = "(int)(255f * Math.max(0f, Math.min(1f, " + opacity.expr + ")))"; + } + if (paint.isReference()) { + SVGNode def = doc.getDefinitions().get(paint.getReference()); + if (def instanceof SVGLinearGradient) { + emitLinearGradient((SVGLinearGradient) def, alphaExpr); + return; + } + // radial or unknown: fall back to first stop or black + if (def instanceof SVGRadialGradient) { + SVGRadialGradient g = (SVGRadialGradient) def; + int color = g.getStops().isEmpty() ? 0xFF000000 : g.getStops().get(0).getColor(); + line("g.setColor(0x" + hex(color & 0xFFFFFF) + ");"); + line("g.setAlpha(" + alphaExpr + ");"); + return; + } + // unresolved + line("g.setColor(0x000000);"); + line("g.setAlpha(" + alphaExpr + ");"); + return; + } + int argb = paint.getColor(); + line("g.setColor(0x" + hex(argb & 0xFFFFFF) + ");"); + // baked alpha from color overrides explicit opacity expr to keep simple + int colorAlpha = (argb >>> 24) & 0xFF; + if (colorAlpha != 0xFF) { + line("g.setAlpha((int)(" + colorAlpha + " * Math.max(0f, Math.min(1f, " + + (groupOpacity == null ? opacity.expr : opacity.expr + " * " + floatLit(groupOpacity)) + + ")) + 0.5f));"); + } else { + line("g.setAlpha(" + alphaExpr + ");"); + } + } + + private void emitLinearGradient(SVGLinearGradient lg, String alphaExpr) { + // resolve href-chained stops + SVGLinearGradient effective = lg; + if (lg.getStops().isEmpty() && lg.getHref() != null) { + SVGNode ref = doc.getDefinitions().get(lg.getHref()); + if (ref instanceof SVGLinearGradient) effective = (SVGLinearGradient) ref; + } + List stops = effective.getStops(); + if (stops.isEmpty()) { + line("g.setColor(0x000000);"); + line("g.setAlpha(" + alphaExpr + ");"); + return; + } + // Build the gradient using the SVG-declared coordinates. + // For objectBoundingBox (default) we map [0..1] to the path bounds at runtime + // by querying the path. For userSpaceOnUse we use the coords directly. + StringBuilder fracs = new StringBuilder("new float[]{"); + StringBuilder cols = new StringBuilder("new int[]{"); + for (int i = 0; i < stops.size(); i++) { + if (i > 0) { fracs.append(", "); cols.append(", "); } + float off = Math.max(0f, Math.min(1f, stops.get(i).getOffset())); + int color = stops.get(i).getColor() & 0xFFFFFF; + int sAlpha = Math.round(255f * stops.get(i).getOpacity()); + fracs.append(floatLit(off)); + cols.append("0x").append(Integer.toHexString((sAlpha << 24) | color).toUpperCase(Locale.ROOT)); + } + fracs.append("}"); + cols.append("}"); + + if (lg.isUserSpace()) { + line("g.setColor(new LinearGradientPaint(" + + floatLit(lg.getX1()) + ", " + floatLit(lg.getY1()) + ", " + + floatLit(lg.getX2()) + ", " + floatLit(lg.getY2()) + ", " + + fracs + ", " + cols + ", " + + "MultipleGradientPaint.CycleMethod.NO_CYCLE, " + + "MultipleGradientPaint.ColorSpaceType.SRGB, " + + "Transform.makeIdentity()));"); + } else { + // objectBoundingBox — map [0..1] to path bounds + line("{"); + indent++; + line("float[] __b = new float[4];"); + line("__p.getBounds2D(__b);"); + line("float __bx = __b[0], __by = __b[1];"); + line("float __bw = __b[2], __bh = __b[3];"); + line("g.setColor(new LinearGradientPaint(" + + "__bx + " + floatLit(lg.getX1()) + " * __bw, " + + "__by + " + floatLit(lg.getY1()) + " * __bh, " + + "__bx + " + floatLit(lg.getX2()) + " * __bw, " + + "__by + " + floatLit(lg.getY2()) + " * __bh, " + + fracs + ", " + cols + ", " + + "MultipleGradientPaint.CycleMethod.NO_CYCLE, " + + "MultipleGradientPaint.ColorSpaceType.SRGB, " + + "Transform.makeIdentity()));"); + indent--; + line("}"); + } + line("g.setAlpha(" + alphaExpr + ");"); + } + + // ---- transforms -------------------------------------------------------- + + private void emitApplyMatrix(String varName, SVGTransform t) { + // Emit as a setAffine on a fresh transform then concatenate. + // Simpler: emit translate + multiply by matrix using direct field writes. + line(varName + ".concatenate(Transform.makeAffine(" + + floatLit(t.a) + ", " + floatLit(t.b) + ", " + + floatLit(t.c) + ", " + floatLit(t.d) + ", " + + floatLit(t.e) + ", " + floatLit(t.f) + "));"); + } + + private void emitApplyAnimatedTransform(String varName, SVGAnimation a) { + SVGAnimation.TransformType type = a.getTransformType(); + String pExpr = "GeneratedSVGImage.progress(__t, " + a.getBeginMs() + "L, " + + a.getDurMs() + "L, " + a.getRepeatCount() + ", " + a.isFreeze() + ")"; + // values | from/to + float[] from = parseFloatList(a.getFrom()); + float[] to = parseFloatList(a.getTo()); + List vals = a.getValues(); + if (vals != null && vals.size() >= 2) { + from = parseFloatList(vals.get(0)); + to = parseFloatList(vals.get(vals.size() - 1)); + } + if (from == null) from = new float[]{0f}; + if (to == null) to = from; + + switch (type) { + case TRANSLATE: { + float fx = from.length > 0 ? from[0] : 0f; + float fy = from.length > 1 ? from[1] : 0f; + float tx = to.length > 0 ? to[0] : 0f; + float ty = to.length > 1 ? to[1] : 0f; + line(varName + ".translate(" + + "GeneratedSVGImage.lerp(" + floatLit(fx) + ", " + floatLit(tx) + ", " + pExpr + "), " + + "GeneratedSVGImage.lerp(" + floatLit(fy) + ", " + floatLit(ty) + ", " + pExpr + "));"); + break; + } + case SCALE: { + float fx = from.length > 0 ? from[0] : 1f; + float fy = from.length > 1 ? from[1] : fx; + float tx = to.length > 0 ? to[0] : 1f; + float ty = to.length > 1 ? to[1] : tx; + line(varName + ".scale(" + + "GeneratedSVGImage.lerp(" + floatLit(fx) + ", " + floatLit(tx) + ", " + pExpr + "), " + + "GeneratedSVGImage.lerp(" + floatLit(fy) + ", " + floatLit(ty) + ", " + pExpr + "));"); + break; + } + case ROTATE: { + float fAng = from.length > 0 ? from[0] : 0f; + float fCx = from.length > 1 ? from[1] : 0f; + float fCy = from.length > 2 ? from[2] : 0f; + float tAng = to.length > 0 ? to[0] : 0f; + float tCx = to.length > 1 ? to[1] : fCx; + float tCy = to.length > 2 ? to[2] : fCy; + line(varName + ".rotate(" + + "(float) Math.toRadians(GeneratedSVGImage.lerp(" + + floatLit(fAng) + ", " + floatLit(tAng) + ", " + pExpr + ")), " + + "GeneratedSVGImage.lerp(" + floatLit(fCx) + ", " + floatLit(tCx) + ", " + pExpr + "), " + + "GeneratedSVGImage.lerp(" + floatLit(fCy) + ", " + floatLit(tCy) + ", " + pExpr + "));"); + break; + } + case SKEW_X: + case SKEW_Y: + // skewing via Transform isn't a primitive op; bake into matrix concatenation + break; + } + } + + // ---- animated attribute resolution ------------------------------------- + + /** Look for an `` on this attribute and return either a constant + * or an expression that interpolates the value at runtime. */ + private AnimatedFloat animFloat(String attr, float fallback, List anims) { + if (anims != null) { + for (SVGAnimation a : anims) { + if (a.getKind() == SVGAnimation.Kind.ANIMATE + && attr.equals(a.getAttributeName())) { + float from = parseSingleFloat(a.getFrom(), fallback); + float to = parseSingleFloat(a.getTo(), from); + List vals = a.getValues(); + if (vals != null && vals.size() >= 2) { + from = parseSingleFloat(vals.get(0), from); + to = parseSingleFloat(vals.get(vals.size() - 1), to); + } + String pExpr = "GeneratedSVGImage.progress(__t, " + a.getBeginMs() + "L, " + + a.getDurMs() + "L, " + a.getRepeatCount() + ", " + a.isFreeze() + ")"; + return new AnimatedFloat("GeneratedSVGImage.lerp(" + + floatLit(from) + ", " + floatLit(to) + ", " + pExpr + ")"); + } + if (a.getKind() == SVGAnimation.Kind.SET + && attr.equals(a.getAttributeName())) { + float to = parseSingleFloat(a.getTo(), fallback); + String pExpr = "(__t >= " + a.getBeginMs() + "L)"; + return new AnimatedFloat("(" + pExpr + " ? " + floatLit(to) + " : " + + floatLit(fallback) + ")"); + } + } + } + return new AnimatedFloat(floatLit(fallback)); + } + + private boolean hasTransformAnimation(List anims) { + if (anims == null) return false; + for (SVGAnimation a : anims) { + if (a.getKind() == SVGAnimation.Kind.ANIMATE_TRANSFORM) return true; + } + return false; + } + + private SVGStyle mergedStyle(SVGNode n, SVGStyle parent) { + SVGStyle s = n.getStyle(); + if (s == null) { + SVGStyle empty = new SVGStyle(); + return empty.inherit(parent); + } + return s.inherit(parent); + } + + private boolean containsAnimation(SVGGroup g) { + if (!g.getAnimations().isEmpty()) return true; + for (SVGNode c : g.getChildren()) { + if (!c.getAnimations().isEmpty()) return true; + if (c instanceof SVGGroup && containsAnimation((SVGGroup) c)) return true; + } + return false; + } + + // ---- helpers ----------------------------------------------------------- + + private static final class AnimatedFloat { + final String expr; + AnimatedFloat(String expr) { this.expr = expr; } + } + + private static float[] parseFloatList(String s) { + if (s == null) return null; + NumberParser np = new NumberParser(s); + java.util.ArrayList list = new java.util.ArrayList(); + while (np.hasMore()) { + try { list.add(np.nextFloat()); } catch (RuntimeException e) { break; } + } + if (list.isEmpty()) return null; + float[] r = new float[list.size()]; + for (int i = 0; i < r.length; i++) r[i] = list.get(i); + return r; + } + + private static float parseSingleFloat(String s, float fallback) { + if (s == null) return fallback; + try { return NumberParser.parseFloat(s); } catch (RuntimeException e) { return fallback; } + } + + private static String floatLit(float f) { + if (Float.isNaN(f) || Float.isInfinite(f)) f = 0f; + // f suffix to make Java treat it as float + return f + "f"; + } + + private static String intLit(int i) { return Integer.toString(i); } + + private static String hex(int v) { + return Integer.toHexString(v & 0xFFFFFF).toUpperCase(Locale.ROOT); + } + + private static String plus(String a, String b) { + return "(" + a + " + " + b + ")"; + } + + private static String capConst(int cap) { + switch (cap) { + case SVGStyle.LINECAP_ROUND: return "Stroke.CAP_ROUND"; + case SVGStyle.LINECAP_SQUARE: return "Stroke.CAP_SQUARE"; + default: return "Stroke.CAP_BUTT"; + } + } + + private static String joinConst(int join) { + switch (join) { + case SVGStyle.LINEJOIN_ROUND: return "Stroke.JOIN_ROUND"; + case SVGStyle.LINEJOIN_BEVEL: return "Stroke.JOIN_BEVEL"; + default: return "Stroke.JOIN_MITER"; + } + } + + private void line(String s) { + for (int i = 0; i < indent; i++) body.append(" "); + body.append(s).append("\n"); + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGAnimation.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGAnimation.java new file mode 100644 index 0000000000..5beb0f5b63 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGAnimation.java @@ -0,0 +1,70 @@ +package com.codename1.svg.transcoder.model; + +import java.util.List; + +/** + * SMIL animation: <animate>, <animateTransform>, <set>. + * + * Stored as data so the code generator can emit a runtime descriptor. + * Time values are pre-parsed into milliseconds; "indefinite" repeat is + * represented by {@link #REPEAT_INDEFINITE}. + */ +public final class SVGAnimation { + + public static final int REPEAT_INDEFINITE = -1; + + public enum Kind { ANIMATE, ANIMATE_TRANSFORM, SET } + + public enum TransformType { TRANSLATE, ROTATE, SCALE, SKEW_X, SKEW_Y } + + public enum CalcMode { LINEAR, DISCRETE, PACED } + + private Kind kind = Kind.ANIMATE; + private String attributeName; + private TransformType transformType; + private CalcMode calcMode = CalcMode.LINEAR; + private List values; // raw value strings (already trimmed) + private String from; + private String to; + private String by; + private long beginMs; + private long durMs; + private int repeatCount = 1; + private boolean freeze; + + public Kind getKind() { return kind; } + public void setKind(Kind kind) { this.kind = kind; } + + public String getAttributeName() { return attributeName; } + public void setAttributeName(String attributeName) { this.attributeName = attributeName; } + + public TransformType getTransformType() { return transformType; } + public void setTransformType(TransformType transformType) { this.transformType = transformType; } + + public CalcMode getCalcMode() { return calcMode; } + public void setCalcMode(CalcMode calcMode) { this.calcMode = calcMode; } + + public List getValues() { return values; } + public void setValues(List values) { this.values = values; } + + public String getFrom() { return from; } + public void setFrom(String from) { this.from = from; } + + public String getTo() { return to; } + public void setTo(String to) { this.to = to; } + + public String getBy() { return by; } + public void setBy(String by) { this.by = by; } + + public long getBeginMs() { return beginMs; } + public void setBeginMs(long beginMs) { this.beginMs = beginMs; } + + public long getDurMs() { return durMs; } + public void setDurMs(long durMs) { this.durMs = durMs; } + + public int getRepeatCount() { return repeatCount; } + public void setRepeatCount(int repeatCount) { this.repeatCount = repeatCount; } + + public boolean isFreeze() { return freeze; } + public void setFreeze(boolean freeze) { this.freeze = freeze; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGCircle.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGCircle.java new file mode 100644 index 0000000000..f7ff22ddce --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGCircle.java @@ -0,0 +1,12 @@ +package com.codename1.svg.transcoder.model; + +public final class SVGCircle extends SVGShape { + private float cx, cy, r; + + public float getCx() { return cx; } + public void setCx(float cx) { this.cx = cx; } + public float getCy() { return cy; } + public void setCy(float cy) { this.cy = cy; } + public float getR() { return r; } + public void setR(float r) { this.r = r; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGDocument.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGDocument.java new file mode 100644 index 0000000000..f8b520c53b --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGDocument.java @@ -0,0 +1,35 @@ +package com.codename1.svg.transcoder.model; + +import java.util.HashMap; +import java.util.Map; + +/** Top-level <svg> document. */ +public final class SVGDocument extends SVGGroup { + private float viewBoxX; + private float viewBoxY; + private float viewBoxWidth; + private float viewBoxHeight; + private float width; + private float height; + private final Map definitions = new HashMap(); + + public float getViewBoxX() { return viewBoxX; } + public void setViewBoxX(float v) { this.viewBoxX = v; } + + public float getViewBoxY() { return viewBoxY; } + public void setViewBoxY(float v) { this.viewBoxY = v; } + + public float getViewBoxWidth() { return viewBoxWidth; } + public void setViewBoxWidth(float v) { this.viewBoxWidth = v; } + + public float getViewBoxHeight() { return viewBoxHeight; } + public void setViewBoxHeight(float v) { this.viewBoxHeight = v; } + + public float getWidth() { return width; } + public void setWidth(float w) { this.width = w; } + + public float getHeight() { return height; } + public void setHeight(float h) { this.height = h; } + + public Map getDefinitions() { return definitions; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGEllipse.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGEllipse.java new file mode 100644 index 0000000000..1ebb5bfcce --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGEllipse.java @@ -0,0 +1,14 @@ +package com.codename1.svg.transcoder.model; + +public final class SVGEllipse extends SVGShape { + private float cx, cy, rx, ry; + + public float getCx() { return cx; } + public void setCx(float cx) { this.cx = cx; } + public float getCy() { return cy; } + public void setCy(float cy) { this.cy = cy; } + public float getRx() { return rx; } + public void setRx(float rx) { this.rx = rx; } + public float getRy() { return ry; } + public void setRy(float ry) { this.ry = ry; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGGradientStop.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGGradientStop.java new file mode 100644 index 0000000000..209d6dcc5f --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGGradientStop.java @@ -0,0 +1,14 @@ +package com.codename1.svg.transcoder.model; + +public final class SVGGradientStop { + private float offset; + private int color; + private float opacity = 1f; + + public float getOffset() { return offset; } + public void setOffset(float offset) { this.offset = offset; } + public int getColor() { return color; } + public void setColor(int color) { this.color = color; } + public float getOpacity() { return opacity; } + public void setOpacity(float opacity) { this.opacity = opacity; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGGroup.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGGroup.java new file mode 100644 index 0000000000..325483d9d9 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGGroup.java @@ -0,0 +1,12 @@ +package com.codename1.svg.transcoder.model; + +import java.util.ArrayList; +import java.util.List; + +/** <g> or <svg> container. */ +public class SVGGroup extends SVGNode { + private final List children = new ArrayList(); + + public List getChildren() { return children; } + public void addChild(SVGNode n) { children.add(n); } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGLine.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGLine.java new file mode 100644 index 0000000000..66ec1535d4 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGLine.java @@ -0,0 +1,14 @@ +package com.codename1.svg.transcoder.model; + +public final class SVGLine extends SVGShape { + private float x1, y1, x2, y2; + + public float getX1() { return x1; } + public void setX1(float v) { this.x1 = v; } + public float getY1() { return y1; } + public void setY1(float v) { this.y1 = v; } + public float getX2() { return x2; } + public void setX2(float v) { this.x2 = v; } + public float getY2() { return y2; } + public void setY2(float v) { this.y2 = v; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGLinearGradient.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGLinearGradient.java new file mode 100644 index 0000000000..652e760c45 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGLinearGradient.java @@ -0,0 +1,28 @@ +package com.codename1.svg.transcoder.model; + +import java.util.ArrayList; +import java.util.List; + +public final class SVGLinearGradient extends SVGNode { + private float x1 = 0f, y1 = 0f, x2 = 1f, y2 = 0f; + private boolean userSpace; + private String href; + private final List stops = new ArrayList(); + + public float getX1() { return x1; } + public void setX1(float v) { this.x1 = v; } + public float getY1() { return y1; } + public void setY1(float v) { this.y1 = v; } + public float getX2() { return x2; } + public void setX2(float v) { this.x2 = v; } + public float getY2() { return y2; } + public void setY2(float v) { this.y2 = v; } + + public boolean isUserSpace() { return userSpace; } + public void setUserSpace(boolean userSpace) { this.userSpace = userSpace; } + + public String getHref() { return href; } + public void setHref(String href) { this.href = href; } + + public List getStops() { return stops; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGNode.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGNode.java new file mode 100644 index 0000000000..8c328e9d53 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGNode.java @@ -0,0 +1,27 @@ +package com.codename1.svg.transcoder.model; + +import com.codename1.svg.transcoder.parser.SVGStyle; +import com.codename1.svg.transcoder.parser.SVGTransform; + +import java.util.ArrayList; +import java.util.List; + +/** Base class for every parsed SVG element. */ +public abstract class SVGNode { + private String id; + private SVGStyle style = new SVGStyle(); + private SVGTransform transform; + private final List animations = new ArrayList(); + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public SVGStyle getStyle() { return style; } + public void setStyle(SVGStyle style) { this.style = style; } + + public SVGTransform getTransform() { return transform; } + public void setTransform(SVGTransform transform) { this.transform = transform; } + + public List getAnimations() { return animations; } + public void addAnimation(SVGAnimation a) { animations.add(a); } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPath.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPath.java new file mode 100644 index 0000000000..eddac7f4d1 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPath.java @@ -0,0 +1,12 @@ +package com.codename1.svg.transcoder.model; + +import com.codename1.svg.transcoder.parser.PathCommand; + +import java.util.List; + +public final class SVGPath extends SVGShape { + private List commands; + + public List getCommands() { return commands; } + public void setCommands(List commands) { this.commands = commands; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPolygon.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPolygon.java new file mode 100644 index 0000000000..81da68fb42 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPolygon.java @@ -0,0 +1,6 @@ +package com.codename1.svg.transcoder.model; + +public final class SVGPolygon extends SVGPolyline { + @Override + public boolean isClosed() { return true; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPolyline.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPolyline.java new file mode 100644 index 0000000000..94c8896e04 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGPolyline.java @@ -0,0 +1,12 @@ +package com.codename1.svg.transcoder.model; + +/** <polyline> — an open polyline. */ +public class SVGPolyline extends SVGShape { + private float[] points = new float[0]; + + public float[] getPoints() { return points; } + public void setPoints(float[] points) { this.points = points == null ? new float[0] : points; } + + /** True when the figure should be closed (polygon). */ + public boolean isClosed() { return false; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGRadialGradient.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGRadialGradient.java new file mode 100644 index 0000000000..04db143c0f --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGRadialGradient.java @@ -0,0 +1,26 @@ +package com.codename1.svg.transcoder.model; + +import java.util.ArrayList; +import java.util.List; + +public final class SVGRadialGradient extends SVGNode { + private float cx = 0.5f, cy = 0.5f, r = 0.5f; + private boolean userSpace; + private String href; + private final List stops = new ArrayList(); + + public float getCx() { return cx; } + public void setCx(float cx) { this.cx = cx; } + public float getCy() { return cy; } + public void setCy(float cy) { this.cy = cy; } + public float getR() { return r; } + public void setR(float r) { this.r = r; } + + public boolean isUserSpace() { return userSpace; } + public void setUserSpace(boolean userSpace) { this.userSpace = userSpace; } + + public String getHref() { return href; } + public void setHref(String href) { this.href = href; } + + public List getStops() { return stops; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGRect.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGRect.java new file mode 100644 index 0000000000..61638f04a7 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGRect.java @@ -0,0 +1,18 @@ +package com.codename1.svg.transcoder.model; + +public final class SVGRect extends SVGShape { + private float x, y, width, height, rx, ry; + + public float getX() { return x; } + public void setX(float x) { this.x = x; } + public float getY() { return y; } + public void setY(float y) { this.y = y; } + public float getWidth() { return width; } + public void setWidth(float w) { this.width = w; } + public float getHeight() { return height; } + public void setHeight(float h) { this.height = h; } + public float getRx() { return rx; } + public void setRx(float rx) { this.rx = rx; } + public float getRy() { return ry; } + public void setRy(float ry) { this.ry = ry; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGShape.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGShape.java new file mode 100644 index 0000000000..23ec8b40a5 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/model/SVGShape.java @@ -0,0 +1,5 @@ +package com.codename1.svg.transcoder.model; + +/** Base for shape elements: rect, circle, ellipse, line, path, polyline, polygon. */ +public abstract class SVGShape extends SVGNode { +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/package-info.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/package-info.java new file mode 100644 index 0000000000..dc6ae5cf47 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/package-info.java @@ -0,0 +1,6 @@ +/** + * Build-time SVG → Java transcoder. See {@link com.codename1.svg.transcoder.SVGTranscoder} + * for the entry point. Generated classes extend + * {@code com.codename1.svg.GeneratedSVGImage} from the core runtime. + */ +package com.codename1.svg.transcoder; diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/ColorParser.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/ColorParser.java new file mode 100644 index 0000000000..f953eff0bd --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/ColorParser.java @@ -0,0 +1,276 @@ +package com.codename1.svg.transcoder.parser; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parses CSS-style color values used by SVG: #RGB, #RRGGBB, rgb(r,g,b), + * rgba(r,g,b,a), or one of the named CSS colors. Returns an ARGB int. + * + * "none" and "currentColor" do not return a color — callers must check for + * them up front via {@link #isNone(String)} and {@link #isCurrentColor(String)}. + */ +public final class ColorParser { + + public static final int TRANSPARENT = 0x00000000; + public static final int BLACK = 0xFF000000; + + private static final Map NAMED = new HashMap(); + + static { + // CSS color names (subset of the SVG 1.1 named-color list, covers the common ones) + NAMED.put("aliceblue", 0xFFF0F8FF); + NAMED.put("antiquewhite", 0xFFFAEBD7); + NAMED.put("aqua", 0xFF00FFFF); + NAMED.put("aquamarine", 0xFF7FFFD4); + NAMED.put("azure", 0xFFF0FFFF); + NAMED.put("beige", 0xFFF5F5DC); + NAMED.put("bisque", 0xFFFFE4C4); + NAMED.put("black", 0xFF000000); + NAMED.put("blanchedalmond", 0xFFFFEBCD); + NAMED.put("blue", 0xFF0000FF); + NAMED.put("blueviolet", 0xFF8A2BE2); + NAMED.put("brown", 0xFFA52A2A); + NAMED.put("burlywood", 0xFFDEB887); + NAMED.put("cadetblue", 0xFF5F9EA0); + NAMED.put("chartreuse", 0xFF7FFF00); + NAMED.put("chocolate", 0xFFD2691E); + NAMED.put("coral", 0xFFFF7F50); + NAMED.put("cornflowerblue", 0xFF6495ED); + NAMED.put("cornsilk", 0xFFFFF8DC); + NAMED.put("crimson", 0xFFDC143C); + NAMED.put("cyan", 0xFF00FFFF); + NAMED.put("darkblue", 0xFF00008B); + NAMED.put("darkcyan", 0xFF008B8B); + NAMED.put("darkgoldenrod", 0xFFB8860B); + NAMED.put("darkgray", 0xFFA9A9A9); + NAMED.put("darkgrey", 0xFFA9A9A9); + NAMED.put("darkgreen", 0xFF006400); + NAMED.put("darkkhaki", 0xFFBDB76B); + NAMED.put("darkmagenta", 0xFF8B008B); + NAMED.put("darkolivegreen", 0xFF556B2F); + NAMED.put("darkorange", 0xFFFF8C00); + NAMED.put("darkorchid", 0xFF9932CC); + NAMED.put("darkred", 0xFF8B0000); + NAMED.put("darksalmon", 0xFFE9967A); + NAMED.put("darkseagreen", 0xFF8FBC8F); + NAMED.put("darkslateblue", 0xFF483D8B); + NAMED.put("darkslategray", 0xFF2F4F4F); + NAMED.put("darkslategrey", 0xFF2F4F4F); + NAMED.put("darkturquoise", 0xFF00CED1); + NAMED.put("darkviolet", 0xFF9400D3); + NAMED.put("deeppink", 0xFFFF1493); + NAMED.put("deepskyblue", 0xFF00BFFF); + NAMED.put("dimgray", 0xFF696969); + NAMED.put("dimgrey", 0xFF696969); + NAMED.put("dodgerblue", 0xFF1E90FF); + NAMED.put("firebrick", 0xFFB22222); + NAMED.put("floralwhite", 0xFFFFFAF0); + NAMED.put("forestgreen", 0xFF228B22); + NAMED.put("fuchsia", 0xFFFF00FF); + NAMED.put("gainsboro", 0xFFDCDCDC); + NAMED.put("ghostwhite", 0xFFF8F8FF); + NAMED.put("gold", 0xFFFFD700); + NAMED.put("goldenrod", 0xFFDAA520); + NAMED.put("gray", 0xFF808080); + NAMED.put("grey", 0xFF808080); + NAMED.put("green", 0xFF008000); + NAMED.put("greenyellow", 0xFFADFF2F); + NAMED.put("honeydew", 0xFFF0FFF0); + NAMED.put("hotpink", 0xFFFF69B4); + NAMED.put("indianred", 0xFFCD5C5C); + NAMED.put("indigo", 0xFF4B0082); + NAMED.put("ivory", 0xFFFFFFF0); + NAMED.put("khaki", 0xFFF0E68C); + NAMED.put("lavender", 0xFFE6E6FA); + NAMED.put("lavenderblush", 0xFFFFF0F5); + NAMED.put("lawngreen", 0xFF7CFC00); + NAMED.put("lemonchiffon", 0xFFFFFACD); + NAMED.put("lightblue", 0xFFADD8E6); + NAMED.put("lightcoral", 0xFFF08080); + NAMED.put("lightcyan", 0xFFE0FFFF); + NAMED.put("lightgoldenrodyellow", 0xFFFAFAD2); + NAMED.put("lightgray", 0xFFD3D3D3); + NAMED.put("lightgrey", 0xFFD3D3D3); + NAMED.put("lightgreen", 0xFF90EE90); + NAMED.put("lightpink", 0xFFFFB6C1); + NAMED.put("lightsalmon", 0xFFFFA07A); + NAMED.put("lightseagreen", 0xFF20B2AA); + NAMED.put("lightskyblue", 0xFF87CEFA); + NAMED.put("lightslategray", 0xFF778899); + NAMED.put("lightslategrey", 0xFF778899); + NAMED.put("lightsteelblue", 0xFFB0C4DE); + NAMED.put("lightyellow", 0xFFFFFFE0); + NAMED.put("lime", 0xFF00FF00); + NAMED.put("limegreen", 0xFF32CD32); + NAMED.put("linen", 0xFFFAF0E6); + NAMED.put("magenta", 0xFFFF00FF); + NAMED.put("maroon", 0xFF800000); + NAMED.put("mediumaquamarine", 0xFF66CDAA); + NAMED.put("mediumblue", 0xFF0000CD); + NAMED.put("mediumorchid", 0xFFBA55D3); + NAMED.put("mediumpurple", 0xFF9370DB); + NAMED.put("mediumseagreen", 0xFF3CB371); + NAMED.put("mediumslateblue", 0xFF7B68EE); + NAMED.put("mediumspringgreen", 0xFF00FA9A); + NAMED.put("mediumturquoise", 0xFF48D1CC); + NAMED.put("mediumvioletred", 0xFFC71585); + NAMED.put("midnightblue", 0xFF191970); + NAMED.put("mintcream", 0xFFF5FFFA); + NAMED.put("mistyrose", 0xFFFFE4E1); + NAMED.put("moccasin", 0xFFFFE4B5); + NAMED.put("navajowhite", 0xFFFFDEAD); + NAMED.put("navy", 0xFF000080); + NAMED.put("oldlace", 0xFFFDF5E6); + NAMED.put("olive", 0xFF808000); + NAMED.put("olivedrab", 0xFF6B8E23); + NAMED.put("orange", 0xFFFFA500); + NAMED.put("orangered", 0xFFFF4500); + NAMED.put("orchid", 0xFFDA70D6); + NAMED.put("palegoldenrod", 0xFFEEE8AA); + NAMED.put("palegreen", 0xFF98FB98); + NAMED.put("paleturquoise", 0xFFAFEEEE); + NAMED.put("palevioletred", 0xFFDB7093); + NAMED.put("papayawhip", 0xFFFFEFD5); + NAMED.put("peachpuff", 0xFFFFDAB9); + NAMED.put("peru", 0xFFCD853F); + NAMED.put("pink", 0xFFFFC0CB); + NAMED.put("plum", 0xFFDDA0DD); + NAMED.put("powderblue", 0xFFB0E0E6); + NAMED.put("purple", 0xFF800080); + NAMED.put("rebeccapurple", 0xFF663399); + NAMED.put("red", 0xFFFF0000); + NAMED.put("rosybrown", 0xFFBC8F8F); + NAMED.put("royalblue", 0xFF4169E1); + NAMED.put("saddlebrown", 0xFF8B4513); + NAMED.put("salmon", 0xFFFA8072); + NAMED.put("sandybrown", 0xFFF4A460); + NAMED.put("seagreen", 0xFF2E8B57); + NAMED.put("seashell", 0xFFFFF5EE); + NAMED.put("sienna", 0xFFA0522D); + NAMED.put("silver", 0xFFC0C0C0); + NAMED.put("skyblue", 0xFF87CEEB); + NAMED.put("slateblue", 0xFF6A5ACD); + NAMED.put("slategray", 0xFF708090); + NAMED.put("slategrey", 0xFF708090); + NAMED.put("snow", 0xFFFFFAFA); + NAMED.put("springgreen", 0xFF00FF7F); + NAMED.put("steelblue", 0xFF4682B4); + NAMED.put("tan", 0xFFD2B48C); + NAMED.put("teal", 0xFF008080); + NAMED.put("thistle", 0xFFD8BFD8); + NAMED.put("tomato", 0xFFFF6347); + NAMED.put("transparent", 0x00000000); + NAMED.put("turquoise", 0xFF40E0D0); + NAMED.put("violet", 0xFFEE82EE); + NAMED.put("wheat", 0xFFF5DEB3); + NAMED.put("white", 0xFFFFFFFF); + NAMED.put("whitesmoke", 0xFFF5F5F5); + NAMED.put("yellow", 0xFFFFFF00); + NAMED.put("yellowgreen", 0xFF9ACD32); + } + + private ColorParser() { } + + public static boolean isNone(String value) { + return value != null && "none".equalsIgnoreCase(value.trim()); + } + + public static boolean isCurrentColor(String value) { + return value != null && "currentColor".equalsIgnoreCase(value.trim()); + } + + /** + * Parse a color value. Returns ARGB int with alpha set to 0xFF for opaque + * formats. Throws IllegalArgumentException for unknown values. + */ + public static int parse(String value) { + if (value == null) throw new IllegalArgumentException("null color"); + String v = value.trim(); + if (v.isEmpty()) throw new IllegalArgumentException("empty color"); + if (v.charAt(0) == '#') return parseHex(v); + if (v.startsWith("rgb")) return parseRgb(v); + Integer named = NAMED.get(v.toLowerCase()); + if (named != null) return named; + throw new IllegalArgumentException("Unrecognized color: " + value); + } + + /** Same as {@link #parse} but returns {@code fallback} on unknown input. */ + public static int parseOrDefault(String value, int fallback) { + try { + return parse(value); + } catch (RuntimeException e) { + return fallback; + } + } + + private static int parseHex(String v) { + String hex = v.substring(1); + int r, g, b, a = 0xFF; + if (hex.length() == 3) { + r = nib(hex.charAt(0)); r |= r << 4; + g = nib(hex.charAt(1)); g |= g << 4; + b = nib(hex.charAt(2)); b |= b << 4; + } else if (hex.length() == 4) { + r = nib(hex.charAt(0)); r |= r << 4; + g = nib(hex.charAt(1)); g |= g << 4; + b = nib(hex.charAt(2)); b |= b << 4; + a = nib(hex.charAt(3)); a |= a << 4; + } else if (hex.length() == 6) { + r = (nib(hex.charAt(0)) << 4) | nib(hex.charAt(1)); + g = (nib(hex.charAt(2)) << 4) | nib(hex.charAt(3)); + b = (nib(hex.charAt(4)) << 4) | nib(hex.charAt(5)); + } else if (hex.length() == 8) { + r = (nib(hex.charAt(0)) << 4) | nib(hex.charAt(1)); + g = (nib(hex.charAt(2)) << 4) | nib(hex.charAt(3)); + b = (nib(hex.charAt(4)) << 4) | nib(hex.charAt(5)); + a = (nib(hex.charAt(6)) << 4) | nib(hex.charAt(7)); + } else { + throw new IllegalArgumentException("Bad hex color: " + v); + } + return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } + + private static int nib(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + throw new IllegalArgumentException("Bad hex digit: " + c); + } + + private static int parseRgb(String v) { + boolean hasAlpha = v.startsWith("rgba"); + int open = v.indexOf('('); + int close = v.lastIndexOf(')'); + if (open < 0 || close < 0 || close <= open) { + throw new IllegalArgumentException("Bad rgb color: " + v); + } + String inside = v.substring(open + 1, close); + String[] parts = inside.split(","); + if (parts.length < 3) throw new IllegalArgumentException("Bad rgb color: " + v); + int r = component(parts[0]); + int g = component(parts[1]); + int b = component(parts[2]); + int a = 0xFF; + if (hasAlpha && parts.length >= 4) { + float af = Float.parseFloat(parts[3].trim()); + a = Math.round(af * 255f) & 0xFF; + } + return ((a & 0xFF) << 24) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } + + private static int component(String s) { + String t = s.trim(); + if (t.endsWith("%")) { + float p = Float.parseFloat(t.substring(0, t.length() - 1).trim()); + return clamp(Math.round(p * 255f / 100f)); + } + return clamp((int) Math.round(Double.parseDouble(t))); + } + + private static int clamp(int v) { + if (v < 0) return 0; + if (v > 255) return 255; + return v; + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/NumberParser.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/NumberParser.java new file mode 100644 index 0000000000..1d9412da1c --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/NumberParser.java @@ -0,0 +1,82 @@ +package com.codename1.svg.transcoder.parser; + +/** Shared scanner state for SVG numeric lists. Trims units like px, pt, %. */ +public final class NumberParser { + + private final String s; + private int pos; + + public NumberParser(String s) { + this.s = s == null ? "" : s; + } + + public boolean hasMore() { + skipWsAndCommas(); + return pos < s.length(); + } + + public float nextFloat() { + skipWsAndCommas(); + int start = pos; + int len = s.length(); + if (pos < len && (s.charAt(pos) == '+' || s.charAt(pos) == '-')) pos++; + boolean sawDigit = false; + while (pos < len && Character.isDigit(s.charAt(pos))) { pos++; sawDigit = true; } + if (pos < len && s.charAt(pos) == '.') { + pos++; + while (pos < len && Character.isDigit(s.charAt(pos))) { pos++; sawDigit = true; } + } + if (pos < len && (s.charAt(pos) == 'e' || s.charAt(pos) == 'E')) { + pos++; + if (pos < len && (s.charAt(pos) == '+' || s.charAt(pos) == '-')) pos++; + while (pos < len && Character.isDigit(s.charAt(pos))) pos++; + } + if (!sawDigit) { + throw new IllegalArgumentException("Expected number at " + start + " in '" + s + "'"); + } + String tok = s.substring(start, pos); + // tolerate trailing unit + while (pos < len) { + char c = s.charAt(pos); + if (Character.isLetter(c) || c == '%') pos++; + else break; + } + return Float.parseFloat(tok); + } + + /** Read a binary flag (0 or 1) — used by SVG arc commands. */ + public int nextFlag() { + skipWsAndCommas(); + if (pos >= s.length()) { + throw new IllegalArgumentException("Expected flag in '" + s + "'"); + } + char c = s.charAt(pos++); + if (c != '0' && c != '1') { + throw new IllegalArgumentException("Expected flag (0 or 1) at " + (pos - 1) + " in '" + s + "'"); + } + return c - '0'; + } + + private void skipWsAndCommas() { + int len = s.length(); + while (pos < len) { + char c = s.charAt(pos); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',') pos++; + else break; + } + } + + /** Parse a single float possibly suffixed with a unit. */ + public static float parseFloat(String value) { + if (value == null) return 0f; + String v = value.trim(); + if (v.isEmpty()) return 0f; + int end = v.length(); + while (end > 0) { + char c = v.charAt(end - 1); + if (Character.isLetter(c) || c == '%') end--; + else break; + } + return Float.parseFloat(v.substring(0, end)); + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/PathCommand.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/PathCommand.java new file mode 100644 index 0000000000..ccf9cd3a80 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/PathCommand.java @@ -0,0 +1,25 @@ +package com.codename1.svg.transcoder.parser; + +/** + * One absolute-coordinate command from an SVG path's d="..." attribute. + * + * The parser collapses relative commands to absolute, S/T smooth curves to + * the equivalent C/Q with the implicit control point already resolved, and + * H/V to L. Arc commands are kept as ARC so the generator can decompose + * them into cubic Bezier segments at codegen time. + */ +public final class PathCommand { + + public enum Type { MOVE, LINE, CUBIC, QUAD, ARC, CLOSE } + + private final Type type; + private final float[] args; + + public PathCommand(Type type, float[] args) { + this.type = type; + this.args = args; + } + + public Type getType() { return type; } + public float[] getArgs() { return args; } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/PathDataParser.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/PathDataParser.java new file mode 100644 index 0000000000..538ec23fab --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/PathDataParser.java @@ -0,0 +1,238 @@ +package com.codename1.svg.transcoder.parser; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parses the SVG path d="..." mini-language to a flat list of absolute-coordinate + * {@link PathCommand}s. Implicit repeats, relative coordinates and smooth-curve + * control-point reflection are resolved here so the code generator stays simple. + */ +public final class PathDataParser { + + private PathDataParser() { } + + public static List parse(String d) { + List out = new ArrayList(); + if (d == null || d.trim().isEmpty()) return out; + + // We scan the string by splitting on command letters but preserving them as separators. + // It is easier to walk character-by-character. + char[] cs = d.toCharArray(); + int i = 0; + int len = cs.length; + + float curX = 0, curY = 0; // current point + float startX = 0, startY = 0; // subpath start point (for Z) + char lastCmd = 0; + float prevCubicCtrlX = 0, prevCubicCtrlY = 0; + float prevQuadCtrlX = 0, prevQuadCtrlY = 0; + boolean haveCubic = false; + boolean haveQuad = false; + + while (i < len) { + // skip whitespace and commas + while (i < len && (isWs(cs[i]) || cs[i] == ',')) i++; + if (i >= len) break; + + char c = cs[i]; + char cmd; + if (isCmdLetter(c)) { + cmd = c; + i++; + lastCmd = cmd; + } else { + // Implicit repeat: re-use last cmd (M repeats become L, m become l). + if (lastCmd == 0) { + throw new IllegalArgumentException("Path data starts with a number: '" + d + "'"); + } + if (lastCmd == 'M') cmd = 'L'; + else if (lastCmd == 'm') cmd = 'l'; + else cmd = lastCmd; + } + + // Find the end of this command's numeric arguments — the next command letter. + int argStart = i; + while (i < len && !isCmdLetter(cs[i])) i++; + String argStr = new String(cs, argStart, i - argStart); + NumberParser np = new NumberParser(argStr); + + switch (cmd) { + case 'M': + case 'm': { + boolean rel = cmd == 'm'; + boolean first = true; + while (np.hasMore()) { + float x = np.nextFloat(); + float y = np.nextFloat(); + if (rel) { x += curX; y += curY; } + if (first) { + out.add(new PathCommand(PathCommand.Type.MOVE, new float[]{x, y})); + startX = x; startY = y; + first = false; + } else { + out.add(new PathCommand(PathCommand.Type.LINE, new float[]{x, y})); + } + curX = x; curY = y; + } + haveCubic = haveQuad = false; + break; + } + case 'L': + case 'l': { + boolean rel = cmd == 'l'; + while (np.hasMore()) { + float x = np.nextFloat(); + float y = np.nextFloat(); + if (rel) { x += curX; y += curY; } + out.add(new PathCommand(PathCommand.Type.LINE, new float[]{x, y})); + curX = x; curY = y; + } + haveCubic = haveQuad = false; + break; + } + case 'H': + case 'h': { + boolean rel = cmd == 'h'; + while (np.hasMore()) { + float x = np.nextFloat(); + if (rel) x += curX; + out.add(new PathCommand(PathCommand.Type.LINE, new float[]{x, curY})); + curX = x; + } + haveCubic = haveQuad = false; + break; + } + case 'V': + case 'v': { + boolean rel = cmd == 'v'; + while (np.hasMore()) { + float y = np.nextFloat(); + if (rel) y += curY; + out.add(new PathCommand(PathCommand.Type.LINE, new float[]{curX, y})); + curY = y; + } + haveCubic = haveQuad = false; + break; + } + case 'C': + case 'c': { + boolean rel = cmd == 'c'; + while (np.hasMore()) { + float x1 = np.nextFloat(), y1 = np.nextFloat(); + float x2 = np.nextFloat(), y2 = np.nextFloat(); + float x = np.nextFloat(), y = np.nextFloat(); + if (rel) { x1 += curX; y1 += curY; x2 += curX; y2 += curY; x += curX; y += curY; } + out.add(new PathCommand(PathCommand.Type.CUBIC, new float[]{x1, y1, x2, y2, x, y})); + prevCubicCtrlX = x2; prevCubicCtrlY = y2; + curX = x; curY = y; + haveCubic = true; haveQuad = false; + } + break; + } + case 'S': + case 's': { + boolean rel = cmd == 's'; + while (np.hasMore()) { + float x1, y1; + if (haveCubic) { + x1 = 2 * curX - prevCubicCtrlX; + y1 = 2 * curY - prevCubicCtrlY; + } else { + x1 = curX; y1 = curY; + } + float x2 = np.nextFloat(), y2 = np.nextFloat(); + float x = np.nextFloat(), y = np.nextFloat(); + if (rel) { x2 += curX; y2 += curY; x += curX; y += curY; } + out.add(new PathCommand(PathCommand.Type.CUBIC, new float[]{x1, y1, x2, y2, x, y})); + prevCubicCtrlX = x2; prevCubicCtrlY = y2; + curX = x; curY = y; + haveCubic = true; haveQuad = false; + } + break; + } + case 'Q': + case 'q': { + boolean rel = cmd == 'q'; + while (np.hasMore()) { + float x1 = np.nextFloat(), y1 = np.nextFloat(); + float x = np.nextFloat(), y = np.nextFloat(); + if (rel) { x1 += curX; y1 += curY; x += curX; y += curY; } + out.add(new PathCommand(PathCommand.Type.QUAD, new float[]{x1, y1, x, y})); + prevQuadCtrlX = x1; prevQuadCtrlY = y1; + curX = x; curY = y; + haveQuad = true; haveCubic = false; + } + break; + } + case 'T': + case 't': { + boolean rel = cmd == 't'; + while (np.hasMore()) { + float x1, y1; + if (haveQuad) { + x1 = 2 * curX - prevQuadCtrlX; + y1 = 2 * curY - prevQuadCtrlY; + } else { + x1 = curX; y1 = curY; + } + float x = np.nextFloat(), y = np.nextFloat(); + if (rel) { x += curX; y += curY; } + out.add(new PathCommand(PathCommand.Type.QUAD, new float[]{x1, y1, x, y})); + prevQuadCtrlX = x1; prevQuadCtrlY = y1; + curX = x; curY = y; + haveQuad = true; haveCubic = false; + } + break; + } + case 'A': + case 'a': { + boolean rel = cmd == 'a'; + while (np.hasMore()) { + float rx = np.nextFloat(); + float ry = np.nextFloat(); + float xRot = np.nextFloat(); + int largeArc = np.nextFlag(); + int sweep = np.nextFlag(); + float x = np.nextFloat(); + float y = np.nextFloat(); + if (rel) { x += curX; y += curY; } + out.add(new PathCommand(PathCommand.Type.ARC, + new float[]{curX, curY, rx, ry, xRot, largeArc, sweep, x, y})); + curX = x; curY = y; + haveCubic = haveQuad = false; + } + break; + } + case 'Z': + case 'z': { + out.add(new PathCommand(PathCommand.Type.CLOSE, new float[0])); + curX = startX; curY = startY; + haveCubic = haveQuad = false; + break; + } + default: + throw new IllegalArgumentException("Unknown path command '" + cmd + "' in '" + d + "'"); + } + } + return out; + } + + private static boolean isCmdLetter(char c) { + switch (c) { + case 'M': case 'm': case 'L': case 'l': + case 'H': case 'h': case 'V': case 'v': + case 'C': case 'c': case 'S': case 's': + case 'Q': case 'q': case 'T': case 't': + case 'A': case 'a': + case 'Z': case 'z': + return true; + default: + return false; + } + } + + private static boolean isWs(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGPaint.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGPaint.java new file mode 100644 index 0000000000..4bdfea6e5e --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGPaint.java @@ -0,0 +1,51 @@ +package com.codename1.svg.transcoder.parser; + +/** + * A paint value: either a solid ARGB color, a reference to a gradient + * definition by id (set via {@link #setReference}), or "none". + */ +public final class SVGPaint { + + public static final SVGPaint NONE = new SVGPaint(true, 0, null); + public static final SVGPaint BLACK = new SVGPaint(false, 0xFF000000, null); + + private final boolean none; + private final int color; + private final String reference; + + private SVGPaint(boolean none, int color, String reference) { + this.none = none; + this.color = color; + this.reference = reference; + } + + public static SVGPaint ofColor(int argb) { + return new SVGPaint(false, argb, null); + } + + public static SVGPaint ofReference(String id) { + return new SVGPaint(false, 0, id); + } + + public boolean isNone() { return none; } + public int getColor() { return color; } + public String getReference() { return reference; } + public boolean isReference() { return reference != null; } + + public static SVGPaint setReference(String value) { + String r = stripUrl(value); + return r == null ? null : ofReference(r); + } + + /** "url(#foo)" → "foo"; returns null otherwise. */ + public static String stripUrl(String s) { + if (s == null) return null; + String t = s.trim(); + if (!t.startsWith("url(")) return null; + int close = t.indexOf(')', 4); + if (close < 0) return null; + String inside = t.substring(4, close).trim(); + if (inside.startsWith("#")) inside = inside.substring(1); + return inside; + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGParser.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGParser.java new file mode 100644 index 0000000000..48b8a9406b --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGParser.java @@ -0,0 +1,417 @@ +package com.codename1.svg.transcoder.parser; + +import com.codename1.svg.transcoder.animation.SMILParser; +import com.codename1.svg.transcoder.model.*; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Walks an SVG XML document with StAX and builds a {@link SVGDocument} tree. + * + * Element coverage: + * svg, g, defs + * rect, circle, ellipse, line, polyline, polygon, path + * linearGradient, radialGradient, stop + * animate, animateTransform, set, title, desc (last two ignored). + * + * Anything else is skipped silently so an unfamiliar element won't fail the + * whole build — the transcoder errs on the side of "render what we can". + */ +public final class SVGParser { + + public SVGDocument parse(InputStream in) throws IOException { + XMLInputFactory f = XMLInputFactory.newInstance(); + // harden against XXE + f.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE); + f.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE); + try { + XMLStreamReader r = f.createXMLStreamReader(in); + try { + return parseDocument(r); + } finally { + r.close(); + } + } catch (XMLStreamException e) { + throw new IOException(e); + } + } + + private SVGDocument parseDocument(XMLStreamReader r) throws XMLStreamException { + while (r.hasNext()) { + int ev = r.next(); + if (ev == XMLStreamConstants.START_ELEMENT && "svg".equals(r.getLocalName())) { + SVGDocument doc = new SVGDocument(); + readSVGRoot(r, doc); + readChildren(r, doc, doc); + return doc; + } + } + throw new XMLStreamException("No root found"); + } + + private void readSVGRoot(XMLStreamReader r, SVGDocument doc) { + Map a = attrs(r); + applyCommon(doc, a); + doc.setWidth(NumberParser.parseFloat(a.get("width"))); + doc.setHeight(NumberParser.parseFloat(a.get("height"))); + String vb = a.get("viewBox"); + if (vb != null) { + NumberParser np = new NumberParser(vb); + try { + doc.setViewBoxX(np.nextFloat()); + doc.setViewBoxY(np.nextFloat()); + doc.setViewBoxWidth(np.nextFloat()); + doc.setViewBoxHeight(np.nextFloat()); + } catch (RuntimeException e) { + // leave defaults + } + } + if (doc.getViewBoxWidth() == 0) doc.setViewBoxWidth(doc.getWidth()); + if (doc.getViewBoxHeight() == 0) doc.setViewBoxHeight(doc.getHeight()); + if (doc.getWidth() == 0) doc.setWidth(doc.getViewBoxWidth()); + if (doc.getHeight() == 0) doc.setHeight(doc.getViewBoxHeight()); + } + + private void readChildren(XMLStreamReader r, SVGGroup parent, SVGDocument doc) throws XMLStreamException { + while (r.hasNext()) { + int ev = r.next(); + if (ev == XMLStreamConstants.END_ELEMENT) return; + if (ev != XMLStreamConstants.START_ELEMENT) continue; + + String name = r.getLocalName(); + if ("g".equals(name)) { + SVGGroup g = new SVGGroup(); + applyCommon(g, attrs(r)); + parent.addChild(g); + readChildren(r, g, doc); + } else if ("defs".equals(name)) { + readDefs(r, doc); + } else if ("rect".equals(name)) { + SVGRect rect = readRect(r); + parent.addChild(rect); + readNestedAnimations(r, rect); + } else if ("circle".equals(name)) { + SVGCircle circle = readCircle(r); + parent.addChild(circle); + readNestedAnimations(r, circle); + } else if ("ellipse".equals(name)) { + SVGEllipse el = readEllipse(r); + parent.addChild(el); + readNestedAnimations(r, el); + } else if ("line".equals(name)) { + SVGLine ln = readLine(r); + parent.addChild(ln); + readNestedAnimations(r, ln); + } else if ("polyline".equals(name)) { + SVGPolyline pl = readPolyline(r, false); + parent.addChild(pl); + readNestedAnimations(r, pl); + } else if ("polygon".equals(name)) { + SVGPolyline pg = readPolyline(r, true); + parent.addChild(pg); + readNestedAnimations(r, pg); + } else if ("path".equals(name)) { + SVGPath path = readPath(r); + parent.addChild(path); + readNestedAnimations(r, path); + } else if ("linearGradient".equals(name)) { + SVGLinearGradient lg = readLinearGradient(r); + if (lg.getId() != null) doc.getDefinitions().put(lg.getId(), lg); + } else if ("radialGradient".equals(name)) { + SVGRadialGradient rg = readRadialGradient(r); + if (rg.getId() != null) doc.getDefinitions().put(rg.getId(), rg); + } else if ("animate".equals(name) || "animateTransform".equals(name) || "set".equals(name)) { + SVGAnimation an = readAnimation(r, name); + // SVG semantics: as a sibling of shapes inside a + // animates the group itself (typically its transform). The + // shape-nested case ( inside , , etc.) + // is handled separately by readNestedAnimations. + parent.addAnimation(an); + consumeUntilEnd(r); + } else { + skip(r); + } + } + } + + private void readDefs(XMLStreamReader r, SVGDocument doc) throws XMLStreamException { + while (r.hasNext()) { + int ev = r.next(); + if (ev == XMLStreamConstants.END_ELEMENT) return; + if (ev != XMLStreamConstants.START_ELEMENT) continue; + String name = r.getLocalName(); + if ("linearGradient".equals(name)) { + SVGLinearGradient lg = readLinearGradient(r); + if (lg.getId() != null) doc.getDefinitions().put(lg.getId(), lg); + } else if ("radialGradient".equals(name)) { + SVGRadialGradient rg = readRadialGradient(r); + if (rg.getId() != null) doc.getDefinitions().put(rg.getId(), rg); + } else { + skip(r); + } + } + } + + private SVGRect readRect(XMLStreamReader r) { + SVGRect s = new SVGRect(); + Map a = attrs(r); + applyCommon(s, a); + s.setX(NumberParser.parseFloat(a.get("x"))); + s.setY(NumberParser.parseFloat(a.get("y"))); + s.setWidth(NumberParser.parseFloat(a.get("width"))); + s.setHeight(NumberParser.parseFloat(a.get("height"))); + s.setRx(NumberParser.parseFloat(a.get("rx"))); + s.setRy(NumberParser.parseFloat(a.get("ry"))); + return s; + } + + private SVGCircle readCircle(XMLStreamReader r) { + SVGCircle s = new SVGCircle(); + Map a = attrs(r); + applyCommon(s, a); + s.setCx(NumberParser.parseFloat(a.get("cx"))); + s.setCy(NumberParser.parseFloat(a.get("cy"))); + s.setR(NumberParser.parseFloat(a.get("r"))); + return s; + } + + private SVGEllipse readEllipse(XMLStreamReader r) { + SVGEllipse s = new SVGEllipse(); + Map a = attrs(r); + applyCommon(s, a); + s.setCx(NumberParser.parseFloat(a.get("cx"))); + s.setCy(NumberParser.parseFloat(a.get("cy"))); + s.setRx(NumberParser.parseFloat(a.get("rx"))); + s.setRy(NumberParser.parseFloat(a.get("ry"))); + return s; + } + + private SVGLine readLine(XMLStreamReader r) { + SVGLine s = new SVGLine(); + Map a = attrs(r); + applyCommon(s, a); + s.setX1(NumberParser.parseFloat(a.get("x1"))); + s.setY1(NumberParser.parseFloat(a.get("y1"))); + s.setX2(NumberParser.parseFloat(a.get("x2"))); + s.setY2(NumberParser.parseFloat(a.get("y2"))); + return s; + } + + private SVGPolyline readPolyline(XMLStreamReader r, boolean closed) { + SVGPolyline s = closed ? new SVGPolygon() : new SVGPolyline(); + Map a = attrs(r); + applyCommon(s, a); + String pts = a.get("points"); + if (pts != null) { + NumberParser np = new NumberParser(pts); + List list = new ArrayList(); + while (np.hasMore()) list.add(np.nextFloat()); + float[] arr = new float[list.size()]; + for (int i = 0; i < arr.length; i++) arr[i] = list.get(i); + s.setPoints(arr); + } + return s; + } + + private SVGPath readPath(XMLStreamReader r) { + SVGPath p = new SVGPath(); + Map a = attrs(r); + applyCommon(p, a); + p.setCommands(PathDataParser.parse(a.get("d"))); + return p; + } + + private SVGLinearGradient readLinearGradient(XMLStreamReader r) throws XMLStreamException { + SVGLinearGradient g = new SVGLinearGradient(); + Map a = attrs(r); + g.setId(a.get("id")); + if (a.containsKey("x1")) g.setX1(parseGradCoord(a.get("x1"))); + if (a.containsKey("y1")) g.setY1(parseGradCoord(a.get("y1"))); + if (a.containsKey("x2")) g.setX2(parseGradCoord(a.get("x2"))); + if (a.containsKey("y2")) g.setY2(parseGradCoord(a.get("y2"))); + if ("userSpaceOnUse".equals(a.get("gradientUnits"))) g.setUserSpace(true); + String href = a.get("href"); + if (href == null) href = a.get("xlink:href"); + if (href != null && href.startsWith("#")) g.setHref(href.substring(1)); + readGradientStops(r, g.getStops()); + return g; + } + + private SVGRadialGradient readRadialGradient(XMLStreamReader r) throws XMLStreamException { + SVGRadialGradient g = new SVGRadialGradient(); + Map a = attrs(r); + g.setId(a.get("id")); + if (a.containsKey("cx")) g.setCx(parseGradCoord(a.get("cx"))); + if (a.containsKey("cy")) g.setCy(parseGradCoord(a.get("cy"))); + if (a.containsKey("r")) g.setR(parseGradCoord(a.get("r"))); + if ("userSpaceOnUse".equals(a.get("gradientUnits"))) g.setUserSpace(true); + String href = a.get("href"); + if (href == null) href = a.get("xlink:href"); + if (href != null && href.startsWith("#")) g.setHref(href.substring(1)); + readGradientStops(r, g.getStops()); + return g; + } + + private float parseGradCoord(String s) { + if (s == null) return 0f; + String v = s.trim(); + if (v.endsWith("%")) { + return Float.parseFloat(v.substring(0, v.length() - 1)) / 100f; + } + return NumberParser.parseFloat(v); + } + + private void readGradientStops(XMLStreamReader r, List stops) throws XMLStreamException { + while (r.hasNext()) { + int ev = r.next(); + if (ev == XMLStreamConstants.END_ELEMENT) return; + if (ev != XMLStreamConstants.START_ELEMENT) continue; + if (!"stop".equals(r.getLocalName())) { skip(r); continue; } + Map a = attrs(r); + SVGGradientStop stop = new SVGGradientStop(); + stop.setOffset(parseGradCoord(a.get("offset"))); + SVGStyle s = StyleParser.parse(presentationFor(a, "stop-color", "stop-opacity"), a.get("style")); + // stop-color is held as fill in our merged map. Use directly: + String sc = mergedValue(a, "stop-color"); + if (sc != null && !ColorParser.isNone(sc)) { + try { + stop.setColor(ColorParser.parse(sc)); + } catch (RuntimeException e) { + stop.setColor(ColorParser.BLACK); + } + } else if (s.getFill() != null && !s.getFill().isNone() && !s.getFill().isReference()) { + stop.setColor(s.getFill().getColor()); + } else { + stop.setColor(ColorParser.BLACK); + } + String so = mergedValue(a, "stop-opacity"); + if (so != null) { + try { stop.setOpacity(NumberParser.parseFloat(so)); } catch (RuntimeException e) { /* keep default */ } + } else if (s.getFillOpacity() != null) { + stop.setOpacity(s.getFillOpacity()); + } + stops.add(stop); + consumeUntilEnd(r); + } + } + + private String mergedValue(Map attrs, String key) { + if (attrs.containsKey(key)) return attrs.get(key); + String style = attrs.get("style"); + if (style == null) return null; + for (String decl : style.split(";")) { + int colon = decl.indexOf(':'); + if (colon <= 0) continue; + if (decl.substring(0, colon).trim().equals(key)) { + return decl.substring(colon + 1).trim(); + } + } + return null; + } + + private Map presentationFor(Map attrs, String... keys) { + Map out = new HashMap(); + for (String k : keys) { + if (attrs.containsKey(k)) out.put(k.startsWith("stop-") ? "fill" : k, attrs.get(k)); + } + // map stop-color → fill, stop-opacity → fill-opacity for reuse with StyleParser + if (attrs.containsKey("stop-color")) out.put("fill", attrs.get("stop-color")); + if (attrs.containsKey("stop-opacity")) out.put("fill-opacity", attrs.get("stop-opacity")); + return out; + } + + private SVGAnimation readAnimation(XMLStreamReader r, String elementName) { + SVGAnimation an = new SVGAnimation(); + Map a = attrs(r); + if ("animateTransform".equals(elementName)) { + an.setKind(SVGAnimation.Kind.ANIMATE_TRANSFORM); + an.setTransformType(SMILParser.parseTransformType(a.get("type"))); + } else if ("set".equals(elementName)) { + an.setKind(SVGAnimation.Kind.SET); + } else { + an.setKind(SVGAnimation.Kind.ANIMATE); + } + an.setAttributeName(a.get("attributeName")); + an.setFrom(a.get("from")); + an.setTo(a.get("to")); + an.setBy(a.get("by")); + an.setValues(SMILParser.parseValues(a.get("values"))); + an.setBeginMs(SMILParser.parseClock(a.get("begin"), 0)); + an.setDurMs(SMILParser.parseClock(a.get("dur"), 0)); + an.setRepeatCount(SMILParser.parseRepeatCount(a.get("repeatCount"))); + an.setCalcMode(SMILParser.parseCalcMode(a.get("calcMode"))); + an.setFreeze("freeze".equalsIgnoreCase(a.get("fill"))); + return an; + } + + private void applyCommon(SVGNode n, Map a) { + n.setId(a.get("id")); + String tr = a.get("transform"); + if (tr != null) { + SVGTransform t = TransformParser.parse(tr); + if (t != null) n.setTransform(t); + } + Map pres = new HashMap(); + for (Map.Entry e : a.entrySet()) { + String k = e.getKey(); + if ("fill".equals(k) || "stroke".equals(k) || "fill-opacity".equals(k) || "stroke-opacity".equals(k) + || "opacity".equals(k) || "stroke-width".equals(k) || "stroke-linecap".equals(k) + || "stroke-linejoin".equals(k) || "stroke-miterlimit".equals(k)) { + pres.put(k, e.getValue()); + } + } + n.setStyle(StyleParser.parse(pres, a.get("style"))); + } + + private Map attrs(XMLStreamReader r) { + Map m = new HashMap(); + int n = r.getAttributeCount(); + for (int i = 0; i < n; i++) { + String prefix = r.getAttributePrefix(i); + String name = r.getAttributeLocalName(i); + String key = (prefix == null || prefix.isEmpty()) ? name : prefix + ":" + name; + m.put(key, r.getAttributeValue(i)); + // also stash bare local name so callers can ignore namespace prefixes + m.put(name, r.getAttributeValue(i)); + } + return m; + } + + /** Read child elements of a shape — currently only animation children matter. */ + private void readNestedAnimations(XMLStreamReader r, SVGNode shape) throws XMLStreamException { + while (r.hasNext()) { + int ev = r.next(); + if (ev == XMLStreamConstants.END_ELEMENT) return; + if (ev != XMLStreamConstants.START_ELEMENT) continue; + String name = r.getLocalName(); + if ("animate".equals(name) || "animateTransform".equals(name) || "set".equals(name)) { + shape.addAnimation(readAnimation(r, name)); + consumeUntilEnd(r); + } else { + skip(r); + } + } + } + + private void consumeUntilEnd(XMLStreamReader r) throws XMLStreamException { + int depth = 1; + while (r.hasNext() && depth > 0) { + int ev = r.next(); + if (ev == XMLStreamConstants.START_ELEMENT) depth++; + else if (ev == XMLStreamConstants.END_ELEMENT) depth--; + } + } + + private void skip(XMLStreamReader r) throws XMLStreamException { + consumeUntilEnd(r); + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGStyle.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGStyle.java new file mode 100644 index 0000000000..bc0cbc67db --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGStyle.java @@ -0,0 +1,60 @@ +package com.codename1.svg.transcoder.parser; + +/** + * Resolved style block for a node — everything the renderer needs to fill / + * stroke this shape. Field "null" means "inherit from parent / leave unchanged". + */ +public final class SVGStyle { + + public static final int LINECAP_BUTT = 0; + public static final int LINECAP_ROUND = 1; + public static final int LINECAP_SQUARE = 2; + + public static final int LINEJOIN_MITER = 0; + public static final int LINEJOIN_ROUND = 1; + public static final int LINEJOIN_BEVEL = 2; + + private SVGPaint fill; + private SVGPaint stroke; + private Float fillOpacity; + private Float strokeOpacity; + private Float opacity; + private Float strokeWidth; + private Integer strokeLineCap; + private Integer strokeLineJoin; + private Float strokeMiterLimit; + + public SVGPaint getFill() { return fill; } + public void setFill(SVGPaint fill) { this.fill = fill; } + public SVGPaint getStroke() { return stroke; } + public void setStroke(SVGPaint stroke) { this.stroke = stroke; } + public Float getFillOpacity() { return fillOpacity; } + public void setFillOpacity(Float v) { this.fillOpacity = v; } + public Float getStrokeOpacity() { return strokeOpacity; } + public void setStrokeOpacity(Float v) { this.strokeOpacity = v; } + public Float getOpacity() { return opacity; } + public void setOpacity(Float v) { this.opacity = v; } + public Float getStrokeWidth() { return strokeWidth; } + public void setStrokeWidth(Float v) { this.strokeWidth = v; } + public Integer getStrokeLineCap() { return strokeLineCap; } + public void setStrokeLineCap(Integer v) { this.strokeLineCap = v; } + public Integer getStrokeLineJoin() { return strokeLineJoin; } + public void setStrokeLineJoin(Integer v) { this.strokeLineJoin = v; } + public Float getStrokeMiterLimit() { return strokeMiterLimit; } + public void setStrokeMiterLimit(Float v) { this.strokeMiterLimit = v; } + + /** Overlay other's set fields on top of this. */ + public SVGStyle inherit(SVGStyle parent) { + if (parent == null) return this; + if (fill == null) fill = parent.fill; + if (stroke == null) stroke = parent.stroke; + if (fillOpacity == null) fillOpacity = parent.fillOpacity; + if (strokeOpacity == null) strokeOpacity = parent.strokeOpacity; + // opacity does NOT inherit per SVG spec — leave alone. + if (strokeWidth == null) strokeWidth = parent.strokeWidth; + if (strokeLineCap == null) strokeLineCap = parent.strokeLineCap; + if (strokeLineJoin == null) strokeLineJoin = parent.strokeLineJoin; + if (strokeMiterLimit == null) strokeMiterLimit = parent.strokeMiterLimit; + return this; + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGTransform.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGTransform.java new file mode 100644 index 0000000000..04fa0c82e4 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/SVGTransform.java @@ -0,0 +1,65 @@ +package com.codename1.svg.transcoder.parser; + +/** + * Holds a fully-resolved 2D affine matrix: + *
+ *   [ a c e ]
+ *   [ b d f ]
+ *   [ 0 0 1 ]
+ * 
+ */ +public final class SVGTransform { + public final float a, b, c, d, e, f; + + public SVGTransform(float a, float b, float c, float d, float e, float f) { + this.a = a; this.b = b; this.c = c; this.d = d; this.e = e; this.f = f; + } + + public static SVGTransform identity() { + return new SVGTransform(1, 0, 0, 1, 0, 0); + } + + public static SVGTransform translate(float tx, float ty) { + return new SVGTransform(1, 0, 0, 1, tx, ty); + } + + public static SVGTransform scale(float sx, float sy) { + return new SVGTransform(sx, 0, 0, sy, 0, 0); + } + + public static SVGTransform rotate(float angleDeg, float cx, float cy) { + double r = Math.toRadians(angleDeg); + float cos = (float) Math.cos(r); + float sin = (float) Math.sin(r); + // Translate(cx,cy) * Rotate(angle) * Translate(-cx,-cy) + float a = cos, b = sin, c = -sin, d = cos; + float e = cx - cos * cx + sin * cy; + float f = cy - sin * cx - cos * cy; + return new SVGTransform(a, b, c, d, e, f); + } + + public static SVGTransform skewX(float angleDeg) { + float t = (float) Math.tan(Math.toRadians(angleDeg)); + return new SVGTransform(1, 0, t, 1, 0, 0); + } + + public static SVGTransform skewY(float angleDeg) { + float t = (float) Math.tan(Math.toRadians(angleDeg)); + return new SVGTransform(1, t, 0, 1, 0, 0); + } + + /** Returns this * o (this applied first conceptually under SVG's column-vector convention). */ + public SVGTransform multiply(SVGTransform o) { + return new SVGTransform( + a * o.a + c * o.b, + b * o.a + d * o.b, + a * o.c + c * o.d, + b * o.c + d * o.d, + a * o.e + c * o.f + e, + b * o.e + d * o.f + f); + } + + public boolean isIdentity() { + return a == 1f && b == 0f && c == 0f && d == 1f && e == 0f && f == 0f; + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/StyleParser.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/StyleParser.java new file mode 100644 index 0000000000..5a4825e425 --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/StyleParser.java @@ -0,0 +1,83 @@ +package com.codename1.svg.transcoder.parser; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parses the SVG style="..." attribute and the equivalent presentation + * attributes (fill, stroke, ...). The two sources are merged with inline + * style winning, matching the SVG cascade rules. + */ +public final class StyleParser { + + private StyleParser() { } + + public static SVGStyle parse(Map presentationAttrs, String styleAttr) { + Map merged = new HashMap(); + if (presentationAttrs != null) { + for (Map.Entry e : presentationAttrs.entrySet()) { + merged.put(e.getKey(), e.getValue()); + } + } + if (styleAttr != null && !styleAttr.trim().isEmpty()) { + for (String decl : styleAttr.split(";")) { + int colon = decl.indexOf(':'); + if (colon <= 0) continue; + String k = decl.substring(0, colon).trim(); + String v = decl.substring(colon + 1).trim(); + if (!k.isEmpty()) merged.put(k, v); + } + } + + SVGStyle out = new SVGStyle(); + out.setFill(toPaint(merged.get("fill"))); + out.setStroke(toPaint(merged.get("stroke"))); + out.setFillOpacity(toFloat(merged.get("fill-opacity"))); + out.setStrokeOpacity(toFloat(merged.get("stroke-opacity"))); + out.setOpacity(toFloat(merged.get("opacity"))); + out.setStrokeWidth(toFloat(merged.get("stroke-width"))); + + String cap = merged.get("stroke-linecap"); + if (cap != null) { + cap = cap.trim().toLowerCase(); + if ("round".equals(cap)) out.setStrokeLineCap(SVGStyle.LINECAP_ROUND); + else if ("square".equals(cap)) out.setStrokeLineCap(SVGStyle.LINECAP_SQUARE); + else out.setStrokeLineCap(SVGStyle.LINECAP_BUTT); + } + String join = merged.get("stroke-linejoin"); + if (join != null) { + join = join.trim().toLowerCase(); + if ("round".equals(join)) out.setStrokeLineJoin(SVGStyle.LINEJOIN_ROUND); + else if ("bevel".equals(join)) out.setStrokeLineJoin(SVGStyle.LINEJOIN_BEVEL); + else out.setStrokeLineJoin(SVGStyle.LINEJOIN_MITER); + } + out.setStrokeMiterLimit(toFloat(merged.get("stroke-miterlimit"))); + return out; + } + + private static SVGPaint toPaint(String value) { + if (value == null) return null; + String v = value.trim(); + if (v.isEmpty()) return null; + if (ColorParser.isNone(v)) return SVGPaint.NONE; + if (ColorParser.isCurrentColor(v)) return null; // treat as inherit; renderer defaults to black + SVGPaint ref = SVGPaint.setReference(v); + if (ref != null) return ref; + try { + return SVGPaint.ofColor(ColorParser.parse(v)); + } catch (RuntimeException e) { + return null; + } + } + + private static Float toFloat(String s) { + if (s == null) return null; + String t = s.trim(); + if (t.isEmpty()) return null; + try { + return NumberParser.parseFloat(t); + } catch (RuntimeException e) { + return null; + } + } +} diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/TransformParser.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/TransformParser.java new file mode 100644 index 0000000000..400330542c --- /dev/null +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/parser/TransformParser.java @@ -0,0 +1,79 @@ +package com.codename1.svg.transcoder.parser; + +/** + * Parses an SVG transform attribute: a sequence of translate / rotate / scale + * / skewX / skewY / matrix functions applied left-to-right. + */ +public final class TransformParser { + + private TransformParser() { } + + public static SVGTransform parse(String s) { + if (s == null) return null; + String t = s.trim(); + if (t.isEmpty()) return null; + + SVGTransform result = SVGTransform.identity(); + int i = 0; + int len = t.length(); + while (i < len) { + while (i < len && (t.charAt(i) == ' ' || t.charAt(i) == ',')) i++; + if (i >= len) break; + + int nameStart = i; + while (i < len && (Character.isLetter(t.charAt(i)))) i++; + String name = t.substring(nameStart, i).trim(); + while (i < len && t.charAt(i) != '(') i++; + if (i >= len) throw new IllegalArgumentException("Expected '(' after " + name); + i++; + int close = t.indexOf(')', i); + if (close < 0) throw new IllegalArgumentException("Unclosed transform " + name); + String inside = t.substring(i, close); + i = close + 1; + + float[] args = parseArgs(inside); + SVGTransform op = build(name, args); + result = result.multiply(op); + } + return result.isIdentity() ? null : result; + } + + private static float[] parseArgs(String s) { + NumberParser np = new NumberParser(s); + java.util.ArrayList list = new java.util.ArrayList(); + while (np.hasMore()) list.add(np.nextFloat()); + float[] r = new float[list.size()]; + for (int i = 0; i < r.length; i++) r[i] = list.get(i); + return r; + } + + private static SVGTransform build(String name, float[] args) { + if ("translate".equals(name)) { + float tx = args.length > 0 ? args[0] : 0; + float ty = args.length > 1 ? args[1] : 0; + return SVGTransform.translate(tx, ty); + } + if ("scale".equals(name)) { + float sx = args.length > 0 ? args[0] : 1; + float sy = args.length > 1 ? args[1] : sx; + return SVGTransform.scale(sx, sy); + } + if ("rotate".equals(name)) { + float ang = args.length > 0 ? args[0] : 0; + float cx = args.length > 1 ? args[1] : 0; + float cy = args.length > 2 ? args[2] : 0; + return SVGTransform.rotate(ang, cx, cy); + } + if ("skewX".equals(name)) { + return SVGTransform.skewX(args.length > 0 ? args[0] : 0); + } + if ("skewY".equals(name)) { + return SVGTransform.skewY(args.length > 0 ? args[0] : 0); + } + if ("matrix".equals(name)) { + if (args.length < 6) throw new IllegalArgumentException("matrix() needs 6 args"); + return new SVGTransform(args[0], args[1], args[2], args[3], args[4], args[5]); + } + throw new IllegalArgumentException("Unknown transform: " + name); + } +} diff --git a/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/animation/SMILParserTest.java b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/animation/SMILParserTest.java new file mode 100644 index 0000000000..bb5a6676f2 --- /dev/null +++ b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/animation/SMILParserTest.java @@ -0,0 +1,61 @@ +package com.codename1.svg.transcoder.animation; + +import com.codename1.svg.transcoder.model.SVGAnimation; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class SMILParserTest { + + @Test + public void plainSeconds() { + assertEquals(1000L, SMILParser.parseClock("1s", -1L)); + assertEquals(500L, SMILParser.parseClock("0.5s", -1L)); + } + + @Test + public void milliseconds() { + assertEquals(250L, SMILParser.parseClock("250ms", -1L)); + } + + @Test + public void minutes() { + assertEquals(60000L, SMILParser.parseClock("1min", -1L)); + } + + @Test + public void hours() { + assertEquals(3600000L, SMILParser.parseClock("1h", -1L)); + } + + @Test + public void rawNumberIsSeconds() { + assertEquals(2000L, SMILParser.parseClock("2", -1L)); + } + + @Test + public void clockColonForm() { + // 1:30 = 1 minute 30 seconds = 90000 ms + assertEquals(90000L, SMILParser.parseClock("1:30", -1L)); + } + + @Test + public void indefiniteRepeats() { + assertEquals(SVGAnimation.REPEAT_INDEFINITE, SMILParser.parseRepeatCount("indefinite")); + } + + @Test + public void integerRepeats() { + assertEquals(3, SMILParser.parseRepeatCount("3")); + assertEquals(1, SMILParser.parseRepeatCount(null)); + assertEquals(1, SMILParser.parseRepeatCount("notanumber")); + } + + @Test + public void transformTypes() { + assertEquals(SVGAnimation.TransformType.ROTATE, SMILParser.parseTransformType("rotate")); + assertEquals(SVGAnimation.TransformType.SCALE, SMILParser.parseTransformType("scale")); + assertEquals(SVGAnimation.TransformType.TRANSLATE, SMILParser.parseTransformType("translate")); + assertEquals(SVGAnimation.TransformType.TRANSLATE, SMILParser.parseTransformType(null)); + } +} diff --git a/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/codegen/CompileGeneratedSourceTest.java b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/codegen/CompileGeneratedSourceTest.java new file mode 100644 index 0000000000..397633f200 --- /dev/null +++ b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/codegen/CompileGeneratedSourceTest.java @@ -0,0 +1,148 @@ +package com.codename1.svg.transcoder.codegen; + +import com.codename1.svg.transcoder.SVGTranscoder; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.Assert.*; + +/** + * Hands-on end-to-end test: transcode each fixture SVG, hand the resulting + * Java source to the in-process JDK compiler, and fail if the result doesn't + * compile against the real {@code com.codename1.svg.GeneratedSVGImage} + + * graphics API. This is the test that catches sloppy code emission in the + * generator, where a syntactic check would not be enough. + */ +public class CompileGeneratedSourceTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private JavaCompiler compiler; + + @Before + public void setUp() { + compiler = ToolProvider.getSystemJavaCompiler(); + org.junit.Assume.assumeNotNull("JDK compiler available", compiler); + } + + @Test + public void shapesCompile() throws Exception { + compileSvg("" + + "" + + "" + + "" + + "" + + "" + + "" + + "", "Shapes"); + } + + @Test + public void pathCompiles() throws Exception { + compileSvg("" + + "" + + "" + + "" + + "", "Pathy"); + } + + @Test + public void transformsCompile() throws Exception { + compileSvg("" + + "" + + "" + + "", "Transformed"); + } + + @Test + public void linearGradientCompiles() throws Exception { + compileSvg("" + + "" + + "" + + "" + + "" + + "" + + "", "Gradient"); + } + + @Test + public void animationCompiles() throws Exception { + compileSvg("" + + "" + + "" + + "" + + "" + + "" + + "" + + "", "Animated"); + } + + @Test + public void registryCompiles() throws Exception { + StringWriter sw = new StringWriter(); + SVGTranscoder.writeRegistry("com.test.gen", "SVGRegistry", + Arrays.asList( + new SVGTranscoder.GeneratedClass("com.test.gen", "FooSvg", "foo.svg"), + new SVGTranscoder.GeneratedClass("com.test.gen", "BarSvg", "bar.svg") + ), sw); + // We can't compile the registry without the actual generated classes, so + // just sanity-check the source contents. + String src = sw.toString(); + assertTrue(src.contains("package com.test.gen;")); + assertTrue(src.contains("public static void install(Resources r)")); + assertTrue(src.contains("new com.test.gen.FooSvg()")); + assertTrue(src.contains("Resources.registerGeneratedImage(\"foo.svg\"")); + } + + private void compileSvg(String svg, String className) throws Exception { + StringWriter sw = new StringWriter(); + SVGTranscoder.transcode(new ByteArrayInputStream(svg.getBytes("UTF-8")), + "gen", className, sw); + String source = sw.toString(); + File outDir = tmp.newFolder("classes"); + + StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null); + try { + fileManager.setLocation(StandardLocation.CLASS_OUTPUT, Collections.singleton(outDir)); + JavaFileObject src = new InMemorySource("gen." + className, source); + JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, + Arrays.asList("-Xlint:none", "-proc:none"), null, Collections.singleton(src)); + boolean ok = task.call(); + if (!ok) { + fail("Generated source failed to compile:\n" + source); + } + } finally { + fileManager.close(); + } + } + + private static final class InMemorySource extends SimpleJavaFileObject { + private final String content; + InMemorySource(String fqn, String content) { + super(URI.create("mem:///" + fqn.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.content = content; + } + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { + return content; + } + } +} diff --git a/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/codegen/JavaCodeGeneratorTest.java b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/codegen/JavaCodeGeneratorTest.java new file mode 100644 index 0000000000..c8d9acb626 --- /dev/null +++ b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/codegen/JavaCodeGeneratorTest.java @@ -0,0 +1,129 @@ +package com.codename1.svg.transcoder.codegen; + +import com.codename1.svg.transcoder.SVGTranscoder; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.StringWriter; + +import static org.junit.Assert.*; + +public class JavaCodeGeneratorTest { + + private static String transcode(String svg) throws Exception { + StringWriter sw = new StringWriter(); + SVGTranscoder.transcode(new ByteArrayInputStream(svg.getBytes("UTF-8")), + "com.example", "TestIcon", sw); + return sw.toString(); + } + + @Test + public void classBoilerplateEmitted() throws Exception { + String out = transcode(""); + assertTrue(out.contains("package com.example;")); + assertTrue(out.contains("public final class TestIcon extends GeneratedSVGImage")); + assertTrue(out.contains("import com.codename1.svg.GeneratedSVGImage;")); + assertTrue(out.contains("import com.codename1.ui.geom.GeneralPath;")); + assertTrue(out.contains("protected void paintSVG(Graphics g, long __t)")); + } + + @Test + public void rectGeneratesPath() throws Exception { + String out = transcode("" + + ""); + assertTrue(out.contains("new GeneralPath()")); + assertTrue(out.contains("moveTo")); + assertTrue(out.contains("lineTo")); + assertTrue(out.contains("closePath")); + assertTrue(out.contains("g.fillShape(__p)")); + // fill="red" → 0xFF0000 + assertTrue(out.contains("g.setColor(0xFF0000)")); + } + + @Test + public void circleUsesArc() throws Exception { + String out = transcode("" + + ""); + assertTrue(out.contains("__p.arc(")); + assertTrue(out.contains("g.setColor(0x")); + } + + @Test + public void strokeEmitsStroke() throws Exception { + String out = transcode("" + + ""); + assertTrue(out.contains("g.drawShape(__p, __s)")); + assertTrue(out.contains("new Stroke(")); + } + + @Test + public void animationIsReportedAtConstruction() throws Exception { + String out = transcode("" + + "" + + "" + + ""); + assertTrue("animated flag should be true", + out.contains("super(10, 10, 0.0f, 0.0f, 10.0f, 10.0f, true);")); + // r animation should reach into runtime helper + assertTrue(out.contains("GeneratedSVGImage.progress(__t,")); + assertTrue(out.contains("GeneratedSVGImage.lerp(3.0f, 5.0f")); + } + + @Test + public void groupTransformEmitsConcatenate() throws Exception { + String out = transcode("" + + "" + + ""); + // Each transform block declares a unique pair of locals (__tsaveN / __tnewN) + // so sibling transforms compile without local-variable shadowing. + assertTrue("expected makeAffine call", + out.contains(".concatenate(Transform.makeAffine(")); + assertTrue("expected setTransform on the fresh transform", + out.matches("(?s).*g\\.setTransform\\(__tnew\\d+\\);.*")); + assertTrue("expected setTransform on the saved transform in finally", + out.matches("(?s).*g\\.setTransform\\(__tsave\\d+\\);.*")); + } + + @Test + public void siblingTransformedRectsUseFreshVariableNames() throws Exception { + // Regression: a group with multiple transformed siblings used to + // emit two `Transform __new = ...` declarations in the same scope, + // which doesn't compile under Java's no-shadowing rule for locals. + String out = transcode("" + + "" + + "" + + "" + + ""); + java.util.regex.Matcher m = java.util.regex.Pattern.compile("__tnew(\\d+)").matcher(out); + java.util.Set ids = new java.util.HashSet(); + while (m.find()) ids.add(m.group(1)); + assertTrue("expected multiple distinct transform-block IDs but found " + ids, ids.size() >= 3); + } + + @Test + public void pathArcCallsRuntimeHelper() throws Exception { + String out = transcode("" + + ""); + assertTrue(out.contains("GeneratedSVGImage.svgArc(__p")); + } + + @Test + public void linearGradientEmitsPaint() throws Exception { + String out = transcode("" + + "" + + "" + + "" + + "" + + ""); + assertTrue(out.contains("new LinearGradientPaint(")); + assertTrue(out.contains("CycleMethod.NO_CYCLE")); + } + + @Test + public void classNameFor() { + assertEquals("HomeIcon", SVGTranscoder.classNameFor("home-icon.svg")); + assertEquals("Foo", SVGTranscoder.classNameFor("foo")); + assertEquals("_1Item", SVGTranscoder.classNameFor("1item.svg")); + assertEquals("MyWeirdName", SVGTranscoder.classNameFor("my.weird name.svg")); + } +} diff --git a/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/ColorParserTest.java b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/ColorParserTest.java new file mode 100644 index 0000000000..149a924fe7 --- /dev/null +++ b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/ColorParserTest.java @@ -0,0 +1,76 @@ +package com.codename1.svg.transcoder.parser; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ColorParserTest { + + @Test + public void hex3() { + assertEquals(0xFFAABBCC, ColorParser.parse("#abc")); + } + + @Test + public void hex6() { + assertEquals(0xFF112233, ColorParser.parse("#112233")); + } + + @Test + public void hex8WithAlpha() { + // SVG-style #RRGGBBAA — alpha is last + int v = ColorParser.parse("#11223380"); + assertEquals(0x80, (v >>> 24) & 0xFF); + assertEquals(0x11, (v >>> 16) & 0xFF); + } + + @Test + public void namedColor() { + assertEquals(0xFFFF0000, ColorParser.parse("red")); + assertEquals(0xFF000000, ColorParser.parse("black")); + assertEquals(0xFFFFFFFF, ColorParser.parse("WHITE")); + } + + @Test + public void rgbFunction() { + assertEquals(0xFF80A0C0, ColorParser.parse("rgb(128,160,192)")); + } + + @Test + public void rgbaFunction() { + int v = ColorParser.parse("rgba(255,128,0,0.5)"); + // alpha is round(0.5 * 255) == 128 + assertEquals(128, (v >>> 24) & 0xFF); + assertEquals(255, (v >>> 16) & 0xFF); + } + + @Test + public void rgbWithPercent() { + int v = ColorParser.parse("rgb(100%,0%,0%)"); + assertEquals(0xFF, (v >>> 16) & 0xFF); + assertEquals(0x00, (v >>> 8) & 0xFF); + } + + @Test + public void noneRecognized() { + assertTrue(ColorParser.isNone("none")); + assertTrue(ColorParser.isNone(" NONE ")); + assertFalse(ColorParser.isNone("red")); + } + + @Test + public void currentColorRecognized() { + assertTrue(ColorParser.isCurrentColor("currentColor")); + assertFalse(ColorParser.isCurrentColor("red")); + } + + @Test(expected = IllegalArgumentException.class) + public void unknownColorThrows() { + ColorParser.parse("definitely-not-a-color"); + } + + @Test + public void parseOrDefaultReturnsFallback() { + assertEquals(0xDEADBEEF, ColorParser.parseOrDefault("nope", 0xDEADBEEF)); + } +} diff --git a/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/PathDataParserTest.java b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/PathDataParserTest.java new file mode 100644 index 0000000000..339f221f22 --- /dev/null +++ b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/PathDataParserTest.java @@ -0,0 +1,108 @@ +package com.codename1.svg.transcoder.parser; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +public class PathDataParserTest { + + @Test + public void emptyReturnsEmpty() { + assertTrue(PathDataParser.parse("").isEmpty()); + assertTrue(PathDataParser.parse(null).isEmpty()); + } + + @Test + public void moveAndLine() { + List cmds = PathDataParser.parse("M 10 20 L 30 40"); + assertEquals(2, cmds.size()); + assertEquals(PathCommand.Type.MOVE, cmds.get(0).getType()); + assertArrayEquals(new float[]{10f, 20f}, cmds.get(0).getArgs(), 0f); + assertEquals(PathCommand.Type.LINE, cmds.get(1).getType()); + assertArrayEquals(new float[]{30f, 40f}, cmds.get(1).getArgs(), 0f); + } + + @Test + public void relativeMoveBecomesAbsolute() { + List cmds = PathDataParser.parse("M 10 10 m 5 5"); + // M absolute then m relative produces second MOVE at (15, 15) + assertEquals(2, cmds.size()); + assertArrayEquals(new float[]{15f, 15f}, cmds.get(1).getArgs(), 0f); + } + + @Test + public void implicitLineAfterMove() { + List cmds = PathDataParser.parse("M 0 0 10 10 20 20"); + // After M, subsequent coordinate pairs are implicit L + assertEquals(3, cmds.size()); + assertEquals(PathCommand.Type.MOVE, cmds.get(0).getType()); + assertEquals(PathCommand.Type.LINE, cmds.get(1).getType()); + assertEquals(PathCommand.Type.LINE, cmds.get(2).getType()); + } + + @Test + public void horizontalVerticalLines() { + List cmds = PathDataParser.parse("M 0 0 H 50 V 60 h 10 v 10"); + assertEquals(5, cmds.size()); + // H 50 → LINE (50, 0) + assertArrayEquals(new float[]{50f, 0f}, cmds.get(1).getArgs(), 0f); + // V 60 → LINE (50, 60) + assertArrayEquals(new float[]{50f, 60f}, cmds.get(2).getArgs(), 0f); + // h 10 → LINE (60, 60) + assertArrayEquals(new float[]{60f, 60f}, cmds.get(3).getArgs(), 0f); + // v 10 → LINE (60, 70) + assertArrayEquals(new float[]{60f, 70f}, cmds.get(4).getArgs(), 0f); + } + + @Test + public void cubicBezier() { + List cmds = PathDataParser.parse("M 0 0 C 1 2 3 4 5 6"); + assertEquals(2, cmds.size()); + assertEquals(PathCommand.Type.CUBIC, cmds.get(1).getType()); + assertArrayEquals(new float[]{1, 2, 3, 4, 5, 6}, cmds.get(1).getArgs(), 0f); + } + + @Test + public void smoothCubicReflectsControlPoint() { + // After C 1,1 3,3 5,5 the implicit S control = 2*(5,5) - (3,3) = (7, 7) + List cmds = PathDataParser.parse("M 0 0 C 1 1 3 3 5 5 S 8 8 10 10"); + assertEquals(3, cmds.size()); + PathCommand smooth = cmds.get(2); + assertEquals(PathCommand.Type.CUBIC, smooth.getType()); + assertEquals(7f, smooth.getArgs()[0], 1e-6f); + assertEquals(7f, smooth.getArgs()[1], 1e-6f); + } + + @Test + public void closePath() { + List cmds = PathDataParser.parse("M 0 0 L 10 0 L 10 10 Z"); + assertEquals(4, cmds.size()); + assertEquals(PathCommand.Type.CLOSE, cmds.get(3).getType()); + } + + @Test + public void arcCommand() { + List cmds = PathDataParser.parse("M 0 0 A 5 5 0 0 1 10 0"); + assertEquals(2, cmds.size()); + assertEquals(PathCommand.Type.ARC, cmds.get(1).getType()); + float[] a = cmds.get(1).getArgs(); + // expected: curX, curY, rx, ry, xRot, largeArc, sweep, x, y + assertEquals(0f, a[0], 0f); + assertEquals(0f, a[1], 0f); + assertEquals(5f, a[2], 0f); + assertEquals(5f, a[3], 0f); + assertEquals(0f, a[5], 0f); + assertEquals(1f, a[6], 0f); + assertEquals(10f, a[7], 0f); + } + + @Test + public void implicitSignSeparator() { + // "10-20" should parse as two numbers 10 and -20 + List cmds = PathDataParser.parse("M0 0L10-20"); + assertEquals(2, cmds.size()); + assertArrayEquals(new float[]{10f, -20f}, cmds.get(1).getArgs(), 0f); + } +} diff --git a/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/SVGParserTest.java b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/SVGParserTest.java new file mode 100644 index 0000000000..19d9748e86 --- /dev/null +++ b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/SVGParserTest.java @@ -0,0 +1,110 @@ +package com.codename1.svg.transcoder.parser; + +import com.codename1.svg.transcoder.model.*; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; + +import static org.junit.Assert.*; + +public class SVGParserTest { + + private static SVGDocument parse(String svg) throws IOException { + return new SVGParser().parse(new ByteArrayInputStream(svg.getBytes("UTF-8"))); + } + + @Test + public void viewBoxParsed() throws Exception { + SVGDocument d = parse(""); + assertEquals(100f, d.getWidth(), 0f); + assertEquals(200f, d.getHeight(), 0f); + assertEquals(100f, d.getViewBoxWidth(), 0f); + assertEquals(200f, d.getViewBoxHeight(), 0f); + } + + @Test + public void rectShape() throws Exception { + SVGDocument d = parse("" + + ""); + assertEquals(1, d.getChildren().size()); + SVGRect r = (SVGRect) d.getChildren().get(0); + assertEquals(1f, r.getX(), 0f); + assertEquals(3f, r.getWidth(), 0f); + assertEquals(0xFFFF0000, r.getStyle().getFill().getColor()); + } + + @Test + public void groupAndCircle() throws Exception { + SVGDocument d = parse("" + + "" + + "" + + ""); + assertEquals(1, d.getChildren().size()); + SVGGroup g = (SVGGroup) d.getChildren().get(0); + assertNotNull(g.getTransform()); + assertEquals(5f, g.getTransform().e, 0f); + SVGCircle c = (SVGCircle) g.getChildren().get(0); + assertEquals(2f, c.getR(), 0f); + assertEquals(0xFF00FF00, c.getStyle().getFill().getColor()); + } + + @Test + public void linearGradientStored() throws Exception { + SVGDocument d = parse("" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""); + SVGLinearGradient lg = (SVGLinearGradient) d.getDefinitions().get("g1"); + assertNotNull(lg); + assertEquals(2, lg.getStops().size()); + assertEquals(0xFF000000, lg.getStops().get(0).getColor()); + assertEquals(0xFFFFFFFF, lg.getStops().get(1).getColor()); + + SVGRect r = (SVGRect) d.getChildren().get(0); + assertTrue(r.getStyle().getFill().isReference()); + assertEquals("g1", r.getStyle().getFill().getReference()); + } + + @Test + public void pathParsed() throws Exception { + SVGDocument d = parse("" + + ""); + SVGPath p = (SVGPath) d.getChildren().get(0); + assertEquals(4, p.getCommands().size()); + } + + @Test + public void smilAnimationParsed() throws Exception { + SVGDocument d = parse("" + + "" + + "" + + ""); + SVGCircle c = (SVGCircle) d.getChildren().get(0); + List anims = c.getAnimations(); + assertEquals(1, anims.size()); + SVGAnimation a = anims.get(0); + assertEquals("r", a.getAttributeName()); + assertEquals("2", a.getFrom()); + assertEquals("5", a.getTo()); + assertEquals(1000L, a.getDurMs()); + assertEquals(SVGAnimation.REPEAT_INDEFINITE, a.getRepeatCount()); + } + + @Test + public void styleAttributeParsed() throws Exception { + SVGDocument d = parse("" + + ""); + SVGRect r = (SVGRect) d.getChildren().get(0); + assertEquals(0xFFABCDEF, r.getStyle().getFill().getColor()); + assertTrue(r.getStyle().getStroke().isNone()); + assertEquals(0.5f, r.getStyle().getOpacity(), 1e-5f); + } +} diff --git a/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/TransformParserTest.java b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/TransformParserTest.java new file mode 100644 index 0000000000..7951b61225 --- /dev/null +++ b/maven/svg-transcoder/src/test/java/com/codename1/svg/transcoder/parser/TransformParserTest.java @@ -0,0 +1,73 @@ +package com.codename1.svg.transcoder.parser; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class TransformParserTest { + + @Test + public void emptyOrNull() { + assertNull(TransformParser.parse(null)); + assertNull(TransformParser.parse("")); + assertNull(TransformParser.parse(" ")); + } + + @Test + public void singleTranslate() { + SVGTransform t = TransformParser.parse("translate(10, 20)"); + assertNotNull(t); + assertEquals(10f, t.e, 0f); + assertEquals(20f, t.f, 0f); + assertEquals(1f, t.a, 0f); + assertEquals(1f, t.d, 0f); + } + + @Test + public void singleScale() { + SVGTransform t = TransformParser.parse("scale(2, 3)"); + assertNotNull(t); + assertEquals(2f, t.a, 0f); + assertEquals(3f, t.d, 0f); + } + + @Test + public void scaleUniform() { + SVGTransform t = TransformParser.parse("scale(2)"); + assertEquals(2f, t.a, 0f); + assertEquals(2f, t.d, 0f); + } + + @Test + public void rotateAt90Degrees() { + SVGTransform t = TransformParser.parse("rotate(90)"); + // cos(90) ~ 0, sin(90) = 1 + assertEquals(0f, t.a, 1e-5f); + assertEquals(1f, t.b, 1e-5f); + assertEquals(-1f, t.c, 1e-5f); + assertEquals(0f, t.d, 1e-5f); + } + + @Test + public void translateThenScaleAccumulates() { + SVGTransform t = TransformParser.parse("translate(5, 5) scale(2)"); + // translate then scale: T = translate * scale + // For a point P at (1,1): scale -> (2,2), then translate -> (7,7) + // Matrix form: a=2 b=0 c=0 d=2 e=5 f=5 + assertEquals(2f, t.a, 0f); + assertEquals(2f, t.d, 0f); + assertEquals(5f, t.e, 0f); + assertEquals(5f, t.f, 0f); + } + + @Test + public void matrixFunction() { + SVGTransform t = TransformParser.parse("matrix(1 2 3 4 5 6)"); + assertEquals(1f, t.a, 0f); + assertEquals(2f, t.b, 0f); + assertEquals(3f, t.c, 0f); + assertEquals(4f, t.d, 0f); + assertEquals(5f, t.e, 0f); + assertEquals(6f, t.f, 0f); + } +} diff --git a/scripts/hellocodenameone/common/pom.xml b/scripts/hellocodenameone/common/pom.xml index 142ce7943d..9067bc1352 100644 --- a/scripts/hellocodenameone/common/pom.xml +++ b/scripts/hellocodenameone/common/pom.xml @@ -339,6 +339,13 @@ codenameone-maven-plugin + + transcode-svg + generate-sources + + transcode-svg + + generate-gui-sources process-sources diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SVGAnimatedScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SVGAnimatedScreenshotTest.java new file mode 100644 index 0000000000..7e59e1444d --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SVGAnimatedScreenshotTest.java @@ -0,0 +1,35 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.svg.GeneratedSVGImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; + +/** + * Captures the SMIL-animated SVGs at a fixed frame offset so the screenshot + * output is deterministic. {@link GeneratedSVGImage#setAnimationTimeMillis} + * pins the animation clock to a chosen elapsed time before the frame paint. + */ +public class SVGAnimatedScreenshotTest extends BaseTest { + + /** Frame offset chosen so each animation is partway through its first cycle. */ + private static final long FRAME_MS = 250L; + + @Override + public boolean runTest() throws Exception { + Form form = createForm("Animated SVG", BoxLayout.y(), "SVGAnimated"); + form.add(label("Spinner @ 250 ms", new com.codename1.generated.svg.SpinnerAnimated())); + form.add(label("Pulse @ 250 ms", new com.codename1.generated.svg.PulsingCircle())); + form.show(); + return true; + } + + private Label label(String text, GeneratedSVGImage img) { + img.setAnimationTimeMillis(FRAME_MS); + Label l = new Label(text, img); + Style s = l.getAllStyles(); + s.setMargin(8, 8, 8, 8); + return l; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SVGStaticScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SVGStaticScreenshotTest.java new file mode 100644 index 0000000000..de47a8e654 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SVGStaticScreenshotTest.java @@ -0,0 +1,37 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.svg.GeneratedSVGImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; + +/** + * Renders the three static SVGs (star, gradient circle, path arrow) generated + * by the build-time SVG transcoder into a single form so the screenshot + * framework can verify shapes, gradients, paths and stroke styling all + * compose correctly on every platform. + * + *

The generated classes live in {@code com.codename1.generated.svg.*} — + * they are emitted by the {@code transcode-svg} goal in this module's pom + * based on the SVG files under {@code src/main/svg}.

+ */ +public class SVGStaticScreenshotTest extends BaseTest { + + @Override + public boolean runTest() throws Exception { + Form form = createForm("Static SVG", BoxLayout.y(), "SVGStatic"); + form.add(label("Star", new com.codename1.generated.svg.Star())); + form.add(label("Gradient Circle", new com.codename1.generated.svg.GradientCircle())); + form.add(label("Path Arrow", new com.codename1.generated.svg.PathArrow())); + form.show(); + return true; + } + + private Label label(String text, GeneratedSVGImage img) { + Label l = new Label(text, img); + Style s = l.getAllStyles(); + s.setMargin(8, 8, 8, 8); + return l; + } +} diff --git a/scripts/hellocodenameone/common/src/main/svg/gradient_circle.svg b/scripts/hellocodenameone/common/src/main/svg/gradient_circle.svg new file mode 100644 index 0000000000..4a27db2365 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/svg/gradient_circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/scripts/hellocodenameone/common/src/main/svg/path_arrow.svg b/scripts/hellocodenameone/common/src/main/svg/path_arrow.svg new file mode 100644 index 0000000000..073232b2ea --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/svg/path_arrow.svg @@ -0,0 +1,6 @@ + + + + diff --git a/scripts/hellocodenameone/common/src/main/svg/pulsing_circle.svg b/scripts/hellocodenameone/common/src/main/svg/pulsing_circle.svg new file mode 100644 index 0000000000..9b11b9d6e5 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/svg/pulsing_circle.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/scripts/hellocodenameone/common/src/main/svg/spinner_animated.svg b/scripts/hellocodenameone/common/src/main/svg/spinner_animated.svg new file mode 100644 index 0000000000..4bf8d1941e --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/svg/spinner_animated.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/scripts/hellocodenameone/common/src/main/svg/star.svg b/scripts/hellocodenameone/common/src/main/svg/star.svg new file mode 100644 index 0000000000..9dd76cee9c --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/svg/star.svg @@ -0,0 +1,5 @@ + + + + From c5622bc95a95a29abab47759907aea940a408932 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 25 May 2026 23:25:00 +0300 Subject: [PATCH 02/29] Drop reflective SVGRegistry probe to keep ParparVM iOS build alive The lazy Class.forName/getMethod/invoke probe in Resources.getImage made java.lang.reflect.Method.invoke and java.lang.Class.getMethod reachable through Resources, which on iOS pulled symbols ParparVM's static reachability analyzer otherwise strips. The generated Resources.m then emitted calls to virtual_java_lang_Class_getMethod___..., virtual_java_lang_reflect_Method_invoke___... that the linker never sees, failing the iOS / native-ios / packaging jobs. Removes the auto-probe and instead documents the one-line SVGRegistry.install(resources) call required after loading a theme. The generated registry's install method still populates both the per-instance map and the global fallback, so a single startup call covers all Resources opened in the VM. JavaSE-only behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/ui/util/Resources.java | 43 ++++++++----------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index ce6c716361..abc0c6d599 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -118,12 +118,11 @@ public class Resources { private static boolean runtimeMultiImages; private static boolean failOnMissingTruetype = true; - /// Global image registry populated by the build-time SVG transcoder. Keyed by - /// the source filename ("home.svg") and also under the filename stem ("home") - /// so CSS-style `url(home.svg)` references and direct `getImage("home")` calls - /// both resolve. Lazily probed via [#ensureGeneratedSVGsInstalled]. + /// Global image registry populated by the build-time SVG transcoder. Keyed + /// by the source filename ("home.svg") and also under the filename stem + /// ("home") so CSS-style `url(home.svg)` references and direct + /// `getImage("home")` calls both resolve to the same instance. private static final Map generatedImages = new HashMap(); - private static volatile boolean generatedSVGProbed; /// Hashtable containing the mapping between element types and their names in the /// resource hashtable private final HashMap resourceTypes = new HashMap(); @@ -889,7 +888,6 @@ public boolean isImage(String name) { public Image getImage(String id) { Image local = (Image) resources.get(id); if (local != null) return local; - ensureGeneratedSVGsInstalled(); Image gen; synchronized (generatedImages) { gen = generatedImages.get(id); @@ -917,6 +915,19 @@ public void setImage(String id, Image image) { /// `.svg`) under the bare filename stem so a CSS reference like /// `url(home.svg)` and a code reference like `getImage("home")` both /// resolve to the same instance. + /// + /// To wire up the generated SVGs in a Codename One app, invoke the + /// generated registry once during startup — typically right after the + /// theme is loaded: + /// ``` + /// com.codename1.generated.svg.SVGRegistry.install(theme); + /// ``` + /// This call also populates the global registry so any other + /// [Resources] instance opened later will resolve the same SVGs by name. + /// The two-step (explicit install + global fallback) design is what lets + /// the transcoder coexist with ParparVM's static-reachability analysis on + /// iOS, which cannot tolerate the reflective probe a fully transparent + /// hook would require. public static void registerGeneratedImage(String id, Image image) { if (id == null || image == null) return; synchronized (generatedImages) { @@ -927,26 +938,6 @@ public static void registerGeneratedImage(String id, Image image) { } } - /// Lazily probe for `com.codename1.generated.svg.SVGRegistry` and ask it - /// to populate the global registry. If the application has no transcoded - /// SVGs, the class will not exist and the probe is silently skipped. - /// Marked package-private so {@link com.codename1.svg} tests can re-probe. - static void ensureGeneratedSVGsInstalled() { - if (generatedSVGProbed) return; - synchronized (generatedImages) { - if (generatedSVGProbed) return; - generatedSVGProbed = true; - try { - Class registry = Class.forName("com.codename1.generated.svg.SVGRegistry"); - registry.getMethod("installGlobal").invoke(null); - } catch (ClassNotFoundException notPresent) { - // No SVGs — nothing to install. - } catch (Throwable t) { - Log.e(t); - } - } - } - /// Returns the data resource from the file /// /// #### Parameters From 425ef1458b30bfbe83573f6bbd3becf908d3d127 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 26 May 2026 00:38:58 +0300 Subject: [PATCH 03/29] Satisfy PMD: braces on every control statement, one decl per line CI's quality report failed on ControlStatementBraces, MissingOverride, and OneDeclarationPerLine for the new SVG runtime + transcoder mojo. Wrap every single-line if/for body in braces, split the px/py decl, and add the @Override annotation on MutableResource.setImage that became required once Resources gained the matching method. No behavioral change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/codename1/svg/GeneratedSVGImage.java | 66 +- .../codename1/ui/util/MutableResource.java | 1 + .../src/com/codename1/ui/util/Resources.java | 12 +- .../archetype-resources/.idea/compiler.xml | 19 + .../archetype-resources/.idea/debugger.xml | 11 + .../archetype-resources/.idea/encodings.xml | 8 + .../archetype-resources/.idea/misc.xml | 18 + .../archetype-resources/.idea/workspace.xml | 842 +++++++++++++ .../com/codename1/maven/TranscodeSVGMojo.java | 17 +- .../svg/transcoder/SVGTranscoder.java | 16 +- quality-report.md | 33 + scripts/website/SYNDICATE_BROWSER.md | 134 ++ .../website/com.codenameone.syndication.plist | 67 + scripts/website/logs/.gitignore | 3 + scripts/website/run-syndication.sh | 74 ++ scripts/website/syndicate_browser.py | 1121 +++++++++++++++++ scripts/website/syndicate_login.py | 180 +++ scripts/website/syndicate_profile.py | 152 +++ scripts/website/syndication-auth/.gitignore | 5 + 19 files changed, 2751 insertions(+), 28 deletions(-) create mode 100644 maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/compiler.xml create mode 100644 maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/debugger.xml create mode 100644 maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/encodings.xml create mode 100644 maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/misc.xml create mode 100644 maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/workspace.xml create mode 100644 quality-report.md create mode 100644 scripts/website/SYNDICATE_BROWSER.md create mode 100644 scripts/website/com.codenameone.syndication.plist create mode 100644 scripts/website/logs/.gitignore create mode 100755 scripts/website/run-syndication.sh create mode 100644 scripts/website/syndicate_browser.py create mode 100644 scripts/website/syndicate_login.py create mode 100644 scripts/website/syndicate_profile.py create mode 100644 scripts/website/syndication-auth/.gitignore diff --git a/CodenameOne/src/com/codename1/svg/GeneratedSVGImage.java b/CodenameOne/src/com/codename1/svg/GeneratedSVGImage.java index 98e0db81d9..43c6d4f79c 100644 --- a/CodenameOne/src/com/codename1/svg/GeneratedSVGImage.java +++ b/CodenameOne/src/com/codename1/svg/GeneratedSVGImage.java @@ -185,9 +185,13 @@ public void setAnimationTimeMillis(long elapsedMs) { /// behavior. Generated code calls this per animated attribute per paint. public static float progress(long elapsedMs, long beginMs, long durMs, int repeatCount, boolean freeze) { - if (durMs <= 0L) return freeze ? 1f : 0f; + if (durMs <= 0L) { + return freeze ? 1f : 0f; + } long t = elapsedMs - beginMs; - if (t < 0L) return 0f; + if (t < 0L) { + return 0f; + } if (repeatCount == REPEAT_INDEFINITE) { long cycle = t % durMs; return (float) cycle / (float) durMs; @@ -223,21 +227,35 @@ public static int lerpColor(int fromArgb, int toArgb, float t) { /// Multi-stop floating point lerp. Stops are evenly spaced in `[0, 1]`. public static float lerpValues(float[] values, float t) { - if (values == null || values.length == 0) return 0f; - if (values.length == 1) return values[0]; - if (t <= 0f) return values[0]; - if (t >= 1f) return values[values.length - 1]; + if (values == null || values.length == 0) { + return 0f; + } + if (values.length == 1) { + return values[0]; + } + if (t <= 0f) { + return values[0]; + } + if (t >= 1f) { + return values[values.length - 1]; + } float seg = 1f / (values.length - 1); int i = (int) Math.floor(t / seg); - if (i >= values.length - 1) i = values.length - 2; + if (i >= values.length - 1) { + i = values.length - 2; + } float local = (t - i * seg) / seg; return values[i] + (values[i + 1] - values[i]) * local; } private static int round(float v) { int r = (int) (v + 0.5f); - if (r < 0) return 0; - if (r > 255) return 255; + if (r < 0) { + return 0; + } + if (r > 255) { + return 255; + } return r; } @@ -285,7 +303,9 @@ public static void svgArc(com.codename1.ui.geom.GeneralPath p, // F.6.5.2 — compute (cx', cy') double sign = (largeArc == sweep) ? -1.0 : 1.0; double sq = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2) / (rx2 * y1p2 + ry2 * x1p2); - if (sq < 0.0) sq = 0.0; + if (sq < 0.0) { + sq = 0.0; + } double coef = sign * Math.sqrt(sq); double cxp = coef * (arx * y1p / ary); double cyp = coef * -(ary * x1p / arx); @@ -303,18 +323,24 @@ public static void svgArc(com.codename1.ui.geom.GeneralPath p, double vy = (-y1p - cyp) / ary; double theta1 = vectorAngle(1.0, 0.0, ux, uy); double deltaTheta = vectorAngle(ux, uy, vx, vy); - if (!sweep && deltaTheta > 0.0) deltaTheta -= 2.0 * Math.PI; - else if (sweep && deltaTheta < 0.0) deltaTheta += 2.0 * Math.PI; + if (!sweep && deltaTheta > 0.0) { + deltaTheta -= 2.0 * Math.PI; + } else if (sweep && deltaTheta < 0.0) { + deltaTheta += 2.0 * Math.PI; + } // Split into segments small enough that the cubic approximation stays accurate. int segments = (int) Math.ceil(Math.abs(deltaTheta) / (Math.PI / 2.0)); - if (segments < 1) segments = 1; + if (segments < 1) { + segments = 1; + } double dt = deltaTheta / segments; double t = (4.0 / 3.0) * Math.tan(dt / 4.0); double cosTheta1 = Math.cos(theta1); double sinTheta1 = Math.sin(theta1); - double px = x1, py = y1; + double px = x1; + double py = y1; for (int i = 0; i < segments; i++) { double theta2 = theta1 + dt; double cosTheta2 = Math.cos(theta2); @@ -346,10 +372,16 @@ private static double vectorAngle(double ux, double uy, double vx, double vy) { double dot = ux * vx + uy * vy; double len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)); double cos = dot / len; - if (cos < -1.0) cos = -1.0; - if (cos > 1.0) cos = 1.0; + if (cos < -1.0) { + cos = -1.0; + } + if (cos > 1.0) { + cos = 1.0; + } double a = Math.acos(cos); - if ((ux * vy - uy * vx) < 0.0) a = -a; + if ((ux * vy - uy * vx) < 0.0) { + a = -a; + } return a; } } diff --git a/CodenameOne/src/com/codename1/ui/util/MutableResource.java b/CodenameOne/src/com/codename1/ui/util/MutableResource.java index e1a322b504..a693df4ebe 100644 --- a/CodenameOne/src/com/codename1/ui/util/MutableResource.java +++ b/CodenameOne/src/com/codename1/ui/util/MutableResource.java @@ -102,6 +102,7 @@ Image createImage(DataInputStream input) throws IOException { } } + @Override public void setImage(String name, Image value) { if (value instanceof Timeline) { throw new UnsupportedOperationException("Timeline resources are not supported in MutableResource"); diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index abc0c6d599..b44487a7fe 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -887,7 +887,9 @@ public boolean isImage(String name) { /// cached image instance public Image getImage(String id) { Image local = (Image) resources.get(id); - if (local != null) return local; + if (local != null) { + return local; + } Image gen; synchronized (generatedImages) { gen = generatedImages.get(id); @@ -900,7 +902,9 @@ public Image getImage(String id) { /// transcoder registry to inject generated images alongside the resources /// loaded from the `.res` file. public void setImage(String id, Image image) { - if (id == null || image == null) return; + if (id == null || image == null) { + return; + } resources.put(id, image); resourceTypes.put(id, Byte.valueOf(MAGIC_IMAGE)); } @@ -929,7 +933,9 @@ public void setImage(String id, Image image) { /// iOS, which cannot tolerate the reflective probe a fully transparent /// hook would require. public static void registerGeneratedImage(String id, Image image) { - if (id == null || image == null) return; + if (id == null || image == null) { + return; + } synchronized (generatedImages) { generatedImages.put(id, image); if (id.endsWith(".svg")) { diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/compiler.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/compiler.xml new file mode 100644 index 0000000000..55321d901f --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/compiler.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/debugger.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/debugger.xml new file mode 100644 index 0000000000..7134d43bd0 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/debugger.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/encodings.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/encodings.xml new file mode 100644 index 0000000000..5d04979714 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/encodings.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/misc.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/misc.xml new file mode 100644 index 0000000000..f133cb6da2 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/misc.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/workspace.xml b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/workspace.xml new file mode 100644 index 0000000000..1802217460 --- /dev/null +++ b/maven/cn1app-archetype/src/main/resources/archetype-resources/.idea/workspace.xml @@ -0,0 +1,842 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1613589489573 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java index 615da94e8d..a3bdb4958d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/TranscodeSVGMojo.java @@ -145,19 +145,28 @@ private void registerSourceRoot() { private static void collect(File dir, List out) { File[] entries = dir.listFiles(); - if (entries == null) return; + if (entries == null) { + return; + } Arrays.sort(entries, new Comparator() { @Override public int compare(File a, File b) { return a.getName().compareTo(b.getName()); } }); for (File f : entries) { - if (f.isDirectory()) collect(f, out); - else if (f.getName().toLowerCase().endsWith(".svg")) out.add(f); + if (f.isDirectory()) { + collect(f, out); + } else if (f.getName().toLowerCase().endsWith(".svg")) { + out.add(f); + } } } private static long lastModified(List files) { long max = 0; - for (File f : files) if (f.lastModified() > max) max = f.lastModified(); + for (File f : files) { + if (f.lastModified() > max) { + max = f.lastModified(); + } + } return max; } } diff --git a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/SVGTranscoder.java b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/SVGTranscoder.java index ce8874c33e..7da277086a 100644 --- a/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/SVGTranscoder.java +++ b/maven/svg-transcoder/src/main/java/com/codename1/svg/transcoder/SVGTranscoder.java @@ -43,7 +43,9 @@ public static void transcode(InputStream svg, String packageName, String classNa } public static void transcode(File svgFile, String packageName, String className, File outFile) throws IOException { - if (outFile.getParentFile() != null) outFile.getParentFile().mkdirs(); + if (outFile.getParentFile() != null) { + outFile.getParentFile().mkdirs(); + } InputStream in = new BufferedInputStream(new FileInputStream(svgFile)); try { Writer w = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outFile), "UTF-8")); @@ -68,7 +70,9 @@ public static void transcode(File svgFile, String packageName, String className, public static void writeRegistry(String packageName, String className, java.util.List classes, Writer out) throws IOException { // dedupe by resource name; last wins Map unique = new LinkedHashMap(); - for (GeneratedClass c : classes) unique.put(c.resourceName, c); + for (GeneratedClass c : classes) { + unique.put(c.resourceName, c); + } if (packageName != null && !packageName.isEmpty()) { out.write("package " + packageName + ";\n\n"); @@ -111,7 +115,9 @@ public static void writeRegistry(String packageName, String className, java.util public static String classNameFor(String fileName) { String stem = fileName; int dot = stem.lastIndexOf('.'); - if (dot > 0) stem = stem.substring(0, dot); + if (dot > 0) { + stem = stem.substring(0, dot); + } StringBuilder sb = new StringBuilder(); boolean upper = true; for (int i = 0; i < stem.length(); i++) { @@ -126,7 +132,9 @@ public static String classNameFor(String fileName) { sb.append(upper ? Character.toUpperCase(c) : c); upper = false; } - if (sb.length() == 0) sb.append("Svg"); + if (sb.length() == 0) { + sb.append("Svg"); + } return sb.toString(); } diff --git a/quality-report.md b/quality-report.md new file mode 100644 index 0000000000..104021ab50 --- /dev/null +++ b/quality-report.md @@ -0,0 +1,33 @@ +## ✅ Continuous Quality Report + +### Test & Coverage +- ✅ **Tests:** 2543 total, 0 failed, 0 skipped +- 📊 **Line coverage:** 54.18% + - **Lowest covered classes** + - `com.codename1.components.OtpField` – 0.00% + - `com.codename1.security.AuthenticationOptions` – 0.00% + - `com.codename1.system.SimulatorHookExecutor` – 0.00% + - `com.codename1.ui.Display$EdtException` – 0.00% + - `com.codename1.ui.plaf.CSSBorder$LinearGradient` – 0.00% + - `com.codename1.security.SecureStorage` – 0.00% + - `com.codename1.security.Biometrics` – 0.00% + - `com.codename1.security.BiometricError` – 0.00% + - `com.codename1.util.EasyThread$InQueueRunnable` – 0.00% + - `com.codename1.security.BiometricException` – 0.00% + +### Static Analysis +- ✅ SpotBugs: no findings (report was not generated by the build). +- ❌ **PMD:** 2 findings (P3: 2) + - **Top findings** + - P3: `CodenameOne/src/com/codename1/ui/Display.java:6058` – The method 'setCause(Throwable)' is missing an @Override annotation. _(rule: `MissingOverride`)_ + - P3: `CodenameOne/src/com/codename1/ui/html/HTMLComponent.java:3907` – The method 'run()' is missing an @Override annotation. _(rule: `MissingOverride`)_ +- ❌ **Checkstyle:** 109 findings (Error: 109) + - **Top findings** + - Error: `CodenameOne/src/com/codename1/security/Base32.java:42:49` – '{' at column 49 should have line break after. _(rule: `LeftCurlyCheck`)_ + - Error: `CodenameOne/src/com/codename1/security/Base32.java:43:51` – '{' at column 51 should have line break after. _(rule: `LeftCurlyCheck`)_ + - Error: `CodenameOne/src/com/codename1/security/Base32.java:47:36` – '{' at column 36 should have line break after. _(rule: `LeftCurlyCheck`)_ + - Error: `CodenameOne/src/com/codename1/security/Base32.java:53:47` – '{' at column 47 should have line break after. _(rule: `LeftCurlyCheck`)_ + - Error: `CodenameOne/src/com/codename1/security/Base32.java:69:37` – '{' at column 37 should have line break after. _(rule: `LeftCurlyCheck`)_ + - …and 104 more + +_Generated automatically by the PR CI workflow._ diff --git a/scripts/website/SYNDICATE_BROWSER.md b/scripts/website/SYNDICATE_BROWSER.md new file mode 100644 index 0000000000..2571888e46 --- /dev/null +++ b/scripts/website/SYNDICATE_BROWSER.md @@ -0,0 +1,134 @@ +# Browser-syndication runner (Playwright) + +Drives Medium and DZone editors locally via headed Playwright + your +**system Firefox** (not Playwright's bundled Firefox build, which +Cloudflare reliably fingerprints and challenge-loops). Each site gets +a dedicated persistent profile under +`scripts/website/syndication-auth/-profile/` so cookies survive +across runs without sharing your day-to-day Firefox profile. + +## Why Playwright instead of an extension + +| Wall | Extension | Headed Playwright | +|---|---|---| +| Cloudflare bot detection | Bypassed (real browser session) | Bypassed (saved storage state with `cf_clearance` cookie) | +| Medium's `isTrusted` input gate | **Blocks all synthetic events** — content is invisible to Medium's editor model, autosave never fires | Routes input through Firefox's real input pipeline — events carry `isTrusted=true`, accepted as if typed | + +## One-time setup + +```bash +# Inside the repo root, in a venv if you prefer +pip install playwright +playwright install firefox + +# Capture session cookies + cf_clearance for each site (interactive): +python scripts/website/syndicate_login.py --site medium +python scripts/website/syndicate_login.py --site dzone +``` + +Each `syndicate_login.py` invocation launches your system Firefox with +a dedicated profile under `scripts/website/syndication-auth/-profile/`, +opens the site's login URL, and watches the page URL. When you finish +signing in and navigate to the verify URL the script prints, it +auto-detects the navigation and exits cleanly. The profile retains +cookies + `cf_clearance` between runs. Re-run when cookies expire +(Cloudflare clearance ~30 days; site logins last longer). + +The whole `syndication-auth/` directory is gitignored — never commit it. + +## Weekly run + +```bash +python scripts/website/syndicate_browser.py +``` + +Reads `scripts/website/syndication-queue.json` (still produced by the +existing `queue_browser_syndication.py`), opens a headed Firefox per +task using the appropriate `syndication-auth/.json` storage +state, drives the editor, and writes the resulting draft URL into +`scripts/website/syndication-state.json`. + +Useful flags: + +```bash +# Preview only: +python scripts/website/syndicate_browser.py --dry-run + +# One specific task: +python scripts/website/syndicate_browser.py --task-id medium:liquid-glass-material-3-modern-native-themes + +# Only one platform: +python scripts/website/syndicate_browser.py --site dzone +``` + +The browser windows are visible — if anything goes sideways (Cloudflare +challenge re-pops, Medium UI changed, DZone hits a validation banner) +you can intervene manually before the script's timeout. + +## What each driver does + +**Medium (`/new-story`):** +1. Locate `h3[data-testid="editorTitleParagraph"]`, click, type title with + `page.keyboard.type` (real keystrokes → Medium's typing handler fires). +2. Press Enter, type body markdown line-by-line. Medium converts `## ` → + heading, `* ` → bullet, ``` ``` ``` → code block, `**bold**` → bold, + `[text](url)` → link on the fly. +3. Wait for autosave to redirect to `/p//edit`. Record that URL. + +**DZone (`/articles/post.html`):** +1. `page.fill` title / subtitle / meta-description / original-source URL. +2. Click article-type dropdown, click matching item. +3. `page.set_input_files` for the cover image (downloaded to a tempfile + from `task.cover_image_url`). +4. `page.evaluate` to call `FroalaEditor.INSTANCES[0].html.set(html)` — + runs in the page world (no xray issues), Froala accepts cleanly. +5. Click Save Draft. Record resulting URL. + +## Scheduling (macOS launchd) + +Once you've verified the flow works end-to-end, install the launchd +LaunchAgent so the runner fires daily without manual invocation. The +schedule fires after the daily blog-syndication.yml GitHub Action +commits any new queue entries, so the wrapper picks them up on the +next run. + +```bash +# Install +cp scripts/website/com.codenameone.syndication.plist ~/Library/LaunchAgents/ +launchctl load ~/Library/LaunchAgents/com.codenameone.syndication.plist + +# Trigger now (one-shot test) +launchctl start com.codenameone.syndication + +# Watch what happens +tail -f scripts/website/logs/syndication-$(date +%Y-%m-%d).log + +# Uninstall +launchctl unload ~/Library/LaunchAgents/com.codenameone.syndication.plist +rm ~/Library/LaunchAgents/com.codenameone.syndication.plist +``` + +The wrapper script (`scripts/website/run-syndication.sh`) does: + +1. `git pull --ff-only origin master` to grab any queue entries CI + appended since the last run. +2. Runs `syndicate_browser.py`. The runner self-skips if no tasks are + pending (no Chrome window opens), so the schedule is safe to fire + daily. +3. If `syndication-state.json` changed, commits just that one file + and pushes so the next run (and CI) sees it. + +Per-run logs land in `scripts/website/logs/syndication-YYYY-MM-DD.log`. +launchd's own stdio is captured separately in +`scripts/website/logs/launchd.out` and `launchd.err`. The whole `logs/` +directory is gitignored. + +The default schedule is daily at 14:00 local time — edit the +`StartCalendarInterval` block in the plist to change it. If you want +the runner to also fire once at login, set `RunAtLoad` to `true`. + +Heads-up: the runner opens visible Chrome windows because Cloudflare +blocks headless. If you're away from the machine when it fires, the +windows still open but anomalies (Cloudflare re-challenge, Medium UI +change, etc.) won't get human intervention. Check the per-day log +file when you return. diff --git a/scripts/website/com.codenameone.syndication.plist b/scripts/website/com.codenameone.syndication.plist new file mode 100644 index 0000000000..27f11bd2f4 --- /dev/null +++ b/scripts/website/com.codenameone.syndication.plist @@ -0,0 +1,67 @@ + + + + + + Label + com.codenameone.syndication + + ProgramArguments + + /bin/bash + /Users/shai/dev/cn3/CodenameOne/scripts/website/run-syndication.sh + + + + StartCalendarInterval + + Hour + 14 + Minute + 0 + + + + RunAtLoad + + + + StandardOutPath + /Users/shai/dev/cn3/CodenameOne/scripts/website/logs/launchd.out + StandardErrorPath + /Users/shai/dev/cn3/CodenameOne/scripts/website/logs/launchd.err + + + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + diff --git a/scripts/website/logs/.gitignore b/scripts/website/logs/.gitignore new file mode 100644 index 0000000000..a222a4e808 --- /dev/null +++ b/scripts/website/logs/.gitignore @@ -0,0 +1,3 @@ +# Per-day syndication run logs and launchd stdio. Local-only. +* +!.gitignore diff --git a/scripts/website/run-syndication.sh b/scripts/website/run-syndication.sh new file mode 100755 index 0000000000..1578c209ad --- /dev/null +++ b/scripts/website/run-syndication.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Wrapper invoked by launchd to drive Medium / DZone syndication on a +# schedule. Steps: +# 1. Fast-forward the local repo to pick up new queue entries the +# blog-syndication.yml workflow committed since last run. +# 2. Invoke syndicate_browser.py — it self-skips when there's +# nothing pending (no Chrome window opens), so the schedule can +# fire daily without spamming. +# 3. If syndication-state.json changed, commit just that one file +# and push so the next run (and CI) sees the updated state. +# +# All output is appended to scripts/website/logs/syndication-YYYY-MM-DD.log +# for after-the-fact debugging when the run fires while you're away. +# +# Re-run by hand any time: +# bash scripts/website/run-syndication.sh + +set -euo pipefail + +REPO_ROOT="/Users/shai/dev/cn3/CodenameOne" +VENV_PYTHON="$REPO_ROOT/scripts/website/.venv/bin/python" +STATE_FILE="$REPO_ROOT/scripts/website/syndication-state.json" +LOG_DIR="$REPO_ROOT/scripts/website/logs" + +mkdir -p "$LOG_DIR" +LOG_FILE="$LOG_DIR/syndication-$(date +%Y-%m-%d).log" + +exec >> "$LOG_FILE" 2>&1 + +echo +echo "==========================================================" +echo " Syndication run: $(date '+%Y-%m-%d %H:%M:%S %Z')" +echo "==========================================================" + +cd "$REPO_ROOT" + +# 1. Pull latest queue from CI. Fast-forward only — if the local repo +# has diverged (uncommitted state changes from a prior failed run), +# bail loudly rather than auto-merging. +echo +echo ">>> git pull --ff-only origin master" +if ! git pull --ff-only origin master; then + echo "!!! pull failed; investigate before next run." + exit 1 +fi + +# 2. Run the browser-driven syndication. syndicate_browser.py exits 0 +# silently (no Chrome opened) when no tasks are pending. +echo +echo ">>> syndicate_browser.py" +if ! "$VENV_PYTHON" -u "$REPO_ROOT/scripts/website/syndicate_browser.py"; then + echo "!!! syndicate_browser.py exited non-zero; check log above." + # Continue to commit any state changes that did happen before the + # failure — partial success is better than losing the record. +fi + +# 3. Commit + push state changes if the runner produced any. +echo +if git diff --quiet -- "$STATE_FILE"; then + echo "No state changes — nothing to commit." +else + echo ">>> committing state changes" + git add "$STATE_FILE" + git commit -m "ci: record browser syndication results" --no-verify + if ! git push origin master; then + echo "!!! push failed; commit is local. Push manually with:" + echo " cd $REPO_ROOT && git push origin master" + exit 1 + fi + echo "Pushed state changes." +fi + +echo +echo "=== Done $(date '+%Y-%m-%d %H:%M:%S %Z') ===" diff --git a/scripts/website/syndicate_browser.py b/scripts/website/syndicate_browser.py new file mode 100644 index 0000000000..3495d3a5df --- /dev/null +++ b/scripts/website/syndicate_browser.py @@ -0,0 +1,1121 @@ +#!/usr/bin/env python3 +"""Selenium + undetected-chromedriver runner that drives Medium and +DZone editors locally using your real Chrome. + +Architecture: + +* ``syndicate_login.py`` captures cookies + Cloudflare clearance into + a shared Chrome user-data-dir under + ``scripts/website/syndication-auth/chrome-profile/``. +* This runner reuses that profile, so Cloudflare doesn't re-challenge + and Medium/DZone recognise you as already-signed-in. +* All input goes through Selenium's ``ActionChains.send_keys`` / + ``element.send_keys``, which routes through Chrome's real input + pipeline. The events carry ``isTrusted=true``, so Medium's typing + handler converts markdown shortcuts (``## `` → heading, + ``* `` → bullet, ```` ``` ```` → code block, ``**bold**`` → bold, + ``[text](url)`` → link) on the fly. +* DZone uses simple text inputs + the Froala JS API; ``execute_script`` + drives the Froala instance directly in the page context. + +Usage:: + + pip install undetected-chromedriver selenium setuptools + python scripts/website/syndicate_browser.py + python scripts/website/syndicate_browser.py --task-id medium:my-slug + python scripts/website/syndicate_browser.py --site dzone --dry-run +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import platform +import re +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable + + +REPO_ROOT = Path(__file__).resolve().parents[2] +BLOG_DIR = REPO_ROOT / "docs" / "website" / "content" / "blog" +QUEUE_FILE = Path(__file__).resolve().parent / "syndication-queue.json" +STATE_FILE = Path(__file__).resolve().parent / "syndication-state.json" +AUTH_DIR = Path(__file__).resolve().parent / "syndication-auth" +PROFILE_DIR = AUTH_DIR / "chrome-profile" +SITE_BASE = "https://www.codenameone.com" + +# Reuse front-matter parsing + Hugo-footer stripping from the existing +# syndication tooling so the markdown handling stays consistent. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from syndicate_blog_posts import ( # noqa: E402 + parse_front_matter, + _HUGO_FOOTER_RE, + _HUGO_SHORTCODE_RE, + absolutize_links, +) + + +# --------------------------------------------------------------------- +# Queue / state IO +# --------------------------------------------------------------------- + +def _load_json(path: Path, default: dict) -> dict: + if not path.exists(): + return default + return json.loads(path.read_text(encoding="utf-8")) + + +def is_already_done(state: dict, task: dict) -> bool: + return task["site"] in state.get("posts", {}).get(task["slug"], {}) + + +def record_result(state: dict, task: dict, url: str) -> None: + state.setdefault("posts", {}).setdefault(task["slug"], {})[task["site"]] = { + "url": url, + "syndicated_at": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), + } + + +# --------------------------------------------------------------------- +# Markdown prep (shared) +# --------------------------------------------------------------------- + +def read_post_markdown(slug: str) -> tuple[dict[str, Any], str]: + """Read the local blog post markdown for ``slug`` and return + ``(front_matter, body)`` with the Hugo discussion footer + shortcodes + stripped and root-relative URLs absolutized.""" + md_path = BLOG_DIR / f"{slug}.md" + if not md_path.exists(): + raise FileNotFoundError(f"blog post not found: {md_path}") + raw = md_path.read_text(encoding="utf-8") + fm, body = parse_front_matter(raw) + body = body.strip("\n") + body = _HUGO_FOOTER_RE.sub("", body) + body = _HUGO_SHORTCODE_RE.sub("", body).rstrip() + body = absolutize_links(body) + return fm, body + + +def download_to_tempfile(url: str) -> Path: + """Fetch ``url`` and store the bytes in a temp file. Returns the + path; caller cleans up. Used for cover images. + + Uses a browser-like User-Agent header — codenameone.com (Hugo + + Cloudflare in front) returns HTTP 403 to python-urllib's default + ``Python-urllib/3.x`` UA.""" + suffix = Path(urllib.request.urlparse(url).path).suffix or ".bin" + fd = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + try: + req = urllib.request.Request( + url, + headers={ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + "Accept": "image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8", + }, + ) + with urllib.request.urlopen(req) as resp: + fd.write(resp.read()) + return Path(fd.name) + finally: + fd.close() + + +# --------------------------------------------------------------------- +# Chrome version auto-detection +# --------------------------------------------------------------------- + +def detect_chrome_major_version() -> int | None: + """Read Chrome's major version without subprocess invocation — + on macOS we read the .app's Info.plist directly. The previous + subprocess approach would silently fail (and the script would + fall back to the latest chromedriver, which mismatches Chrome) + when Chrome was already running with our profile.""" + if platform.system() == "Darwin": + plist_paths = [ + "/Applications/Google Chrome.app/Contents/Info.plist", + "/Applications/Chromium.app/Contents/Info.plist", + ] + for p in plist_paths: + if not os.path.exists(p): + continue + try: + import plistlib + with open(p, "rb") as f: + data = plistlib.load(f) + version = data.get("CFBundleShortVersionString", "") + m = re.match(r"(\d+)", version) + if m: + return int(m.group(1)) + except Exception: + continue + return None + # Linux / Windows: subprocess --version is reliable enough. + candidates: list[str] = [] + if platform.system() == "Linux": + candidates = ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"] + elif platform.system() == "Windows": + candidates = [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + ] + for c in candidates: + if not os.path.exists(c): + continue + try: + out = subprocess.check_output([c, "--version"], stderr=subprocess.DEVNULL, timeout=5).decode() + m = re.search(r"(\d+)\.\d+\.\d+", out) + if m: + return int(m.group(1)) + except Exception: + continue + return None + + +# --------------------------------------------------------------------- +# Medium driver +# --------------------------------------------------------------------- + +def process_medium(driver, task: dict, log: Callable[[str], None]) -> str: + """Drive Medium's /new-story editor. Returns the resulting draft URL. + + Strategy: + 1. Type the title via real keystrokes (already proven to work and + to allocate the /p//edit URL on first input). + 2. Render the markdown body to clean HTML (python-markdown). + 3. Set the macOS clipboard to ``text/html`` via ``osascript``. + 4. Press Cmd+V in the body editor — a real isTrusted=true paste + event Medium's paste handler accepts and converts into native + graf elements, including / for bold/italic, +

/

for headings,
 for code blocks,
+          for links,