diff --git a/.github/workflows/_build-ios-port.yml b/.github/workflows/_build-ios-port.yml
index acddf3e8cb..9b785ae5de 100644
--- a/.github/workflows/_build-ios-port.yml
+++ b/.github/workflows/_build-ios-port.yml
@@ -68,8 +68,19 @@ jobs:
id: src_hash
run: |
set -euo pipefail
+ # `maven/svg-transcoder/src/main` belongs in here: the cn1-built
+ # cache contains ~/.m2/repository/com/codenameone, including the
+ # svg-transcoder JAR that the test-app build resolves at
+ # transcode-svg time. Without svg-transcoder in the source hash, a
+ # fix to SVGParser etc. doesn't change the cache key, the cn1-built
+ # cache hit short-circuits setup-workspace, the test app keeps
+ # generating against the stale JAR, and the SVG render bug never
+ # gets fixed in CI (most visible on clipped_badge.svg, whose outer
+ # rect kept rendering as a square long after the clip-path
+ # forwarding fix landed in the transcoder source).
SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \
maven/codenameone-maven-plugin/src/main \
+ maven/svg-transcoder/src/main \
-type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \
| sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}')
POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \
diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml
index 008bd9dd11..ca42c6019d 100644
--- a/.github/workflows/developer-guide-docs.yml
+++ b/.github/workflows/developer-guide-docs.yml
@@ -289,17 +289,27 @@ jobs:
fi
- name: Set up Java 17 for LanguageTool
+ if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true'
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install language-tool-python
+ if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true'
run: |
set -euo pipefail
pip install --user language-tool-python==2.9.4
- name: Run LanguageTool grammar check
+ # LanguageTool consumes the rendered HTML produced by "Build
+ # Developer Guide HTML and PDF". That step is gated on the same
+ # paths filter (docs/demos/workflow). Without this gate the
+ # LanguageTool step ran on PRs that didn't touch docs and exited
+ # non-zero because `developer-guide.html` was never generated,
+ # turning every unrelated PR (e.g. iOS port fixes) red on the
+ # final quality-gate check.
+ if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true'
run: |
set -euo pipefail
REPORT_DIR="build/developer-guide/reports"
diff --git a/.github/workflows/ios-packaging.yml b/.github/workflows/ios-packaging.yml
index e40214adfc..e13d4681cf 100644
--- a/.github/workflows/ios-packaging.yml
+++ b/.github/workflows/ios-packaging.yml
@@ -132,13 +132,18 @@ jobs:
cn1-binaries-${{ runner.os }}-
- name: Restore built CN1 + iOS port artifacts
+ # Use the exact key build-port saved against (published as a job
+ # output). Recomputing src_hash locally drifts whenever new
+ # source paths are added to the build-port hash without
+ # mirroring the change here, causing fail-on-cache-miss to
+ # blow up the entire packaging suite.
uses: actions/cache/restore@v4
with:
path: |
~/.m2/repository/com/codenameone
Themes
Ports/iOSPort/nativeSources
- key: cn1-built-${{ runner.os }}-${{ steps.src_hash.outputs.hash }}
+ key: ${{ needs.build-port.outputs.cn1_built_cache_key }}
fail-on-cache-miss: true
- name: Build sample iOS app
diff --git a/.github/workflows/scripts-ios-native.yml b/.github/workflows/scripts-ios-native.yml
index 306555ea55..57771bae77 100644
--- a/.github/workflows/scripts-ios-native.yml
+++ b/.github/workflows/scripts-ios-native.yml
@@ -154,13 +154,17 @@ jobs:
cn1-binaries-${{ runner.os }}-
- name: Restore built CN1 + iOS port artifacts
+ # Use the exact key build-port saved against (published as a job
+ # output). Recomputing src_hash locally drifts whenever new
+ # source paths are added to the build-port hash without
+ # mirroring the change here.
uses: actions/cache/restore@v4
with:
path: |
~/.m2/repository/com/codenameone
Themes
Ports/iOSPort/nativeSources
- key: cn1-built-${{ runner.os }}-${{ steps.src_hash.outputs.hash }}
+ key: ${{ needs.build-port.outputs.cn1_built_cache_key }}
fail-on-cache-miss: true
- name: Build sample iOS app
diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml
index 2d2b659f0d..a65d969500 100644
--- a/.github/workflows/scripts-ios.yml
+++ b/.github/workflows/scripts-ios.yml
@@ -166,13 +166,20 @@ jobs:
cn1-binaries-${{ runner.os }}-
- name: Restore built CN1 + iOS port artifacts
+ # Use the exact key build-port saved against (published as a job
+ # output). Recomputing the src_hash on this runner has been
+ # observed to produce a different value than build-port on the
+ # same SHA -- and even when the algorithm is identical, any new
+ # source path added to the hash here has to land in three
+ # places at once or the keys diverge. The same workaround
+ # protects the build-ios-metal job a few hundred lines below.
uses: actions/cache/restore@v4
with:
path: |
~/.m2/repository/com/codenameone
Themes
Ports/iOSPort/nativeSources
- key: cn1-built-${{ runner.os }}-${{ steps.src_hash.outputs.hash }}
+ key: ${{ needs.build-port.outputs.cn1_built_cache_key }}
fail-on-cache-miss: true
- name: Build sample iOS app and compile workspace
diff --git a/CodenameOne/src/com/codename1/ui/LinearGradientPaint.java b/CodenameOne/src/com/codename1/ui/LinearGradientPaint.java
index 1e50fa0b5d..7138c43a80 100644
--- a/CodenameOne/src/com/codename1/ui/LinearGradientPaint.java
+++ b/CodenameOne/src/com/codename1/ui/LinearGradientPaint.java
@@ -181,7 +181,6 @@ private float[] reverseFractions() {
@Override
@SuppressWarnings("UnusedFormalParameter")
public void paint(Graphics g, double x, double y, double w, double h) {
- // TODO: x and y probably need to be taken into consideration here...
paint(g, w, h, true);
}
@@ -200,11 +199,26 @@ private void paint(Graphics g, double w, double h, boolean processCycles) {
if (getTransform() != null) {
t2.concatenate(getTransform());
}
- int tx = g.getTranslateX();
- int ty = g.getTranslateY();
- g.translate(-tx, -ty);
-
- t2.translate((float) (startX + tx), (float) (startY + ty));
+ // Build the gradient frame on top of the caller's transform. The
+ // previous version captured `g.getTranslateX()/getTranslateY()` and
+ // baked them into `t2.translate(startX + tx, startY + ty)`, then
+ // zeroed `g.translate(-tx, -ty)` before `g.setTransform(t2)`. On
+ // ports where `isTranslationSupported()` is false (iOS, Android,
+ // JavaSE -- every active port today), Graphics already conjugates
+ // setTransform with `T(xTranslate)` so the user matrix operates in
+ // local coordinates regardless of prior g.translate; that
+ // conjugation re-applies the cell offset at the *screen* level
+ // automatically. Baking `tx, ty` into a translate that sits
+ // *inside* the SVG / theme scale meant the cell offset went
+ // through the scale a second time, shifting the gradient fill
+ // away from the stroke. Most visible on SVGStaticScreenshotTest's
+ // gradient_circle, where the filled circle appeared stacked below
+ // the dark-blue outline on Android (and would have appeared the
+ // same on iOS Metal once the triangle-clip bug was unmasked).
+ // Just build `t * Translate(startX, startY) * Rotate * Translate(0,
+ // -ph/2)` and let Graphics.setTransform's existing conjugation
+ // restore the screen-level offset.
+ t2.translate((float) startX, (float) startY);
t2.rotate((float) theta, 0, 0);
t2.translate(0, -(float) ph / 2);
@@ -293,7 +307,6 @@ private void paint(Graphics g, double w, double h, boolean processCycles) {
}*/
g.setAlpha(alpha);
g.setTransform(t);
- g.translate(tx, ty);
if (p != null) {
g.setColor(p);
}
diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m
index ec09e2d8df..92bf453791 100644
--- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m
+++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m
@@ -755,13 +755,81 @@ void CN1MetalTileImage(id texture, int alpha,
// the failure rather than papering over it with a different pipeline
// that would silently mask the bug.
+// Returns the effective screen-pixel scale baked into the current
+// transform. The vertex shader applies `projection * modelView *
+// transform * pos`; projection / modelView are stable per frame and
+// expressed in framebuffer units, so any *additional* scaling comes
+// from `currentTransform`. For text rendering we want to know that
+// effective scale up front so the glyph atlas can rasterise at the
+// matching pixel size; otherwise the atlas glyph art (rasterised at
+// font.pointSize) is sampled through a stretched quad and the glyph
+// turns into a smear at every `g.setTransform(scale)` site -- e.g.
+// the SVG transcoder painting `` under a viewBox-to-display
+// scale, which is the most visible offender.
+//
+// Pulls a uniform scale by averaging the magnitudes of the two basis
+// vectors of the upper-left 2x2 (sx along the X column, sy along the
+// Y column). Shear-only or pure-rotation matrices return 1 because
+// both column magnitudes stay at 1; pure scale returns the scale.
+// We do *not* try to handle non-uniform scale separately -- the
+// glyph atlas slot key is one float (pointSize), so even if the
+// SVG draws with sx != sy we have to pick one. Going with the
+// geometric mean keeps the rasterised glyph close to either bound
+// and the residual GPU stretch only kicks in along the dimension
+// that's farther from the mean.
+static inline float currentTransformGlyphScale(void) {
+ float c0x = currentTransform.columns[0].x;
+ float c0y = currentTransform.columns[0].y;
+ float c1x = currentTransform.columns[1].x;
+ float c1y = currentTransform.columns[1].y;
+ float sx = sqrtf(c0x * c0x + c0y * c0y);
+ float sy = sqrtf(c1x * c1x + c1y * c1y);
+ float s = (sx + sy) * 0.5f;
+ // Reject NaN / inf / non-positive values: any of those would
+ // poison `font.pointSize * s` below and produce a UIFont with
+ // bad metrics that hangs the CTLine layout. `isfinite` is true
+ // only for finite numbers; treat anything else as "use unscaled
+ // font" by returning 1.0 (the `useScaledFont` gate at the call
+ // site clears that to the fast path).
+ if (!isfinite(s) || s <= 0.0f) return 1.0f;
+ // Cap at 8x to keep the atlas from rasterising absurdly large
+ // bitmaps for a runaway transform; well past 8x the difference
+ // between "atlas-perfect" and "sampled-and-filtered" is below
+ // what the user can see anyway.
+ if (s < 1.0f) s = 1.0f; // No down-rasterising; 1px atlas is fine for downscale.
+ if (s > 8.0f) s = 8.0f;
+ return s;
+}
+
void CN1MetalDrawString(NSString *str, UIFont *font, int color, int alpha, int x, int y) {
if (str == nil || font == nil || str.length == 0) return;
- CN1MetalGlyphAtlas *atlas = [CN1MetalGlyphAtlas atlasForFont:font];
+ // CoreText shapes glyphs and the atlas rasterises them at font.pointSize
+ // — but the active Graphics transform may be scaling the whole drawing
+ // up before the framebuffer write. If we hand the shader a quad sized to
+ // the unscaled glyph and let the transform stretch it on the GPU, the
+ // result is a smeared/blurry glyph (Codename One's SVG transcoder paints
+ // viewBox-relative text through `g.setTransform(scale*translate)`, so the
+ // screen scale is routinely 2x-4x). Detect the scale baked into
+ // `currentTransform` and rasterise the atlas at the effective pixel size
+ // so the shader transform produces a 1:1 sample. We then divide the
+ // returned glyph metrics back down by the same factor so the vertex
+ // coords stay in unscaled space — the GPU re-applies `currentTransform`
+ // for free and the final on-screen position matches the unscaled path.
+ float glyphScale = currentTransformGlyphScale();
+ BOOL useScaledFont = (glyphScale > 1.01f);
+ UIFont *renderFont = useScaledFont
+ ? [font fontWithSize:font.pointSize * glyphScale]
+ : font;
+ if (renderFont == nil) {
+ renderFont = font;
+ useScaledFont = NO;
+ }
+
+ CN1MetalGlyphAtlas *atlas = [CN1MetalGlyphAtlas atlasForFont:renderFont];
if (atlas == nil) {
NSLog(@"CN1MetalDrawString: no atlas available for font %@ pt=%g; string skipped",
- font.fontName, (double)font.pointSize);
+ renderFont.fontName, (double)renderFont.pointSize);
return;
}
@@ -774,7 +842,7 @@ void CN1MetalDrawString(NSString *str, UIFont *font, int color, int alpha, int x
// fresh form, which surfaced as the TL panel of graphics-draw-string-
// decorated rendering larger/wider glyphs than TR/BL/BR despite
// identical Java state.
- NSDictionary *attrs = @{ (__bridge NSString *)kCTFontAttributeName: font };
+ NSDictionary *attrs = @{ (__bridge NSString *)kCTFontAttributeName: renderFont };
CFAttributedStringRef attrStr = CFAttributedStringCreate(NULL,
(__bridge CFStringRef)str,
(__bridge CFDictionaryRef)attrs);
@@ -798,7 +866,14 @@ void CN1MetalDrawString(NSString *str, UIFont *font, int color, int alpha, int x
// (not CTFontGetAscent) is intentional — UIKit's metric is what
// drawAtPoint references and the values can disagree slightly across
// fonts.
+ //
+ // Use the ORIGINAL font's ascender (and the original pointSize) so the
+ // baseline lands where the caller-side framework expects, even when we
+ // upscaled the atlas. The atlas-internal metrics (renderFont) reflect
+ // the rasterised size; we divide them by `glyphScale` below to bring
+ // them back into caller-side coords.
float baselineY = (float)y + (float)font.ascender;
+ float invScale = useScaledFont ? (1.0f / glyphScale) : 1.0f;
simd_float4 colorV = premultipliedColor(color, alpha);
int textureW = atlas.textureWidth;
@@ -838,11 +913,24 @@ void CN1MetalDrawString(NSString *str, UIFont *font, int color, int alpha, int x
// bbox-left-on-screen = x + posX + bearingX
// bbox-top-on-screen = baselineY - posY - (bearingY + bbox.height)
// Slot extends 1px above and to the left of the bbox.
- float gx = (float)x + (float)posPtr[i].x + slot.bearingX - 1.0f;
- float gy = baselineY - (float)posPtr[i].y
- - (slot.bearingY + slot.bboxHeight) - 1.0f;
- float gw = (float)slot.width;
- float gh = (float)slot.height;
+ //
+ // When the atlas was rasterised at the upscaled size, the
+ // CoreText positions and slot metrics are in renderFont-pixel
+ // space (which is glyphScale times the caller-side pixel space).
+ // Divide each one back down by glyphScale so the emitted vertex
+ // coords live in caller-side space — the vertex shader will
+ // re-apply currentTransform (the same scale we factored out) and
+ // produce a quad of the correct on-screen size, sampling
+ // 1:1 against the now-matching atlas.
+ float posX = (float)posPtr[i].x * invScale;
+ float posY = (float)posPtr[i].y * invScale;
+ float bearingX = slot.bearingX * invScale;
+ float bearingY = slot.bearingY * invScale;
+ float bboxHeight = slot.bboxHeight * invScale;
+ float gx = (float)x + posX + bearingX - invScale;
+ float gy = baselineY - posY - (bearingY + bboxHeight) - invScale;
+ float gw = (float)slot.width * invScale;
+ float gh = (float)slot.height * invScale;
float vertices[8] = {
gx, gy,
diff --git a/Ports/iOSPort/nativeSources/DrawPath.m b/Ports/iOSPort/nativeSources/DrawPath.m
index 60f4306a42..f746303b24 100644
--- a/Ports/iOSPort/nativeSources/DrawPath.m
+++ b/Ports/iOSPort/nativeSources/DrawPath.m
@@ -49,14 +49,15 @@ -(void)execute
JAVA_INT outputBounds[4];
Renderer_getOutputBounds(renderer, (JAVA_INT*)&outputBounds);
- if ( outputBounds[2] < 0 || outputBounds[3] < 0 ){
- return;
- }
+ // outputBounds is { minX, minY, maxX, maxY } in renderer pixel
+ // space; maxX / maxY are legitimately negative when the path sits
+ // in the negative quadrant. Filter on width / height (computed
+ // below) rather than the raw max values.
JAVA_INT x = min(outputBounds[0], outputBounds[2]);
JAVA_INT y = min(outputBounds[1], outputBounds[3]);
JAVA_INT width = outputBounds[2]-outputBounds[0];
JAVA_INT height = outputBounds[3]-outputBounds[1];
-
+
if ( width < 0 ) width = -width;
if ( height < 0 ) height = -height;
if (width == 0 || height == 0) {
diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m
index 4cc9f21401..e39fb62cea 100644
--- a/Ports/iOSPort/nativeSources/IOSNative.m
+++ b/Ports/iOSPort/nativeSources/IOSNative.m
@@ -9238,19 +9238,23 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_nativePathRendererToARGB___long_int
JAVA_INT outputBounds[4];
Renderer_getOutputBounds(renderer, (JAVA_INT*)&outputBounds);
- if ( outputBounds[2] < 0 || outputBounds[3] < 0 ){
- return 0;
- }
-
+ // outputBounds is { minX, minY, maxX, maxY }; maxX / maxY can be
+ // legitimately negative for shapes drawn at negative coordinates
+ // (see the comment in nativePathRendererCreateTexture above).
+ // Filter on the actual width / height below.
+
//GLuint tex=0;
JAVA_INT x = min(outputBounds[0], outputBounds[2]);
JAVA_INT y = min(outputBounds[1], outputBounds[3]);
JAVA_INT width = outputBounds[2]-outputBounds[0];
JAVA_INT height = outputBounds[3]-outputBounds[1];
-
+
if ( width < 0 ) width = -width;
if ( height < 0 ) height = -height;
-
+ if (width == 0 || height == 0) {
+ return 0;
+ }
+
AlphaConsumer ac = {
x,
y,
@@ -9300,7 +9304,19 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreateTexture___lon
Renderer *r = (Renderer*)renderer;
JAVA_INT outputBounds[4];
Renderer_getOutputBounds(renderer, (JAVA_INT*)&outputBounds);
- if (outputBounds[2] < 0 || outputBounds[3] < 0) return 0;
+ // outputBounds is { minX, minY, maxX, maxY } in renderer pixel
+ // space, which can legitimately be entirely negative when the
+ // input shape sits at negative coordinates (e.g. the SVG
+ // transcoder emits ``
+ // for the spinner_animated.svg children -- after the SVG scale
+ // bake the renderer sees a path with bounds (-7, -60, 8, -30)).
+ // The previous check rejected those legitimate negative maxX /
+ // maxY values, returned 0 / nil texture, and silently dropped
+ // every fillShape on negatively-positioned paths -- the
+ // spinner column was blank on iOS Metal screenshots as a
+ // result. Only reject *empty* bounds (max <= min on either
+ // axis); the unsigned width / height computed below carry the
+ // actual extent.
JAVA_INT x = min(outputBounds[0], outputBounds[2]);
JAVA_INT y = min(outputBounds[1], outputBounds[3]);
JAVA_INT width = outputBounds[2] - outputBounds[0];
@@ -9350,20 +9366,21 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_nativePathRendererCreateTexture___lon
Renderer *r = (Renderer*)renderer;
JAVA_INT outputBounds[4];
-
+
Renderer_getOutputBounds(renderer, (JAVA_INT*)&outputBounds);
- if ( outputBounds[2] < 0 || outputBounds[3] < 0 ){
- //return 0;
- POOL_END();
- return;
- }
-
+ // outputBounds is { minX, minY, maxX, maxY }; the maxX/maxY
+ // values can legitimately be negative when the shape sits in
+ // the negative quadrant (e.g. the spinner SVG draws each
+ // rotated rect at y in [-40, -20]). The width / height check
+ // below filters degenerate / empty paths. Mirrors the Metal
+ // branch above.
+
GLuint tex=0;
JAVA_INT x = min(outputBounds[0], outputBounds[2]);
JAVA_INT y = min(outputBounds[1], outputBounds[3]);
JAVA_INT width = outputBounds[2]-outputBounds[0];
JAVA_INT height = outputBounds[3]-outputBounds[1];
-
+
if ( width < 0 ) width = -width;
if ( height < 0 ) height = -height;
if (width == 0 || height == 0) {
diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
index e4e7d73439..1db7a3fe4c 100644
--- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
+++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java
@@ -1735,8 +1735,19 @@ private void setNativeClippingGlobal(ClipShape shape){
Rectangle bounds = shape.getBounds();
if ( shape.isRectangle() || bounds.getWidth() <= 0 || bounds.getHeight() <= 0){
setNativeClippingGlobal(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight(), true);
- } else if (shape.isPolygon()) {
- int pointsSize = shape.getPointsSize();
+ return;
+ }
+ // Curved clips (anything containing QUADTO / CUBICTO) get
+ // flattened first so the polygon path below sees real polyline
+ // vertices instead of interleaved control / anchor pairs. Without
+ // this, setClip(circularPath) reaches the native side as 17 raw
+ // floats that include 8 outside-the-curve control points, and
+ // the triangle-fan stencil writer turns the circle into the
+ // visible "triangle clip" on gradient_circle.svg and
+ // clipped_badge.svg (see SVGStaticScreenshotTest).
+ ClipShape polyShape = flattenClipShapeIfNeeded(shape);
+ if (polyShape.isPolygon()) {
+ int pointsSize = polyShape.getPointsSize();
// Reallocate when the buffer doesn't EXACTLY match -- previously
// this only reallocated when undersized, so a smaller polygon
// reused a larger buffer and the trailing slots retained the
@@ -1749,23 +1760,27 @@ private void setNativeClippingGlobal(ClipShape shape){
if (polygonPointsBuffer == null || polygonPointsBuffer.length != pointsSize) {
polygonPointsBuffer = new float[pointsSize];
}
- shapeToPolygon(shape, polygonPointsBuffer);
+ shapeToPolygon(polyShape, polygonPointsBuffer);
nativeInstance.setNativeClippingPolygonGlobal(polygonPointsBuffer);
} else {
-
+ // The path didn't reduce to a polygon (still has multiple
+ // disjoint sub-paths or other oddities). Fall back to the
+ // alpha-mask Renderer; on the GL backend this paints the
+ // shape into the stencil, on the Metal backend the texture
+ // handle isn't compatible with MTLTexture and the bounding
+ // box is used as a coarse fallback (see ClipRect.m).
TextureAlphaMask mask = (TextureAlphaMask)textureCache.get(shape, null);
if ( mask == null ){
mask = (TextureAlphaMask)this.createAlphaMask(shape, null);
textureCache.add(shape, null, mask);
}
-
+
if ( mask != null ){
- //Log.p("Setting native clipping mask global with bounds "+mask.getBounds()+" : "+shape);
nativeInstance.setNativeClippingMaskGlobal(mask.getTextureName(), mask.getBounds().getX(), mask.getBounds().getY(), mask.getBounds().getWidth(), mask.getBounds().getHeight());
} else {
Log.p("Failed to create texture mask for clipping region");
}
-
+
}
}
@@ -2317,7 +2332,184 @@ private void shapeToPolygon(ClipShape shape, float[] pointsOut){
throw new RuntimeException("shapeToPolygon requires out array at least the size of the points in the polygon. Requires "+size+" but found "+pointsOut.length);
}
shape.getPoints(pointsOut);
-
+
+ }
+
+ // Reusable buffer for flattening curves into a polyline GeneralPath
+ // before handing the clip down to the native polygon path. Reused
+ // across clip applications to avoid per-frame allocation.
+ private GeneralPath flattenedClipPath;
+ private ClipShape flattenedClipShape;
+
+ /// Walks `src` and builds a polyline GeneralPath in `dst` by replacing
+ /// every QUADTO / CUBICTO with a chain of straight LINETO segments
+ /// produced by midpoint subdivision. The native iOS clip pipeline
+ /// (GL ES2 FillPolygon and Metal CN1MetalApplyPolygonStencilClip) both
+ /// consume their input as a flat polygon: the only points they look
+ /// at are the (x, y) pairs in the buffer. When the source path is a
+ /// curve (e.g. a circle built from arc() emits 8 quadTos) the raw
+ /// points buffer contains alternating control / anchor pairs, and the
+ /// stencil writer treats every control point as a real polygon
+ /// vertex. The result is the degenerate "triangle clip" described in
+ /// the SVG tests on gradient_circle.svg / clipped_badge.svg. Flatten
+ /// first so only true vertices survive.
+ private void flattenShapeToPolyline(Shape src, GeneralPath dst) {
+ dst.reset();
+ PathIterator it = src.getPathIterator();
+ dst.setWindingRule(it.getWindingRule());
+ float[] coords = new float[6];
+ float curX = 0f, curY = 0f, moveX = 0f, moveY = 0f;
+ while (!it.isDone()) {
+ int seg = it.currentSegment(coords);
+ switch (seg) {
+ case PathIterator.SEG_MOVETO:
+ dst.moveTo(coords[0], coords[1]);
+ curX = moveX = coords[0];
+ curY = moveY = coords[1];
+ break;
+ case PathIterator.SEG_LINETO:
+ dst.lineTo(coords[0], coords[1]);
+ curX = coords[0];
+ curY = coords[1];
+ break;
+ case PathIterator.SEG_QUADTO:
+ flattenQuadInto(dst, curX, curY, coords[0], coords[1], coords[2], coords[3], 0);
+ curX = coords[2];
+ curY = coords[3];
+ break;
+ case PathIterator.SEG_CUBICTO:
+ flattenCubicInto(dst, curX, curY,
+ coords[0], coords[1], coords[2], coords[3], coords[4], coords[5], 0);
+ curX = coords[4];
+ curY = coords[5];
+ break;
+ case PathIterator.SEG_CLOSE:
+ dst.closePath();
+ curX = moveX;
+ curY = moveY;
+ break;
+ }
+ it.next();
+ }
+ }
+
+ // Squared distance threshold (in user-space units) for the
+ // subdivision flatness test. 0.25 px is well below 1 device pixel
+ // even after the typical retina upscale and matches the precision of
+ // the alpha-mask Renderer used by the rest of the iOS port.
+ private static final float FLATTEN_TOLERANCE_SQ = 0.25f * 0.25f;
+ // Safety cap on the recursion depth. 18 = 2^18 sub-segments which is
+ // far past anything a real SVG path needs; the flatness test should
+ // always converge well before this.
+ private static final int FLATTEN_MAX_DEPTH = 18;
+
+ private static void flattenQuadInto(GeneralPath dst,
+ float x0, float y0,
+ float x1, float y1,
+ float x2, float y2,
+ int depth) {
+ // Distance from the control point to the chord P0-P2. For a
+ // quadratic Bezier the maximum deviation between the curve and
+ // its chord is bounded by half the control-point-to-chord
+ // distance, so testing the control point against the threshold
+ // is a safe (slightly conservative) flatness criterion.
+ float dx = x2 - x0;
+ float dy = y2 - y0;
+ float lenSq = dx * dx + dy * dy;
+ float distSq;
+ if (lenSq < 1e-6f) {
+ distSq = (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0);
+ } else {
+ float cross = (x1 - x0) * dy - (y1 - y0) * dx;
+ distSq = (cross * cross) / lenSq;
+ }
+ if (distSq <= FLATTEN_TOLERANCE_SQ || depth >= FLATTEN_MAX_DEPTH) {
+ dst.lineTo(x2, y2);
+ return;
+ }
+ float mx1 = (x0 + x1) * 0.5f, my1 = (y0 + y1) * 0.5f;
+ float mx2 = (x1 + x2) * 0.5f, my2 = (y1 + y2) * 0.5f;
+ float mx = (mx1 + mx2) * 0.5f, my = (my1 + my2) * 0.5f;
+ flattenQuadInto(dst, x0, y0, mx1, my1, mx, my, depth + 1);
+ flattenQuadInto(dst, mx, my, mx2, my2, x2, y2, depth + 1);
+ }
+
+ private static void flattenCubicInto(GeneralPath dst,
+ float x0, float y0,
+ float x1, float y1,
+ float x2, float y2,
+ float x3, float y3,
+ int depth) {
+ // Max distance from either inner control point to the chord
+ // P0-P3. A cubic curve never strays farther than its furthest
+ // control point from its chord, so the larger of the two
+ // perpendicular distances is a conservative flatness bound.
+ float dx = x3 - x0;
+ float dy = y3 - y0;
+ float lenSq = dx * dx + dy * dy;
+ float d1Sq, d2Sq;
+ if (lenSq < 1e-6f) {
+ d1Sq = (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0);
+ d2Sq = (x2 - x0) * (x2 - x0) + (y2 - y0) * (y2 - y0);
+ } else {
+ float c1 = (x1 - x0) * dy - (y1 - y0) * dx;
+ float c2 = (x2 - x0) * dy - (y2 - y0) * dx;
+ d1Sq = (c1 * c1) / lenSq;
+ d2Sq = (c2 * c2) / lenSq;
+ }
+ float distSq = d1Sq > d2Sq ? d1Sq : d2Sq;
+ if (distSq <= FLATTEN_TOLERANCE_SQ || depth >= FLATTEN_MAX_DEPTH) {
+ dst.lineTo(x3, y3);
+ return;
+ }
+ float mx01 = (x0 + x1) * 0.5f, my01 = (y0 + y1) * 0.5f;
+ float mx12 = (x1 + x2) * 0.5f, my12 = (y1 + y2) * 0.5f;
+ float mx23 = (x2 + x3) * 0.5f, my23 = (y2 + y3) * 0.5f;
+ float mxA = (mx01 + mx12) * 0.5f, myA = (my01 + my12) * 0.5f;
+ float mxB = (mx12 + mx23) * 0.5f, myB = (my12 + my23) * 0.5f;
+ float mx = (mxA + mxB) * 0.5f, my = (myA + myB) * 0.5f;
+ flattenCubicInto(dst, x0, y0, mx01, my01, mxA, myA, mx, my, depth + 1);
+ flattenCubicInto(dst, mx, my, mxB, myB, mx23, my23, x3, y3, depth + 1);
+ }
+
+ // True if the path has only MOVETO / LINETO / CLOSE segments, i.e.
+ // it is already a polyline and flattening would just copy it.
+ private boolean isAlreadyFlat(Shape s) {
+ if (s instanceof ClipShape && ((ClipShape) s).isRect()) {
+ return true;
+ }
+ PathIterator it = s.getPathIterator();
+ float[] coords = new float[6];
+ while (!it.isDone()) {
+ int seg = it.currentSegment(coords);
+ if (seg == PathIterator.SEG_QUADTO || seg == PathIterator.SEG_CUBICTO) {
+ return false;
+ }
+ it.next();
+ }
+ return true;
+ }
+
+ // Flatten if necessary and return the ClipShape that should be sent
+ // through the native polygon clip path. When the input is already a
+ // polyline (the common case for rectangular clipRect intersections
+ // built by NativeGraphics.clipRect) the input is returned as-is. The
+ // returned ClipShape is reused across calls (not shared with the
+ // input), so callers must finish reading from it before the next
+ // clip is applied.
+ private ClipShape flattenClipShapeIfNeeded(ClipShape src) {
+ if (isAlreadyFlat(src)) {
+ return src;
+ }
+ if (flattenedClipPath == null) {
+ flattenedClipPath = new GeneralPath();
+ }
+ flattenShapeToPolyline(src, flattenedClipPath);
+ if (flattenedClipShape == null) {
+ flattenedClipShape = new ClipShape();
+ }
+ flattenedClipShape.setShape(flattenedClipPath, null);
+ return flattenedClipShape;
}
/*
public void drawConvexPolygon(Object graphics, Shape shape, Stroke stroke, int color, int alpha){
@@ -5027,18 +5219,27 @@ void setNativeClipping(int x, int y, int width, int height, boolean firstClip) {
}
void setNativeClipping(ClipShape shape){
-
+
if (shape.isRect()) {
shape.getBounds(reusableRect);
setNativeClippingMutable(reusableRect.getX(), reusableRect.getY(), reusableRect.getWidth(), reusableRect.getHeight(), clipApplied);
-
+
} else {
- int commandsLen = shape.getTypesSize();
- int pointsLen = shape.getPointsSize();
+ // The native side (setNativeClippingShapeMutableImpl in
+ // CodenameOne_GLViewController.m) ignores the commands
+ // array and treats every (x, y) pair in the points buffer
+ // as a polygon vertex. For a path with curves that means
+ // control points appear as polygon vertices, producing
+ // the SVG "triangle clip" symptom for gradient_circle.svg
+ // and clipped_badge.svg. Flatten on the Java side so the
+ // points buffer contains only true polyline vertices.
+ ClipShape polyShape = flattenClipShapeIfNeeded(shape);
+ int commandsLen = polyShape.getTypesSize();
+ int pointsLen = polyShape.getPointsSize();
byte[] commandsArr = getTmpNativeDrawShape_commands(commandsLen);
float[] pointsArr = getTmpNativeDrawShape_coords(pointsLen);
- shape.getTypes(commandsArr);
- shape.getPoints(pointsArr);
+ polyShape.getTypes(commandsArr);
+ polyShape.getPoints(pointsArr);
nativeInstance.setNativeClippingMutable(commandsLen, commandsArr, pointsLen, pointsArr);
}
}
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
index d0197c841d..d122fd09ec 100644
--- 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
@@ -444,9 +444,16 @@ private void applyCommon(SVGNode n, Map a) {
Map pres = new HashMap();
for (Map.Entry e : a.entrySet()) {
String k = e.getKey();
+ // Whitelist of SVG presentation attributes we forward to
+ // StyleParser. Missing `clip-path` here was why
+ // clipped_badge.svg's outer rect lost its rounded clip --
+ // StyleParser only sees the keys that land in `pres`, so any
+ // attribute *not* listed is silently dropped even if it is a
+ // well-formed presentation attribute on the element.
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)) {
+ || "stroke-linejoin".equals(k) || "stroke-miterlimit".equals(k)
+ || "clip-path".equals(k)) {
pres.put(k, e.getValue());
}
}
diff --git a/scripts/android/screenshots/SVGStatic.png b/scripts/android/screenshots/SVGStatic.png
index ad49a2366c..60fdc265f9 100644
Binary files a/scripts/android/screenshots/SVGStatic.png and b/scripts/android/screenshots/SVGStatic.png differ
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
index 3c97322cf0..067101221a 100644
--- 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
@@ -23,26 +23,6 @@
* startup, replacing every placeholder with the transcoded
* {@code GeneratedSVGImage}. No glue code in app land.
*
- * Known port-side rendering bugs the goldens encode
- *
- * The screenshot baselines record the current per-port behavior so
- * regressions in the rendering pipeline show up as diffs. These items
- * are tracked separately and a follow-up port-side PR will refresh the
- * goldens once the underlying bugs are fixed:
- *
- * - iOS (legacy + Metal): {@code gradient_circle.svg} and
- * {@code clipped_badge.svg} render as triangles because the
- * iOS port's {@code setClip(GeneralPath)} substitutes a
- * degenerate polygon for arc-decomposed paths.
- * - iOS (legacy + Metal): {@code } on {@code fill}
- * colors doesn't tick. {@code color_morph.svg} freezes on the
- * start color (white on legacy, the first palette stop on
- * Metal); Android animates it as expected.
- * - Android: {@code gradient_circle.svg} draws both the filled
- * circle and an outline of the same circle stacked, rather than
- * a single filled circle with a darker stroke.
- *
- *
* If this test calls {@code SVGRegistry.install(...)} explicitly it
* has failed the point of the test: the registry is supposed to be
* seamless to the developer.
diff --git a/scripts/ios/screenshots-metal/SVGAnimatedScreenshotTest.png b/scripts/ios/screenshots-metal/SVGAnimatedScreenshotTest.png
index e2cad74b18..72db55c115 100644
Binary files a/scripts/ios/screenshots-metal/SVGAnimatedScreenshotTest.png and b/scripts/ios/screenshots-metal/SVGAnimatedScreenshotTest.png differ
diff --git a/scripts/ios/screenshots-metal/SVGStatic.png b/scripts/ios/screenshots-metal/SVGStatic.png
index 77922dcd34..2d5ac70ffb 100644
Binary files a/scripts/ios/screenshots-metal/SVGStatic.png and b/scripts/ios/screenshots-metal/SVGStatic.png differ
diff --git a/scripts/ios/screenshots/SVGStatic.png b/scripts/ios/screenshots/SVGStatic.png
index b06cc1ca47..7e96b7d9b5 100644
Binary files a/scripts/ios/screenshots/SVGStatic.png and b/scripts/ios/screenshots/SVGStatic.png differ