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