From e38be7b0d07403950b8423e2a6404e9d055ecdbb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:10:17 +0300 Subject: [PATCH 01/61] Add a pure Java2D rasterizer for CSS boxes The CSS compiler paints a rule that has no native theme equivalent into an image and slices it. That painting was done by a CEF browser, which needs a display. This package paints the same box -- backgrounds, gradients, per-side borders, elliptical radii, outer and inset shadows, border images -- with Java2D into a BufferedImage, and runs under java.awt.headless=true. It depends on the JDK only. Nothing uses it yet. Co-Authored-By: Claude Opus 5.5 --- .../designer/css/raster/BackgroundImage.java | 133 +++++ .../css/raster/BackgroundImagePainter.java | 181 +++++++ .../designer/css/raster/BorderImage.java | 91 ++++ .../css/raster/BorderImagePainter.java | 211 ++++++++ .../designer/css/raster/BorderPainter.java | 488 ++++++++++++++++++ .../designer/css/raster/BorderSide.java | 72 +++ .../designer/css/raster/BorderStyle.java | 57 ++ .../designer/css/raster/BoxShadowPainter.java | 138 +++++ .../designer/css/raster/BoxStyle.java | 292 +++++++++++ .../designer/css/raster/CssBoxRasterizer.java | 244 +++++++++ .../designer/css/raster/GaussianBlur.java | 242 +++++++++ .../designer/css/raster/GradientPainter.java | 409 +++++++++++++++ .../designer/css/raster/GradientSpec.java | 330 ++++++++++++ .../codename1/designer/css/raster/Pixels.java | 123 +++++ .../designer/css/raster/RoundedBox.java | 304 +++++++++++ .../codename1/designer/css/raster/Shadow.java | 72 +++ .../raster/BackgroundImagePainterTest.java | 288 +++++++++++ .../css/raster/BorderImagePainterTest.java | 229 ++++++++ .../css/raster/BorderPainterTest.java | 410 +++++++++++++++ .../css/raster/BoxShadowPainterTest.java | 354 +++++++++++++ .../css/raster/CssBoxRasterizerTest.java | 295 +++++++++++ .../designer/css/raster/GaussianBlurTest.java | 233 +++++++++ .../css/raster/GradientPainterTest.java | 462 +++++++++++++++++ .../designer/css/raster/RasterAssert.java | 114 ++++ .../designer/css/raster/RoundedBoxTest.java | 184 +++++++ 25 files changed, 5956 insertions(+) create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImage.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImagePainter.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImage.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImagePainter.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderPainter.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderSide.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderStyle.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxShadowPainter.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxStyle.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/CssBoxRasterizer.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GaussianBlur.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GradientPainter.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GradientSpec.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/Pixels.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/RoundedBox.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/raster/Shadow.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BackgroundImagePainterTest.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BorderImagePainterTest.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BorderPainterTest.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BoxShadowPainterTest.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/CssBoxRasterizerTest.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/GaussianBlurTest.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/GradientPainterTest.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/RasterAssert.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/raster/RoundedBoxTest.java diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImage.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImage.java new file mode 100644 index 00000000000..9abbb2a5c74 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImage.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.image.BufferedImage; + +/// A CSS background image layer with its `background-repeat`, +/// `background-position` and `background-size`. Immutable, apart from the +/// image it refers to, which is never modified by this package. +public final class BackgroundImage { + /// The `background-repeat` modes. + public enum Repeat { REPEAT, REPEAT_X, REPEAT_Y, NO_REPEAT } + + /// The `background-size` modes. + public enum Size { AUTO, COVER, CONTAIN, EXPLICIT } + + /// The image to paint. + public final BufferedImage image; + /// How the image tiles. + public final Repeat repeat; + /// Horizontal position. A percentage `p` places the image at + /// `p%` of (positioning area width - image width); otherwise px from + /// the left of the positioning area (the padding box). + public final double posX; + /// Vertical position, see [#posX]. + public final double posY; + /// Whether [#posX] is a percentage. + public final boolean posXPercent; + /// Whether [#posY] is a percentage. + public final boolean posYPercent; + /// The sizing mode. + public final Size size; + /// The width for [Size#EXPLICIT]; a negative value means `auto`. + public final double sizeW; + /// The height for [Size#EXPLICIT]; a negative value means `auto`. + public final double sizeH; + + /// Creates a layer with the CSS initial values: `repeat`, position + /// `0% 0%`, size `auto`. + public BackgroundImage(BufferedImage image) { + this(image, Repeat.REPEAT, 0, true, 0, true, Size.AUTO, -1, -1); + } + + public BackgroundImage(BufferedImage image, Repeat repeat, double posX, boolean posXPercent, + double posY, boolean posYPercent, Size size, double sizeW, double sizeH) { + this.image = image; + this.repeat = repeat; + this.posX = posX; + this.posXPercent = posXPercent; + this.posY = posY; + this.posYPercent = posYPercent; + this.size = size; + this.sizeW = sizeW; + this.sizeH = sizeH; + } + + /// Returns a copy with a different repeat mode. + public BackgroundImage withRepeat(Repeat r) { + return new BackgroundImage(image, r, posX, posXPercent, posY, posYPercent, size, sizeW, sizeH); + } + + /// Returns a copy with a different position. + public BackgroundImage withPosition(double x, boolean xPercent, double y, boolean yPercent) { + return new BackgroundImage(image, repeat, x, xPercent, y, yPercent, size, sizeW, sizeH); + } + + /// Returns a copy sized by keyword (`AUTO`, `COVER` or `CONTAIN`). + public BackgroundImage withSize(Size s) { + return new BackgroundImage(image, repeat, posX, posXPercent, posY, posYPercent, s, -1, -1); + } + + /// Returns a copy with an explicit size; a negative value is `auto` + /// for that axis. + public BackgroundImage withSize(double w, double h) { + return new BackgroundImage(image, repeat, posX, posXPercent, posY, posYPercent, Size.EXPLICIT, w, h); + } + + public BufferedImage getImage() { + return image; + } + + public Repeat getRepeat() { + return repeat; + } + + public double getPosX() { + return posX; + } + + public double getPosY() { + return posY; + } + + public boolean isPosXPercent() { + return posXPercent; + } + + public boolean isPosYPercent() { + return posYPercent; + } + + public Size getSize() { + return size; + } + + public double getSizeW() { + return sizeW; + } + + public double getSizeH() { + return sizeH; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImagePainter.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImagePainter.java new file mode 100644 index 00000000000..b70d2d71dca --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImagePainter.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.TexturePaint; +import java.awt.geom.AffineTransform; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; + +/// Paints a CSS background image: `background-size`, `background-position` +/// and `background-repeat`. +/// +/// The image is sized and positioned against the positioning area, which +/// the rasterizer makes the padding box (`background-origin: padding-box`, +/// the CSS initial value), and tiled over the painting area, the border box +/// (`background-clip: border-box`). The rounded clip is applied by the +/// caller through a coverage mask; this class only clips to the painting +/// rectangle. +/// +/// Scaling is bicubic. An image reduced to less than half its size is first +/// halved repeatedly with bilinear filtering, because a single bicubic step +/// samples too few source pixels and aliases. +public final class BackgroundImagePainter { + /// Above this many tiles the image is tiled with a `TexturePaint` + /// instead of being drawn tile by tile. + private static final long MAX_DRAWN_TILES = 4096; + + private BackgroundImagePainter() { + } + + /// The size of one tile for a background image in a positioning area. + /// + /// #### Returns + /// + /// `{width, height}` in px + public static double[] tileSize(BackgroundImage bg, double areaW, double areaH) { + double iw = bg.image.getWidth(); + double ih = bg.image.getHeight(); + switch (bg.size) { + case COVER: { + double s = Math.max(areaW / iw, areaH / ih); + return new double[] {iw * s, ih * s}; + } + case CONTAIN: { + double s = Math.min(areaW / iw, areaH / ih); + return new double[] {iw * s, ih * s}; + } + case EXPLICIT: { + boolean autoW = bg.sizeW < 0; + boolean autoH = bg.sizeH < 0; + if (autoW && autoH) { + return new double[] {iw, ih}; + } + if (autoW) { + return new double[] {iw * bg.sizeH / ih, bg.sizeH}; + } + if (autoH) { + return new double[] {bg.sizeW, ih * bg.sizeW / iw}; + } + return new double[] {bg.sizeW, bg.sizeH}; + } + default: + return new double[] {iw, ih}; + } + } + + /// Paints the image into a transparent layer the size of the output. + /// + /// #### Parameters + /// + /// - `bg`: the background image + /// + /// - `w`: layer width + /// + /// - `h`: layer height + /// + /// - `area`: the positioning area (padding box) in layer coordinates + /// + /// - `clip`: the painting area (border box bounds) in layer coordinates + /// + /// #### Returns + /// + /// `w * h` non-premultiplied ARGB values, or `null` when nothing is painted + public static int[] paint(BackgroundImage bg, int w, int h, Rectangle2D area, Rectangle2D clip) { + double[] ts = tileSize(bg, area.getWidth(), area.getHeight()); + double tw = ts[0]; + double th = ts[1]; + if (!(tw > 0) || !(th > 0) || Double.isInfinite(tw) || Double.isInfinite(th)) { + return null; + } + double px = area.getX() + (bg.posXPercent ? (area.getWidth() - tw) * bg.posX / 100 : bg.posX); + double py = area.getY() + (bg.posYPercent ? (area.getHeight() - th) * bg.posY / 100 : bg.posY); + boolean repeatX = bg.repeat == BackgroundImage.Repeat.REPEAT || bg.repeat == BackgroundImage.Repeat.REPEAT_X; + boolean repeatY = bg.repeat == BackgroundImage.Repeat.REPEAT || bg.repeat == BackgroundImage.Repeat.REPEAT_Y; + + double x0 = px; + long nx = 1; + if (repeatX) { + x0 = px - Math.ceil((px - clip.getMinX()) / tw) * tw; + nx = (long) Math.ceil((clip.getMaxX() - x0) / tw); + } + double y0 = py; + long ny = 1; + if (repeatY) { + y0 = py - Math.ceil((py - clip.getMinY()) / th) * th; + ny = (long) Math.ceil((clip.getMaxY() - y0) / th); + } + if (nx <= 0 || ny <= 0) { + return null; + } + + BufferedImage src = reduce(bg.image, tw, th); + BufferedImage layer = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = layer.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.clip(clip); + if (nx * ny > MAX_DRAWN_TILES) { + g.setPaint(new TexturePaint(src, new Rectangle2D.Double(x0, y0, tw, th))); + g.fill(new Rectangle2D.Double(x0, y0, nx * tw, ny * th)); + } else { + double sx = tw / src.getWidth(); + double sy = th / src.getHeight(); + for (long j = 0; j < ny; j++) { + for (long i = 0; i < nx; i++) { + AffineTransform at = new AffineTransform(); + at.translate(x0 + i * tw, y0 + j * th); + at.scale(sx, sy); + g.drawImage(src, at, null); + } + } + } + } finally { + g.dispose(); + } + return layer.getRGB(0, 0, w, h, null, 0, w); + } + + /// Halves an image until it is less than twice the target size. + private static BufferedImage reduce(BufferedImage img, double tw, double th) { + BufferedImage cur = img; + while (cur.getWidth() >= tw * 2 && cur.getHeight() >= th * 2 + && cur.getWidth() >= 2 && cur.getHeight() >= 2) { + int nw = cur.getWidth() / 2; + int nh = cur.getHeight() / 2; + BufferedImage half = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = half.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(cur, 0, 0, nw, nh, null); + } finally { + g.dispose(); + } + cur = half; + } + return cur; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImage.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImage.java new file mode 100644 index 00000000000..a0ad2a98d63 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImage.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.image.BufferedImage; + +/// A CSS `border-image` whose source is an image. Immutable, apart from the +/// image it refers to, which is never modified by this package. +public final class BorderImage { + /// The `border-image-repeat` modes. + public enum Mode { STRETCH, REPEAT, ROUND } + + /// The source image. + public final BufferedImage image; + /// The top slice offset in image px. + public final double sliceTop; + /// The right slice offset in image px. + public final double sliceRight; + /// The bottom slice offset in image px. + public final double sliceBottom; + /// The left slice offset in image px. + public final double sliceLeft; + /// Whether the middle slice is painted over the padding box. + public final boolean fill; + /// How the edge and middle slices cover their regions. + public final Mode repeat; + + public BorderImage(BufferedImage image, double sliceTop, double sliceRight, double sliceBottom, + double sliceLeft, boolean fill, Mode repeat) { + this.image = image; + this.sliceTop = sliceTop; + this.sliceRight = sliceRight; + this.sliceBottom = sliceBottom; + this.sliceLeft = sliceLeft; + this.fill = fill; + this.repeat = repeat; + } + + public BufferedImage getImage() { + return image; + } + + public double getSliceTop() { + return sliceTop; + } + + public double getSliceRight() { + return sliceRight; + } + + public double getSliceBottom() { + return sliceBottom; + } + + public double getSliceLeft() { + return sliceLeft; + } + + public boolean isFill() { + return fill; + } + + public Mode getRepeat() { + return repeat; + } + + /// Same as [#getRepeat()]. + public Mode getMode() { + return repeat; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImagePainter.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImagePainter.java new file mode 100644 index 00000000000..3ffd96813d5 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImagePainter.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; + +/// Paints a CSS `border-image`: the source image cut into nine slices and +/// laid over the border area. +/// +/// The image is cut by the four slice offsets. The border image area is the +/// border box, and the thickness of each edge of the target grid is that +/// side's border width (`border-image-width: 1`, the CSS initial value). +/// The four corners are scaled to fit their cell. The edges and, with +/// `fill`, the middle are stretched, repeated or rounded: +/// +/// - `STRETCH` scales the slice to its cell. +/// - `REPEAT` scales the slice to the thickness of its cell, keeping its +/// aspect ratio, and tiles it along the cell, centred, cutting off the +/// tiles at both ends. +/// - `ROUND` does the same and then resizes the tile along the cell so a +/// whole number fits. +/// +/// `REPEAT` and `ROUND` are best effort: the middle slice takes its tile +/// width from the top edge (or the bottom one, or its own) and its tile +/// height from the left edge (or the right one, or its own), which is the +/// CSS rule, but the CSS `space` keyword and per-axis repeat modes are not +/// modelled. +/// +/// Slice offsets are rounded to whole image px. As CSS requires, when the +/// left and right slices together cover the image width there is nothing +/// left for the top and bottom edges or the middle, and likewise +/// vertically. The border radius does not clip a border image. +public final class BorderImagePainter { + private BorderImagePainter() { + } + + /// Paints the border image. + /// + /// #### Parameters + /// + /// - `g`: graphics of the target image + /// + /// - `bi`: the border image + /// + /// - `box`: the border box in image coordinates + /// + /// - `widths`: the border widths for top, right, bottom, left + public static void paint(Graphics2D g, BorderImage bi, Rectangle2D box, double[] widths) { + BufferedImage img = bi.image; + int iw = img.getWidth(); + int ih = img.getHeight(); + int st = clampSlice(bi.sliceTop, ih); + int sb = clampSlice(bi.sliceBottom, ih); + int sl = clampSlice(bi.sliceLeft, iw); + int sr = clampSlice(bi.sliceRight, iw); + int midW = iw - sl - sr; + int midH = ih - st - sb; + + double t = widths[RoundedBox.TOP]; + double r = widths[RoundedBox.RIGHT]; + double b = widths[RoundedBox.BOTTOM]; + double l = widths[RoundedBox.LEFT]; + double x0 = box.getX(); + double y0 = box.getY(); + double x3 = box.getMaxX(); + double y3 = box.getMaxY(); + double x1 = x0 + l; + double x2 = x3 - r; + double y1 = y0 + t; + double y2 = y3 - b; + + Object oldHint = g.getRenderingHint(RenderingHints.KEY_INTERPOLATION); + Shape oldClip = g.getClip(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + try { + BorderImage.Mode mode = bi.repeat == null ? BorderImage.Mode.STRETCH : bi.repeat; + // Corners. + piece(g, img, 0, 0, sl, st, x0, y0, l, t); + piece(g, img, iw - sr, 0, sr, st, x2, y0, r, t); + piece(g, img, iw - sr, ih - sb, sr, sb, x2, y2, r, b); + piece(g, img, 0, ih - sb, sl, sb, x0, y2, l, b); + // Edges: the tile's free dimension follows from the thickness. + if (midW > 0) { + tiled(g, img, sl, 0, midW, st, x1, y0, x2 - x1, t, + st > 0 ? midW * t / st : 0, t, mode); + tiled(g, img, sl, ih - sb, midW, sb, x1, y2, x2 - x1, b, + sb > 0 ? midW * b / sb : 0, b, mode); + } + if (midH > 0) { + tiled(g, img, 0, st, sl, midH, x0, y1, l, y2 - y1, + l, sl > 0 ? midH * l / sl : 0, mode); + tiled(g, img, iw - sr, st, sr, midH, x2, y1, r, y2 - y1, + r, sr > 0 ? midH * r / sr : 0, mode); + } + if (bi.fill && midW > 0 && midH > 0) { + double sx = st > 0 && t > 0 ? t / st : (sb > 0 && b > 0 ? b / sb : 1); + double sy = sl > 0 && l > 0 ? l / sl : (sr > 0 && r > 0 ? r / sr : 1); + tiled(g, img, sl, st, midW, midH, x1, y1, x2 - x1, y2 - y1, midW * sx, midH * sy, mode); + } + } finally { + g.setClip(oldClip); + if (oldHint != null) { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, oldHint); + } + } + } + + private static int clampSlice(double v, int max) { + if (!(v > 0)) { + return 0; + } + long i = Math.round(v); + return i > max ? max : (int) i; + } + + /// Draws one source rectangle scaled into one target rectangle. + private static void piece(Graphics2D g, BufferedImage img, int sx, int sy, int sw, int sh, + double dx, double dy, double dw, double dh) { + if (sw <= 0 || sh <= 0 || !(dw > 0) || !(dh > 0)) { + return; + } + if (sx < 0 || sy < 0 || sx + sw > img.getWidth() || sy + sh > img.getHeight()) { + return; + } + BufferedImage sub = img.getSubimage(sx, sy, sw, sh); + if (isWhole(dx) && isWhole(dy) && isWhole(dw) && isWhole(dh)) { + int ix = (int) Math.round(dx); + int iy = (int) Math.round(dy); + g.drawImage(sub, ix, iy, (int) Math.round(dw), (int) Math.round(dh), null); + return; + } + AffineTransform at = new AffineTransform(); + at.translate(dx, dy); + at.scale(dw / sw, dh / sh); + g.drawImage(sub, at, null); + } + + private static boolean isWhole(double v) { + return Math.abs(v - Math.rint(v)) < 1e-9; + } + + /// Covers a target rectangle with a slice: stretched, or as centred + /// tiles of `tileW` by `tileH`. + private static void tiled(Graphics2D g, BufferedImage img, int sx, int sy, int sw, int sh, + double dx, double dy, double dw, double dh, double tileW, double tileH, BorderImage.Mode mode) { + if (sw <= 0 || sh <= 0 || !(dw > 0) || !(dh > 0)) { + return; + } + if (mode == BorderImage.Mode.STRETCH || !(tileW > 0) || !(tileH > 0)) { + piece(g, img, sx, sy, sw, sh, dx, dy, dw, dh); + return; + } + if (mode == BorderImage.Mode.ROUND) { + tileW = dw / Math.max(1, Math.round(dw / tileW)); + tileH = dh / Math.max(1, Math.round(dh / tileH)); + } + double startX = dx; + double startY = dy; + if (mode == BorderImage.Mode.REPEAT) { + // Tiles are centred: one tile sits in the middle of the cell and + // the row is cut off at both ends. ROUND tiles fit exactly and + // start on the cell edge. + startX = dx + (dw - tileW) / 2; + startX -= Math.ceil((startX - dx) / tileW) * tileW; + startY = dy + (dh - tileH) / 2; + startY -= Math.ceil((startY - dy) / tileH) * tileH; + } + long nx = (long) Math.ceil((dx + dw - startX) / tileW - 1e-9); + long ny = (long) Math.ceil((dy + dh - startY) / tileH - 1e-9); + if (nx * ny > 100000) { + piece(g, img, sx, sy, sw, sh, dx, dy, dw, dh); + return; + } + Shape old = g.getClip(); + g.clip(new Rectangle2D.Double(dx, dy, dw, dh)); + try { + for (long j = 0; j < ny; j++) { + for (long i = 0; i < nx; i++) { + piece(g, img, sx, sy, sw, sh, startX + i * tileW, startY + j * tileH, tileW, tileH); + } + } + } finally { + g.setClip(old); + } + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderPainter.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderPainter.java new file mode 100644 index 00000000000..29b90f17dd6 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderPainter.java @@ -0,0 +1,488 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.BasicStroke; +import java.awt.Shape; +import java.awt.geom.Ellipse2D; +import java.awt.geom.FlatteningPathIterator; +import java.awt.geom.Path2D; +import java.awt.geom.PathIterator; + +/// Paints the four CSS borders of a box into the ring between its border +/// edge and its padding edge. +/// +/// ### Uniform borders +/// +/// When every visible side is `SOLID` and they all share one colour, the +/// ring is filled in one go: the outer path minus the inner path, even-odd. +/// +/// ### Mixed borders +/// +/// Otherwise each side is painted through a mask, the ring coverage times +/// the coverage of that side's wedge. The wedges split the box along the +/// mitre of each corner, the line from the outer corner to the inner +/// (padding edge) corner, continued inwards across the corner's curve. The +/// four contributions are added up premultiplied before they are blended +/// over the image, so the two halves of an antialiased mitre pixel add up to +/// full coverage instead of leaving a translucent seam. +/// +/// ### Styles +/// +/// - `DOUBLE`: two lines of a third of the width with a gap of a third; +/// below 3px there is no room and the side is solid. +/// - `DASHED`: a dash of twice the width and a gap of one width, or for a +/// border thinner than 3px a dash of three widths and a gap of two. That +/// is the choice Chrome makes, as is stretching the gap so a side starts +/// and ends on a whole dash. +/// - `DOTTED`: round dots one width across, about one width apart, with the +/// spacing stretched so a dot sits on each end of the side. +/// - `INSET` and `OUTSET`: the top and left sides are darkened for `INSET`, +/// the bottom and right ones for `OUTSET`; the other two keep the colour. +/// - `GROOVE` and `RIDGE`: the outer half of the width is painted as +/// `INSET` and the inner half as `OUTSET` for a groove, the reverse for a +/// ridge. +/// +/// "Darkened" is Chrome's rule: every channel is multiplied by +/// `max(0, (v - 0.33) / v)` where `v` is the largest channel, 0 to 1. Black +/// therefore stays black, so a black `inset` border looks solid, as it does +/// in Chrome. +/// +/// A dashed or dotted side is laid out along the middle of the ring, and +/// each side owns the half of each corner arc next to it. +public final class BorderPainter { + private static final int TOP = RoundedBox.TOP; + private static final int RIGHT = RoundedBox.RIGHT; + private static final int BOTTOM = RoundedBox.BOTTOM; + private static final int LEFT = RoundedBox.LEFT; + + private BorderPainter() { + } + + /// The effective width of each side: zero for an invisible style, and + /// scaled down together when two opposite borders are wider than the + /// box, which cannot happen in a laid out CSS box but would otherwise + /// turn the padding edge inside out. + /// + /// #### Parameters + /// + /// - `sides`: top, right, bottom, left + /// + /// - `boxW`: border box width + /// + /// - `boxH`: border box height + /// + /// #### Returns + /// + /// the widths for top, right, bottom, left + public static double[] effectiveWidths(BorderSide[] sides, double boxW, double boxH) { + double[] w = new double[4]; + for (int i = 0; i < 4; i++) { + w[i] = sides[i].effectiveWidth(); + } + double f = 1; + if (w[LEFT] + w[RIGHT] > boxW) { + f = Math.min(f, boxW / (w[LEFT] + w[RIGHT])); + } + if (w[TOP] + w[BOTTOM] > boxH) { + f = Math.min(f, boxH / (w[TOP] + w[BOTTOM])); + } + if (f < 1) { + for (int i = 0; i < 4; i++) { + w[i] *= f; + } + } + return w; + } + + /// Chrome's darker shade of a colour, for the 3D border styles. + public static int darken(int argb) { + float r = ((argb >> 16) & 0xff) / 255f; + float g = ((argb >> 8) & 0xff) / 255f; + float b = (argb & 0xff) / 255f; + float v = Math.max(r, Math.max(g, b)); + float m = v > 0f ? Math.max(0f, (v - 0.33f) / v) : 0f; + return (argb & 0xff000000) + | (Pixels.clamp8(r * m * 255f) << 16) + | (Pixels.clamp8(g * m * 255f) << 8) + | Pixels.clamp8(b * m * 255f); + } + + /// Paints the borders. + /// + /// #### Parameters + /// + /// - `dst`: the image, `w * h` non-premultiplied ARGB values + /// + /// - `w`: image width + /// + /// - `h`: image height + /// + /// - `outer`: the border box in image coordinates + /// + /// - `sides`: top, right, bottom, left + /// + /// - `widths`: the effective widths, from [#effectiveWidths(BorderSide[], double, double)] + public static void paint(int[] dst, int w, int h, RoundedBox outer, BorderSide[] sides, double[] widths) { + boolean any = false; + boolean uniform = true; + int color = 0; + for (int i = 0; i < 4; i++) { + if (widths[i] > 0) { + if (!any) { + color = sides[i].color; + any = true; + } + if (sides[i].style != BorderStyle.SOLID || sides[i].color != color) { + uniform = false; + } + } + } + if (!any) { + return; + } + RoundedBox inner = outer.inset(widths[TOP], widths[RIGHT], widths[BOTTOM], widths[LEFT]); + float[] ring = Pixels.coverage(RoundedBox.ring(outer, inner), w, h, 0, 0); + if (uniform) { + Pixels.fill(dst, color, ring); + return; + } + + int n = w * h; + float[] acc = new float[n * 4]; + float[] gap = null; + float[] half = null; + // The four wedges tile the plane, so their coverages add up to one + // in every pixel of the ring. The rasterizer rounds each to 8 bits, + // though, and two halves of a mitre pixel can add up to 254/255. + // Dividing by the sum takes that out, so the ring mask alone decides + // how opaque a border pixel is. + float[][] wedges = new float[4][]; + float[] total = new float[n]; + for (int side = 0; side < 4; side++) { + wedges[side] = Pixels.coverage(wedge(outer, inner, widths, side, w, h), w, h, 0, 0); + for (int i = 0; i < n; i++) { + total[i] += wedges[side][i]; + } + } + for (int side = 0; side < 4; side++) { + double bw = widths[side]; + int c = sides[side].color; + if (!(bw > 0) || (c >>> 24) == 0) { + continue; + } + float[] m = wedges[side]; + for (int i = 0; i < n; i++) { + float r = ring[i]; + m[i] = r > 0f && total[i] > 0f ? m[i] / total[i] * r : 0f; + } + boolean topLeft = side == TOP || side == LEFT; + switch (sides[side].style) { + case DOUBLE: + if (bw >= 3) { + if (gap == null) { + gap = Pixels.coverage(RoundedBox.ring( + outer.inset(widths[TOP] / 3, widths[RIGHT] / 3, widths[BOTTOM] / 3, widths[LEFT] / 3), + outer.inset(widths[TOP] * 2 / 3, widths[RIGHT] * 2 / 3, + widths[BOTTOM] * 2 / 3, widths[LEFT] * 2 / 3)), w, h, 0, 0); + } + for (int i = 0; i < n; i++) { + m[i] *= 1f - gap[i]; + } + } + add(acc, m, null, c); + break; + case DASHED: + case DOTTED: { + RoundedBox mid = outer.inset(widths[TOP] / 2, widths[RIGHT] / 2, widths[BOTTOM] / 2, widths[LEFT] / 2); + double reach = 2 * Math.max(Math.max(widths[TOP], widths[BOTTOM]), + Math.max(widths[LEFT], widths[RIGHT])) + 2; + Shape pattern = sides[side].style == BorderStyle.DASHED + ? dashes(mid.sidePath(side), bw, reach) + : dots(mid.sidePath(side), bw); + float[] p = Pixels.coverage(pattern, w, h, 0, 0); + for (int i = 0; i < n; i++) { + m[i] *= p[i]; + } + add(acc, m, null, c); + break; + } + case INSET: + add(acc, m, null, topLeft ? darken(c) : c); + break; + case OUTSET: + add(acc, m, null, topLeft ? c : darken(c)); + break; + case GROOVE: + case RIDGE: { + if (half == null) { + half = Pixels.coverage(outer.inset(widths[TOP] / 2, widths[RIGHT] / 2, + widths[BOTTOM] / 2, widths[LEFT] / 2).toPath(), w, h, 0, 0); + } + boolean groove = sides[side].style == BorderStyle.GROOVE; + // A groove's outer half is an inset border: dark on the + // top and left. Its inner half is the opposite. + boolean outerDark = groove == topLeft; + add(acc, m, invert(half), outerDark ? darken(c) : c); + add(acc, m, half, outerDark ? c : darken(c)); + break; + } + default: + add(acc, m, null, c); + break; + } + } + + for (int i = 0; i < n; i++) { + float sa = acc[i * 4]; + if (sa <= 0f) { + continue; + } + if (sa > 1f) { + sa = 1f; + } + int src = (0xff << 24) + | (Pixels.clamp8(acc[i * 4 + 1] / acc[i * 4] * 255f) << 16) + | (Pixels.clamp8(acc[i * 4 + 2] / acc[i * 4] * 255f) << 8) + | Pixels.clamp8(acc[i * 4 + 3] / acc[i * 4] * 255f); + Pixels.blend(dst, i, src, sa); + } + } + + private static float[] invert(float[] m) { + float[] out = new float[m.length]; + for (int i = 0; i < m.length; i++) { + out[i] = 1f - m[i]; + } + return out; + } + + /// Adds `color * mask * extra` to the premultiplied accumulator. + private static void add(float[] acc, float[] mask, float[] extra, int color) { + float a = (color >>> 24) / 255f; + float r = ((color >> 16) & 0xff) / 255f * a; + float g = ((color >> 8) & 0xff) / 255f * a; + float b = (color & 0xff) / 255f * a; + for (int i = 0; i < mask.length; i++) { + float m = mask[i]; + if (extra != null) { + m *= extra[i]; + } + if (m > 0f) { + acc[i * 4] += a * m; + acc[i * 4 + 1] += r * m; + acc[i * 4 + 2] += g * m; + acc[i * 4 + 3] += b * m; + } + } + } + + /// The region of the image that belongs to one side. + /// + /// Each corner contributes a boundary of three points shared by the two + /// sides that meet there: a point far outside the box, the inner + /// corner, and the point where the mitre leaves the inner corner's + /// curve. Beyond that the boundary runs to the centre of the padding + /// box, which is inside the padding edge where there is no ring left to + /// divide. + /// + /// The far point lies on the mitre line continued outwards, so the + /// wedge edge never runs along the outer edge of the ring; two + /// coinciding antialiased edges would square the coverage there. When + /// one of the two sides at a corner has no width, the other owns the + /// whole corner and the boundary leaves the box straight across the + /// missing side instead. + static Path2D.Double wedge(RoundedBox outer, RoundedBox inner, double[] widths, int side, int w, int h) { + double far = 4.0 * (w + h) + 16; + double x0 = outer.getX(); + double y0 = outer.getY(); + double x1 = x0 + outer.getWidth(); + double y1 = y0 + outer.getHeight(); + double l = widths[LEFT]; + double t = widths[TOP]; + double r = widths[RIGHT]; + double b = widths[BOTTOM]; + // Per corner (TL, TR, BR, BL): outer corner, inward mitre direction. + double[] ox = {x0, x1, x1, x0}; + double[] oy = {y0, y0, y1, y1}; + double[] dx = {l, -r, -r, l}; + double[] dy = {t, t, -b, -b}; + // The outward diagonal, for a corner where neither side has a width. + double[] qx = {-1, 1, 1, -1}; + double[] qy = {-1, -1, 1, 1}; + double cx = inner.getX() + inner.getWidth() / 2; + double cy = inner.getY() + inner.getHeight() / 2; + + double[] farX = new double[4]; + double[] farY = new double[4]; + double[] inX = new double[4]; + double[] inY = new double[4]; + double[] endX = new double[4]; + double[] endY = new double[4]; + for (int c = 0; c < 4; c++) { + inX[c] = ox[c] + dx[c]; + inY[c] = oy[c] + dy[c]; + double ax = Math.abs(dx[c]); + double ay = Math.abs(dy[c]); + if (ax > 0 && ay > 0) { + double len = Math.sqrt(ax * ax + ay * ay); + farX[c] = ox[c] - dx[c] / len * far; + farY[c] = oy[c] - dy[c] / len * far; + } else if (ay > 0) { + farX[c] = inX[c] + qx[c] * far; + farY[c] = inY[c]; + } else if (ax > 0) { + farX[c] = inX[c]; + farY[c] = inY[c] + qy[c] * far; + } else { + farX[c] = ox[c] + qx[c] * far; + farY[c] = oy[c] + qy[c] * far; + } + double k = Double.POSITIVE_INFINITY; + if (ax > 0) { + k = Math.min(k, inner.radiusX(c) / ax); + } + if (ay > 0) { + k = Math.min(k, inner.radiusY(c) / ay); + } + if (Double.isInfinite(k)) { + k = 0; + } + endX[c] = inX[c] + dx[c] * k; + endY[c] = inY[c] + dy[c] * k; + } + int a = side; + int z = (side + 1) % 4; + Path2D.Double p = new Path2D.Double(Path2D.WIND_NON_ZERO); + // Out through the corner of a square twice as far away, so the + // wedge takes in everything beyond its side whichever way the two + // far points lie. + p.moveTo(farX[a], farY[a]); + p.lineTo(ox[a] + qx[a] * 2 * far, oy[a] + qy[a] * 2 * far); + p.lineTo(ox[z] + qx[z] * 2 * far, oy[z] + qy[z] * 2 * far); + p.lineTo(farX[z], farY[z]); + p.lineTo(inX[z], inY[z]); + p.lineTo(endX[z], endY[z]); + p.lineTo(cx, cy); + p.lineTo(endX[a], endY[a]); + p.lineTo(inX[a], inY[a]); + p.closePath(); + return p; + } + + /// Dashes along a side path, as a shape `reach` px wide so that it + /// overshoots the ring on both sides and only the ring mask shapes the + /// ends of a dash. + private static Shape dashes(Path2D path, double width, double reach) { + double length = length(path); + double dash = width * (width >= 3 ? 2 : 3); + double gap = width * (width >= 3 ? 1 : 2); + if (!(length > dash)) { + return new BasicStroke((float) reach, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL) + .createStrokedShape(path); + } + // Fit a whole number of dashes with one at each end, picking the + // count whose gap is nearest the nominal one. + int few = Math.max(2, (int) Math.floor((length + gap) / (dash + gap))); + int many = few + 1; + double fewGap = (length - few * dash) / (few - 1); + double manyGap = (length - many * dash) / (many - 1); + double best = manyGap <= 0 || Math.abs(fewGap - gap) < Math.abs(manyGap - gap) ? fewGap : manyGap; + if (!(best > 0)) { + return new BasicStroke((float) reach, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL) + .createStrokedShape(path); + } + return new BasicStroke((float) reach, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL, 10f, + new float[] {(float) dash, (float) best}, 0f).createStrokedShape(path); + } + + /// Round dots of diameter `width` centred on a side path, the first on + /// its start and the last on its end. + private static Shape dots(Path2D path, double width) { + double[] pts = flatten(path); + double length = length(pts); + int spaces = Math.max(1, (int) Math.round(length / (2 * width))); + double step = length / spaces; + Path2D.Double out = new Path2D.Double(Path2D.WIND_NON_ZERO); + int seg = 0; + double before = 0; + for (int i = 0; i <= spaces; i++) { + double s = i == spaces ? length : i * step; + double px = pts[0]; + double py = pts[1]; + while (seg < pts.length / 2 - 1) { + double ax = pts[seg * 2]; + double ay = pts[seg * 2 + 1]; + double bx = pts[seg * 2 + 2]; + double by = pts[seg * 2 + 3]; + double d = Math.sqrt((bx - ax) * (bx - ax) + (by - ay) * (by - ay)); + boolean lastSeg = seg == pts.length / 2 - 2; + if (s <= before + d || lastSeg) { + double f = d > 0 ? Math.min(1, (s - before) / d) : 0; + px = ax + (bx - ax) * f; + py = ay + (by - ay) * f; + break; + } + before += d; + seg++; + } + out.append(new Ellipse2D.Double(px - width / 2, py - width / 2, width, width), false); + } + return out; + } + + private static double[] flatten(Path2D path) { + PathIterator it = new FlatteningPathIterator(path.getPathIterator(null), 0.05); + double[] pts = new double[64]; + int n = 0; + double[] c = new double[6]; + while (!it.isDone()) { + it.currentSegment(c); + if (n + 2 > pts.length) { + double[] bigger = new double[pts.length * 2]; + System.arraycopy(pts, 0, bigger, 0, n); + pts = bigger; + } + pts[n++] = c[0]; + pts[n++] = c[1]; + it.next(); + } + double[] out = new double[n]; + System.arraycopy(pts, 0, out, 0, n); + return out; + } + + private static double length(Path2D path) { + return length(flatten(path)); + } + + private static double length(double[] pts) { + double len = 0; + for (int i = 2; i + 1 < pts.length; i += 2) { + double dx = pts[i] - pts[i - 2]; + double dy = pts[i + 1] - pts[i - 1]; + len += Math.sqrt(dx * dx + dy * dy); + } + return len; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderSide.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderSide.java new file mode 100644 index 00000000000..e6201a01f4a --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderSide.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +/// One side of a CSS border. Immutable. +public final class BorderSide { + /// A side that paints nothing: zero width, style `NONE`. + public static final BorderSide NONE = new BorderSide(0, BorderStyle.NONE, 0); + + /// The border width in CSS px. + public final double width; + /// The border style. + public final BorderStyle style; + /// The border colour as non-premultiplied ARGB. + public final int color; + + /// Creates a side. + /// + /// #### Parameters + /// + /// - `width`: the width in CSS px + /// + /// - `style`: the style + /// + /// - `color`: non-premultiplied ARGB + public BorderSide(double width, BorderStyle style, int color) { + this.width = width; + this.style = style; + this.color = color; + } + + public double getWidth() { + return width; + } + + public BorderStyle getStyle() { + return style; + } + + public int getColor() { + return color; + } + + /// The width this side occupies in the layout: its width when the style + /// is visible, and zero for `NONE` and `HIDDEN` as CSS computes it. + public double effectiveWidth() { + if (style == null || !style.isVisible() || !(width > 0)) { + return 0; + } + return width; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderStyle.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderStyle.java new file mode 100644 index 00000000000..82452fbaa5e --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderStyle.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +/// The CSS `border-style` keywords. +public enum BorderStyle { + NONE, HIDDEN, SOLID, DASHED, DOTTED, DOUBLE, GROOVE, RIDGE, INSET, OUTSET; + + /// Parses a CSS `border-style` keyword. The comparison ignores case + /// without folding it, so it does not depend on the default locale. + /// + /// #### Parameters + /// + /// - `cssKeyword`: the keyword, surrounding white space is ignored + /// + /// #### Returns + /// + /// the matching style, or [#NONE] for `null` or an unknown keyword + public static BorderStyle parse(String cssKeyword) { + if (cssKeyword == null) { + return NONE; + } + String k = cssKeyword.trim(); + for (BorderStyle s : values()) { + if (s.name().equalsIgnoreCase(k)) { + return s; + } + } + return NONE; + } + + /// Whether a border of this style paints anything and takes up width. + /// `NONE` and `HIDDEN` do not. + public boolean isVisible() { + return this != NONE && this != HIDDEN; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxShadowPainter.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxShadowPainter.java new file mode 100644 index 00000000000..2011733d16d --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxShadowPainter.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +/// Paints a CSS `box-shadow`, outer or inset. +/// +/// Both kinds blur a coverage mask and tint it. Only the alpha of the shape +/// is blurred, which is the same as blurring a premultiplied image of one +/// colour and avoids its rounding. The gaussian's standard deviation is +/// half the CSS blur radius, as the specification defines it. +/// +/// The mask is drawn into a buffer larger than the image by the reach of +/// the blur, so a shadow shape that runs off the image still contributes +/// correctly to the pixels inside it, and a pad smaller than the halo just +/// crops the halo. +public final class BoxShadowPainter { + private BoxShadowPainter() { + } + + /// Paints an outer shadow. + /// + /// The shadow shape is the border box grown by the spread and moved by + /// the offset. CSS does not draw an outer shadow under the box that + /// casts it, so the shadow is multiplied by the inverse of the border + /// box coverage: a translucent or transparent background does not show + /// the shadow through it. + /// + /// #### Parameters + /// + /// - `dst`: the image, `w * h` non-premultiplied ARGB values + /// + /// - `w`: image width + /// + /// - `h`: image height + /// + /// - `borderBox`: the border box in image coordinates + /// + /// - `borderCoverage`: the coverage mask of `borderBox`, `w * h` values + /// + /// - `shadow`: the shadow, not inset + public static void paintOuter(int[] dst, int w, int h, RoundedBox borderBox, float[] borderCoverage, + Shadow shadow) { + if ((shadow.color >>> 24) == 0) { + return; + } + RoundedBox shape = borderBox.grow(shadow.spread).translate(shadow.offsetX, shadow.offsetY); + if (shape.isEmpty()) { + return; + } + double sigma = Math.max(0, shadow.blur) / 2; + int m = GaussianBlur.radius(sigma) + 1; + int bw = w + 2 * m; + int bh = h + 2 * m; + float[] cov = Pixels.coverage(shape.toPath(), bw, bh, m, m); + GaussianBlur.blur(cov, bw, bh, sigma); + for (int y = 0; y < h; y++) { + int src = (y + m) * bw + m; + int row = y * w; + for (int x = 0; x < w; x++) { + float a = cov[src + x] * (1f - borderCoverage[row + x]); + if (a > 0f) { + Pixels.blend(dst, row + x, shadow.color, a); + } + } + } + } + + /// Paints an inset shadow. + /// + /// The shadow is everything outside a hole: the padding box shrunk by + /// the spread and moved by the offset. Blurring "everything outside" + /// needs no infinite buffer, because the blur of an inverted mask is the + /// inverse of the blurred mask. The result is confined to the rounded + /// padding box. + /// + /// #### Parameters + /// + /// - `dst`: the image, `w * h` non-premultiplied ARGB values + /// + /// - `w`: image width + /// + /// - `h`: image height + /// + /// - `paddingBox`: the padding box in image coordinates + /// + /// - `shadow`: the shadow, inset + public static void paintInset(int[] dst, int w, int h, RoundedBox paddingBox, Shadow shadow) { + if ((shadow.color >>> 24) == 0 || paddingBox.isEmpty()) { + return; + } + float[] clip = Pixels.coverage(paddingBox.toPath(), w, h, 0, 0); + RoundedBox hole = paddingBox.grow(-shadow.spread).translate(shadow.offsetX, shadow.offsetY); + double sigma = Math.max(0, shadow.blur) / 2; + int m = GaussianBlur.radius(sigma) + 1; + int bw = w + 2 * m; + int bh = h + 2 * m; + float[] cov; + if (hole.isEmpty()) { + cov = new float[bw * bh]; + } else { + cov = Pixels.coverage(hole.toPath(), bw, bh, m, m); + GaussianBlur.blur(cov, bw, bh, sigma); + } + for (int y = 0; y < h; y++) { + int src = (y + m) * bw + m; + int row = y * w; + for (int x = 0; x < w; x++) { + float c = clip[row + x]; + if (c > 0f) { + float a = (1f - cov[src + x]) * c; + if (a > 0f) { + Pixels.blend(dst, row + x, shadow.color, a); + } + } + } + } + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxStyle.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxStyle.java new file mode 100644 index 00000000000..c4864bd3c98 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxStyle.java @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +/// Everything [CssBoxRasterizer] needs to paint one CSS box. Immutable; +/// built with [#builder()]. +/// +/// Colours are non-premultiplied ARGB ints, lengths are resolved CSS px. +/// The builder does not validate; [CssBoxRasterizer#rasterize(BoxStyle)] +/// does, and names the offending field. +public final class BoxStyle { + private final double borderBoxWidth; + private final double borderBoxHeight; + private final int padTop; + private final int padRight; + private final int padBottom; + private final int padLeft; + private final int backgroundColor; + private final GradientSpec gradient; + private final BackgroundImage backgroundImage; + private final BorderSide top; + private final BorderSide right; + private final BorderSide bottom; + private final BorderSide left; + private final double[] radii; + private final Shadow shadow; + private final BorderImage borderImage; + + private BoxStyle(Builder b) { + borderBoxWidth = b.borderBoxWidth; + borderBoxHeight = b.borderBoxHeight; + padTop = b.padTop; + padRight = b.padRight; + padBottom = b.padBottom; + padLeft = b.padLeft; + backgroundColor = b.backgroundColor; + gradient = b.gradient == null ? null : new GradientSpec(b.gradient); + backgroundImage = b.backgroundImage; + top = b.top; + right = b.right; + bottom = b.bottom; + left = b.left; + radii = b.radii == null ? null : b.radii.clone(); + shadow = b.shadow; + borderImage = b.borderImage; + } + + /// Starts a style: no padding, a transparent background, no borders, + /// square corners. Only the border box size has to be set. + public static Builder builder() { + return new Builder(); + } + + /// The width of the CSS border box in px; may be fractional. + public double getBorderBoxWidth() { + return borderBoxWidth; + } + + /// The height of the CSS border box in px; may be fractional. + public double getBorderBoxHeight() { + return borderBoxHeight; + } + + /// Transparent rows captured above the border box. + public int getPadTop() { + return padTop; + } + + public int getPadRight() { + return padRight; + } + + public int getPadBottom() { + return padBottom; + } + + /// Transparent columns captured left of the border box. + public int getPadLeft() { + return padLeft; + } + + public int getBackgroundColor() { + return backgroundColor; + } + + /// The gradient painted over the background colour, or `null`. A copy. + public GradientSpec getGradient() { + return gradient == null ? null : new GradientSpec(gradient); + } + + /// The image painted over the background colour and gradient, or `null`. + public BackgroundImage getBackgroundImage() { + return backgroundImage; + } + + public BorderSide getTop() { + return top; + } + + public BorderSide getRight() { + return right; + } + + public BorderSide getBottom() { + return bottom; + } + + public BorderSide getLeft() { + return left; + } + + /// The eight corner radii in px: top-left x and y, top-right x and y, + /// bottom-right x and y, bottom-left x and y. A copy. + public double[] getRadii() { + return radii == null ? null : radii.clone(); + } + + public Shadow getShadow() { + return shadow; + } + + public BorderImage getBorderImage() { + return borderImage; + } + + /// Fluent builder for [BoxStyle]. The setters carry the field names. + public static final class Builder { + private double borderBoxWidth; + private double borderBoxHeight; + private int padTop; + private int padRight; + private int padBottom; + private int padLeft; + private int backgroundColor; + private GradientSpec gradient; + private BackgroundImage backgroundImage; + private BorderSide top = BorderSide.NONE; + private BorderSide right = BorderSide.NONE; + private BorderSide bottom = BorderSide.NONE; + private BorderSide left = BorderSide.NONE; + private double[] radii = new double[8]; + private Shadow shadow; + private BorderImage borderImage; + + private Builder() { + } + + public Builder borderBoxWidth(double v) { + borderBoxWidth = v; + return this; + } + + public Builder borderBoxHeight(double v) { + borderBoxHeight = v; + return this; + } + + /// Sets both border box dimensions. + public Builder size(double width, double height) { + borderBoxWidth = width; + borderBoxHeight = height; + return this; + } + + public Builder padTop(int v) { + padTop = v; + return this; + } + + public Builder padRight(int v) { + padRight = v; + return this; + } + + public Builder padBottom(int v) { + padBottom = v; + return this; + } + + public Builder padLeft(int v) { + padLeft = v; + return this; + } + + /// Sets the same padding on all four sides. + public Builder pad(int all) { + padTop = all; + padRight = all; + padBottom = all; + padLeft = all; + return this; + } + + public Builder backgroundColor(int argb) { + backgroundColor = argb; + return this; + } + + public Builder gradient(GradientSpec v) { + gradient = v; + return this; + } + + public Builder backgroundImage(BackgroundImage v) { + backgroundImage = v; + return this; + } + + public Builder top(BorderSide v) { + top = v; + return this; + } + + public Builder right(BorderSide v) { + right = v; + return this; + } + + public Builder bottom(BorderSide v) { + bottom = v; + return this; + } + + public Builder left(BorderSide v) { + left = v; + return this; + } + + /// Sets the same border on all four sides. + public Builder border(BorderSide all) { + top = all; + right = all; + bottom = all; + left = all; + return this; + } + + /// Sets the corner radii. Eight values are taken as they are + /// (top-left x and y, top-right x and y, bottom-right x and y, + /// bottom-left x and y). As a convenience four values are circular + /// radii for the top-left, top-right, bottom-right and bottom-left + /// corners, and one value is a circular radius for every corner. + /// Any other count is stored unchanged and rejected by the + /// rasterizer. + public Builder radii(double... v) { + if (v == null) { + radii = null; + } else if (v.length == 1) { + radii = new double[] {v[0], v[0], v[0], v[0], v[0], v[0], v[0], v[0]}; + } else if (v.length == 4) { + radii = new double[] {v[0], v[0], v[1], v[1], v[2], v[2], v[3], v[3]}; + } else { + radii = v.clone(); + } + return this; + } + + public Builder shadow(Shadow v) { + shadow = v; + return this; + } + + public Builder borderImage(BorderImage v) { + borderImage = v; + return this; + } + + public BoxStyle build() { + return new BoxStyle(this); + } + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/CssBoxRasterizer.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/CssBoxRasterizer.java new file mode 100644 index 00000000000..7872ada1252 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/CssBoxRasterizer.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.Graphics2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; + +/// Paints one CSS box into an image with plain Java2D. It needs no display, +/// no native toolkit and no browser, and runs with `java.awt.headless=true`. +/// +/// The layers are painted bottom to top: +/// +/// 1. the outer shadow, which is not drawn under the border box; +/// 2. the background colour, then the gradient, then the background image, +/// each clipped to the rounded border box (`background-clip: +/// border-box`). A style that sets both a gradient and an image gets +/// both, the image on top; +/// 3. the inset shadow, clipped to the rounded padding box; +/// 4. the borders, or the border image when there is one. As in CSS, a +/// border image replaces the border styles instead of being drawn over +/// them. +/// +/// The box keeps its fractional size and position; only the image size is +/// truncated to whole pixels. Instances hold no state and may be shared +/// between threads. +public final class CssBoxRasterizer { + /// The most pixels an output image may have. A larger request is a + /// broken length, not a box. + private static final long MAX_PIXELS = 64L * 1024 * 1024; + + public CssBoxRasterizer() { + } + + /// Paints a box. + /// + /// #### Parameters + /// + /// - `style`: the box to paint + /// + /// #### Returns + /// + /// a `TYPE_INT_ARGB` image of + /// `(int) (borderBoxWidth + padLeft + padRight)` by + /// `(int) (borderBoxHeight + padTop + padBottom)` pixels, each at least + /// 1, with the top left of the border box at `(padLeft, padTop)`. Never + /// `null`. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: naming the offending field, when the + /// style is `null` or holds a value that cannot be painted + public BufferedImage rasterize(BoxStyle style) { + validate(style); + double boxW = style.getBorderBoxWidth(); + double boxH = style.getBorderBoxHeight(); + int padLeft = style.getPadLeft(); + int padTop = style.getPadTop(); + double fullW = boxW + padLeft + style.getPadRight(); + double fullH = boxH + padTop + style.getPadBottom(); + if (fullW * fullH > MAX_PIXELS) { + throw new IllegalArgumentException("borderBoxWidth x borderBoxHeight plus padding is " + + fullW + " x " + fullH + " px, more than this rasterizer paints"); + } + int w = Math.max(1, (int) fullW); + int h = Math.max(1, (int) fullH); + + BorderSide[] sides = {style.getTop(), style.getRight(), style.getBottom(), style.getLeft()}; + double[] widths = BorderPainter.effectiveWidths(sides, boxW, boxH); + RoundedBox borderBox = new RoundedBox(padLeft, padTop, boxW, boxH, style.getRadii()); + RoundedBox paddingBox = borderBox.inset(widths[RoundedBox.TOP], widths[RoundedBox.RIGHT], + widths[RoundedBox.BOTTOM], widths[RoundedBox.LEFT]); + + int[] px = new int[w * h]; + float[] borderCoverage = Pixels.coverage(borderBox.toPath(), w, h, 0, 0); + Shadow shadow = style.getShadow(); + + if (shadow != null && !shadow.inset) { + BoxShadowPainter.paintOuter(px, w, h, borderBox, borderCoverage, shadow); + } + + Pixels.fill(px, style.getBackgroundColor(), borderCoverage); + + GradientSpec gradient = style.getGradient(); + if (gradient != null) { + GradientPainter painter = new GradientPainter(gradient, padLeft, padTop, boxW, boxH); + Pixels.layer(px, painter.paint(w, h), borderCoverage); + } + + BackgroundImage bg = style.getBackgroundImage(); + if (bg != null && !paddingBox.isEmpty()) { + int[] layer = BackgroundImagePainter.paint(bg, w, h, + new Rectangle2D.Double(paddingBox.getX(), paddingBox.getY(), + paddingBox.getWidth(), paddingBox.getHeight()), + new Rectangle2D.Double(padLeft, padTop, boxW, boxH)); + if (layer != null) { + Pixels.layer(px, layer, borderCoverage); + } + } + + if (shadow != null && shadow.inset) { + BoxShadowPainter.paintInset(px, w, h, paddingBox, shadow); + } + + BorderImage borderImage = style.getBorderImage(); + if (borderImage == null) { + BorderPainter.paint(px, w, h, borderBox, sides, widths); + } + + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + out.setRGB(0, 0, w, h, px, 0, w); + if (borderImage != null) { + Graphics2D g = out.createGraphics(); + try { + Pixels.hints(g); + BorderImagePainter.paint(g, borderImage, new Rectangle2D.Double(padLeft, padTop, boxW, boxH), widths); + } finally { + g.dispose(); + } + } + return out; + } + + private static void validate(BoxStyle s) { + if (s == null) { + throw new IllegalArgumentException("style must not be null"); + } + positive(s.getBorderBoxWidth(), "borderBoxWidth"); + positive(s.getBorderBoxHeight(), "borderBoxHeight"); + pad(s.getPadTop(), "padTop"); + pad(s.getPadRight(), "padRight"); + pad(s.getPadBottom(), "padBottom"); + pad(s.getPadLeft(), "padLeft"); + side(s.getTop(), "top"); + side(s.getRight(), "right"); + side(s.getBottom(), "bottom"); + side(s.getLeft(), "left"); + double[] radii = s.getRadii(); + if (radii == null || radii.length != 8) { + throw new IllegalArgumentException("radii must hold 8 values, got " + + (radii == null ? "null" : String.valueOf(radii.length))); + } + for (int i = 0; i < 8; i++) { + if (Double.isNaN(radii[i]) || Double.isInfinite(radii[i]) || radii[i] < 0) { + throw new IllegalArgumentException("radii[" + i + "] must be a length of 0 or more, got " + radii[i]); + } + } + Shadow sh = s.getShadow(); + if (sh != null) { + finite(sh.offsetX, "shadow.offsetX"); + finite(sh.offsetY, "shadow.offsetY"); + finite(sh.spread, "shadow.spread"); + if (Double.isNaN(sh.blur) || Double.isInfinite(sh.blur) || sh.blur < 0) { + throw new IllegalArgumentException("shadow.blur must be 0 or more, got " + sh.blur); + } + } + BackgroundImage bg = s.getBackgroundImage(); + if (bg != null) { + if (bg.image == null) { + throw new IllegalArgumentException("backgroundImage.image must not be null"); + } + if (bg.repeat == null) { + throw new IllegalArgumentException("backgroundImage.repeat must not be null"); + } + if (bg.size == null) { + throw new IllegalArgumentException("backgroundImage.size must not be null"); + } + finite(bg.posX, "backgroundImage.posX"); + finite(bg.posY, "backgroundImage.posY"); + if (bg.size == BackgroundImage.Size.EXPLICIT) { + finite(bg.sizeW, "backgroundImage.sizeW"); + finite(bg.sizeH, "backgroundImage.sizeH"); + } + } + BorderImage bi = s.getBorderImage(); + if (bi != null) { + if (bi.image == null) { + throw new IllegalArgumentException("borderImage.image must not be null"); + } + slice(bi.sliceTop, "borderImage.sliceTop"); + slice(bi.sliceRight, "borderImage.sliceRight"); + slice(bi.sliceBottom, "borderImage.sliceBottom"); + slice(bi.sliceLeft, "borderImage.sliceLeft"); + } + // The gradient is validated by GradientPainter, which names its + // fields with a "gradient." prefix. + } + + private static void positive(double v, String field) { + if (Double.isNaN(v) || Double.isInfinite(v) || !(v > 0)) { + throw new IllegalArgumentException(field + " must be greater than 0, got " + v); + } + } + + private static void finite(double v, String field) { + if (Double.isNaN(v) || Double.isInfinite(v)) { + throw new IllegalArgumentException(field + " is not a number: " + v); + } + } + + private static void slice(double v, String field) { + if (Double.isNaN(v) || Double.isInfinite(v) || v < 0) { + throw new IllegalArgumentException(field + " must be 0 or more, got " + v); + } + } + + private static void pad(int v, String field) { + if (v < 0) { + throw new IllegalArgumentException(field + " must not be negative, got " + v); + } + } + + private static void side(BorderSide side, String field) { + if (side == null) { + throw new IllegalArgumentException(field + " must not be null; use BorderSide.NONE for no border"); + } + if (side.style == null) { + throw new IllegalArgumentException(field + ".style must not be null"); + } + if (Double.isNaN(side.width) || Double.isInfinite(side.width) || side.width < 0) { + throw new IllegalArgumentException(field + ".width must be 0 or more, got " + side.width); + } + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GaussianBlur.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GaussianBlur.java new file mode 100644 index 00000000000..ca9eb393b4e --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GaussianBlur.java @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.image.BufferedImage; + +/// A separable gaussian blur over float planes. +/// +/// `java.awt.image.ConvolveOp` is deliberately not used: it either leaves +/// the edge pixels untouched or zero-fills them, and it convolves the +/// non-premultiplied channels of an ARGB image, which bleeds the colour of +/// fully transparent pixels into the result. +/// +/// Everything outside the plane counts as zero (transparent). A caller that +/// needs content beyond its visible area, as a shadow does, adds a margin of +/// [#radius(double)] around it and crops afterwards. +/// +/// Up to a standard deviation of [#EXACT_SIGMA_LIMIT] the kernel is the +/// sampled gaussian truncated at three standard deviations and normalised. +/// Above it the blur is three successive box blurs whose widths are chosen +/// to match the variance, which is visually indistinguishable at that size +/// and costs the same for any radius. +public final class GaussianBlur { + /// The largest standard deviation blurred with the exact kernel. + public static final double EXACT_SIGMA_LIMIT = 16; + + private GaussianBlur() { + } + + /// How far, in pixels, the blur for `sigma` carries a value: the number + /// of pixels on each side of a plane that can influence, or be + /// influenced by, a given pixel. + public static int radius(double sigma) { + if (!(sigma > 0)) { + return 0; + } + if (sigma <= EXACT_SIGMA_LIMIT) { + return (int) Math.ceil(sigma * 3); + } + int r = 0; + for (int size : boxSizes(sigma)) { + r += (size - 1) / 2; + } + return r; + } + + /// The normalised gaussian weights for `sigma`, `2 * radius + 1` of + /// them, symmetric around the middle. + public static float[] kernel(double sigma) { + int r = (int) Math.ceil(sigma * 3); + double[] k = new double[2 * r + 1]; + double sum = 0; + for (int i = -r; i <= r; i++) { + double v = Math.exp(-(i * (double) i) / (2 * sigma * sigma)); + k[i + r] = v; + sum += v; + } + float[] out = new float[k.length]; + for (int i = 0; i < k.length; i++) { + out[i] = (float) (k[i] / sum); + } + return out; + } + + /// The widths of the three box filters that approximate a gaussian of + /// the given standard deviation. All are odd. + static int[] boxSizes(double sigma) { + int n = 3; + double ideal = Math.sqrt(12 * sigma * sigma / n + 1); + int lower = (int) Math.floor(ideal); + if (lower % 2 == 0) { + lower--; + } + int upper = lower + 2; + double m = (12 * sigma * sigma - n * lower * (double) lower - 4.0 * n * lower - 3.0 * n) + / (-4.0 * lower - 4); + int count = (int) Math.round(m); + int[] sizes = new int[n]; + for (int i = 0; i < n; i++) { + sizes[i] = i < count ? lower : upper; + } + return sizes; + } + + /// Blurs one plane in place. + /// + /// #### Parameters + /// + /// - `data`: `w * h` values, row by row + /// + /// - `w`: plane width + /// + /// - `h`: plane height + /// + /// - `sigma`: the standard deviation in pixels; zero or less is a no-op + public static void blur(float[] data, int w, int h, double sigma) { + if (!(sigma > 0) || w <= 0 || h <= 0) { + return; + } + float[] line = new float[Math.max(w, h)]; + if (sigma <= EXACT_SIGMA_LIMIT) { + float[] k = kernel(sigma); + for (int y = 0; y < h; y++) { + exactLine(data, line, y * w, 1, w, k); + } + for (int x = 0; x < w; x++) { + exactLine(data, line, x, w, h, k); + } + } else { + int[] sizes = boxSizes(sigma); + for (int size : sizes) { + int r = (size - 1) / 2; + for (int y = 0; y < h; y++) { + boxLine(data, line, y * w, 1, w, r); + } + } + for (int size : sizes) { + int r = (size - 1) / 2; + for (int x = 0; x < w; x++) { + boxLine(data, line, x, w, h, r); + } + } + } + } + + private static void exactLine(float[] data, float[] line, int off, int stride, int len, float[] k) { + int r = (k.length - 1) / 2; + int first = -1; + int last = -1; + for (int i = 0; i < len; i++) { + float v = data[off + i * stride]; + line[i] = v; + if (v != 0f) { + if (first < 0) { + first = i; + } + last = i; + } + } + if (first < 0) { + return; + } + int from = Math.max(0, first - r); + int to = Math.min(len - 1, last + r); + for (int i = from; i <= to; i++) { + int lo = Math.max(first, i - r); + int hi = Math.min(last, i + r); + float sum = 0f; + for (int j = lo; j <= hi; j++) { + sum += line[j] * k[j - i + r]; + } + data[off + i * stride] = sum; + } + } + + private static void boxLine(float[] data, float[] line, int off, int stride, int len, int r) { + boolean any = false; + for (int i = 0; i < len; i++) { + float v = data[off + i * stride]; + line[i] = v; + any |= v != 0f; + } + if (!any || r <= 0) { + return; + } + float inv = 1f / (2 * r + 1); + double sum = 0; + for (int j = 0; j <= Math.min(r, len - 1); j++) { + sum += line[j]; + } + for (int i = 0; i < len; i++) { + data[off + i * stride] = (float) (sum * inv); + int add = i + r + 1; + int drop = i - r; + if (add < len) { + sum += line[add]; + } + if (drop >= 0) { + sum -= line[drop]; + } + } + } + + /// Blurs an image and returns the result as a new `TYPE_INT_ARGB` image + /// of the same size. The channels are blurred premultiplied by alpha, so + /// a transparent pixel contributes no colour whatever its RGB bits are. + public static BufferedImage blur(BufferedImage src, double sigma) { + int w = src.getWidth(); + int h = src.getHeight(); + int[] px = src.getRGB(0, 0, w, h, null, 0, w); + float[] a = new float[px.length]; + float[] r = new float[px.length]; + float[] g = new float[px.length]; + float[] b = new float[px.length]; + for (int i = 0; i < px.length; i++) { + int p = px[i]; + float al = (p >>> 24) / 255f; + a[i] = al; + r[i] = ((p >> 16) & 0xff) * al; + g[i] = ((p >> 8) & 0xff) * al; + b[i] = (p & 0xff) * al; + } + blur(a, w, h, sigma); + blur(r, w, h, sigma); + blur(g, w, h, sigma); + blur(b, w, h, sigma); + for (int i = 0; i < px.length; i++) { + float al = a[i]; + if (al <= 0f) { + px[i] = 0; + } else { + px[i] = (Pixels.clamp8(al * 255f) << 24) + | (Pixels.clamp8(r[i] / al) << 16) + | (Pixels.clamp8(g[i] / al) << 8) + | Pixels.clamp8(b[i] / al); + } + } + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + out.setRGB(0, 0, w, h, px, 0, w); + return out; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GradientPainter.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GradientPainter.java new file mode 100644 index 00000000000..ead60e986b2 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GradientPainter.java @@ -0,0 +1,409 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.util.List; + +/// Samples a CSS gradient per pixel. +/// +/// `java.awt.MultipleGradientPaint` is deliberately not used. It rejects two +/// stops at the same fraction, which is how CSS writes a hard edge; it has +/// no conic form and no stops in px; and it interpolates non-premultiplied +/// channels, which turns a fade to `transparent` (transparent black) grey +/// in the middle. +/// +/// Stops go through the CSS colour stop fix-up: +/// +/// 1. a first stop without a position is at 0, a last one at 100%; +/// 2. a stop positioned before an earlier one is moved up to it, so the +/// positions never decrease; +/// 3. a run of stops without positions is spread evenly between its +/// positioned neighbours. +/// +/// Colours are interpolated premultiplied by alpha. Two stops at the same +/// position make a hard edge, and the later one wins exactly on it. +/// +/// Each pixel takes the colour at its centre. The gradient box is the box +/// handed in, which the rasterizer makes the border box; sizing a gradient +/// to the padding box (`background-origin`) or with `background-size` is +/// out of scope. +public final class GradientPainter { + private final GradientSpec spec; + private final double boxX; + private final double boxY; + private final double boxW; + private final double boxH; + + /// Resolved stop positions as fractions of the gradient line (linear), + /// the gradient ray (radial) or a full turn (conic). + private final double[] pos; + /// Premultiplied stop colours, each channel 0 to 1. + private final float[] sa; + private final float[] sr; + private final float[] sg; + private final float[] sb; + private final int[] argb; + + /// Unit vector along the linear gradient line and the line's length. + private double dirX; + private double dirY; + private double lineLength; + /// Centre and radii of a radial gradient, centre of a conic one. + private double cx; + private double cy; + private double rx; + private double ry; + + /// Prepares a gradient for a box. + /// + /// #### Parameters + /// + /// - `spec`: the gradient + /// + /// - `boxX`: left edge of the gradient box in image coordinates + /// + /// - `boxY`: top edge of the gradient box + /// + /// - `boxW`: width of the gradient box, positive + /// + /// - `boxH`: height of the gradient box, positive + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: naming the field, for a gradient with + /// fewer than two stops, a `null` enum, or a stop unit that does not + /// apply to the gradient type + public GradientPainter(GradientSpec spec, double boxX, double boxY, double boxW, double boxH) { + if (spec == null) { + throw new IllegalArgumentException("gradient must not be null"); + } + if (spec.getType() == null) { + throw new IllegalArgumentException("gradient.type must not be null"); + } + List stops = spec.getStops(); + if (stops == null || stops.size() < 2) { + throw new IllegalArgumentException("gradient.stops needs at least two stops"); + } + this.spec = spec; + this.boxX = boxX; + this.boxY = boxY; + this.boxW = boxW; + this.boxH = boxH; + double pxLength; + switch (spec.getType()) { + case LINEAR: + setUpLinear(); + pxLength = lineLength; + break; + case RADIAL: + setUpRadial(); + pxLength = rx; + break; + default: + cx = resolve(spec.getCenterX(), spec.isCenterXPercent(), boxX, boxW, "gradient.centerX"); + cy = resolve(spec.getCenterY(), spec.isCenterYPercent(), boxY, boxH, "gradient.centerY"); + pxLength = 0; + break; + } + int n = stops.size(); + pos = new double[n]; + sa = new float[n]; + sr = new float[n]; + sg = new float[n]; + sb = new float[n]; + argb = new int[n]; + boolean conic = spec.getType() == GradientSpec.Type.CONIC; + for (int i = 0; i < n; i++) { + GradientSpec.Stop s = stops.get(i); + if (s == null) { + throw new IllegalArgumentException("gradient.stops[" + i + "] must not be null"); + } + if (s.unit == null) { + throw new IllegalArgumentException("gradient.stops[" + i + "].unit must not be null"); + } + double p; + switch (s.unit) { + case NONE: + p = Double.NaN; + break; + case PERCENT: + p = s.position / 100; + break; + case PX: + if (conic) { + throw new IllegalArgumentException( + "gradient.stops[" + i + "].unit PX is not valid in a conic gradient"); + } + p = pxLength > 0 ? s.position / pxLength : 0; + break; + default: + if (!conic) { + throw new IllegalArgumentException( + "gradient.stops[" + i + "].unit DEGREES is only valid in a conic gradient"); + } + p = s.position / 360; + break; + } + if (s.unit != GradientSpec.Unit.NONE && (Double.isNaN(p) || Double.isInfinite(p))) { + throw new IllegalArgumentException("gradient.stops[" + i + "].position is not a number"); + } + pos[i] = p; + argb[i] = s.color; + float a = (s.color >>> 24) / 255f; + sa[i] = a; + sr[i] = ((s.color >> 16) & 0xff) / 255f * a; + sg[i] = ((s.color >> 8) & 0xff) / 255f * a; + sb[i] = (s.color & 0xff) / 255f * a; + } + fixUp(pos); + } + + /// The CSS colour stop fix-up, in place. `NaN` marks a stop without a + /// position. + static void fixUp(double[] pos) { + int n = pos.length; + if (Double.isNaN(pos[0])) { + pos[0] = 0; + } + if (Double.isNaN(pos[n - 1])) { + pos[n - 1] = 1; + } + double max = pos[0]; + for (int i = 1; i < n; i++) { + if (!Double.isNaN(pos[i])) { + if (pos[i] < max) { + pos[i] = max; + } + max = pos[i]; + } + } + int i = 1; + while (i < n) { + if (Double.isNaN(pos[i])) { + int end = i; + while (Double.isNaN(pos[end])) { + end++; + } + double from = pos[i - 1]; + double step = (pos[end] - from) / (end - i + 1); + for (int j = i; j < end; j++) { + pos[j] = from + step * (j - i + 1); + } + i = end; + } + i++; + } + } + + /// The resolved stop positions after fix-up, as fractions. A copy. + public double[] stopPositions() { + return pos.clone(); + } + + private static double resolve(double v, boolean percent, double origin, double size, String field) { + if (Double.isNaN(v) || Double.isInfinite(v)) { + throw new IllegalArgumentException(field + " is not a number"); + } + return origin + (percent ? size * v / 100 : v); + } + + private void setUpLinear() { + double dx; + double dy; + if (spec.isToCorner()) { + int sx = Integer.signum(spec.getCornerX()); + int sy = Integer.signum(spec.getCornerY()); + if (sx == 0 && sy == 0) { + // No side at all: the CSS default direction, to bottom. + dx = 0; + dy = 1; + } else if (sx == 0 || sy == 0) { + dx = sx; + dy = sy; + } else { + // The CSS "magic corner": the angle is chosen so that the + // 50% line runs through the two OTHER corners. That line is + // the diagonal (sx * W, -sy * H); the gradient line is + // perpendicular to it, which is (sx * H, sy * W). It only + // points at the named corner when the box is square. + dx = sx * boxH; + dy = sy * boxW; + double len = Math.sqrt(dx * dx + dy * dy); + dx /= len; + dy /= len; + } + } else { + double a = spec.getAngleDeg(); + if (Double.isNaN(a) || Double.isInfinite(a)) { + throw new IllegalArgumentException("gradient.angleDeg is not a number"); + } + double rad = Math.toRadians(a); + dx = Math.sin(rad); + dy = -Math.cos(rad); + } + dirX = dx; + dirY = dy; + // The gradient line runs through the centre and is exactly long + // enough for the 0% and 100% lines to touch the two corners. + lineLength = Math.abs(boxW * dx) + Math.abs(boxH * dy); + } + + private void setUpRadial() { + if (spec.getShape() == null) { + throw new IllegalArgumentException("gradient.shape must not be null"); + } + if (spec.getExtent() == null) { + throw new IllegalArgumentException("gradient.extent must not be null"); + } + cx = resolve(spec.getCenterX(), spec.isCenterXPercent(), boxX, boxW, "gradient.centerX"); + cy = resolve(spec.getCenterY(), spec.isCenterYPercent(), boxY, boxH, "gradient.centerY"); + double left = Math.abs(cx - boxX); + double right = Math.abs(boxX + boxW - cx); + double top = Math.abs(cy - boxY); + double bottom = Math.abs(boxY + boxH - cy); + boolean circle = spec.getShape() == GradientSpec.Shape.CIRCLE; + GradientSpec.Extent e = spec.getExtent(); + if (e == GradientSpec.Extent.EXPLICIT) { + rx = spec.getRadiusX(); + ry = circle ? rx : spec.getRadiusY(); + if (Double.isNaN(rx) || rx < 0) { + throw new IllegalArgumentException("gradient.radiusX must not be negative"); + } + if (Double.isNaN(ry) || ry < 0) { + throw new IllegalArgumentException("gradient.radiusY must not be negative"); + } + return; + } + boolean closest = e == GradientSpec.Extent.CLOSEST_SIDE || e == GradientSpec.Extent.CLOSEST_CORNER; + boolean corner = e == GradientSpec.Extent.CLOSEST_CORNER || e == GradientSpec.Extent.FARTHEST_CORNER; + // The horizontal and vertical distance to the closest (or farthest) + // side. These are also the offsets of the closest (or farthest) + // corner, because that corner is where those two sides meet. + double sideX = closest ? Math.min(left, right) : Math.max(left, right); + double sideY = closest ? Math.min(top, bottom) : Math.max(top, bottom); + if (circle) { + if (corner) { + rx = Math.sqrt(sideX * sideX + sideY * sideY); + } else { + rx = closest ? Math.min(sideX, sideY) : Math.max(sideX, sideY); + } + ry = rx; + } else if (corner) { + // An ellipse through the corner with the aspect ratio of the + // matching -side ellipse: (sx/rx)^2 + (sy/ry)^2 = 1 with + // rx/ry = sx/sy gives rx = sx * sqrt(2), ry = sy * sqrt(2). + rx = sideX * Math.sqrt(2); + ry = sideY * Math.sqrt(2); + } else { + rx = sideX; + ry = sideY; + } + } + + /// The gradient parameter at a point: 0 at the first default stop + /// position and 1 at the last. Unbounded for linear and radial + /// gradients, in `[0, 1)` for a conic one. + public double parameterAt(double x, double y) { + switch (spec.getType()) { + case LINEAR: + return ((x - (boxX + boxW / 2)) * dirX + (y - (boxY + boxH / 2)) * dirY) / lineLength + 0.5; + case RADIAL: { + if (!(rx > 0) || !(ry > 0)) { + // A degenerate ending shape: CSS renders it as if it were + // vanishingly small, so every point is past the last stop. + return Double.POSITIVE_INFINITY; + } + double dx = (x - cx) / rx; + double dy = (y - cy) / ry; + return Math.sqrt(dx * dx + dy * dy); + } + default: { + // atan2(dx, -dy) is the CSS bearing: 0 up, clockwise. + double deg = Math.toDegrees(Math.atan2(x - cx, -(y - cy))) - spec.getFromAngleDeg(); + double t = deg / 360; + t -= Math.floor(t); + return t; + } + } + } + + /// The colour at a gradient parameter, as non-premultiplied ARGB. + public int colorAt(double t) { + int n = pos.length; + double first = pos[0]; + double last = pos[n - 1]; + if (spec.isRepeating()) { + double span = last - first; + if (!(span > 1e-9) || Double.isInfinite(t)) { + return argb[n - 1]; + } + double k = (t - first) / span; + t = first + (k - Math.floor(k)) * span; + } + if (t < first) { + return argb[0]; + } + if (!(t < last)) { + return argb[n - 1]; + } + int i = 0; + while (i < n - 2 && t >= pos[i + 1]) { + i++; + } + double span = pos[i + 1] - pos[i]; + float f = span > 0 ? (float) ((t - pos[i]) / span) : 1f; + float a = sa[i] + (sa[i + 1] - sa[i]) * f; + if (a <= 0f) { + return 0; + } + float r = sr[i] + (sr[i + 1] - sr[i]) * f; + float g = sg[i] + (sg[i + 1] - sg[i]) * f; + float b = sb[i] + (sb[i + 1] - sb[i]) * f; + return (Pixels.clamp8(a * 255f) << 24) + | (Pixels.clamp8(r / a * 255f) << 16) + | (Pixels.clamp8(g / a * 255f) << 8) + | Pixels.clamp8(b / a * 255f); + } + + /// The colour at a point in image coordinates. + public int colorAtPoint(double x, double y) { + return colorAt(parameterAt(x, y)); + } + + /// Paints the gradient over a whole `w` by `h` image, one sample at + /// each pixel centre. The gradient is not clipped to its box here. + /// + /// #### Returns + /// + /// `w * h` non-premultiplied ARGB values, row by row + public int[] paint(int w, int h) { + int[] out = new int[w * h]; + for (int y = 0; y < h; y++) { + int row = y * w; + for (int x = 0; x < w; x++) { + out[row + x] = colorAt(parameterAt(x + 0.5, y + 0.5)); + } + } + return out; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GradientSpec.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GradientSpec.java new file mode 100644 index 00000000000..c8d9d791575 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/GradientSpec.java @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// A CSS gradient: `linear-gradient`, `radial-gradient`, `conic-gradient` +/// and their `repeating-` variants. +/// +/// Built by hand with the no-argument constructor (or one of the static +/// factories) and the fluent setters, which carry the field names: +/// +/// ```java +/// GradientSpec g = GradientSpec.linear(90) +/// .addStop(0xffff0000) +/// .addStop(0xff0000ff); +/// ``` +/// +/// The defaults are the CSS ones: a linear gradient `to bottom`, an +/// `ellipse farthest-corner` radial shape, and a centre of `50% 50%`. +/// [BoxStyle] copies the spec it is given, so a spec can be reused and +/// changed after it was handed to a builder. +public final class GradientSpec { + /// The gradient function. + public enum Type { LINEAR, RADIAL, CONIC } + + /// The radial ending shape. + public enum Shape { CIRCLE, ELLIPSE } + + /// The radial size. + public enum Extent { CLOSEST_SIDE, FARTHEST_SIDE, CLOSEST_CORNER, FARTHEST_CORNER, EXPLICIT } + + /// The unit of a stop position. `NONE` is a stop without a position, + /// which the CSS fix-up places. `DEGREES` is only valid in a conic + /// gradient and `PX` only in a linear or radial one. + public enum Unit { NONE, PERCENT, PX, DEGREES } + + /// A colour stop. Immutable. + public static final class Stop { + /// The stop colour as non-premultiplied ARGB. + public final int color; + /// The position, in [#unit]; ignored for [Unit#NONE]. + public final double position; + /// The unit of [#position]. + public final Unit unit; + + /// A stop without a position. + public Stop(int color) { + this(color, 0, Unit.NONE); + } + + public Stop(int color, double position, Unit unit) { + this.color = color; + this.position = position; + this.unit = unit; + } + + public int getColor() { + return color; + } + + public double getPosition() { + return position; + } + + public Unit getUnit() { + return unit; + } + } + + private Type type = Type.LINEAR; + private boolean repeating; + private double angleDeg = 180; + private boolean toCorner; + private int cornerX; + private int cornerY; + private Shape shape = Shape.ELLIPSE; + private Extent extent = Extent.FARTHEST_CORNER; + private double radiusX; + private double radiusY; + private double centerX = 50; + private double centerY = 50; + private boolean centerXPercent = true; + private boolean centerYPercent = true; + private double fromAngleDeg; + private List stops = new ArrayList(); + + /// Creates a linear gradient `to bottom` with no stops. + public GradientSpec() { + } + + /// Creates a copy of another spec. + public GradientSpec(GradientSpec o) { + type = o.type; + repeating = o.repeating; + angleDeg = o.angleDeg; + toCorner = o.toCorner; + cornerX = o.cornerX; + cornerY = o.cornerY; + shape = o.shape; + extent = o.extent; + radiusX = o.radiusX; + radiusY = o.radiusY; + centerX = o.centerX; + centerY = o.centerY; + centerXPercent = o.centerXPercent; + centerYPercent = o.centerYPercent; + fromAngleDeg = o.fromAngleDeg; + stops = o.stops == null ? null : new ArrayList(o.stops); + } + + /// A linear gradient along a CSS angle: 0 is `to top`, 90 is + /// `to right`, clockwise. + public static GradientSpec linear(double angleDeg) { + return new GradientSpec().type(Type.LINEAR).angleDeg(angleDeg); + } + + /// A linear gradient `to` a side or corner. Each argument is -1, 0 or + /// 1; `to top right` is `(1, -1)` and `to left` is `(-1, 0)`. + public static GradientSpec linearTo(int cornerX, int cornerY) { + return new GradientSpec().type(Type.LINEAR).toCorner(true).cornerX(cornerX).cornerY(cornerY); + } + + /// A radial gradient of the given shape and extent centred at `50% 50%`. + public static GradientSpec radial(Shape shape, Extent extent) { + return new GradientSpec().type(Type.RADIAL).shape(shape).extent(extent); + } + + /// A conic gradient starting at the given angle, centred at `50% 50%`. + public static GradientSpec conic(double fromAngleDeg) { + return new GradientSpec().type(Type.CONIC).fromAngleDeg(fromAngleDeg); + } + + public GradientSpec type(Type v) { + type = v; + return this; + } + + public GradientSpec repeating(boolean v) { + repeating = v; + return this; + } + + /// Sets the linear angle and clears [#toCorner(boolean)]. + public GradientSpec angleDeg(double v) { + angleDeg = v; + toCorner = false; + return this; + } + + public GradientSpec toCorner(boolean v) { + toCorner = v; + return this; + } + + public GradientSpec cornerX(int v) { + cornerX = v; + return this; + } + + public GradientSpec cornerY(int v) { + cornerY = v; + return this; + } + + public GradientSpec shape(Shape v) { + shape = v; + return this; + } + + public GradientSpec extent(Extent v) { + extent = v; + return this; + } + + public GradientSpec radiusX(double v) { + radiusX = v; + return this; + } + + public GradientSpec radiusY(double v) { + radiusY = v; + return this; + } + + public GradientSpec centerX(double v) { + centerX = v; + return this; + } + + public GradientSpec centerY(double v) { + centerY = v; + return this; + } + + public GradientSpec centerXPercent(boolean v) { + centerXPercent = v; + return this; + } + + public GradientSpec centerYPercent(boolean v) { + centerYPercent = v; + return this; + } + + /// Sets the centre of a radial or conic gradient in one call. + public GradientSpec center(double x, boolean xPercent, double y, boolean yPercent) { + centerX = x; + centerXPercent = xPercent; + centerY = y; + centerYPercent = yPercent; + return this; + } + + public GradientSpec fromAngleDeg(double v) { + fromAngleDeg = v; + return this; + } + + /// Replaces the stops with a copy of the given list. + public GradientSpec stops(List v) { + stops = v == null ? null : new ArrayList(v); + return this; + } + + /// Appends a stop without a position. + public GradientSpec addStop(int color) { + return addStop(new Stop(color)); + } + + /// Appends a positioned stop. + public GradientSpec addStop(int color, double position, Unit unit) { + return addStop(new Stop(color, position, unit)); + } + + public GradientSpec addStop(Stop stop) { + if (stops == null) { + stops = new ArrayList(); + } + stops.add(stop); + return this; + } + + public Type getType() { + return type; + } + + public boolean isRepeating() { + return repeating; + } + + public double getAngleDeg() { + return angleDeg; + } + + public boolean isToCorner() { + return toCorner; + } + + public int getCornerX() { + return cornerX; + } + + public int getCornerY() { + return cornerY; + } + + public Shape getShape() { + return shape; + } + + public Extent getExtent() { + return extent; + } + + public double getRadiusX() { + return radiusX; + } + + public double getRadiusY() { + return radiusY; + } + + public double getCenterX() { + return centerX; + } + + public double getCenterY() { + return centerY; + } + + public boolean isCenterXPercent() { + return centerXPercent; + } + + public boolean isCenterYPercent() { + return centerYPercent; + } + + public double getFromAngleDeg() { + return fromAngleDeg; + } + + /// The stops, as an unmodifiable view; `null` when they were set to `null`. + public List getStops() { + return stops == null ? null : Collections.unmodifiableList(stops); + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/Pixels.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/Pixels.java new file mode 100644 index 00000000000..8ea39aa1083 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/Pixels.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.image.BufferedImage; + +/// Pixel helpers shared by the painters: antialiased coverage masks and +/// source-over blending on non-premultiplied ARGB. +/// +/// Every layer in this package is composited as "colour times coverage +/// mask", rather than through a `Graphics2D` clip, because a Java2D clip is +/// not antialiased and would leave a jagged edge on every rounded corner. +final class Pixels { + private Pixels() { + } + + static void hints(Graphics2D g) { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + } + + /// The antialiased coverage, 0 to 1 per pixel, of a shape drawn into a + /// `w` by `h` buffer after translating it by `(dx, dy)`. + static float[] coverage(Shape s, int w, int h, double dx, double dy) { + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + try { + hints(g); + g.translate(dx, dy); + g.setColor(Color.WHITE); + g.fill(s); + } finally { + g.dispose(); + } + int[] d = img.getRGB(0, 0, w, h, null, 0, w); + float[] out = new float[d.length]; + for (int i = 0; i < d.length; i++) { + int a = d[i] >>> 24; + if (a != 0) { + out[i] = a == 255 ? 1f : a / 255f; + } + } + return out; + } + + /// Blends `argb`, scaled by `cov`, over pixel `i` of `dst`. + static void blend(int[] dst, int i, int argb, float cov) { + float sa = (argb >>> 24) / 255f * cov; + if (sa <= 0f) { + return; + } + int d = dst[i]; + int da8 = d >>> 24; + if (da8 == 0) { + dst[i] = (clamp8(sa * 255f) << 24) | (argb & 0xffffff); + return; + } + if (sa >= 1f) { + dst[i] = argb | 0xff000000; + return; + } + float da = da8 / 255f * (1f - sa); + float oa = sa + da; + float r = (((argb >> 16) & 0xff) * sa + ((d >> 16) & 0xff) * da) / oa; + float g = (((argb >> 8) & 0xff) * sa + ((d >> 8) & 0xff) * da) / oa; + float b = ((argb & 0xff) * sa + (d & 0xff) * da) / oa; + dst[i] = (clamp8(oa * 255f) << 24) | (clamp8(r) << 16) | (clamp8(g) << 8) | clamp8(b); + } + + /// Blends one colour over `dst` through a coverage mask. + static void fill(int[] dst, int argb, float[] mask) { + if ((argb >>> 24) == 0) { + return; + } + for (int i = 0; i < dst.length; i++) { + float m = mask[i]; + if (m > 0f) { + blend(dst, i, argb, m); + } + } + } + + /// Blends a layer of non-premultiplied ARGB over `dst` through a + /// coverage mask. + static void layer(int[] dst, int[] src, float[] mask) { + for (int i = 0; i < dst.length; i++) { + float m = mask[i]; + if (m > 0f && (src[i] >>> 24) != 0) { + blend(dst, i, src[i], m); + } + } + } + + static int clamp8(float v) { + int i = Math.round(v); + return i < 0 ? 0 : (i > 255 ? 255 : i); + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/RoundedBox.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/RoundedBox.java new file mode 100644 index 00000000000..2b9f7d5ca86 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/RoundedBox.java @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import java.awt.geom.Path2D; + +/// A rectangle with an elliptical radius per corner, the shape of every CSS +/// box edge (border, padding, shadow). Immutable. +/// +/// Radii are stored as eight values in CSS order: top-left x and y, +/// top-right x and y, bottom-right x and y, bottom-left x and y. The +/// constructor normalises them the way CSS does: +/// +/// - a corner with a zero (or negative) radius on either axis is square; +/// - when the radii of two adjacent corners together exceed the side they +/// share, **all** radii are scaled by the same factor, the smallest +/// `side / (sum of the two radii)`, so the corners never overlap. A +/// 1000px radius on a 100x40 box therefore becomes 20px: a pill. +/// +/// Geometry is kept in `double` and never rounded; fractional boxes are +/// antialiased by the caller. +public final class RoundedBox { + /// Index of the top-left corner, for [#radiusX(int)] and friends. + public static final int TOP_LEFT = 0; + public static final int TOP_RIGHT = 1; + public static final int BOTTOM_RIGHT = 2; + public static final int BOTTOM_LEFT = 3; + + /// Index of the top side, for [#sidePath(int)]. + public static final int TOP = 0; + public static final int RIGHT = 1; + public static final int BOTTOM = 2; + public static final int LEFT = 3; + + /// The parametric angle, in degrees, at which each corner's arc starts + /// when the outline is walked clockwise. Angles are measured in screen + /// space (y down), so 270 is straight up. + private static final double[] ARC_START = {180, 270, 0, 90}; + + private final double x; + private final double y; + private final double width; + private final double height; + private final double[] radii; + + /// Creates a box and normalises its radii. + /// + /// #### Parameters + /// + /// - `x`: left edge + /// + /// - `y`: top edge + /// + /// - `width`: width; a value that is not positive makes the box empty + /// + /// - `height`: height; a value that is not positive makes the box empty + /// + /// - `radii`: eight radii, or `null` for square corners + public RoundedBox(double x, double y, double width, double height, double[] radii) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + double[] r = new double[8]; + if (radii != null && width > 0 && height > 0) { + for (int c = 0; c < 4; c++) { + double rx = radii[c * 2]; + double ry = radii[c * 2 + 1]; + if (rx > 0 && ry > 0) { + r[c * 2] = rx; + r[c * 2 + 1] = ry; + } + } + double f = 1; + f = Math.min(f, ratio(width, r[0] + r[2])); + f = Math.min(f, ratio(height, r[3] + r[5])); + f = Math.min(f, ratio(width, r[4] + r[6])); + f = Math.min(f, ratio(height, r[7] + r[1])); + if (f < 1) { + for (int i = 0; i < 8; i++) { + r[i] *= f; + } + } + } + this.radii = r; + } + + private static double ratio(double side, double sum) { + return sum > side ? side / sum : 1; + } + + public double getX() { + return x; + } + + public double getY() { + return y; + } + + public double getWidth() { + return width; + } + + public double getHeight() { + return height; + } + + /// The horizontal radius of a corner after normalisation. + public double radiusX(int corner) { + return radii[corner * 2]; + } + + /// The vertical radius of a corner after normalisation. + public double radiusY(int corner) { + return radii[corner * 2 + 1]; + } + + /// The eight normalised radii. A copy. + public double[] getRadii() { + return radii.clone(); + } + + /// Whether the box encloses no area. + public boolean isEmpty() { + return !(width > 0) || !(height > 0); + } + + /// The box moved by `(dx, dy)`. + public RoundedBox translate(double dx, double dy) { + return new RoundedBox(x + dx, y + dy, width, height, radii); + } + + /// The box shrunk by a distance per side, as the padding edge is from + /// the border edge. Each radius shrinks by the inset of the side it + /// meets and stops at zero, which is the CSS rule for the inner border + /// radius; the result is then normalised again. + public RoundedBox inset(double top, double right, double bottom, double left) { + double[] r = { + radii[0] - left, radii[1] - top, + radii[2] - right, radii[3] - top, + radii[4] - right, radii[5] - bottom, + radii[6] - left, radii[7] - bottom + }; + return new RoundedBox(x + left, y + top, width - left - right, height - top - bottom, r); + } + + /// The box grown by a CSS shadow spread distance (shrunk for a negative + /// one). A square corner stays square. A rounded corner grows by the + /// spread, except that a radius smaller than the spread grows by less, + /// `spread * (1 + (r / spread - 1)^3)`, which is the CSS rule that keeps + /// a barely rounded box from getting a visibly round shadow. + public RoundedBox grow(double spread) { + double[] r = new double[8]; + for (int c = 0; c < 4; c++) { + double rx = radii[c * 2]; + double ry = radii[c * 2 + 1]; + if (rx > 0 && ry > 0) { + r[c * 2] = grownRadius(rx, spread); + r[c * 2 + 1] = grownRadius(ry, spread); + } + } + return new RoundedBox(x - spread, y - spread, width + 2 * spread, height + 2 * spread, r); + } + + private static double grownRadius(double r, double spread) { + if (spread <= 0) { + return Math.max(0, r + spread); + } + if (r >= spread) { + return r + spread; + } + double k = r / spread - 1; + return r + spread * (1 + k * k * k); + } + + private double centerX(int corner) { + switch (corner) { + case TOP_LEFT: + return x + radii[0]; + case TOP_RIGHT: + return x + width - radii[2]; + case BOTTOM_RIGHT: + return x + width - radii[4]; + default: + return x + radii[6]; + } + } + + private double centerY(int corner) { + switch (corner) { + case TOP_LEFT: + return y + radii[1]; + case TOP_RIGHT: + return y + radii[3]; + case BOTTOM_RIGHT: + return y + height - radii[5]; + default: + return y + height - radii[7]; + } + } + + private double pointX(int corner, double deg) { + return centerX(corner) + radii[corner * 2] * Math.cos(Math.toRadians(deg)); + } + + private double pointY(int corner, double deg) { + return centerY(corner) + radii[corner * 2 + 1] * Math.sin(Math.toRadians(deg)); + } + + /// The closed outline, clockwise from the start of the top edge. Empty + /// when the box is. + public Path2D.Double toPath() { + Path2D.Double p = new Path2D.Double(); + appendTo(p); + return p; + } + + /// Appends the closed outline to a path, as its own subpath. + public void appendTo(Path2D p) { + if (isEmpty()) { + return; + } + p.moveTo(pointX(TOP_LEFT, 270), pointY(TOP_LEFT, 270)); + for (int i = 1; i <= 4; i++) { + int c = i % 4; + double a = ARC_START[c]; + p.lineTo(pointX(c, a), pointY(c, a)); + arc(p, c, a, a + 90); + } + p.closePath(); + } + + /// The ring between two boxes as one even-odd path: `outer` with `inner` + /// cut out of it. This is the border area when `inner` is the padding + /// edge of `outer`. + public static Path2D.Double ring(RoundedBox outer, RoundedBox inner) { + Path2D.Double p = new Path2D.Double(Path2D.WIND_EVEN_ODD); + outer.appendTo(p); + inner.appendTo(p); + return p; + } + + /// The open path along one side, from the middle of the corner arc that + /// precedes it to the middle of the corner arc that follows it, walking + /// clockwise. A dashed or dotted border is laid out along this path, so + /// that each side owns half of each of its corners. + public Path2D.Double sidePath(int side) { + Path2D.Double p = new Path2D.Double(); + int c0 = side; + int c1 = (side + 1) % 4; + double a0 = ARC_START[c0]; + double a1 = ARC_START[c1]; + p.moveTo(pointX(c0, a0 + 45), pointY(c0, a0 + 45)); + arc(p, c0, a0 + 45, a0 + 90); + p.lineTo(pointX(c1, a1), pointY(c1, a1)); + arc(p, c1, a1, a1 + 45); + return p; + } + + /// Appends an elliptical arc of at most 90 degrees as one cubic. The + /// control points are the exact tangent directions at both ends scaled + /// by `4/3 * tan(sweep / 4)`, the standard approximation, whose radial + /// error for a quarter arc is under 0.03 percent of the radius. + private void arc(Path2D p, int corner, double fromDeg, double toDeg) { + double rx = radii[corner * 2]; + double ry = radii[corner * 2 + 1]; + if (!(rx > 0) || !(ry > 0)) { + return; + } + double cx = centerX(corner); + double cy = centerY(corner); + double a0 = Math.toRadians(fromDeg); + double a1 = Math.toRadians(toDeg); + double k = 4.0 / 3.0 * Math.tan((a1 - a0) / 4); + double c0 = Math.cos(a0); + double s0 = Math.sin(a0); + double c1 = Math.cos(a1); + double s1 = Math.sin(a1); + p.curveTo( + cx + rx * (c0 - k * s0), cy + ry * (s0 + k * c0), + cx + rx * (c1 + k * s1), cy + ry * (s1 - k * c1), + cx + rx * c1, cy + ry * s1); + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/Shadow.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/Shadow.java new file mode 100644 index 00000000000..1c178b73793 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/Shadow.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +/// A single CSS `box-shadow`. Immutable. +public final class Shadow { + /// Horizontal offset in CSS px, positive moves the shadow right. + public final double offsetX; + /// Vertical offset in CSS px, positive moves the shadow down. + public final double offsetY; + /// The CSS blur radius in px. The gaussian standard deviation is half of it. + public final double blur; + /// The CSS spread distance in px, may be negative. + public final double spread; + /// The shadow colour as non-premultiplied ARGB. + public final int color; + /// `true` for an `inset` shadow. + public final boolean inset; + + public Shadow(double offsetX, double offsetY, double blur, double spread, int color, boolean inset) { + this.offsetX = offsetX; + this.offsetY = offsetY; + this.blur = blur; + this.spread = spread; + this.color = color; + this.inset = inset; + } + + public double getOffsetX() { + return offsetX; + } + + public double getOffsetY() { + return offsetY; + } + + public double getBlur() { + return blur; + } + + public double getSpread() { + return spread; + } + + public int getColor() { + return color; + } + + public boolean isInset() { + return inset; + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BackgroundImagePainterTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BackgroundImagePainterTest.java new file mode 100644 index 00000000000..82c3160ed12 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BackgroundImagePainterTest.java @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static com.codename1.designer.css.raster.RasterAssert.BLUE; +import static com.codename1.designer.css.raster.RasterAssert.GREEN; +import static com.codename1.designer.css.raster.RasterAssert.RED; +import static com.codename1.designer.css.raster.RasterAssert.WHITE; +import static com.codename1.designer.css.raster.RasterAssert.YELLOW; +import static com.codename1.designer.css.raster.RasterAssert.assertPixel; +import static com.codename1.designer.css.raster.RasterAssert.assertPixelNear; +import static com.codename1.designer.css.raster.RasterAssert.assertTransparent; +import static com.codename1.designer.css.raster.RasterAssert.paint; +import static com.codename1.designer.css.raster.RasterAssert.quadrants; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import com.codename1.designer.css.raster.BackgroundImage.Repeat; +import com.codename1.designer.css.raster.BackgroundImage.Size; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +/// Background image repeat, position and size, with a 10x10 image of four +/// coloured 5x5 quadrants: red and green on top, blue and yellow below. +class BackgroundImagePainterTest { + private static BufferedImage box30(BackgroundImage bg) { + return paint(BoxStyle.builder().size(30, 30).backgroundImage(bg)); + } + + @Test + void repeatTilesBothAxes() { + BufferedImage img = box30(new BackgroundImage(quadrants())); + assertPixel(RED, img, 2, 2); + assertPixel(GREEN, img, 7, 2); + assertPixel(BLUE, img, 2, 7); + assertPixel(YELLOW, img, 7, 7); + // Every tile is a copy, pixel for pixel. + for (int y = 0; y < 30; y++) { + for (int x = 0; x < 30; x++) { + assertPixel(img.getRGB(x % 10, y % 10), img, x, y); + } + } + assertPixel(RED, img, 20, 20); + assertPixel(YELLOW, img, 29, 29); + } + + @Test + void noRepeatPaintsOneTile() { + BufferedImage img = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT)); + assertPixel(RED, img, 0, 0); + assertPixel(YELLOW, img, 9, 9); + assertTransparent(img, 10, 2); + assertTransparent(img, 2, 10); + assertTransparent(img, 20, 20); + // Over a background colour the rest of the box keeps that colour. + BufferedImage over = paint(BoxStyle.builder().size(30, 30).backgroundColor(WHITE) + .backgroundImage(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT))); + assertPixel(RED, over, 2, 2); + assertPixel(WHITE, over, 12, 2); + } + + @Test + void repeatXAndRepeatYTileOneAxis() { + BufferedImage x = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.REPEAT_X)); + assertPixel(RED, x, 12, 2); + assertPixel(YELLOW, x, 27, 7); + assertTransparent(x, 2, 12); + assertTransparent(x, 27, 10); + BufferedImage y = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.REPEAT_Y)); + assertPixel(RED, y, 2, 12); + assertPixel(YELLOW, y, 7, 27); + assertTransparent(y, 12, 2); + assertTransparent(y, 10, 27); + } + + @Test + void pixelPositionOffsetsTheTile() { + BufferedImage img = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT) + .withPosition(10, false, 5, false)); + assertPixel(RED, img, 10, 5); + assertPixel(GREEN, img, 15, 5); + assertPixel(YELLOW, img, 19, 14); + assertTransparent(img, 9, 7); + assertTransparent(img, 12, 4); + assertTransparent(img, 20, 7); + assertTransparent(img, 12, 15); + // A repeating image keeps its phase. + BufferedImage rep = box30(new BackgroundImage(quadrants()).withPosition(3, false, 4, false)); + assertPixel(RED, rep, 3, 4); + assertPixel(RED, rep, 13, 14); + assertPixel(YELLOW, rep, 2, 3); + assertPixel(GREEN, rep, 0, 5); + // A negative position cuts the tile off at the box edge. + BufferedImage neg = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT) + .withPosition(-5, false, -5, false)); + assertPixel(YELLOW, neg, 0, 0); + assertPixel(YELLOW, neg, 4, 4); + assertTransparent(neg, 5, 2); + } + + @Test + void percentPositionIsAFractionOfTheFreeSpace() { + BufferedImage end = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT) + .withPosition(100, true, 100, true)); + // 100% of (30 - 10) = 20. + assertPixel(RED, end, 20, 20); + assertPixel(YELLOW, end, 29, 29); + assertTransparent(end, 19, 22); + assertTransparent(end, 22, 19); + BufferedImage mid = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT) + .withPosition(50, true, 50, true)); + assertPixel(RED, mid, 10, 10); + assertPixel(YELLOW, mid, 19, 19); + assertTransparent(mid, 9, 12); + assertTransparent(mid, 20, 12); + BufferedImage mixed = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT) + .withPosition(25, true, 3, false)); + assertPixel(RED, mixed, 5, 3); + assertTransparent(mixed, 4, 3); + assertTransparent(mixed, 5, 2); + } + + @Test + void positionIsRelativeToThePaddingBoxButTheImagePaintsUnderTheBorder() { + BoxStyle.Builder b = BoxStyle.builder().size(30, 30).border(new BorderSide(5, BorderStyle.SOLID, 0)); + BufferedImage one = paint(b.backgroundImage(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT))); + assertPixel(RED, one, 5, 5); + assertPixel(YELLOW, one, 14, 14); + assertTransparent(one, 4, 6); + assertTransparent(one, 2, 2); + // 100% is measured in the padding box too: 5 + (20 - 10) = 15. + BufferedImage end = paint(b.backgroundImage(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT) + .withPosition(100, true, 100, true))); + assertPixel(RED, end, 15, 15); + assertPixel(YELLOW, end, 24, 24); + assertTransparent(end, 25, 20); + // Tiles continue under the border, out to the border edge. + BufferedImage rep = paint(b.backgroundImage(new BackgroundImage(quadrants()))); + assertPixel(YELLOW, rep, 2, 2); + assertPixel(RED, rep, 5, 5); + assertPixel(YELLOW, rep, 0, 0); + assertPixel(RED, rep, 29, 29); + } + + @Test + void explicitSizeScalesTheTile() { + BufferedImage img = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT).withSize(20, 20)); + // Each quadrant is now 10x10. Check away from the interpolated seams. + assertPixel(RED, img, 3, 3); + assertPixel(GREEN, img, 15, 4); + assertPixel(BLUE, img, 4, 15); + assertPixel(YELLOW, img, 16, 16); + assertTransparent(img, 22, 5); + assertTransparent(img, 5, 22); + // A non-uniform size stretches each axis on its own. + BufferedImage wide = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT).withSize(30, 10)); + assertPixel(RED, wide, 5, 1); + assertPixel(GREEN, wide, 24, 1); + assertPixel(BLUE, wide, 5, 8); + assertTransparent(wide, 5, 11); + // The seam between two quadrants is interpolated, not a hard edge + // moved off by a pixel: both sides of it are close to their colour. + assertPixelNear(RED, img, 8, 3, 40); + assertPixelNear(GREEN, img, 11, 3, 40); + } + + @Test + void autoAxisKeepsTheAspectRatio() { + BufferedImage wide = new BufferedImage(20, 10, BufferedImage.TYPE_INT_ARGB); + BackgroundImage bg = new BackgroundImage(wide); + assertArrayEquals(new double[] {20, 10}, BackgroundImagePainter.tileSize(bg, 100, 100)); + assertArrayEquals(new double[] {40, 20}, BackgroundImagePainter.tileSize(bg.withSize(40, -1), 100, 100)); + assertArrayEquals(new double[] {60, 30}, BackgroundImagePainter.tileSize(bg.withSize(-1, 30), 100, 100)); + assertArrayEquals(new double[] {20, 10}, BackgroundImagePainter.tileSize(bg.withSize(-1, -1), 100, 100)); + assertArrayEquals(new double[] {7, 9}, BackgroundImagePainter.tileSize(bg.withSize(7, 9), 100, 100)); + // cover fills the area, contain fits inside it. + assertArrayEquals(new double[] {200, 100}, BackgroundImagePainter.tileSize(bg.withSize(Size.COVER), 100, 100)); + assertArrayEquals(new double[] {100, 50}, BackgroundImagePainter.tileSize(bg.withSize(Size.CONTAIN), 100, 100)); + assertArrayEquals(new double[] {60, 30}, BackgroundImagePainter.tileSize(bg.withSize(Size.COVER), 60, 20)); + assertArrayEquals(new double[] {40, 20}, BackgroundImagePainter.tileSize(bg.withSize(Size.CONTAIN), 60, 20)); + + BufferedImage img = box30(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT).withSize(20, -1)); + assertPixel(YELLOW, img, 16, 16); + assertTransparent(img, 5, 22); + } + + @Test + void coverAndContain() { + // A 40x20 box: cover scales the 10x10 image by 4, contain by 2. + BufferedImage cover = paint(BoxStyle.builder().size(40, 20) + .backgroundImage(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT).withSize(Size.COVER))); + assertPixel(RED, cover, 8, 8); + assertPixel(GREEN, cover, 30, 8); + assertPixel(RED, cover, 8, 17); + // Centred, the lower half comes into view. + BufferedImage centred = paint(BoxStyle.builder().size(40, 20) + .backgroundImage(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT) + .withSize(Size.COVER).withPosition(50, true, 50, true))); + assertPixel(RED, centred, 8, 3); + assertPixel(BLUE, centred, 8, 16); + assertPixel(YELLOW, centred, 30, 16); + + BufferedImage contain = paint(BoxStyle.builder().size(40, 20) + .backgroundImage(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT).withSize(Size.CONTAIN))); + assertPixel(RED, contain, 4, 4); + assertPixel(GREEN, contain, 15, 4); + assertPixel(YELLOW, contain, 15, 15); + assertTransparent(contain, 22, 10); + BufferedImage containRepeat = paint(BoxStyle.builder().size(40, 20) + .backgroundImage(new BackgroundImage(quadrants()).withSize(Size.CONTAIN))); + assertPixel(RED, containRepeat, 24, 4); + assertPixel(YELLOW, containRepeat, 35, 15); + } + + @Test + void largeReductionAveragesTheSource() { + // A 64x64 checkerboard of single black and white pixels shrunk to + // 4x4 is grey, not whichever pixels a single sample happens to hit. + BufferedImage checker = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < 64; y++) { + for (int x = 0; x < 64; x++) { + checker.setRGB(x, y, ((x + y) & 1) == 0 ? 0xff000000 : 0xffffffff); + } + } + BufferedImage img = paint(BoxStyle.builder().size(4, 4) + .backgroundImage(new BackgroundImage(checker).withSize(Size.COVER))); + for (int y = 0; y < 4; y++) { + for (int x = 0; x < 4; x++) { + assertPixelNear(0xff808080, img, x, y, 6); + } + } + } + + @Test + void imageIsClippedToTheRoundedBorderBoxAndPaintedOverTheGradient() { + BufferedImage img = paint(BoxStyle.builder().size(30, 30).radii(10) + .gradient(GradientSpec.linear(90).addStop(WHITE).addStop(WHITE)) + .backgroundImage(new BackgroundImage(quadrants()).withRepeat(Repeat.NO_REPEAT) + .withPosition(10, false, 10, false))); + assertTransparent(img, 0, 0); + assertTransparent(img, 1, 1); + assertPixel(WHITE, img, 5, 15); + assertPixel(RED, img, 12, 12); + assertPixel(YELLOW, img, 17, 17); + BufferedImage corner = paint(BoxStyle.builder().size(30, 30).radii(10) + .backgroundImage(new BackgroundImage(quadrants()))); + assertTransparent(corner, 1, 1); + assertPixel(YELLOW, corner, 7, 7); + assertPixel(GREEN, corner, 15, 0); + } + + @Test + void manyTilesFallBackToATexture() { + // 200x200 tiles of 2x2 is 10000 tiles, over the tile-by-tile limit. + BufferedImage dot = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); + dot.setRGB(0, 0, RED); + dot.setRGB(1, 0, GREEN); + dot.setRGB(0, 1, BLUE); + dot.setRGB(1, 1, YELLOW); + BufferedImage img = paint(BoxStyle.builder().size(200, 200).backgroundImage(new BackgroundImage(dot))); + assertPixel(RED, img, 0, 0); + assertPixel(GREEN, img, 1, 0); + assertPixel(BLUE, img, 0, 1); + assertPixel(YELLOW, img, 1, 1); + assertPixel(RED, img, 100, 100); + assertPixel(YELLOW, img, 199, 199); + assertPixel(GREEN, img, 151, 76); + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BorderImagePainterTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BorderImagePainterTest.java new file mode 100644 index 00000000000..01335de394a --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BorderImagePainterTest.java @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static com.codename1.designer.css.raster.RasterAssert.BLACK; +import static com.codename1.designer.css.raster.RasterAssert.BLUE; +import static com.codename1.designer.css.raster.RasterAssert.CYAN; +import static com.codename1.designer.css.raster.RasterAssert.GREEN; +import static com.codename1.designer.css.raster.RasterAssert.GREY; +import static com.codename1.designer.css.raster.RasterAssert.MAGENTA; +import static com.codename1.designer.css.raster.RasterAssert.RED; +import static com.codename1.designer.css.raster.RasterAssert.WHITE; +import static com.codename1.designer.css.raster.RasterAssert.YELLOW; +import static com.codename1.designer.css.raster.RasterAssert.assertPixel; +import static com.codename1.designer.css.raster.RasterAssert.assertTransparent; +import static com.codename1.designer.css.raster.RasterAssert.paint; + +import com.codename1.designer.css.raster.BorderImage.Mode; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +/// The nine slices of a border image and where they land. +class BorderImagePainterTest { + private static final int BORDER = 0xff123456; + + /// A 30x30 image cut 10px from each edge: red, green, blue and yellow + /// corners clockwise from the top left; cyan, magenta, white and black + /// edges clockwise from the top; a grey middle. + private static BufferedImage nine() { + BufferedImage img = new BufferedImage(30, 30, BufferedImage.TYPE_INT_ARGB); + int[][] colours = { + {RED, CYAN, GREEN}, + {BLACK, GREY, MAGENTA}, + {YELLOW, WHITE, BLUE}, + }; + for (int y = 0; y < 30; y++) { + for (int x = 0; x < 30; x++) { + img.setRGB(x, y, colours[y / 10][x / 10]); + } + } + return img; + } + + /// A 100x60 box with borders of 5 (top), 10 (right), 15 (bottom) and + /// 20 (left), so every cell of the grid has its own size. + private static BoxStyle.Builder box() { + return BoxStyle.builder().size(100, 60) + .top(new BorderSide(5, BorderStyle.SOLID, BORDER)) + .right(new BorderSide(10, BorderStyle.SOLID, BORDER)) + .bottom(new BorderSide(15, BorderStyle.SOLID, BORDER)) + .left(new BorderSide(20, BorderStyle.SOLID, BORDER)); + } + + @Test + void stretchPutsEachSliceInItsCell() { + BufferedImage img = paint(box().borderImage(new BorderImage(nine(), 10, 10, 10, 10, false, Mode.STRETCH))); + // Corners: 20x5, 10x5, 10x15 and 20x15. + assertPixel(RED, img, 0, 0); + assertPixel(RED, img, 19, 4); + assertPixel(GREEN, img, 90, 0); + assertPixel(GREEN, img, 99, 4); + assertPixel(BLUE, img, 90, 45); + assertPixel(BLUE, img, 99, 59); + assertPixel(YELLOW, img, 0, 45); + assertPixel(YELLOW, img, 19, 59); + // Edges, checked at both ends so a misplaced cell shows. + assertPixel(CYAN, img, 20, 0); + assertPixel(CYAN, img, 89, 4); + assertPixel(MAGENTA, img, 90, 5); + assertPixel(MAGENTA, img, 99, 44); + assertPixel(WHITE, img, 20, 45); + assertPixel(WHITE, img, 89, 59); + assertPixel(BLACK, img, 0, 5); + assertPixel(BLACK, img, 19, 44); + // The middle is left alone without fill. + assertTransparent(img, 20, 5); + assertTransparent(img, 55, 25); + assertTransparent(img, 89, 44); + } + + @Test + void fillPaintsTheMiddleSlice() { + BufferedImage img = paint(box().borderImage(new BorderImage(nine(), 10, 10, 10, 10, true, Mode.STRETCH))); + assertPixel(GREY, img, 20, 5); + assertPixel(GREY, img, 55, 25); + assertPixel(GREY, img, 89, 44); + assertPixel(CYAN, img, 55, 4); + assertPixel(BLACK, img, 19, 25); + } + + @Test + void borderImageReplacesTheBorderStyles() { + BufferedImage img = paint(box().backgroundColor(0xff010203) + .borderImage(new BorderImage(nine(), 10, 10, 10, 10, false, Mode.STRETCH))); + for (int y = 0; y < 60; y++) { + for (int x = 0; x < 100; x++) { + if (img.getRGB(x, y) == BORDER) { + throw new AssertionError("the solid border was painted at (" + x + "," + y + ")"); + } + } + } + // The background is still there, under the unfilled middle. + assertPixel(0xff010203, img, 55, 25); + assertPixel(CYAN, img, 55, 2); + } + + @Test + void borderImageIsPlacedOnThePaddedBox() { + BufferedImage img = paint(box().pad(7).borderImage(new BorderImage(nine(), 10, 10, 10, 10, false, Mode.STRETCH))); + assertTransparent(img, 6, 7); + assertTransparent(img, 7, 6); + assertPixel(RED, img, 7, 7); + assertPixel(BLUE, img, 106, 66); + assertTransparent(img, 107, 66); + } + + @Test + void sideWithoutAWidthGetsNoSlice() { + BufferedImage img = paint(BoxStyle.builder().size(60, 40) + .top(new BorderSide(10, BorderStyle.SOLID, BORDER)) + .borderImage(new BorderImage(nine(), 10, 10, 10, 10, false, Mode.STRETCH))); + // Only the top edge has a thickness; the corners have no width. + assertPixel(CYAN, img, 0, 0); + assertPixel(CYAN, img, 59, 9); + assertTransparent(img, 30, 10); + assertTransparent(img, 0, 39); + } + + @Test + void unevenSlicesCutTheImageWhereTheySay() { + // Slice 20 from the top: the top row of cells now takes the red, + // cyan and green of the first band AND the black, grey, magenta of + // the second, squeezed into the 5px border. + BufferedImage img = paint(box().borderImage(new BorderImage(nine(), 20, 10, 10, 10, false, Mode.STRETCH))); + assertPixel(RED, img, 5, 0); + assertPixel(BLACK, img, 5, 4); + assertPixel(CYAN, img, 50, 0); + assertPixel(GREY, img, 50, 4); + // The left edge has nothing left between the top and bottom slices. + assertTransparent(img, 5, 25); + } + + /// The same layout with a top edge slice that is cyan on its left half + /// and magenta on its right half, to tell tiles apart. + private static BufferedImage striped() { + BufferedImage img = nine(); + for (int y = 0; y < 10; y++) { + for (int x = 15; x < 20; x++) { + img.setRGB(x, y, MAGENTA); + } + } + return img; + } + + @Test + void repeatCentresWholeTilesAndCutsTheEnds() { + // A 10px border: the 10x10 edge slice tiles at its own size. The + // top cell is x 10..90, 80 wide; a centred tile starts at 45, so + // tiles start at 5, 15, 25 ... and the first is cut in half. + BufferedImage img = paint(BoxStyle.builder().size(100, 60).border(new BorderSide(10, BorderStyle.SOLID, BORDER)) + .borderImage(new BorderImage(striped(), 10, 10, 10, 10, false, Mode.REPEAT))); + assertPixel(MAGENTA, img, 10, 5); + assertPixel(MAGENTA, img, 14, 5); + assertPixel(CYAN, img, 15, 5); + assertPixel(CYAN, img, 19, 5); + assertPixel(MAGENTA, img, 20, 5); + assertPixel(CYAN, img, 85, 5); + assertPixel(CYAN, img, 89, 5); + // The corners are untouched by the tiling. + assertPixel(RED, img, 9, 5); + assertPixel(GREEN, img, 90, 5); + } + + @Test + void roundFitsAWholeNumberOfTiles() { + BufferedImage img = paint(BoxStyle.builder().size(100, 60).border(new BorderSide(10, BorderStyle.SOLID, BORDER)) + .borderImage(new BorderImage(striped(), 10, 10, 10, 10, false, Mode.ROUND))); + // 80 / 10 is exactly 8 tiles, starting on the cell edge. + assertPixel(CYAN, img, 10, 5); + assertPixel(CYAN, img, 14, 5); + assertPixel(MAGENTA, img, 15, 5); + assertPixel(MAGENTA, img, 19, 5); + assertPixel(CYAN, img, 20, 5); + assertPixel(MAGENTA, img, 89, 5); + // 100 wide cell (box 120): still whole tiles, 10 of them. + BufferedImage wide = paint(BoxStyle.builder().size(120, 60).border(new BorderSide(10, BorderStyle.SOLID, BORDER)) + .borderImage(new BorderImage(striped(), 10, 10, 10, 10, false, Mode.ROUND))); + assertPixel(CYAN, wide, 10, 5); + assertPixel(MAGENTA, wide, 109, 5); + // 84 wide cell: 8 tiles of 10.5. Stretching one tile over the cell + // would put magenta only in the right half. + BufferedImage odd = paint(BoxStyle.builder().size(104, 60).border(new BorderSide(10, BorderStyle.SOLID, BORDER)) + .borderImage(new BorderImage(striped(), 10, 10, 10, 10, false, Mode.ROUND))); + assertPixel(CYAN, odd, 11, 5); + assertPixel(MAGENTA, odd, 18, 5); + assertPixel(CYAN, odd, 22, 5); + assertPixel(MAGENTA, odd, 92, 5); + } + + @Test + void stretchScalesOneTileOverTheCell() { + BufferedImage img = paint(BoxStyle.builder().size(100, 60).border(new BorderSide(10, BorderStyle.SOLID, BORDER)) + .borderImage(new BorderImage(striped(), 10, 10, 10, 10, false, Mode.STRETCH))); + assertPixel(CYAN, img, 12, 5); + assertPixel(CYAN, img, 40, 5); + assertPixel(MAGENTA, img, 60, 5); + assertPixel(MAGENTA, img, 88, 5); + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BorderPainterTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BorderPainterTest.java new file mode 100644 index 00000000000..5038e527317 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BorderPainterTest.java @@ -0,0 +1,410 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static com.codename1.designer.css.raster.RasterAssert.BLACK; +import static com.codename1.designer.css.raster.RasterAssert.BLUE; +import static com.codename1.designer.css.raster.RasterAssert.GREEN; +import static com.codename1.designer.css.raster.RasterAssert.GREY; +import static com.codename1.designer.css.raster.RasterAssert.RED; +import static com.codename1.designer.css.raster.RasterAssert.WHITE; +import static com.codename1.designer.css.raster.RasterAssert.YELLOW; +import static com.codename1.designer.css.raster.RasterAssert.alpha; +import static com.codename1.designer.css.raster.RasterAssert.assertOpaque; +import static com.codename1.designer.css.raster.RasterAssert.assertPixel; +import static com.codename1.designer.css.raster.RasterAssert.assertTransparent; +import static com.codename1.designer.css.raster.RasterAssert.paint; +import static com.codename1.designer.css.raster.RasterAssert.runsInRow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +/// Border geometry, mitres and the border styles. +class BorderPainterTest { + private static BorderSide solid(double w, int color) { + return new BorderSide(w, BorderStyle.SOLID, color); + } + + private static BoxStyle.Builder fourColours(double t, double r, double b, double l) { + return BoxStyle.builder().size(100, 80).backgroundColor(WHITE) + .top(solid(t, RED)).right(solid(r, GREEN)).bottom(solid(b, BLUE)).left(solid(l, YELLOW)); + } + + @Test + void parseIsCaseInsensitiveAndDefaultsToNone() { + assertEquals(BorderStyle.SOLID, BorderStyle.parse("solid")); + assertEquals(BorderStyle.DASHED, BorderStyle.parse("DASHED")); + assertEquals(BorderStyle.DOTTED, BorderStyle.parse(" Dotted ")); + assertEquals(BorderStyle.DOUBLE, BorderStyle.parse("double")); + assertEquals(BorderStyle.GROOVE, BorderStyle.parse("groove")); + assertEquals(BorderStyle.RIDGE, BorderStyle.parse("ridge")); + assertEquals(BorderStyle.INSET, BorderStyle.parse("inset")); + assertEquals(BorderStyle.OUTSET, BorderStyle.parse("outset")); + assertEquals(BorderStyle.HIDDEN, BorderStyle.parse("hidden")); + assertEquals(BorderStyle.NONE, BorderStyle.parse("none")); + assertEquals(BorderStyle.NONE, BorderStyle.parse(null)); + assertEquals(BorderStyle.NONE, BorderStyle.parse("wavy")); + assertEquals(BorderStyle.NONE, BorderStyle.parse("")); + } + + @Test + void eachSideHasItsOwnColour() { + BufferedImage img = paint(fourColours(10, 10, 10, 10)); + assertPixel(RED, img, 50, 5); + assertPixel(GREEN, img, 95, 40); + assertPixel(BLUE, img, 50, 75); + assertPixel(YELLOW, img, 5, 40); + assertPixel(WHITE, img, 50, 40); + // The padding edge is exactly one border width in. + assertPixel(YELLOW, img, 9, 40); + assertPixel(WHITE, img, 10, 40); + assertPixel(RED, img, 50, 9); + assertPixel(WHITE, img, 50, 10); + assertPixel(GREEN, img, 90, 40); + assertPixel(WHITE, img, 89, 40); + assertPixel(BLUE, img, 50, 70); + assertPixel(WHITE, img, 50, 69); + } + + @Test + void cornersAreMitredAlongTheDiagonal() { + BufferedImage img = paint(fourColours(10, 10, 10, 10)); + // Top-left: above the diagonal is the top side, below it the left. + assertPixel(RED, img, 7, 3); + assertPixel(YELLOW, img, 3, 7); + assertNotEquals(img.getRGB(7, 3), img.getRGB(3, 7)); + // Top-right, bottom-right, bottom-left. + assertPixel(RED, img, 92, 3); + assertPixel(GREEN, img, 96, 7); + assertPixel(GREEN, img, 96, 72); + assertPixel(BLUE, img, 92, 76); + assertPixel(BLUE, img, 7, 76); + assertPixel(YELLOW, img, 3, 72); + // A pixel the diagonal cuts in half is an even mix of both sides and + // stays fully opaque: the two antialiased halves add up. + int mix = img.getRGB(5, 5); + assertEquals(255, mix >>> 24, "no translucent seam on the mitre"); + assertEquals(255, (mix >> 16) & 0xff, "red and yellow share full red"); + int green = (mix >> 8) & 0xff; + assertTrue(green > 96 && green < 160, "half yellow, half red: green was " + green); + assertEquals(0, mix & 0xff); + } + + @Test + void mitreFollowsUnequalWidths() { + // Top 20, left 5: the mitre runs from (0,0) to (5,20). + BufferedImage img = paint(fourColours(20, 5, 5, 5)); + assertPixel(YELLOW, img, 2, 15); + assertPixel(RED, img, 4, 10); + assertPixel(RED, img, 2, 3); + assertPixel(YELLOW, img, 0, 6); + assertPixel(WHITE, img, 5, 20); + assertPixel(RED, img, 5, 19); + assertPixel(YELLOW, img, 4, 20); + } + + @Test + void noSeamOnATransparentBackground() { + BufferedImage img = paint(BoxStyle.builder().size(100, 100).radii(30) + .top(solid(10, RED)).right(solid(10, RED)).bottom(solid(10, RED)).left(solid(10, YELLOW))); + // Along the top-left mitre, inside the ring (radius 20..30 around (30,30)). + assertOpaque(img, 12, 12); + assertOpaque(img, 14, 14); + assertTransparent(img, 19, 19); + assertTransparent(img, 5, 5); + assertPixel(RED, img, 20, 5); + assertPixel(YELLOW, img, 5, 20); + } + + @Test + void uniformRoundedBorderIsARing() { + BufferedImage img = paint(BoxStyle.builder().size(100, 100).radii(30).border(solid(10, BLACK))); + assertPixel(BLACK, img, 50, 5); + assertPixel(BLACK, img, 5, 50); + assertPixel(BLACK, img, 94, 50); + assertPixel(BLACK, img, 50, 94); + assertTransparent(img, 50, 15); + assertTransparent(img, 50, 50); + // On the 45 degree line through the corner centre (30,30): the outer + // radius is 30 and the inner one 30 - 10 = 20. + assertPixel(BLACK, img, 12, 12); + assertTransparent(img, 19, 19); + assertTransparent(img, 5, 5); + assertTransparent(img, 0, 0); + } + + @Test + void innerRadiusStopsAtZeroWhenTheBorderIsWiderThanTheRadius() { + BufferedImage img = paint(BoxStyle.builder().size(100, 100).radii(5).backgroundColor(WHITE) + .border(solid(10, BLACK))); + // The padding edge has a square corner at (10,10). + assertPixel(WHITE, img, 10, 10); + assertPixel(BLACK, img, 9, 10); + assertPixel(BLACK, img, 10, 9); + assertTransparent(img, 0, 0); + } + + @Test + void noneAndHiddenTakeNoWidthAndPaintNothing() { + BufferedImage none = paint(BoxStyle.builder().size(40, 40).backgroundColor(WHITE) + .border(new BorderSide(10, BorderStyle.NONE, RED))); + assertPixel(WHITE, none, 5, 5); + assertPixel(WHITE, none, 20, 2); + BufferedImage hidden = paint(BoxStyle.builder().size(40, 40).backgroundColor(WHITE) + .border(new BorderSide(10, BorderStyle.HIDDEN, RED))); + assertPixel(WHITE, hidden, 5, 5); + // A zero width side next to a wide one gives the wide one the corner. + BufferedImage one = paint(BoxStyle.builder().size(40, 40).backgroundColor(WHITE).top(solid(10, RED))); + assertPixel(RED, one, 0, 0); + assertPixel(RED, one, 0, 9); + assertPixel(RED, one, 39, 9); + assertPixel(WHITE, one, 0, 10); + } + + @Test + void transparentBorderShowsTheBackgroundUnderIt() { + BufferedImage img = paint(BoxStyle.builder().size(40, 40).backgroundColor(WHITE) + .border(solid(10, 0x00ff0000))); + assertPixel(WHITE, img, 5, 5); + assertPixel(WHITE, img, 20, 20); + } + + @Test + void translucentBorderBlendsOverTheBackground() { + BufferedImage img = paint(BoxStyle.builder().size(40, 40).backgroundColor(WHITE) + .border(solid(10, 0x80000000))); + RasterAssert.assertPixelNear(0xff7f7f7f, img, 20, 5, 1); + assertPixel(WHITE, img, 20, 20); + } + + @Test + void doubleIsTwoLinesAThirdOfTheWidthEach() { + BufferedImage img = paint(BoxStyle.builder().size(100, 100) + .border(new BorderSide(9, BorderStyle.DOUBLE, BLACK))); + for (int y = 0; y < 3; y++) { + assertPixel(BLACK, img, 50, y); + } + for (int y = 3; y < 6; y++) { + assertTransparent(img, 50, y); + } + for (int y = 6; y < 9; y++) { + assertPixel(BLACK, img, 50, y); + } + assertTransparent(img, 50, 9); + // The same on the left side, and the lines turn the corner. + assertPixel(BLACK, img, 1, 50); + assertTransparent(img, 4, 50); + assertPixel(BLACK, img, 7, 50); + assertPixel(BLACK, img, 1, 1); + assertTransparent(img, 4, 4); + assertPixel(BLACK, img, 7, 7); + // The gap is a frame of its own between the two lines. + assertTransparent(img, 7, 4); + assertTransparent(img, 4, 7); + assertPixel(BLACK, img, 7, 1); + assertPixel(BLACK, img, 1, 7); + assertPixel(BLACK, img, 8, 6); + assertPixel(BLACK, img, 6, 8); + } + + @Test + void thinDoubleFallsBackToSolid() { + BufferedImage img = paint(BoxStyle.builder().size(20, 20) + .border(new BorderSide(2, BorderStyle.DOUBLE, BLACK))); + assertPixel(BLACK, img, 10, 0); + assertPixel(BLACK, img, 10, 1); + assertTransparent(img, 10, 2); + } + + @Test + void dashedAlternatesDashAndGap() { + // Width 4 along 100px: dashes of 8 and nine of them fit with a gap + // of (100 - 72) / 8 = 3.5, the count nearest the nominal gap of 4. + BufferedImage img = paint(BoxStyle.builder().size(100, 20) + .top(new BorderSide(4, BorderStyle.DASHED, BLACK))); + assertEquals(9, runsInRow(img, 1)); + assertEquals(9, runsInRow(img, 3)); + assertEquals(0, runsInRow(img, 4), "nothing below the border"); + assertPixel(BLACK, img, 0, 1); + assertPixel(BLACK, img, 7, 1); + assertTransparent(img, 8, 1); + assertTransparent(img, 10, 1); + assertPixel(BLACK, img, 12, 1); + assertPixel(BLACK, img, 18, 1); + assertTransparent(img, 20, 1); + assertPixel(BLACK, img, 99, 1); + assertPixel(BLACK, img, 92, 3); + } + + @Test + void thinDashedUsesLongerDashes() { + // Width 1: a dash of 3 and a nominal gap of 2. + BufferedImage img = paint(BoxStyle.builder().size(48, 10) + .top(new BorderSide(1, BorderStyle.DASHED, BLACK))); + // (48 + 2) / (3 + 2) = 10 dashes with a gap of exactly 2. + assertEquals(10, runsInRow(img, 0)); + assertPixel(BLACK, img, 0, 0); + assertPixel(BLACK, img, 2, 0); + assertTransparent(img, 3, 0); + assertTransparent(img, 4, 0); + assertPixel(BLACK, img, 5, 0); + } + + @Test + void dottedIsARowOfRoundDots() { + // Width 6 along 120px: dots every 12px, centred on x = 0, 12 .. 120. + BufferedImage img = paint(BoxStyle.builder().size(120, 30) + .top(new BorderSide(6, BorderStyle.DOTTED, BLACK))); + assertEquals(11, runsInRow(img, 3)); + assertPixel(BLACK, img, 12, 3); + assertPixel(BLACK, img, 11, 2); + assertTransparent(img, 6, 3); + assertTransparent(img, 18, 2); + // Round, not square: the corner of a dot's bounding box is nearly + // empty while the top of the dot is nearly full. + assertTrue(alpha(img, 9, 0) < 100, "dot corner should be mostly empty, alpha " + alpha(img, 9, 0)); + assertTrue(alpha(img, 12, 0) > 200, "dot top should be mostly full, alpha " + alpha(img, 12, 0)); + assertEquals(0, runsInRow(img, 6), "nothing below the border"); + // Half a dot sits on each end of the side. + assertPixel(BLACK, img, 0, 3); + assertPixel(BLACK, img, 119, 3); + } + + @Test + void dashedBorderFollowsARoundedCorner() { + BufferedImage img = paint(BoxStyle.builder().size(120, 120).radii(40) + .border(new BorderSide(6, BorderStyle.DASHED, BLACK))); + // Nothing may leave the ring: outside the outer arc, inside the inner one. + assertTransparent(img, 3, 3); + assertTransparent(img, 60, 60); + assertTransparent(img, 60, 7); + assertTransparent(img, 18, 18); + int painted = 0; + int empty = 0; + // Walk the middle of the ring around the top-left corner (radius 37). + for (int deg = 185; deg < 270; deg += 2) { + double rad = Math.toRadians(deg); + int x = (int) (40 + 37 * Math.cos(rad)); + int y = (int) (40 + 37 * Math.sin(rad)); + if (alpha(img, x, y) > 200) { + painted++; + } else if (alpha(img, x, y) < 50) { + empty++; + } + } + assertTrue(painted > 10, "dashes on the corner arc, found " + painted); + assertTrue(empty > 4, "gaps on the corner arc, found " + empty); + } + + @Test + void darkenMatchesChrome() { + assertEquals(0xffababab, BorderPainter.darken(WHITE)); + assertEquals(BLACK, BorderPainter.darken(BLACK)); + // v = 128/255; (v - 0.33) / v = 0.3426; 128 * 0.3426 = 43.9 + assertEquals(0xff2c2c2c, BorderPainter.darken(GREY)); + assertEquals(0x80ab0000, BorderPainter.darken(0x80ff0000), "alpha is kept"); + } + + @Test + void insetDarkensTopAndLeftOutsetBottomAndRight() { + int dark = BorderPainter.darken(GREY); + BufferedImage inset = paint(BoxStyle.builder().size(100, 80) + .border(new BorderSide(10, BorderStyle.INSET, GREY))); + assertPixel(dark, inset, 50, 5); + assertPixel(dark, inset, 5, 40); + assertPixel(GREY, inset, 50, 75); + assertPixel(GREY, inset, 95, 40); + BufferedImage outset = paint(BoxStyle.builder().size(100, 80) + .border(new BorderSide(10, BorderStyle.OUTSET, GREY))); + assertPixel(GREY, outset, 50, 5); + assertPixel(GREY, outset, 5, 40); + assertPixel(dark, outset, 50, 75); + assertPixel(dark, outset, 95, 40); + } + + @Test + void grooveAndRidgeSplitTheWidthInTwo() { + int dark = BorderPainter.darken(GREY); + BufferedImage groove = paint(BoxStyle.builder().size(100, 80) + .border(new BorderSide(10, BorderStyle.GROOVE, GREY))); + assertPixel(dark, groove, 50, 2); + assertPixel(GREY, groove, 50, 7); + assertPixel(dark, groove, 2, 40); + assertPixel(GREY, groove, 7, 40); + assertPixel(GREY, groove, 50, 77); + assertPixel(dark, groove, 50, 72); + assertPixel(GREY, groove, 97, 40); + assertPixel(dark, groove, 92, 40); + BufferedImage ridge = paint(BoxStyle.builder().size(100, 80) + .border(new BorderSide(10, BorderStyle.RIDGE, GREY))); + assertPixel(GREY, ridge, 50, 2); + assertPixel(dark, ridge, 50, 7); + assertPixel(dark, ridge, 50, 77); + assertPixel(GREY, ridge, 50, 72); + // The split is exactly half way. + assertPixel(GREY, ridge, 50, 4); + assertPixel(dark, ridge, 50, 5); + } + + @Test + void effectiveWidthsIgnoreInvisibleStylesAndFitTheBox() { + BorderSide[] sides = { + solid(4, RED), new BorderSide(6, BorderStyle.NONE, RED), + new BorderSide(8, BorderStyle.HIDDEN, RED), solid(2, RED) + }; + double[] w = BorderPainter.effectiveWidths(sides, 100, 100); + assertEquals(4, w[0]); + assertEquals(0, w[1]); + assertEquals(0, w[2]); + assertEquals(2, w[3]); + BorderSide[] wide = {solid(30, RED), solid(30, RED), solid(30, RED), solid(30, RED)}; + double[] scaled = BorderPainter.effectiveWidths(wide, 40, 100); + assertEquals(20, scaled[1], 1e-9); + assertEquals(20, scaled[3], 1e-9); + // A box that is all border paints, and paints nothing but border. + BufferedImage img = paint(BoxStyle.builder().size(40, 100).backgroundColor(WHITE).border(solid(30, RED))); + assertPixel(RED, img, 20, 50); + assertPixel(RED, img, 1, 1); + } + + @Test + void fractionalBoxKeepsFullCoverageOnTheBorderEdge() { + // The box starts at x = 0 and is 30.5 wide: the last column is half + // covered. A wedge edge coinciding with the ring edge would square + // that to a quarter. + BufferedImage img = paint(BoxStyle.builder().size(30.5, 30.5).pad(0) + .top(solid(5, RED)).right(solid(5, GREEN)).bottom(solid(5, BLUE)).left(solid(5, YELLOW))); + assertEquals(30, img.getWidth()); + BufferedImage padded = paint(BoxStyle.builder().size(30.5, 30.5).padRight(1).padBottom(1) + .top(solid(5, RED)).right(solid(5, GREEN)).bottom(solid(5, BLUE)).left(solid(5, YELLOW))); + int a = alpha(padded, 30, 15); + assertTrue(a >= 120 && a <= 136, "half covered edge column, alpha was " + a); + assertEquals(GREEN & 0xffffff, padded.getRGB(30, 15) & 0xffffff); + int b = alpha(padded, 15, 30); + assertTrue(b >= 120 && b <= 136, "half covered edge row, alpha was " + b); + assertEquals(BLUE & 0xffffff, padded.getRGB(15, 30) & 0xffffff); + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BoxShadowPainterTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BoxShadowPainterTest.java new file mode 100644 index 00000000000..09ebc2d197a --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/BoxShadowPainterTest.java @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static com.codename1.designer.css.raster.RasterAssert.BLACK; +import static com.codename1.designer.css.raster.RasterAssert.WHITE; +import static com.codename1.designer.css.raster.RasterAssert.alpha; +import static com.codename1.designer.css.raster.RasterAssert.assertPixel; +import static com.codename1.designer.css.raster.RasterAssert.assertPixelNear; +import static com.codename1.designer.css.raster.RasterAssert.assertTransparent; +import static com.codename1.designer.css.raster.RasterAssert.paint; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +/// Outer and inset box shadows. The outer tests use a 60x40 box padded by +/// 20, so the border box is x 20..80, y 20..60 in a 100x80 image. +class BoxShadowPainterTest { + private static BoxStyle.Builder box() { + return BoxStyle.builder().size(60, 40).pad(20); + } + + private static Shadow outer(double dx, double dy, double blur, double spread, int color) { + return new Shadow(dx, dy, blur, spread, color, false); + } + + private static Shadow inset(double dx, double dy, double blur, double spread, int color) { + return new Shadow(dx, dy, blur, spread, color, true); + } + + private static long totalAlpha(BufferedImage img) { + long s = 0; + for (int y = 0; y < img.getHeight(); y++) { + for (int x = 0; x < img.getWidth(); x++) { + s += alpha(img, x, y); + } + } + return s; + } + + @Test + void outerShadowIsNotDrawnUnderTheBox() { + // No background at all: whatever is under the box shows. + BufferedImage img = paint(box().shadow(outer(0, 0, 10, 0, BLACK))); + for (int y = 20; y < 60; y++) { + for (int x = 20; x < 80; x++) { + assertTransparent(img, x, y); + } + } + // And it is there right outside, on all four sides. + assertTrue(alpha(img, 19, 40) > 60, "left of the box: " + alpha(img, 19, 40)); + assertTrue(alpha(img, 80, 40) > 60, "right of the box: " + alpha(img, 80, 40)); + assertTrue(alpha(img, 50, 19) > 60, "above the box: " + alpha(img, 50, 19)); + assertTrue(alpha(img, 50, 60) > 60, "below the box: " + alpha(img, 50, 60)); + } + + @Test + void outerShadowDoesNotShowThroughATranslucentBackground() { + BufferedImage img = paint(box().backgroundColor(0x40ffffff).shadow(outer(0, 0, 10, 0, BLACK))); + assertPixel(0x40ffffff, img, 50, 40); + assertPixel(0x40ffffff, img, 21, 21); + BufferedImage opaque = paint(box().backgroundColor(WHITE).shadow(outer(4, 4, 10, 2, BLACK))); + assertPixel(WHITE, opaque, 50, 40); + assertPixel(WHITE, opaque, 20, 20); + assertPixel(WHITE, opaque, 79, 59); + } + + @Test + void blurFadesWithDistanceAndIsSymmetric() { + BufferedImage img = paint(box().shadow(outer(0, 0, 10, 0, BLACK))); + int a1 = alpha(img, 19, 40); + int a5 = alpha(img, 15, 40); + int a9 = alpha(img, 11, 40); + int a19 = alpha(img, 1, 40); + assertTrue(a1 > a5 && a5 > a9 && a9 >= a19, "fades: " + a1 + " " + a5 + " " + a9 + " " + a19); + // Sigma is blur / 2 = 5. Half a pixel outside a long straight edge + // the gaussian is at 1 - Phi(0.1) = 0.46. + assertEquals(0.46 * 255, a1, 6); + // 4.5px out: 1 - Phi(0.9) = 0.184. + assertEquals(0.184 * 255, a5, 6); + // 18.5px out, 3.7 sigma: nothing left. + assertTrue(a19 <= 1, "beyond three sigma: " + a19); + assertEquals(a1, alpha(img, 80, 40), 1); + assertEquals(a5, alpha(img, 84, 40), 1); + assertEquals(alpha(img, 50, 15), alpha(img, 50, 64), 1); + assertEquals(alpha(img, 15, 15), alpha(img, 84, 64), 1); + assertEquals(alpha(img, 15, 15), alpha(img, 84, 15), 1); + // The colour is the shadow colour, untouched by the blur. + assertEquals(0, img.getRGB(15, 40) & 0xffffff); + } + + @Test + void zeroBlurZeroSpreadZeroOffsetIsInvisible() { + BufferedImage img = paint(box().shadow(outer(0, 0, 0, 0, BLACK))); + assertEquals(0, totalAlpha(img)); + } + + @Test + void spreadGrowsAHardEdgedShadow() { + BufferedImage img = paint(box().shadow(outer(0, 0, 0, 5, BLACK))); + // The shadow shape is x 15..85, y 15..65. + assertPixel(BLACK, img, 15, 40); + assertPixel(BLACK, img, 19, 40); + assertTransparent(img, 14, 40); + assertPixel(BLACK, img, 84, 40); + assertTransparent(img, 85, 40); + assertPixel(BLACK, img, 50, 15); + assertTransparent(img, 50, 14); + assertPixel(BLACK, img, 50, 64); + assertTransparent(img, 50, 65); + assertTransparent(img, 50, 40); + // A square box keeps square shadow corners. + assertPixel(BLACK, img, 15, 15); + BufferedImage wider = paint(box().shadow(outer(0, 0, 0, 8, BLACK))); + assertPixel(BLACK, wider, 12, 40); + assertTransparent(wider, 11, 40); + assertTrue(totalAlpha(wider) > totalAlpha(img)); + } + + @Test + void negativeSpreadShrinksTheShadow() { + // Shrunk by 5 and moved down 10: x 25..75, y 35..65. Only the strip + // below the box (y 60..65) shows. + BufferedImage img = paint(box().shadow(outer(0, 10, 0, -5, BLACK))); + assertPixel(BLACK, img, 50, 62); + assertPixel(BLACK, img, 25, 64); + assertTransparent(img, 24, 62); + assertTransparent(img, 75, 62); + assertTransparent(img, 50, 65); + assertTransparent(img, 50, 50); + } + + @Test + void offsetMovesTheShadow() { + BufferedImage img = paint(box().shadow(outer(10, 0, 0, 0, BLACK))); + // Moved right by 10: x 30..90. Only x 80..90 is outside the box. + assertPixel(BLACK, img, 80, 40); + assertPixel(BLACK, img, 89, 40); + assertTransparent(img, 90, 40); + assertTransparent(img, 19, 40); + assertTransparent(img, 50, 19); + assertTransparent(img, 50, 60); + assertTransparent(img, 85, 19); + assertPixel(BLACK, img, 85, 20); + BufferedImage down = paint(box().shadow(outer(0, 7, 0, 0, BLACK))); + assertPixel(BLACK, down, 50, 66); + assertTransparent(down, 50, 67); + assertTransparent(down, 50, 19); + BufferedImage up = paint(box().shadow(outer(-3, -4, 0, 0, BLACK))); + assertPixel(BLACK, up, 17, 16); + assertTransparent(up, 16, 16); + assertTransparent(up, 17, 15); + assertTransparent(up, 80, 61); + } + + @Test + void shadowIsTintedWithItsColour() { + BufferedImage img = paint(box().shadow(outer(0, 0, 0, 5, 0x80ff0000))); + assertPixel(0x80ff0000, img, 17, 40); + BufferedImage blurred = paint(box().shadow(outer(0, 0, 8, 5, 0x80336699))); + int p = blurred.getRGB(12, 40); + assertEquals(0x336699, p & 0xffffff, "the blur changes alpha only"); + assertTrue((p >>> 24) > 0 && (p >>> 24) < 128, "at most the shadow's own alpha: " + (p >>> 24)); + assertEquals(0, totalAlpha(paint(box().shadow(outer(3, 3, 5, 5, 0x00ff0000)))), "a transparent shadow"); + } + + @Test + void shadowFollowsTheBorderRadius() { + // Radius 10 grown by a spread of 5 is 15, around a centre at (30,30). + BufferedImage round = paint(box().radii(10).shadow(outer(0, 0, 0, 5, BLACK))); + assertTransparent(round, 16, 16); + assertTransparent(round, 18, 18); + assertPixel(BLACK, round, 15, 40); + assertPixel(BLACK, round, 40, 15); + // Between the shadow arc (radius 15) and the box arc (radius 10). + assertPixel(BLACK, round, 21, 21); + // Under the rounded box itself: nothing. + assertTransparent(round, 24, 24); + // A square corner stays square however far it spreads. + BufferedImage square = paint(box().shadow(outer(0, 0, 0, 5, BLACK))); + assertPixel(BLACK, square, 16, 16); + } + + @Test + void smallRadiusGrowsByLessThanTheSpread() { + RoundedBox b = new RoundedBox(0, 0, 100, 100, new double[] {2, 2, 20, 20, 0, 0, 0, 0}); + RoundedBox g = b.grow(10); + // r = 2, spread 10: 2 + 10 * (1 + (0.2 - 1)^3) = 6.88 + assertEquals(6.88, g.radiusX(RoundedBox.TOP_LEFT), 1e-9); + assertEquals(30, g.radiusX(RoundedBox.TOP_RIGHT), 1e-9); + assertEquals(0, g.radiusX(RoundedBox.BOTTOM_RIGHT), 0); + assertEquals(-10, g.getX(), 0); + assertEquals(120, g.getWidth(), 0); + } + + @Test + void haloClippedByASmallPadIsNotAnError() { + // A 30px blur reaches about 45px; there are only 2px of room. + BufferedImage img = paint(BoxStyle.builder().size(60, 40).pad(2).backgroundColor(WHITE) + .shadow(outer(0, 0, 30, 0, BLACK))); + assertEquals(64, img.getWidth()); + assertEquals(44, img.getHeight()); + assertPixel(WHITE, img, 32, 22); + assertTrue(alpha(img, 0, 22) > 40, "the clipped halo is still there: " + alpha(img, 0, 22)); + assertTrue(alpha(img, 1, 22) > alpha(img, 0, 22), "and still fading outwards"); + assertTrue(alpha(img, 63, 22) > 40); + // No padding at all: the shadow is entirely cut off, the box is intact. + BufferedImage none = paint(BoxStyle.builder().size(60, 40).backgroundColor(WHITE) + .shadow(outer(5, 5, 30, 10, BLACK))); + assertEquals(60, none.getWidth()); + assertPixel(WHITE, none, 0, 0); + assertPixel(WHITE, none, 59, 39); + } + + @Test + void hugeBlurUsesTheBoxPathAndStaysSmooth() { + // Sigma 20 is above the exact kernel limit. + BufferedImage img = paint(BoxStyle.builder().size(60, 40).pad(50).shadow(outer(0, 0, 40, 0, BLACK))); + int prev = 256; + for (int x = 49; x >= 0; x -= 4) { + int a = alpha(img, x, 70); + assertTrue(a <= prev, "monotonic fade at x = " + x + ": " + a + " after " + prev); + prev = a; + } + assertTrue(alpha(img, 49, 70) > 40, "next to the box: " + alpha(img, 49, 70)); + assertEquals(alpha(img, 45, 70), alpha(img, 114, 70), 1, "symmetric"); + assertTransparent(img, 80, 70); + } + + // The inset tests use a 60x40 box padded by 5 with a 5px border: the + // border box is x 5..65, y 5..45 and the padding box x 10..60, y 10..40. + + private static BoxStyle.Builder bordered() { + return BoxStyle.builder().size(60, 40).pad(5).backgroundColor(WHITE) + .border(new BorderSide(5, BorderStyle.SOLID, 0)); + } + + @Test + void insetShadowIsConfinedToThePaddingBox() { + BufferedImage img = paint(bordered().shadow(inset(0, 0, 0, 4, BLACK))); + // The hole is the padding box shrunk by 4: x 14..56, y 14..36. + assertPixel(BLACK, img, 10, 25); + assertPixel(BLACK, img, 13, 25); + assertPixel(WHITE, img, 14, 25); + assertPixel(WHITE, img, 35, 25); + assertPixel(WHITE, img, 55, 25); + assertPixel(BLACK, img, 56, 25); + assertPixel(BLACK, img, 59, 25); + assertPixel(BLACK, img, 35, 10); + assertPixel(WHITE, img, 35, 14); + assertPixel(BLACK, img, 35, 39); + // Not over the (transparent) border, where the background shows... + assertPixel(WHITE, img, 9, 25); + assertPixel(WHITE, img, 60, 25); + assertPixel(WHITE, img, 35, 9); + assertPixel(WHITE, img, 35, 40); + assertPixel(WHITE, img, 7, 7); + // ...and not outside the box. + assertTransparent(img, 4, 25); + assertTransparent(img, 2, 2); + assertTransparent(img, 65, 25); + } + + @Test + void insetShadowWithoutSpreadOrBlurIsInvisible() { + BufferedImage img = paint(bordered().shadow(inset(0, 0, 0, 0, BLACK))); + for (int y = 5; y < 45; y++) { + for (int x = 5; x < 65; x++) { + assertPixel(WHITE, img, x, y); + } + } + } + + @Test + void insetBlurFadesInwards() { + BufferedImage img = paint(bordered().shadow(inset(0, 0, 8, 0, BLACK))); + int edge = img.getRGB(10, 25) & 0xff; + int near = img.getRGB(13, 25) & 0xff; + int far = img.getRGB(18, 25) & 0xff; + assertTrue(edge < near && near < far, "lighter away from the edge: " + edge + " " + near + " " + far); + // Sigma 4; half a pixel inside the edge the shadow is 1 - Phi(0.125) = 0.45. + assertEquals(255 * (1 - 0.45), edge, 6); + assertPixel(WHITE, img, 35, 25); + assertPixelNear(img.getRGB(10, 25), img, 59, 25, 1); + assertPixelNear(img.getRGB(13, 25), img, 56, 25, 1); + assertEquals(255, alpha(img, 10, 25)); + assertPixel(WHITE, img, 9, 25); + assertTransparent(img, 4, 25); + } + + @Test + void insetOffsetMovesTheHole() { + BufferedImage img = paint(bordered().shadow(inset(6, 0, 0, 0, BLACK))); + // The hole moves right by 6: the left 6 columns of the padding box + // are in shadow, the right edge is not. + assertPixel(BLACK, img, 10, 25); + assertPixel(BLACK, img, 15, 25); + assertPixel(WHITE, img, 16, 25); + assertPixel(WHITE, img, 59, 25); + assertPixel(WHITE, img, 35, 10); + BufferedImage up = paint(bordered().shadow(inset(0, -3, 0, 0, BLACK))); + assertPixel(BLACK, up, 35, 39); + assertPixel(BLACK, up, 35, 37); + assertPixel(WHITE, up, 35, 36); + assertPixel(WHITE, up, 35, 10); + } + + @Test + void insetShadowIsClippedToTheRoundedPaddingBox() { + BufferedImage img = paint(BoxStyle.builder().size(60, 60).pad(5).radii(20) + .shadow(inset(0, 0, 0, 100, 0x80ff0000))); + // A spread larger than the box leaves no hole: the whole padding box + // (here also the border box) is shadow, and nothing else is. + assertPixel(0x80ff0000, img, 35, 35); + assertPixel(0x80ff0000, img, 35, 5); + assertTransparent(img, 6, 6); + assertTransparent(img, 8, 8); + assertTransparent(img, 63, 63); + assertTransparent(img, 2, 35); + assertPixelNear(0x80ff0000, img, 12, 12, 0); + } + + @Test + void insetShadowIsPaintedOverTheBackgroundAndUnderTheBorder() { + BufferedImage img = paint(BoxStyle.builder().size(60, 40).pad(5).backgroundColor(WHITE) + .border(new BorderSide(5, BorderStyle.SOLID, 0xff0000ff)) + .shadow(inset(0, 0, 0, 3, 0x80000000))); + assertPixel(0xff0000ff, img, 7, 25); + assertPixelNear(0xff7f7f7f, img, 11, 25, 1); + assertPixel(WHITE, img, 35, 25); + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/CssBoxRasterizerTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/CssBoxRasterizerTest.java new file mode 100644 index 00000000000..727fb1576b9 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/CssBoxRasterizerTest.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static com.codename1.designer.css.raster.RasterAssert.RED; +import static com.codename1.designer.css.raster.RasterAssert.alpha; +import static com.codename1.designer.css.raster.RasterAssert.assertOpaque; +import static com.codename1.designer.css.raster.RasterAssert.assertPixel; +import static com.codename1.designer.css.raster.RasterAssert.assertTransparent; +import static com.codename1.designer.css.raster.RasterAssert.paint; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +/// Image size, box placement, fills, rounded corners and input validation. +class CssBoxRasterizerTest { + private static final int FILL = 0xff336699; + + @Test + void imageIsArgbAndTheSizeOfAnUnpaddedBox() { + BufferedImage img = paint(BoxStyle.builder().size(100, 40).backgroundColor(FILL)); + assertEquals(BufferedImage.TYPE_INT_ARGB, img.getType()); + assertEquals(100, img.getWidth()); + assertEquals(40, img.getHeight()); + assertPixel(FILL, img, 0, 0); + assertPixel(FILL, img, 99, 39); + assertPixel(FILL, img, 50, 20); + } + + @Test + void paddingSurroundsTheBoxWithTransparentPixels() { + BufferedImage img = paint(BoxStyle.builder().size(100, 40) + .padTop(3).padRight(5).padBottom(7).padLeft(9).backgroundColor(FILL)); + assertEquals(114, img.getWidth()); + assertEquals(50, img.getHeight()); + // The border box starts exactly at (padLeft, padTop)... + assertPixel(FILL, img, 9, 3); + assertTransparent(img, 8, 3); + assertTransparent(img, 9, 2); + // ...and ends exactly padRight / padBottom short of the far edge. + assertPixel(FILL, img, 108, 42); + assertTransparent(img, 109, 42); + assertTransparent(img, 108, 43); + assertTransparent(img, 0, 0); + assertTransparent(img, 113, 49); + } + + @Test + void fractionalBoxTruncatesTheImageButNotTheBox() { + BufferedImage img = paint(BoxStyle.builder().size(100.5, 40.5).pad(1).backgroundColor(FILL)); + assertEquals(102, img.getWidth(), "(int) (100.5 + 1 + 1)"); + assertEquals(42, img.getHeight(), "(int) (40.5 + 1 + 1)"); + assertPixel(FILL, img, 100, 20); + // The box ends at x = 101.5, half way through the last column. + int a = alpha(img, 101, 20); + assertTrue(a >= 120 && a <= 136, "half covered column should be about 50% opaque, alpha was " + a); + int b = alpha(img, 50, 41); + assertTrue(b >= 120 && b <= 136, "half covered row should be about 50% opaque, alpha was " + b); + assertEquals(FILL & 0xffffff, img.getRGB(101, 20) & 0xffffff, "an edge pixel keeps the fill colour"); + } + + @Test + void imageIsNeverSmallerThanOnePixel() { + BufferedImage img = paint(BoxStyle.builder().size(0.4, 0.4).backgroundColor(FILL)); + assertEquals(1, img.getWidth()); + assertEquals(1, img.getHeight()); + int a = alpha(img, 0, 0); + assertTrue(a > 20 && a < 60, "a 0.4 x 0.4 box covers 16% of the pixel, alpha was " + a); + } + + @Test + void translucentFillKeepsItsExactAlphaAndColour() { + BufferedImage img = paint(BoxStyle.builder().size(20, 20).pad(2).backgroundColor(0x80336699)); + assertPixel(0x80336699, img, 10, 10); + assertTransparent(img, 1, 10); + } + + @Test + void emptyStylePaintsNothing() { + BufferedImage img = paint(BoxStyle.builder().size(10, 10)); + for (int y = 0; y < 10; y++) { + for (int x = 0; x < 10; x++) { + assertTransparent(img, x, y); + } + } + } + + @Test + void roundedCornerIsTransparentOutsideAndOpaqueInside() { + BufferedImage img = paint(BoxStyle.builder().size(100, 100).radii(20).backgroundColor(FILL)); + assertTransparent(img, 0, 0); + assertTransparent(img, 2, 2); + assertTransparent(img, 99, 0); + assertTransparent(img, 0, 99); + assertTransparent(img, 97, 97); + // (6,6) lies wholly inside the arc of radius 20 around (20,20). + assertPixel(FILL, img, 6, 6); + assertPixel(FILL, img, 50, 50); + assertPixel(FILL, img, 50, 0); + assertPixel(FILL, img, 0, 50); + // The arc crosses pixel (5,5): antialiased, neither empty nor full. + int a = alpha(img, 5, 5); + assertTrue(a > 0 && a < 255, "the pixel on the arc should be partially covered, alpha was " + a); + } + + @Test + void perCornerRadiiOnlyRoundTheirOwnCorner() { + BufferedImage img = paint(BoxStyle.builder().size(100, 100) + .radii(30, 30, 0, 0, 0, 0, 0, 0).backgroundColor(FILL)); + assertTransparent(img, 2, 2); + assertPixel(FILL, img, 99, 0); + assertPixel(FILL, img, 99, 99); + assertPixel(FILL, img, 0, 99); + } + + @Test + void ellipticalCornerFollowsBothRadii() { + // Top-left radius 40 wide by 10 high: the ellipse is centred on (40,10). + BufferedImage img = paint(BoxStyle.builder().size(100, 60) + .radii(40, 10, 0, 0, 0, 0, 0, 0).backgroundColor(FILL)); + // ((10.5-40)/40)^2 + ((1.5-10)/10)^2 = 1.27: outside the ellipse, + // although a circle of radius 10 would have covered it. + assertTransparent(img, 10, 1); + // ((20.5-40)/40)^2 + ((5.5-10)/10)^2 = 0.44: inside. + assertPixel(FILL, img, 20, 5); + // The ellipse meets the top edge at x = 40 and the left edge at y = 10. + assertPixel(FILL, img, 41, 0); + assertPixel(FILL, img, 0, 11); + assertTransparent(img, 0, 0); + assertTrue(alpha(img, 30, 0) < 255, "x = 30 on the top row is still on the curve"); + } + + @Test + void oversizedRadiusIsScaledDownToAPill() { + BufferedImage img = paint(BoxStyle.builder().size(100, 40).radii(1000).backgroundColor(FILL)); + // The overlap rule scales 1000 to 20: half the height. + assertTransparent(img, 0, 0); + assertTransparent(img, 3, 3); + assertTransparent(img, 96, 36); + assertPixel(FILL, img, 7, 7); + assertPixel(FILL, img, 50, 0); + assertPixel(FILL, img, 50, 39); + // The straight part of the top edge is exactly x = 20..80. + assertPixel(FILL, img, 21, 0); + assertPixel(FILL, img, 78, 0); + assertTrue(alpha(img, 15, 0) < 255, "x = 15 on the top row is on the curve"); + // The ends are semicircles touching the left and right edge mid height. + assertTrue(alpha(img, 0, 20) > 200, "the pill touches the left edge at mid height"); + assertTrue(alpha(img, 99, 19) > 200, "the pill touches the right edge at mid height"); + assertTrue(alpha(img, 0, 10) < 30, "the left edge is clear of the pill away from mid height"); + } + + @Test + void zeroSizeNamesTheField() { + IllegalArgumentException w = assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(0, 10))); + assertTrue(w.getMessage().startsWith("borderBoxWidth"), w.getMessage()); + IllegalArgumentException h = assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, -1))); + assertTrue(h.getMessage().startsWith("borderBoxHeight"), h.getMessage()); + IllegalArgumentException nan = assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(Double.NaN, 10))); + assertTrue(nan.getMessage().startsWith("borderBoxWidth"), nan.getMessage()); + } + + @Test + void nullSideNamesTheSide() { + IllegalArgumentException top = assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).top(null))); + assertTrue(top.getMessage().startsWith("top "), top.getMessage()); + IllegalArgumentException left = assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).left(null))); + assertTrue(left.getMessage().startsWith("left "), left.getMessage()); + IllegalArgumentException style = assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).right(new BorderSide(1, null, RED)))); + assertTrue(style.getMessage().startsWith("right.style"), style.getMessage()); + IllegalArgumentException width = assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).bottom(new BorderSide(-1, BorderStyle.SOLID, RED)))); + assertTrue(width.getMessage().startsWith("bottom.width"), width.getMessage()); + } + + @Test + void otherInvalidInputNamesItsField() { + assertTrue(assertThrows(IllegalArgumentException.class, + () -> new CssBoxRasterizer().rasterize(null)).getMessage().startsWith("style")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).padLeft(-1))).getMessage().startsWith("padLeft")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).radii(1, 2, 3))).getMessage().startsWith("radii")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).radii((double[]) null))).getMessage().startsWith("radii")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).radii(1, 1, 1, 1, 1, -4, 1, 1))) + .getMessage().startsWith("radii[5]")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).shadow(new Shadow(0, 0, -1, 0, RED, false)))) + .getMessage().startsWith("shadow.blur")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).backgroundImage(new BackgroundImage(null)))) + .getMessage().startsWith("backgroundImage.image")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).borderImage( + new BorderImage(null, 1, 1, 1, 1, false, BorderImage.Mode.STRETCH)))) + .getMessage().startsWith("borderImage.image")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(10, 10).gradient(GradientSpec.linear(0).addStop(RED)))) + .getMessage().startsWith("gradient.stops")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> paint(BoxStyle.builder().size(1e6, 1e6))).getMessage().startsWith("borderBoxWidth")); + } + + @Test + void builderRoundTripsEveryField() { + BorderSide t = new BorderSide(1, BorderStyle.SOLID, 1); + BorderSide r = new BorderSide(2, BorderStyle.DASHED, 2); + BorderSide b = new BorderSide(3, BorderStyle.DOTTED, 3); + BorderSide l = new BorderSide(4, BorderStyle.DOUBLE, 4); + Shadow shadow = new Shadow(1, 2, 3, 4, 5, true); + BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); + BackgroundImage bg = new BackgroundImage(image); + BorderImage bi = new BorderImage(image, 1, 1, 1, 1, true, BorderImage.Mode.ROUND); + GradientSpec gradient = GradientSpec.linear(33).addStop(1).addStop(2); + BoxStyle s = BoxStyle.builder().borderBoxWidth(11.5).borderBoxHeight(12.5) + .padTop(1).padRight(2).padBottom(3).padLeft(4).backgroundColor(0x12345678) + .gradient(gradient).backgroundImage(bg).top(t).right(r).bottom(b).left(l) + .radii(1, 2, 3, 4, 5, 6, 7, 8).shadow(shadow).borderImage(bi).build(); + assertEquals(11.5, s.getBorderBoxWidth()); + assertEquals(12.5, s.getBorderBoxHeight()); + assertEquals(1, s.getPadTop()); + assertEquals(2, s.getPadRight()); + assertEquals(3, s.getPadBottom()); + assertEquals(4, s.getPadLeft()); + assertEquals(0x12345678, s.getBackgroundColor()); + assertSame(bg, s.getBackgroundImage()); + assertSame(t, s.getTop()); + assertSame(r, s.getRight()); + assertSame(b, s.getBottom()); + assertSame(l, s.getLeft()); + assertSame(shadow, s.getShadow()); + assertSame(bi, s.getBorderImage()); + assertArrayEquals(new double[] {1, 2, 3, 4, 5, 6, 7, 8}, s.getRadii()); + assertEquals(33, s.getGradient().getAngleDeg()); + // The style is immutable: neither the caller's spec nor a returned + // array reaches back into it. + gradient.angleDeg(90); + s.getRadii()[0] = 99; + assertEquals(33, s.getGradient().getAngleDeg()); + assertEquals(1, s.getRadii()[0]); + assertNotNull(new CssBoxRasterizer().rasterize(s)); + } + + @Test + void radiiShorthandsExpand() { + assertArrayEquals(new double[] {5, 5, 5, 5, 5, 5, 5, 5}, + BoxStyle.builder().radii(5).build().getRadii()); + assertArrayEquals(new double[] {1, 1, 2, 2, 3, 3, 4, 4}, + BoxStyle.builder().radii(1, 2, 3, 4).build().getRadii()); + } + + @Test + void defaultSidesAreNeverNull() { + BoxStyle s = BoxStyle.builder().size(1, 1).build(); + assertSame(BorderSide.NONE, s.getTop()); + assertSame(BorderSide.NONE, s.getRight()); + assertSame(BorderSide.NONE, s.getBottom()); + assertSame(BorderSide.NONE, s.getLeft()); + assertOpaque(paint(BoxStyle.builder().size(1, 1).backgroundColor(RED)), 0, 0); + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/GaussianBlurTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/GaussianBlurTest.java new file mode 100644 index 00000000000..98a1bf573a2 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/GaussianBlurTest.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +/// The gaussian blur: conservation, symmetry and width, on both the exact +/// kernel and the box approximation used for large blurs. +class GaussianBlurTest { + private static double sum(float[] d) { + double s = 0; + for (float v : d) { + s += v; + } + return s; + } + + private static float[] impulse(int size) { + float[] d = new float[size * size]; + d[(size / 2) * size + size / 2] = 1f; + return d; + } + + /// The variance of the plane along x around the middle column. + private static double varianceX(float[] d, int size) { + double v = 0; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + double dx = x - size / 2; + v += d[y * size + x] * dx * dx; + } + } + return v / sum(d); + } + + @Test + void kernelIsNormalisedSymmetricAndPeaksInTheMiddle() { + float[] k = GaussianBlur.kernel(2.5); + assertEquals(2 * 8 + 1, k.length, "truncated at three standard deviations"); + double s = 0; + for (int i = 0; i < k.length; i++) { + s += k[i]; + assertEquals(k[i], k[k.length - 1 - i], 0f); + if (i > 0 && i <= k.length / 2) { + assertTrue(k[i] > k[i - 1], "weights rise towards the middle"); + } + } + assertEquals(1, s, 1e-6); + } + + @Test + void zeroSigmaLeavesThePlaneAlone() { + float[] d = impulse(9); + float[] copy = d.clone(); + GaussianBlur.blur(d, 9, 9, 0); + assertArrayEquals(copy, d); + GaussianBlur.blur(d, 9, 9, -3); + assertArrayEquals(copy, d); + assertEquals(0, GaussianBlur.radius(0)); + assertEquals(0, GaussianBlur.radius(-1)); + assertEquals(6, GaussianBlur.radius(2)); + assertEquals(8, GaussianBlur.radius(2.5)); + } + + @Test + void massIsConservedAwayFromTheEdges() { + int size = 101; + float[] d = new float[size * size]; + for (int y = 45; y < 55; y++) { + for (int x = 45; x < 55; x++) { + d[y * size + x] = 1f; + } + } + GaussianBlur.blur(d, size, size, 3); + assertEquals(100, sum(d), 0.01); + for (float v : d) { + assertTrue(v >= 0f && v <= 1.0001f, "a blur never leaves the input range: " + v); + } + } + + @Test + void blurIsSymmetric() { + int size = 101; + float[] d = new float[size * size]; + // A block centred on 49.5, 49.5. + for (int y = 45; y < 55; y++) { + for (int x = 45; x < 55; x++) { + d[y * size + x] = 1f; + } + } + GaussianBlur.blur(d, size, size, 4); + for (int k = 0; k < 30; k++) { + float left = d[50 * size + (44 - k)]; + float right = d[50 * size + (55 + k)]; + float up = d[(44 - k) * size + 50]; + float down = d[(55 + k) * size + 50]; + assertEquals(left, right, 1e-6f, "left/right at distance " + k); + assertEquals(up, down, 1e-6f, "up/down at distance " + k); + assertEquals(left, up, 1e-6f, "the kernel is the same on both axes, distance " + k); + if (k > 0 && left > 1e-4f) { + assertTrue(left < d[50 * size + (44 - k + 1)], "values fall off with distance"); + } + } + } + + @Test + void widerBlurHasALowerPeakAndTheRightVariance() { + int size = 81; + float[] narrow = impulse(size); + float[] wide = impulse(size); + GaussianBlur.blur(narrow, size, size, 2); + GaussianBlur.blur(wide, size, size, 5); + int mid = (size / 2) * size + size / 2; + assertTrue(narrow[mid] > wide[mid] * 4, "sigma 2 peak " + narrow[mid] + " vs sigma 5 peak " + wide[mid]); + // The peak of a 2D gaussian is 1 / (2 pi sigma^2). + assertEquals(1 / (2 * Math.PI * 4), narrow[mid], 0.004); + assertEquals(1 / (2 * Math.PI * 25), wide[mid], 0.0007); + assertEquals(1, sum(narrow), 1e-4); + assertEquals(1, sum(wide), 1e-4); + // Truncating at 3 sigma loses a little of the tails. + assertEquals(4, varianceX(narrow, size), 0.25); + assertEquals(25, varianceX(wide, size), 1.5); + // The value one sigma out is exp(-1/2) of the peak. + assertEquals(Math.exp(-0.5), wide[mid + 5] / wide[mid], 0.01); + } + + @Test + void largeSigmaUsesBoxesThatMatchTheGaussian() { + double sigma = 20; + assertTrue(sigma > GaussianBlur.EXACT_SIGMA_LIMIT); + int[] sizes = GaussianBlur.boxSizes(sigma); + assertEquals(3, sizes.length); + double variance = 0; + int reach = 0; + for (int s : sizes) { + assertEquals(1, s % 2, "box widths are odd"); + variance += (s * (double) s - 1) / 12; + reach += (s - 1) / 2; + } + // Odd widths cannot hit the variance exactly; 39, 39, 41 is 393.3. + assertEquals(sigma * sigma, variance, 8, "three boxes add up to the gaussian's variance"); + assertEquals(reach, GaussianBlur.radius(sigma)); + + int size = 2 * reach + 41; + float[] d = impulse(size); + GaussianBlur.blur(d, size, size, sigma); + assertEquals(1, sum(d), 1e-3, "mass conserved"); + assertEquals(variance, varianceX(d, size), 1); + int mid = (size / 2) * size + size / 2; + assertEquals(1 / (2 * Math.PI * sigma * sigma), d[mid], 0.00006); + for (int k = 1; k < reach; k += 3) { + assertEquals(d[mid - k], d[mid + k], 1e-7f, "symmetric in x at " + k); + assertEquals(d[mid - k * size], d[mid + k * size], 1e-7f, "symmetric in y at " + k); + assertEquals(d[mid - k], d[mid - k * size], 1e-7f, "same on both axes at " + k); + } + // Nothing travels further than radius() says. + // (The running sums leave rounding residue, so "nothing" is not + // bit-exact zero.) + assertTrue(Math.abs(d[mid + reach + 1]) < 1e-10f, "beyond the reach: " + d[mid + reach + 1]); + assertTrue(d[mid + reach] > 1e-8f, "at the reach: " + d[mid + reach]); + } + + @Test + void outsideThePlaneCountsAsTransparent() { + int size = 31; + float[] d = new float[size * size]; + java.util.Arrays.fill(d, 1f); + GaussianBlur.blur(d, size, size, 2); + assertEquals(1, d[15 * size + 15], 1e-4, "the middle is too far from an edge to notice it"); + // An edge pixel sees its own weight plus one of the two tails. + float[] k = GaussianBlur.kernel(2); + double edge = 0.5 + k[k.length / 2] / 2; + assertEquals(edge, d[15 * size], 1e-4, "an edge pixel sees half its kernel"); + assertEquals(edge * edge, d[0], 1e-4, "a corner pixel sees half of it on both axes"); + assertTrue(d[0] < 0.4, "well short of the full value"); + } + + @Test + void imageBlurIsPremultiplied() { + // Left half: fully transparent but with green in its RGB bits. + // Right half: opaque red. + BufferedImage src = new BufferedImage(40, 20, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < 20; y++) { + for (int x = 0; x < 40; x++) { + src.setRGB(x, y, x < 20 ? 0x0000ff00 : 0xffff0000); + } + } + BufferedImage out = GaussianBlur.blur(src, 3); + assertEquals(BufferedImage.TYPE_INT_ARGB, out.getType()); + assertEquals(40, out.getWidth()); + assertEquals(20, out.getHeight()); + boolean sawPartial = false; + for (int x = 0; x < 40; x++) { + int p = out.getRGB(x, 10); + int a = p >>> 24; + if (a > 0) { + assertEquals(0, (p >> 8) & 0xff, "no green may leak from transparent pixels, x = " + x); + assertEquals(255, (p >> 16) & 0xff, "the colour stays pure red, x = " + x); + } + sawPartial |= a > 20 && a < 235; + } + assertTrue(sawPartial, "the edge is blurred"); + int a19 = out.getRGB(19, 10) >>> 24; + int a20 = out.getRGB(20, 10) >>> 24; + assertEquals(255, a19 + a20, 2, "the two pixels next to the edge mirror each other"); + assertEquals(0, src.getRGB(0, 0) >>> 24, "the source image is not modified"); + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/GradientPainterTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/GradientPainterTest.java new file mode 100644 index 00000000000..74e644112fe --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/GradientPainterTest.java @@ -0,0 +1,462 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static com.codename1.designer.css.raster.RasterAssert.BLUE; +import static com.codename1.designer.css.raster.RasterAssert.GREEN; +import static com.codename1.designer.css.raster.RasterAssert.RED; +import static com.codename1.designer.css.raster.RasterAssert.assertNear; +import static com.codename1.designer.css.raster.RasterAssert.assertPixelNear; +import static com.codename1.designer.css.raster.RasterAssert.assertTransparent; +import static com.codename1.designer.css.raster.RasterAssert.paint; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codename1.designer.css.raster.GradientSpec.Extent; +import com.codename1.designer.css.raster.GradientSpec.Shape; +import com.codename1.designer.css.raster.GradientSpec.Unit; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +/// Gradient geometry and colour, checked at points with a known answer. +class GradientPainterTest { + private static final double EPS = 1e-9; + /// A quarter of the way from red to blue. + private static final int QUARTER = 0xffbf0040; + private static final int HALF = 0xff800080; + + private static GradientSpec redBlue(GradientSpec g) { + return g.addStop(RED).addStop(BLUE); + } + + private static GradientPainter on(GradientSpec g, double w, double h) { + return new GradientPainter(g, 0, 0, w, h); + } + + @Test + void ninetyDegreesRunsLeftToRight() { + GradientPainter p = on(redBlue(GradientSpec.linear(90)), 100, 50); + assertEquals(0, p.parameterAt(0, 10), EPS); + assertEquals(0.25, p.parameterAt(25, 40), EPS); + assertEquals(1, p.parameterAt(100, 0), EPS); + assertEquals(RED, p.colorAtPoint(0, 25)); + assertNear(QUARTER, p.colorAtPoint(25, 25), 1, "25%"); + assertNear(HALF, p.colorAtPoint(50, 25), 1, "50%"); + assertEquals(BLUE, p.colorAtPoint(100, 25)); + // Beyond the ends the end colours continue. + assertEquals(RED, p.colorAtPoint(-20, 25)); + assertEquals(BLUE, p.colorAtPoint(140, 25)); + } + + @Test + void zeroDegreesRunsBottomToTop() { + GradientPainter p = on(redBlue(GradientSpec.linear(0)), 100, 50); + assertEquals(0, p.parameterAt(30, 50), EPS); + assertEquals(1, p.parameterAt(30, 0), EPS); + assertEquals(0.5, p.parameterAt(99, 25), EPS); + assertEquals(RED, p.colorAtPoint(10, 50)); + assertEquals(BLUE, p.colorAtPoint(10, 0)); + } + + @Test + void oneEightyDegreesRunsTopToBottomAndIsTheDefault() { + GradientPainter p = on(redBlue(GradientSpec.linear(180)), 100, 50); + assertEquals(0, p.parameterAt(30, 0), EPS); + assertEquals(1, p.parameterAt(30, 50), EPS); + assertNear(QUARTER, p.colorAtPoint(70, 12.5), 1, "25% down"); + GradientPainter d = on(redBlue(new GradientSpec()), 100, 50); + assertEquals(0.25, d.parameterAt(5, 12.5), EPS); + } + + @Test + void twoSeventyDegreesRunsRightToLeft() { + GradientPainter p = on(redBlue(GradientSpec.linear(270)), 100, 50); + assertEquals(0, p.parameterAt(100, 7), EPS); + assertEquals(1, p.parameterAt(0, 7), EPS); + } + + @Test + void fortyFiveDegreesReachesTheCornersOfASquare() { + GradientPainter p = on(redBlue(GradientSpec.linear(45)), 100, 100); + assertEquals(0, p.parameterAt(0, 100), EPS); + assertEquals(1, p.parameterAt(100, 0), EPS); + assertEquals(0.5, p.parameterAt(50, 50), EPS); + assertEquals(0.5, p.parameterAt(0, 0), EPS); + assertEquals(0.5, p.parameterAt(100, 100), EPS); + assertEquals(RED, p.colorAtPoint(0, 100)); + assertEquals(BLUE, p.colorAtPoint(100, 0)); + } + + @Test + void angledGradientLineIsLongEnoughToReachTheCorners() { + // In a 200x100 box at 45 degrees the 0% and 100% lines still pass + // through the bottom-left and top-right corners. + GradientPainter p = on(redBlue(GradientSpec.linear(45)), 200, 100); + assertEquals(0, p.parameterAt(0, 100), EPS); + assertEquals(1, p.parameterAt(200, 0), EPS); + // But the 50% line does not pass through the other two corners. + assertEquals(1.0 / 3, p.parameterAt(0, 0), 1e-6); + } + + @Test + void toCornerUsesTheMagicAngle() { + // "to top right" in a 200x100 box: 0% at the bottom left, 100% at the + // top right, and the 50% line through the two other corners. + GradientPainter p = on(redBlue(GradientSpec.linearTo(1, -1)), 200, 100); + assertEquals(0, p.parameterAt(0, 100), EPS); + assertEquals(1, p.parameterAt(200, 0), EPS); + assertEquals(0.5, p.parameterAt(0, 0), EPS); + assertEquals(0.5, p.parameterAt(200, 100), EPS); + assertEquals(0.5, p.parameterAt(100, 50), EPS); + + GradientPainter bl = on(redBlue(GradientSpec.linearTo(-1, 1)), 200, 100); + assertEquals(1, bl.parameterAt(0, 100), EPS); + assertEquals(0, bl.parameterAt(200, 0), EPS); + assertEquals(0.5, bl.parameterAt(0, 0), EPS); + + GradientPainter br = on(redBlue(GradientSpec.linearTo(1, 1)), 200, 100); + assertEquals(1, br.parameterAt(200, 100), EPS); + assertEquals(0.5, br.parameterAt(200, 0), EPS); + + GradientPainter tl = on(redBlue(GradientSpec.linearTo(-1, -1)), 200, 100); + assertEquals(1, tl.parameterAt(0, 0), EPS); + assertEquals(0.5, tl.parameterAt(0, 100), EPS); + } + + @Test + void toSideMatchesItsAngle() { + assertEquals(0.25, on(redBlue(GradientSpec.linearTo(1, 0)), 100, 50).parameterAt(25, 9), EPS); + assertEquals(0.75, on(redBlue(GradientSpec.linearTo(-1, 0)), 100, 50).parameterAt(25, 9), EPS); + assertEquals(0.2, on(redBlue(GradientSpec.linearTo(0, 1)), 100, 50).parameterAt(25, 10), EPS); + assertEquals(0.8, on(redBlue(GradientSpec.linearTo(0, -1)), 100, 50).parameterAt(25, 10), EPS); + // Without a side the direction is the CSS default, to bottom. + assertEquals(0.2, on(redBlue(GradientSpec.linearTo(0, 0)), 100, 50).parameterAt(25, 10), EPS); + } + + @Test + void gradientBoxOffsetIsHonoured() { + GradientPainter p = new GradientPainter(redBlue(GradientSpec.linear(90)), 10, 20, 100, 50); + assertEquals(0, p.parameterAt(10, 0), EPS); + assertEquals(1, p.parameterAt(110, 0), EPS); + } + + @Test + void hardStopIsASharpEdge() { + GradientSpec g = GradientSpec.linear(90) + .addStop(RED, 0, Unit.PERCENT).addStop(RED, 50, Unit.PERCENT) + .addStop(BLUE, 50, Unit.PERCENT).addStop(BLUE, 100, Unit.PERCENT); + GradientPainter p = on(g, 100, 10); + assertEquals(RED, p.colorAtPoint(49.999, 5)); + assertEquals(BLUE, p.colorAtPoint(50, 5), "exactly on the edge the later stop wins"); + assertEquals(BLUE, p.colorAtPoint(50.001, 5)); + int[] px = p.paint(100, 10); + assertEquals(RED, px[49]); + assertEquals(BLUE, px[50]); + } + + @Test + void stopsWithoutPositionsAreSpreadEvenly() { + GradientSpec g = GradientSpec.linear(90).addStop(RED).addStop(GREEN).addStop(BLUE).addStop(RED); + assertArrayEquals(new double[] {0, 1.0 / 3, 2.0 / 3, 1}, on(g, 90, 10).stopPositions(), EPS); + assertEquals(GREEN, on(g, 90, 10).colorAtPoint(30, 5)); + assertEquals(BLUE, on(g, 90, 10).colorAtPoint(60, 5)); + } + + @Test + void fixUpClampsAndFillsRuns() { + // red, green 30%, blue, red, green 20%, blue + GradientSpec g = GradientSpec.linear(90) + .addStop(RED).addStop(GREEN, 30, Unit.PERCENT).addStop(BLUE).addStop(RED) + .addStop(GREEN, 20, Unit.PERCENT).addStop(BLUE); + // 20% is before 30% and is moved up to it; the run between two + // stops at 30% collapses onto 30%. + assertArrayEquals(new double[] {0, 0.3, 0.3, 0.3, 0.3, 1}, on(g, 100, 10).stopPositions(), EPS); + + GradientSpec h = GradientSpec.linear(90) + .addStop(RED, 10, Unit.PERCENT).addStop(GREEN).addStop(BLUE).addStop(RED) + .addStop(GREEN, 50, Unit.PERCENT); + assertArrayEquals(new double[] {0.1, 0.2, 0.3, 0.4, 0.5}, on(h, 100, 10).stopPositions(), EPS); + + double[] raw = {Double.NaN, Double.NaN, 0.5, Double.NaN, 0.25, Double.NaN}; + GradientPainter.fixUp(raw); + assertArrayEquals(new double[] {0, 0.25, 0.5, 0.5, 0.5, 1}, raw, EPS); + } + + @Test + void pixelStopsUseTheGradientLineLength() { + GradientSpec g = GradientSpec.linear(90).addStop(RED, 10, Unit.PX).addStop(BLUE, 30, Unit.PX); + GradientPainter p = on(g, 200, 10); + assertArrayEquals(new double[] {0.05, 0.15}, p.stopPositions(), EPS); + assertEquals(RED, p.colorAtPoint(5, 5)); + assertNear(HALF, p.colorAtPoint(20, 5), 1, "half way between 10px and 30px"); + assertEquals(BLUE, p.colorAtPoint(40, 5)); + // A px stop mixed with a percentage one. + GradientSpec m = GradientSpec.linear(90).addStop(RED, 50, Unit.PX).addStop(BLUE, 75, Unit.PERCENT); + assertArrayEquals(new double[] {0.25, 0.75}, on(m, 200, 10).stopPositions(), EPS); + } + + @Test + void repeatingGradientWraps() { + GradientSpec g = GradientSpec.linear(90).repeating(true) + .addStop(RED, 0, Unit.PX).addStop(BLUE, 20, Unit.PX); + GradientPainter p = on(g, 100, 10); + int first = p.colorAtPoint(5, 5); + assertNear(QUARTER, first, 1, "5px into a 20px period"); + assertEquals(first, p.colorAtPoint(25, 5)); + assertEquals(first, p.colorAtPoint(45, 5)); + assertEquals(first, p.colorAtPoint(85, 5)); + assertNear(BLUE, p.colorAtPoint(19.99, 5), 1, "end of a period"); + assertNear(RED, p.colorAtPoint(20.01, 5), 1, "start of the next period"); + // It also repeats before the first stop. + GradientSpec late = GradientSpec.linear(90).repeating(true) + .addStop(RED, 40, Unit.PX).addStop(BLUE, 60, Unit.PX); + assertNear(QUARTER, on(late, 100, 10).colorAtPoint(5, 5), 1, "wrapped backwards"); + // The non-repeating form of the same gradient is flat there. + GradientSpec flat = GradientSpec.linear(90).addStop(RED, 40, Unit.PX).addStop(BLUE, 60, Unit.PX); + assertEquals(RED, on(flat, 100, 10).colorAtPoint(5, 5)); + } + + @Test + void fadeToTransparentIsNotGreyed() { + // "transparent" is transparent BLACK. Interpolating the channels + // unpremultiplied would give 0x80800000 half way. + GradientSpec g = GradientSpec.linear(90).addStop(0x00000000).addStop(RED); + GradientPainter p = on(g, 100, 10); + assertNear(0x80ff0000, p.colorAtPoint(50, 5), 1, "half way to transparent"); + assertNear(0x40ff0000, p.colorAtPoint(25, 5), 1, "a quarter of the way"); + assertEquals(0, p.colorAtPoint(0, 5)); + // Between two colours of different alpha the more opaque one weighs more. + GradientSpec h = GradientSpec.linear(90).addStop(0x40ff0000).addStop(0xff0000ff); + // a = 0.6255; red = 0.5 * 0.251 / 0.6255 = 0.2006; blue = 0.5 / 0.6255 = 0.7994 + assertNear(0xa03300cc, on(h, 100, 10).colorAtPoint(50, 5), 2, "premultiplied mix"); + } + + @Test + void radialCircleExtents() { + // Centre at (50,25) in a 200x100 box: 50 left, 150 right, 25 top, 75 bottom. + double[][] cases = { + {Extent.CLOSEST_SIDE.ordinal(), 25}, + {Extent.FARTHEST_SIDE.ordinal(), 150}, + {Extent.CLOSEST_CORNER.ordinal(), Math.sqrt(50 * 50 + 25 * 25)}, + {Extent.FARTHEST_CORNER.ordinal(), Math.sqrt(150 * 150 + 75 * 75)}, + }; + for (double[] c : cases) { + Extent e = Extent.values()[(int) c[0]]; + double r = c[1]; + GradientPainter p = on(redBlue(GradientSpec.radial(Shape.CIRCLE, e).center(50, false, 25, false)), 200, 100); + assertEquals(0, p.parameterAt(50, 25), EPS, e + " centre"); + assertEquals(1, p.parameterAt(50 + r, 25), 1e-9, e + " right"); + assertEquals(1, p.parameterAt(50, 25 + r), 1e-9, e + " below"); + assertEquals(0.5, p.parameterAt(50 - r / 2, 25), 1e-9, e + " half way left"); + assertEquals(RED, p.colorAtPoint(50, 25), e + " centre colour"); + assertEquals(BLUE, p.colorAtPoint(50 + r, 25), e + " edge colour"); + assertNear(HALF, p.colorAtPoint(50, 25 - r / 2), 1, e + " half way colour"); + } + } + + @Test + void radialEllipseExtents() { + Shape s = Shape.ELLIPSE; + GradientPainter cs = on(redBlue(GradientSpec.radial(s, Extent.CLOSEST_SIDE).center(50, false, 25, false)), 200, 100); + assertEquals(1, cs.parameterAt(100, 25), EPS); + assertEquals(1, cs.parameterAt(0, 25), EPS); + assertEquals(1, cs.parameterAt(50, 50), EPS); + assertEquals(1, cs.parameterAt(50, 0), EPS); + + GradientPainter fs = on(redBlue(GradientSpec.radial(s, Extent.FARTHEST_SIDE).center(50, false, 25, false)), 200, 100); + assertEquals(1, fs.parameterAt(200, 25), EPS); + assertEquals(1, fs.parameterAt(50, 100), EPS); + + // The -corner ellipses pass through the corner and keep the aspect + // ratio of the matching -side ellipse (2:1 here). + GradientPainter cc = on(redBlue(GradientSpec.radial(s, Extent.CLOSEST_CORNER).center(50, false, 25, false)), 200, 100); + assertEquals(1, cc.parameterAt(0, 0), EPS); + assertEquals(1, cc.parameterAt(50 + 50 * Math.sqrt(2), 25), EPS); + assertEquals(1, cc.parameterAt(50, 25 + 25 * Math.sqrt(2)), EPS); + + GradientPainter fc = on(redBlue(GradientSpec.radial(s, Extent.FARTHEST_CORNER).center(50, false, 25, false)), 200, 100); + assertEquals(1, fc.parameterAt(200, 100), EPS); + assertEquals(1, fc.parameterAt(50 + 150 * Math.sqrt(2), 25), EPS); + + // The CSS default: ellipse farthest-corner at the centre. + GradientPainter d = on(redBlue(new GradientSpec().type(GradientSpec.Type.RADIAL)), 200, 100); + assertEquals(0, d.parameterAt(100, 50), EPS); + assertEquals(1, d.parameterAt(0, 0), EPS); + assertEquals(1, d.parameterAt(200, 100), EPS); + assertEquals(RED, d.colorAtPoint(100, 50)); + assertEquals(BLUE, d.colorAtPoint(200, 0)); + } + + @Test + void radialExplicitRadiiAndPercentCentre() { + GradientPainter e = on(redBlue(GradientSpec.radial(Shape.ELLIPSE, Extent.EXPLICIT) + .radiusX(40).radiusY(20).center(50, false, 25, false)), 200, 100); + assertEquals(1, e.parameterAt(90, 25), EPS); + assertEquals(1, e.parameterAt(50, 45), EPS); + assertEquals(0.5, e.parameterAt(30, 25), EPS); + + GradientPainter c = on(redBlue(GradientSpec.radial(Shape.CIRCLE, Extent.EXPLICIT) + .radiusX(30).radiusY(999).center(50, false, 25, false)), 200, 100); + assertEquals(1, c.parameterAt(50, 55), EPS, "a circle ignores radiusY"); + + // 25% 50% of a 200x100 box at (10,20) is (60,70); closest side is 50 away. + GradientPainter pc = new GradientPainter(redBlue(GradientSpec.radial(Shape.CIRCLE, Extent.CLOSEST_SIDE) + .center(25, true, 50, true)), 10, 20, 200, 100); + assertEquals(0, pc.parameterAt(60, 70), EPS); + assertEquals(1, pc.parameterAt(110, 70), EPS); + assertEquals(1, pc.parameterAt(60, 20), EPS); + + // Stops in px are measured along the horizontal radius. + GradientPainter px = on(GradientSpec.radial(Shape.CIRCLE, Extent.EXPLICIT).radiusX(50) + .addStop(RED, 10, Unit.PX).addStop(BLUE, 30, Unit.PX), 200, 100); + assertEquals(RED, px.colorAtPoint(105, 50)); + assertNear(HALF, px.colorAtPoint(120, 50), 1, "20px from the centre"); + assertEquals(BLUE, px.colorAtPoint(100, 85)); + } + + @Test + void degenerateRadialIsTheLastColour() { + GradientPainter p = on(redBlue(GradientSpec.radial(Shape.CIRCLE, Extent.CLOSEST_SIDE) + .center(0, true, 50, true)), 200, 100); + assertEquals(BLUE, p.colorAtPoint(100, 50)); + assertEquals(BLUE, p.colorAtPoint(0, 50)); + } + + @Test + void conicStartsAtTheTopAndTurnsClockwise() { + GradientPainter p = on(redBlue(GradientSpec.conic(0)), 100, 100); + assertEquals(0.25, p.parameterAt(90, 50), EPS); + assertEquals(0.5, p.parameterAt(50, 90), EPS); + assertEquals(0.75, p.parameterAt(10, 50), EPS); + assertEquals(0.125, p.parameterAt(80, 20), EPS); + assertNear(RED, p.colorAtPoint(50.01, 10), 1, "just past 0 degrees"); + assertNear(QUARTER, p.colorAtPoint(90, 50), 1, "90 degrees"); + assertNear(HALF, p.colorAtPoint(50, 90), 1, "180 degrees"); + assertNear(0xff4000bf, p.colorAtPoint(10, 50), 1, "270 degrees"); + assertNear(BLUE, p.colorAtPoint(49.99, 10), 1, "just before 360 degrees"); + } + + @Test + void conicFromAngleRotatesTheStart() { + GradientPainter p = on(redBlue(GradientSpec.conic(90)), 100, 100); + assertNear(RED, p.colorAtPoint(90, 50.01), 1, "0 from the from angle"); + assertEquals(0.25, p.parameterAt(50, 90), EPS); + assertEquals(0.5, p.parameterAt(10, 50), EPS); + assertEquals(0.75, p.parameterAt(50, 10), EPS); + assertNear(QUARTER, p.colorAtPoint(50, 90), 1, "90 past the from angle"); + assertNear(HALF, p.colorAtPoint(10, 50), 1, "180 past the from angle"); + assertNear(0xff4000bf, p.colorAtPoint(50, 10), 1, "270 past the from angle"); + GradientPainter neg = on(redBlue(GradientSpec.conic(-90)), 100, 100); + assertEquals(0.25, neg.parameterAt(50, 10), EPS); + } + + @Test + void conicCentreAndDegreeStops() { + GradientPainter p = on(GradientSpec.conic(0).center(20, false, 30, false) + .addStop(RED, 0, Unit.DEGREES).addStop(BLUE, 180, Unit.DEGREES), 100, 100); + assertArrayEquals(new double[] {0, 0.5}, p.stopPositions(), EPS); + assertEquals(0.25, p.parameterAt(60, 30), EPS); + assertNear(HALF, p.colorAtPoint(60, 30), 1, "90 of 180 degrees"); + assertEquals(BLUE, p.colorAtPoint(20, 70)); + assertEquals(BLUE, p.colorAtPoint(5, 30), "past the last stop"); + // A pie chart: hard stops. + GradientPainter pie = on(GradientSpec.conic(0) + .addStop(RED, 0, Unit.PERCENT).addStop(RED, 25, Unit.PERCENT) + .addStop(BLUE, 25, Unit.PERCENT).addStop(BLUE, 100, Unit.PERCENT), 100, 100); + assertEquals(RED, pie.colorAtPoint(80, 20)); + assertEquals(BLUE, pie.colorAtPoint(80, 80)); + assertEquals(BLUE, pie.colorAtPoint(20, 20)); + // Repeating: four identical quarter turns. + GradientPainter rep = on(GradientSpec.conic(0).repeating(true) + .addStop(RED, 0, Unit.DEGREES).addStop(BLUE, 90, Unit.DEGREES), 100, 100); + int c = rep.colorAtPoint(70, 20); + // 33.69 of 90 degrees. + assertNear(0xffa0005f, c, 1, "first quarter turn"); + assertNear(c, rep.colorAtPoint(80, 70), 1, "second quarter turn"); + assertNear(c, rep.colorAtPoint(30, 80), 1, "third quarter turn"); + assertNear(c, rep.colorAtPoint(20, 30), 1, "fourth quarter turn"); + } + + @Test + void invalidGradientsNameTheField() { + assertTrue(assertThrows(IllegalArgumentException.class, + () -> on(GradientSpec.linear(0).addStop(RED), 10, 10)).getMessage().startsWith("gradient.stops")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> on(GradientSpec.linear(0).stops(null), 10, 10)).getMessage().startsWith("gradient.stops")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> on(redBlue(GradientSpec.linear(0)).type(null), 10, 10)).getMessage().startsWith("gradient.type")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> on(GradientSpec.linear(0).addStop(RED, 10, Unit.DEGREES).addStop(BLUE), 10, 10)) + .getMessage().startsWith("gradient.stops[0].unit")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> on(GradientSpec.conic(0).addStop(RED).addStop(BLUE, 10, Unit.PX), 10, 10)) + .getMessage().startsWith("gradient.stops[1].unit")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> on(redBlue(GradientSpec.linear(Double.NaN)), 10, 10)) + .getMessage().startsWith("gradient.angleDeg")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> on(redBlue(GradientSpec.radial(Shape.CIRCLE, Extent.EXPLICIT).radiusX(-1)), 10, 10)) + .getMessage().startsWith("gradient.radiusX")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> on(redBlue(GradientSpec.radial(null, Extent.EXPLICIT)), 10, 10)) + .getMessage().startsWith("gradient.shape")); + } + + @Test + void rasterizerPaintsTheGradientOverTheBorderBox() { + BufferedImage img = paint(BoxStyle.builder().size(100, 50).pad(10).radii(10) + .backgroundColor(GREEN).gradient(redBlue(GradientSpec.linear(90)))); + // Pixel (35,30) is centred 25.5px into the box: 25.5% of the way. + assertPixelNear(0xffbe0041, img, 35, 30, 1); + assertPixelNear(0xff7e0081, img, 60, 30, 1); + // The same column has one colour from top to bottom. + assertEquals(img.getRGB(35, 12), img.getRGB(35, 57)); + // Nothing outside the box, nor outside its rounded corner. + assertTransparent(img, 5, 30); + assertTransparent(img, 112, 30); + assertTransparent(img, 10, 10); + assertTransparent(img, 11, 11); + } + + @Test + void translucentGradientLetsTheBackgroundColourThrough() { + BufferedImage img = paint(BoxStyle.builder().size(100, 20).backgroundColor(0xff0000ff) + .gradient(GradientSpec.linear(90).addStop(0x00ff0000).addStop(0xffff0000))); + assertPixelNear(0xff0000ff, img, 0, 10, 2); + assertPixelNear(0xff7f0080, img, 49, 10, 2); + assertPixelNear(0xffff0000, img, 99, 10, 3); + } + + @Test + void gradientUnderABorderStartsAtTheBorderEdge() { + // The gradient box is the border box, so a transparent border shows + // the very start of the gradient. + BufferedImage img = paint(BoxStyle.builder().size(100, 20) + .border(new BorderSide(10, BorderStyle.SOLID, 0)) + .gradient(redBlue(GradientSpec.linear(90)))); + assertPixelNear(0xfffe0001, img, 0, 10, 1); + assertPixelNear(0xff0100fe, img, 99, 10, 1); + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/RasterAssert.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/RasterAssert.java new file mode 100644 index 00000000000..887ed142208 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/RasterAssert.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.awt.image.BufferedImage; + +/// Pixel assertions shared by the rasterizer tests. +final class RasterAssert { + static final int RED = 0xffff0000; + static final int GREEN = 0xff00ff00; + static final int BLUE = 0xff0000ff; + static final int YELLOW = 0xffffff00; + static final int CYAN = 0xff00ffff; + static final int MAGENTA = 0xffff00ff; + static final int WHITE = 0xffffffff; + static final int BLACK = 0xff000000; + static final int GREY = 0xff808080; + + private RasterAssert() { + } + + static BufferedImage paint(BoxStyle.Builder b) { + return new CssBoxRasterizer().rasterize(b.build()); + } + + static int alpha(BufferedImage img, int x, int y) { + return img.getRGB(x, y) >>> 24; + } + + static String hex(int argb) { + return "0x" + Integer.toHexString(argb); + } + + static void assertPixel(int expected, BufferedImage img, int x, int y) { + int actual = img.getRGB(x, y); + if (expected != actual) { + fail("pixel (" + x + "," + y + ") expected " + hex(expected) + " but was " + hex(actual)); + } + } + + static void assertTransparent(BufferedImage img, int x, int y) { + assertEquals(0, alpha(img, x, y), "pixel (" + x + "," + y + ") should be fully transparent, was " + + hex(img.getRGB(x, y))); + } + + static void assertOpaque(BufferedImage img, int x, int y) { + assertEquals(255, alpha(img, x, y), "pixel (" + x + "," + y + ") should be fully opaque, was " + + hex(img.getRGB(x, y))); + } + + /// Asserts every channel, alpha included, is within `tol` of `expected`. + static void assertNear(int expected, int actual, int tol, String what) { + for (int shift = 24; shift >= 0; shift -= 8) { + int e = (expected >> shift) & 0xff; + int a = (actual >> shift) & 0xff; + assertTrue(Math.abs(e - a) <= tol, + what + ": expected " + hex(expected) + " within " + tol + " but was " + hex(actual)); + } + } + + static void assertPixelNear(int expected, BufferedImage img, int x, int y, int tol) { + assertNear(expected, img.getRGB(x, y), tol, "pixel (" + x + "," + y + ")"); + } + + /// The number of runs of pixels with alpha above 128 along a row. + static int runsInRow(BufferedImage img, int y) { + int runs = 0; + boolean in = false; + for (int x = 0; x < img.getWidth(); x++) { + boolean on = alpha(img, x, y) > 128; + if (on && !in) { + runs++; + } + in = on; + } + return runs; + } + + /// A 10x10 image of four 5x5 quadrants: red top left, green top right, + /// blue bottom left, yellow bottom right. + static BufferedImage quadrants() { + BufferedImage img = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < 10; y++) { + for (int x = 0; x < 10; x++) { + img.setRGB(x, y, y < 5 ? (x < 5 ? RED : GREEN) : (x < 5 ? BLUE : YELLOW)); + } + } + return img; + } +} diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/RoundedBoxTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/RoundedBoxTest.java new file mode 100644 index 00000000000..e59b7d27150 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/raster/RoundedBoxTest.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css.raster; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.awt.geom.Path2D; +import java.awt.geom.PathIterator; +import java.awt.geom.Rectangle2D; +import org.junit.jupiter.api.Test; + +/// Radius normalisation and the geometry of the box outline. +class RoundedBoxTest { + private static double[] all(double r) { + return new double[] {r, r, r, r, r, r, r, r}; + } + + @Test + void radiiThatFitAreKept() { + double[] r = {1, 2, 3, 4, 5, 6, 7, 8}; + assertArrayEquals(r, new RoundedBox(0, 0, 100, 100, r).getRadii()); + assertArrayEquals(new double[8], new RoundedBox(0, 0, 100, 100, null).getRadii()); + } + + @Test + void overlappingRadiiAreAllScaledByTheSameFactor() { + // 1000 on a 100x40 box: the left side allows 40 / 2000, the smallest factor. + assertArrayEquals(all(20), new RoundedBox(0, 0, 100, 40, all(1000)).getRadii(), 1e-9); + // 50% everywhere is an ellipse and needs no scaling. + double[] ellipse = {50, 20, 50, 20, 50, 20, 50, 20}; + assertArrayEquals(ellipse, new RoundedBox(0, 0, 100, 40, ellipse).getRadii(), 1e-9); + // Only the top side overflows (80 + 40 > 100), but every radius + // shrinks by 100 / 120, including the ones that had room. + double[] r = {80, 10, 40, 10, 6, 6, 12, 12}; + double f = 100.0 / 120; + assertArrayEquals(new double[] {80 * f, 10 * f, 40 * f, 10 * f, 6 * f, 6 * f, 12 * f, 12 * f}, + new RoundedBox(0, 0, 100, 100, r).getRadii(), 1e-9); + } + + @Test + void cornerWithOneZeroRadiusIsSquare() { + RoundedBox b = new RoundedBox(0, 0, 100, 100, new double[] {10, 0, 0, 10, -5, 5, 4, 4}); + assertArrayEquals(new double[] {0, 0, 0, 0, 0, 0, 4, 4}, b.getRadii()); + } + + @Test + void insetShrinksEachRadiusByItsAdjacentSide() { + RoundedBox b = new RoundedBox(10, 20, 100, 80, new double[] {20, 20, 20, 20, 20, 20, 20, 20}); + RoundedBox in = b.inset(5, 10, 25, 15); + assertEquals(25, in.getX()); + assertEquals(25, in.getY()); + assertEquals(75, in.getWidth()); + assertEquals(50, in.getHeight()); + // TL: x - left, y - top. TR: x - right, y - top. BR: the bottom + // border (25) is wider than the radius, so that corner is square. + // BL: x - left, y - bottom < 0, square too. + assertArrayEquals(new double[] {5, 15, 10, 15, 0, 0, 0, 0}, in.getRadii(), 1e-9); + assertFalse(in.isEmpty()); + assertTrue(b.inset(50, 0, 30, 0).isEmpty()); + assertTrue(b.inset(0, 60, 0, 60).isEmpty()); + } + + @Test + void growAndTranslate() { + RoundedBox b = new RoundedBox(10, 20, 100, 80, all(20)); + RoundedBox g = b.grow(5); + assertEquals(5, g.getX()); + assertEquals(15, g.getY()); + assertEquals(110, g.getWidth()); + assertEquals(90, g.getHeight()); + assertArrayEquals(all(25), g.getRadii(), 1e-9); + assertArrayEquals(all(12), b.grow(-8).getRadii(), 1e-9); + assertArrayEquals(all(0), b.grow(-30).getRadii(), 1e-9); + assertTrue(b.grow(-40).isEmpty()); + assertArrayEquals(all(0), new RoundedBox(0, 0, 10, 10, null).grow(50).getRadii(), 0); + RoundedBox t = b.translate(3, -4); + assertEquals(13, t.getX()); + assertEquals(16, t.getY()); + assertArrayEquals(all(20), t.getRadii()); + } + + @Test + void pathBoundsAreTheBox() { + RoundedBox b = new RoundedBox(10.5, 20.25, 100.5, 80, new double[] {30, 10, 5, 5, 0, 0, 20, 40}); + Rectangle2D r = b.toPath().getBounds2D(); + assertEquals(10.5, r.getMinX(), 1e-6); + assertEquals(20.25, r.getMinY(), 1e-6); + assertEquals(111, r.getMaxX(), 1e-6); + assertEquals(100.25, r.getMaxY(), 1e-6); + assertTrue(new RoundedBox(0, 0, 0, 10, null).toPath().getBounds2D().isEmpty()); + } + + @Test + void arcsAreEllipticalToWithinAHairOfTheTrueCurve() { + // Top-left radii 40 x 10 centred on (40,10). Every point of that + // corner's curve must satisfy the ellipse equation. + RoundedBox b = new RoundedBox(0, 0, 100, 60, new double[] {40, 10, 0, 0, 0, 0, 0, 0}); + Path2D.Double p = b.toPath(); + assertFalse(p.contains(10.5, 1.5), "outside the ellipse"); + assertTrue(p.contains(20.5, 5.5), "inside the ellipse"); + assertTrue(p.contains(99.9, 0.1), "the other corners are square"); + assertTrue(p.contains(0.1, 59.9)); + PathIterator it = p.getPathIterator(null, 0.001); + double[] c = new double[6]; + int onCurve = 0; + while (!it.isDone()) { + it.currentSegment(c); + if (c[0] < 40 - 1e-9 && c[1] < 10 - 1e-9) { + double ex = (c[0] - 40) / 40; + double ey = (c[1] - 10) / 10; + assertEquals(1, Math.sqrt(ex * ex + ey * ey), 0.0005, "point " + c[0] + "," + c[1]); + onCurve++; + } + it.next(); + } + assertTrue(onCurve > 8, "the flattened corner has points to check: " + onCurve); + } + + @Test + void ringIsTheOuterBoxMinusTheInnerOne() { + RoundedBox outer = new RoundedBox(0, 0, 100, 100, all(30)); + RoundedBox inner = outer.inset(10, 10, 10, 10); + Path2D.Double ring = RoundedBox.ring(outer, inner); + assertTrue(ring.contains(50, 5)); + assertFalse(ring.contains(50, 15)); + assertFalse(ring.contains(50, 50)); + assertTrue(ring.contains(12.5, 12.5)); + assertFalse(ring.contains(19.5, 19.5)); + assertFalse(ring.contains(5, 5)); + // With an empty inner box the ring is the whole outer box. + assertTrue(RoundedBox.ring(outer, outer.inset(60, 0, 60, 0)).contains(50, 50)); + } + + @Test + void sidePathRunsFromMidCornerToMidCorner() { + RoundedBox b = new RoundedBox(0, 0, 100, 60, all(20)); + double mid = 20 - 20 * Math.sqrt(0.5); + Rectangle2D top = b.sidePath(RoundedBox.TOP).getBounds2D(); + assertEquals(mid, top.getMinX(), 1e-6); + assertEquals(100 - mid, top.getMaxX(), 1e-6); + assertEquals(0, top.getMinY(), 1e-6); + assertEquals(mid, top.getMaxY(), 1e-6); + Rectangle2D left = b.sidePath(RoundedBox.LEFT).getBounds2D(); + assertEquals(0, left.getMinX(), 1e-6); + assertEquals(mid, left.getMaxX(), 1e-6); + assertEquals(mid, left.getMinY(), 1e-6); + assertEquals(60 - mid, left.getMaxY(), 1e-6); + Rectangle2D bottom = b.sidePath(RoundedBox.BOTTOM).getBounds2D(); + assertEquals(60, bottom.getMaxY(), 1e-6); + assertEquals(60 - mid, bottom.getMinY(), 1e-6); + Rectangle2D right = b.sidePath(RoundedBox.RIGHT).getBounds2D(); + assertEquals(100, right.getMaxX(), 1e-6); + assertEquals(100 - mid, right.getMinX(), 1e-6); + // Square corners: the side is the plain edge. + Rectangle2D square = new RoundedBox(5, 5, 50, 30, null).sidePath(RoundedBox.TOP).getBounds2D(); + assertEquals(5, square.getMinX(), 1e-9); + assertEquals(55, square.getMaxX(), 1e-9); + assertEquals(5, square.getMinY(), 1e-9); + assertEquals(5, square.getMaxY(), 1e-9); + } +} From 50809cff89d03c6ca72b656b7280b82134bb7dc4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:10:17 +0300 Subject: [PATCH 02/61] CSS compiler: generate images in process, with no display or browser A rule the theme format cannot express natively (cn1-9patch, a blurred or inset shadow, an image or unsupported gradient behind a radius) is now painted by the in-tree rasterizer instead of a CEF screenshot. The geometry the browser path used is kept exactly -- a 640x960 page, a 100px default height, the same shadow margins and unit scales -- because the 9-piece slices are computed from the size of the image it produces. HeadlessCssCompilerImplementation becomes the one implementation the compiler runs on: it decodes and scales images with ImageIO, and pins the unit conversion to the value application themes were always compiled with (measured against the released compiler: 5px/mm, medium density). The framework's own native themes were built with a different conversion and keep it through setNativeThemeUnits, so neither set of themes changes size. EditableResources no longer opens Swing dialogs itself; the resource editor installs them through ResourceEditorUi. Bugs this uncovered, fixed here: - updateResources cleared the border/bgImage keys createImageBorders had just stored, so a generated image was written to the file and never used. - States and UIIDs sharing one generated image read it from a record that had never been given one. - radial-gradient: the two-colour parser looped forever on a first argument that is not a keyword, and the multi-stop parser dropped the first colour of a gradient with no shape or position. - getImageBorderInsets looped forever on a non-function token after a gradient. - @import was parsed and ignored; local imports are now inlined. - The background shorthand read no-repeat and position keywords as colours. Co-Authored-By: Claude Opus 5.5 --- .../designer/SwingResourceEditorUi.java | 74 ++ .../ui/plaf/DesktopNativeThemeParityTest.java | 2 +- maven/css-compiler/pom.xml | 66 +- .../com/codename1/designer/css/CSSTheme.java | 1152 +++++++++++------ .../codename1/designer/css/CssImports.java | 305 +++++ .../HeadlessCssCompilerImplementation.java | 182 ++- .../designer/css/HeadlessImages.java | 85 ++ .../css/MissingNativeBrowserException.java | 27 - .../codename1/designer/css/NoCefCSSCLI.java | 174 --- .../designer/css/ResourcesMutator.java | 321 +---- .../designer/css/SimpleWebServer.java | 333 ----- .../designer/css/WebviewSnapshotter.java | 218 ---- .../java/com/codename1/ui/EditorFont.java | 3 +- .../codename1/ui/util/EditableResources.java | 42 +- .../codename1/ui/util/ResourceEditorUi.java | 73 ++ .../com/codename1/designer/css/capture.js | 151 --- .../css/CSSBoxShadowNativeBorderTest.java | 23 +- .../designer/css/CSSGeneratedImageTest.java | 348 +++++ .../designer/css/CSSHoverStateTest.java | 39 +- .../codename1/designer/css/CSSImportTest.java | 149 +++ .../designer/css/HeadlessTestSupport.java | 16 +- 21 files changed, 2006 insertions(+), 1777 deletions(-) create mode 100644 CodenameOneDesigner/src/com/codename1/designer/SwingResourceEditorUi.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/CssImports.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessImages.java delete mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/MissingNativeBrowserException.java delete mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/NoCefCSSCLI.java delete mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/SimpleWebServer.java delete mode 100644 maven/css-compiler/src/main/java/com/codename1/designer/css/WebviewSnapshotter.java create mode 100644 maven/css-compiler/src/main/java/com/codename1/ui/util/ResourceEditorUi.java delete mode 100644 maven/css-compiler/src/main/resources/com/codename1/designer/css/capture.js create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/CSSGeneratedImageTest.java create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/CSSImportTest.java diff --git a/CodenameOneDesigner/src/com/codename1/designer/SwingResourceEditorUi.java b/CodenameOneDesigner/src/com/codename1/designer/SwingResourceEditorUi.java new file mode 100644 index 00000000000..fcdd3ddbad0 --- /dev/null +++ b/CodenameOneDesigner/src/com/codename1/designer/SwingResourceEditorUi.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer; + +import com.codename1.ui.util.ResourceEditorUi; + +import javax.swing.JFrame; +import javax.swing.JOptionPane; +import javax.swing.JPasswordField; + +/// The resource editor's dialogs, for [com.codename1.ui.util.EditableResources]. +/// +/// Installed once when the editor starts. Nothing else may install it: the +/// CSS compiler shares `EditableResources` and has to keep running without a +/// display. +final class SwingResourceEditorUi extends ResourceEditorUi { + + private static final boolean IS_MAC = + System.getProperty("os.name", "").regionMatches(true, 0, "mac", 0, 3); + + private static java.awt.Frame owner() { + java.awt.Frame[] frames = java.awt.Frame.getFrames(); + return frames.length == 0 ? null : frames[0]; + } + + @Override + public String promptPassword(String current) { + JPasswordField password = new JPasswordField(); + if (current != null) { + password.setText(current); + } + int v = JOptionPane.showConfirmDialog(owner(), password, "Enter Password", + JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE); + return v == JOptionPane.OK_OPTION ? new String(password.getPassword()) : null; + } + + @Override + public void reportError(String title, String message) { + JOptionPane.showMessageDialog(owner(), message, title, JOptionPane.ERROR_MESSAGE); + } + + @Override + public void modifiedChanged(boolean modified) { + if (!IS_MAC) { + return; + } + // macOS shows unsaved changes as a dot in the window's close button. + for (java.awt.Window w : java.awt.Frame.getWindows()) { + if (w instanceof JFrame) { + ((JFrame) w).getRootPane().putClientProperty("Window.documentModified", Boolean.valueOf(modified)); + } + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DesktopNativeThemeParityTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DesktopNativeThemeParityTest.java index dd0f012fed3..607c5ac4032 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DesktopNativeThemeParityTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/DesktopNativeThemeParityTest.java @@ -49,7 +49,7 @@ * *

They are three separate hand-written CSS files by deliberate choice: a shared * base would make "what you read is what compiles" false, which is what makes the - * strictNoCef failure messages actionable. The cost of that choice is drift. A UIID + * -no-raster failure messages actionable. The cost of that choice is drift. A UIID * added to Fluent and forgotten in Aqua does not fail anything -- the missing UIID * falls back to the blank default style, which is white-on-black and looks like a * theme bug reported months later by a user on the one platform nobody tested. diff --git a/maven/css-compiler/pom.xml b/maven/css-compiler/pom.xml index 7f8334296e3..8aeb8ad1635 100644 --- a/maven/css-compiler/pom.xml +++ b/maven/css-compiler/pom.xml @@ -1,16 +1,14 @@ com.codenameone @@ -56,49 +54,13 @@ org.apache.maven.plugins - maven-assembly-plugin - 3.3.0 - - - build-jar-with-dependencies - package - - single - - - - - com.codename1.designer.css.NoCefCSSCLI - - - - jar-with-dependencies - - - - - - - org.codehaus.mojo - build-helper-maven-plugin - - - attach-jar-with-dependencies - package - - attach-artifact - - - - - ${project.build.directory}/${project.build.finalName}-jar-with-dependencies.jar - jar - jar-with-dependencies - - - - - + maven-surefire-plugin + + + -Djava.awt.headless=true + diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index ee339bb8118..ce04d1eb9da 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -22,6 +22,14 @@ */ package com.codename1.designer.css; +import com.codename1.designer.css.raster.BackgroundImage; +import com.codename1.designer.css.raster.BorderImage; +import com.codename1.designer.css.raster.BorderSide; +import com.codename1.designer.css.raster.BorderStyle; +import com.codename1.designer.css.raster.BoxStyle; +import com.codename1.designer.css.raster.CssBoxRasterizer; +import com.codename1.designer.css.raster.GradientSpec; +import com.codename1.designer.css.raster.Shadow; import com.codename1.io.JSONParser; import com.codename1.io.Util; import com.codename1.processing.Result; @@ -47,6 +55,7 @@ import java.io.ByteArrayInputStream; import java.io.CharArrayReader; import java.io.DataOutputStream; +import java.awt.image.BufferedImage; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; @@ -71,6 +80,7 @@ import java.util.Hashtable; import java.util.Iterator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; @@ -118,13 +128,16 @@ public class CSSTheme { private String themeName = "Theme"; private ImagesMetadata imagesMetadata = new ImagesMetadata(); - /** - * When true, {@link #createImageBorders(WebViewProvider)} refuses to rasterize any - * style via CEF and instead throws an {@link IllegalStateException} listing offending - * rules. Used by the native-themes build to guarantee that shipped platform themes - * contain no rasterized fallback images (which would bloat the .res file). - */ - public static boolean strictNoCef = false; + /// When false, [#createImageBorders()] refuses to generate an image for any + /// rule and throws an [IllegalStateException] listing the rules that would + /// have needed one. The native-themes build sets this so a shipped platform + /// theme never carries a generated image where a native primitive was meant. + private boolean rasterizationAllowed = true; + + /// See [#rasterizationAllowed]. The default is true. + public void setRasterizationAllowed(boolean rasterizationAllowed) { + this.rasterizationAllowed = rasterizationAllowed; + } private List fontFaces = new ArrayList(); public static final int DEFAULT_TARGET_DENSITY = com.codename1.ui.Display.DENSITY_HD; @@ -327,6 +340,69 @@ private static String identValue(ScaledUnit u) { return s; } + /// Describes a gradient for [CssBoxRasterizer], which paints it into a + /// generated image. + /// + /// Always goes through the multi-stop parsers. The two-colour legacy + /// parsers answer a narrower question (can the oldest theme format hold + /// this?) and record less than is needed to paint it. + static GradientSpec describeForRaster(ScaledUnit background, int width, int height) { + CN1Gradient g = new CN1Gradient(); + g.rasterWidth = width; + g.rasterHeight = height; + String fn = background.getFunctionName(); + if ("linear-gradient".equals(fn)) { + g.parseLinearGradientExtended(background, false); + } else if ("repeating-linear-gradient".equals(fn)) { + g.parseLinearGradientExtended(background, true); + } else if ("radial-gradient".equals(fn)) { + g.parseRadialGradientExtended(background, false); + } else if ("repeating-radial-gradient".equals(fn)) { + g.parseRadialGradientExtended(background, true); + } else if ("conic-gradient".equals(fn)) { + g.parseConicGradient(background); + } + Gradient d = g.extendedDescriptor; + if (!g.valid || d == null) { + throw new IllegalArgumentException("cannot paint " + fn + "(): " + + (g.reason == null ? "unsupported syntax" : g.reason)); + } + GradientSpec spec = new GradientSpec(); + spec.repeating(d.getCycleMethod() != Gradient.CYCLE_NONE); + int[] colors = d.getColors(); + float[] positions = d.getPositions(); + List stops = new ArrayList(); + for (int i = 0; i < colors.length; i++) { + stops.add(new GradientSpec.Stop(colors[i], positions[i] * 100.0, GradientSpec.Unit.PERCENT)); + } + spec.stops(stops); + if (d instanceof LinearGradient) { + spec.type(GradientSpec.Type.LINEAR); + spec.angleDeg(((LinearGradient) d).getAngleDegrees()); + } else if (d instanceof RadialGradient) { + RadialGradient rg = (RadialGradient) d; + spec.type(GradientSpec.Type.RADIAL); + spec.shape(rg.getShape() == RadialGradient.SHAPE_CIRCLE + ? GradientSpec.Shape.CIRCLE : GradientSpec.Shape.ELLIPSE); + // The descriptor resolves its own extent keyword against a box, + // so ask it for the radii rather than restating that arithmetic. + float[] geometry = new float[4]; + rg.computeRadii(width, height, geometry); + spec.extent(GradientSpec.Extent.EXPLICIT); + spec.radiusX(geometry[2]); + spec.radiusY(geometry[3]); + spec.center(rg.getRelativeCenterX() * 100.0, true, rg.getRelativeCenterY() * 100.0, true); + } else if (d instanceof ConicGradient) { + ConicGradient cg = (ConicGradient) d; + spec.type(GradientSpec.Type.CONIC); + spec.fromAngleDeg(cg.getFromAngleDegrees()); + spec.center(cg.getRelativeCenterX() * 100.0, true, cg.getRelativeCenterY() * 100.0, true); + } else { + throw new IllegalArgumentException("cannot paint " + fn + "()"); + } + return spec; + } + private void parseLinearGradientExtended(ScaledUnit background, boolean repeating) { ScaledUnit fn = background; // Already passed by isGradient check. @@ -360,7 +436,11 @@ private void parseLinearGradientExtended(ScaledUnit background, boolean repeatin p = (ScaledUnit) p.getNextLexicalUnit(); } } - ParsedStops st = parseStops(p); + // A length on a linear gradient is measured along the gradient line, + // whose length for a box is |w sin a| + |h cos a|. + double radians = Math.toRadians(angle); + double lineLength = Math.abs(rasterWidth * Math.sin(radians)) + Math.abs(rasterHeight * Math.cos(radians)); + ParsedStops st = parseStops(p, lineLength); if (st == null || st.colors.length < 2) { reason = "Could not parse stops for linear-gradient"; return; @@ -382,9 +462,11 @@ private void parseRadialGradientExtended(ScaledUnit background, boolean repeatin float rx = 1f, ry = 1f; boolean sawShapeOrExtent = false; boolean sawAt = false; + float[] centre = {cx, cy}; // Consume optional shape / extent / at-position clauses until a comma. while (p != null && p.getLexicalUnitType() != LexicalUnit.SAC_OPERATOR_COMMA) { int t = p.getLexicalUnitType(); + boolean inPrelude = sawShapeOrExtent || sawAt; if (isIdentLike(p)) { String s = identValue(p); if ("circle".equals(s)) { @@ -404,9 +486,11 @@ private void parseRadialGradientExtended(ScaledUnit background, boolean repeatin } else if ("at".equals(s)) { sawAt = true; } else if (sawAt) { - float[] pos = cssPositionKeyword(s); - cx = pos[0]; - cy = pos[1]; + applyPositionKeyword(s, centre); + } else if (!inPrelude) { + // Not a shape or an extent, so the gradient has no + // prelude and this is its first stop, a named colour. + break; } else { reason = "Unrecognized radial-gradient ident: " + s; return; } @@ -416,11 +500,11 @@ private void parseRadialGradientExtended(ScaledUnit background, boolean repeatin float v = (float) (p.getNumericValue() / 100f); ScaledUnit n = (ScaledUnit) p.getNextLexicalUnit(); if (n != null && n.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { - cx = v; - cy = (float) (n.getNumericValue() / 100f); + centre[0] = v; + centre[1] = (float) (n.getNumericValue() / 100f); p = n; } else { - cx = v; + centre[0] = v; } sawShapeOrExtent = true; } else { @@ -436,13 +520,65 @@ private void parseRadialGradientExtended(ScaledUnit background, boolean repeatin extent = RadialGradient.EXTENT_EXPLICIT; sawShapeOrExtent = true; } + } else if (isLength(t)) { + if (rasterWidth <= 0 || rasterHeight <= 0) { + // Used to be skipped without a word, leaving a gradient + // centred or sized somewhere other than where the rule + // put it. Declining sends the rule to a generated + // image, where the length has a box to be measured in. + reason = "A length in the shape or position of a radial-gradient " + + "cannot be expressed as a native gradient"; + return; + } + ScaledUnit n = (ScaledUnit) p.getNextLexicalUnit(); + boolean pair = n != null && isLength(n.getLexicalUnitType()); + if (sawAt) { + centre[0] = (float) (rasterLength(p, rasterWidth) / rasterWidth); + if (pair) { + centre[1] = (float) (rasterLength(n, rasterHeight) / rasterHeight); + p = n; + } + } else { + // RadialGradient measures an explicit radius against the + // longer side of the box. + float ref = Math.max(rasterWidth, rasterHeight); + rx = (float) (rasterLength(p, ref) / ref); + if (pair) { + ry = (float) (rasterLength(n, ref) / ref); + p = n; + } else { + ry = rx; + } + extent = RadialGradient.EXTENT_EXPLICIT; + } + sawShapeOrExtent = true; + } else if (!inPrelude) { + // The first stop, written as a colour value: the plain + // radial-gradient(#fff, #000). Stepping past it here cost + // the gradient its first colour and, with only two, the + // whole parse. + break; } p = (ScaledUnit) p.getNextLexicalUnit(); } + cx = centre[0]; + cy = centre[1]; if (sawShapeOrExtent && p != null && p.getLexicalUnitType() == LexicalUnit.SAC_OPERATOR_COMMA) { p = (ScaledUnit) p.getNextLexicalUnit(); } - ParsedStops st = parseStops(p); + // A length on a radial gradient is measured along its horizontal + // radius, which the descriptor works out from the shape and extent. + double stopBasis = 0; + if (rasterWidth > 0 && rasterHeight > 0) { + float[] geometry = new float[4]; + new RadialGradient(new int[] {0, 0}, new float[] {0f, 1f}) + .setShape(shape).setExtent(extent) + .setRelativeCenterX(cx).setRelativeCenterY(cy) + .setRelativeRadiusX(rx).setRelativeRadiusY(ry) + .computeRadii(rasterWidth, rasterHeight, geometry); + stopBasis = geometry[2]; + } + ParsedStops st = parseStops(p, stopBasis); if (st == null || st.colors.length < 2) { reason = "Could not parse stops for radial-gradient"; return; @@ -483,8 +619,14 @@ private void parseConicGradient(ScaledUnit background) { } else if ("at".equals(s)) { ScaledUnit nx = (ScaledUnit) p.getNextLexicalUnit(); if (nx != null && isIdentLike(nx)) { - float[] pos = cssPositionKeyword(identValue(nx)); - cx = pos[0]; cy = pos[1]; + float[] centre = {cx, cy}; + applyPositionKeyword(identValue(nx), centre); + ScaledUnit second = (ScaledUnit) nx.getNextLexicalUnit(); + if (second != null && isIdentLike(second)) { + applyPositionKeyword(identValue(second), centre); + nx = second; + } + cx = centre[0]; cy = centre[1]; p = nx; } else if (nx != null && nx.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { cx = (float) (nx.getNumericValue() / 100f); @@ -508,7 +650,7 @@ private void parseConicGradient(ScaledUnit background) { if (consumedHeader && p != null && p.getLexicalUnitType() == LexicalUnit.SAC_OPERATOR_COMMA) { p = (ScaledUnit) p.getNextLexicalUnit(); } - ParsedStops st = parseStops(p); + ParsedStops st = parseStops(p, 0); if (st == null || st.colors.length < 2) { reason = "Could not parse stops for conic-gradient"; return; @@ -538,15 +680,46 @@ private static float cssSideToAngle(String s1, String s2) { return 180f; } - private static float[] cssPositionKeyword(String s) { - float cx = 0.5f, cy = 0.5f; - if ("left".equals(s)) cx = 0f; - else if ("right".equals(s)) cx = 1f; - else if ("top".equals(s)) cy = 0f; - else if ("bottom".equals(s)) cy = 1f; - return new float[]{cx, cy}; + /// Applies one position keyword to a centre, touching only the axis it + /// names, so `at top left` lands in the corner rather than on whichever + /// edge was written last. + private static void applyPositionKeyword(String s, float[] centre) { + if ("left".equals(s)) { + centre[0] = 0f; + } else if ("right".equals(s)) { + centre[0] = 1f; + } else if ("top".equals(s)) { + centre[1] = 0f; + } else if ("bottom".equals(s)) { + centre[1] = 1f; + } } + private static boolean isLength(int type) { + switch (type) { + case LexicalUnit.SAC_PIXEL: + case LexicalUnit.SAC_POINT: + case LexicalUnit.SAC_MILLIMETER: + case LexicalUnit.SAC_CENTIMETER: + case LexicalUnit.SAC_INCH: + case LexicalUnit.SAC_EM: + return true; + default: + return false; + } + } + + /// Size of the box a gradient is being parsed for, or 0 when it is + /// parsed as a resolution-independent theme entry. + /// + /// A native gradient is described in fractions of whatever box it is + /// later painted into, so a length in it (a stop at `20px`, a centre at + /// `10px 10px`) cannot be recorded and the parse is declined. A gradient + /// being painted into a generated image has a known box, and the length + /// is converted against it. + int rasterWidth; + int rasterHeight; + private static final class ParsedStops { int[] colors; float[] positions; @@ -555,7 +728,12 @@ private static final class ParsedStops { /// Parses a comma-separated stops list (color, color stop?, ...) into /// arrays of ARGB ints and [0,1] positions. Missing positions are /// auto-distributed linearly between the surrounding fixed positions. - private static ParsedStops parseStops(ScaledUnit start) { + /// + /// A position may be a percentage, an angle (the stops of a conic + /// gradient, as a fraction of the full turn), or a length when + /// `lengthBasis` is positive: the length the gradient runs over, in the + /// pixels of the image it is being painted into. + private static ParsedStops parseStops(ScaledUnit start, double lengthBasis) { if (start == null) return null; java.util.ArrayList colors = new java.util.ArrayList<>(); java.util.ArrayList positions = new java.util.ArrayList<>(); @@ -581,6 +759,19 @@ private static ParsedStops parseStops(ScaledUnit start) { if (nx != null && nx.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { pos = (float) (nx.getNumericValue() / 100f); nx = (ScaledUnit) nx.getNextLexicalUnit(); + } else if (nx != null && nx.getLexicalUnitType() == LexicalUnit.SAC_DEGREE) { + pos = (float) (nx.getNumericValue() / 360.0); + nx = (ScaledUnit) nx.getNextLexicalUnit(); + } else if (nx != null && nx.getLexicalUnitType() == LexicalUnit.SAC_RADIAN) { + pos = (float) (nx.getNumericValue() / (2 * Math.PI)); + nx = (ScaledUnit) nx.getNextLexicalUnit(); + } else if (nx != null && isLength(nx.getLexicalUnitType())) { + if (lengthBasis <= 0) { + // No box to measure the length in; see rasterWidth. + return null; + } + pos = (float) (rasterLength(nx, lengthBasis) / lengthBasis); + nx = (ScaledUnit) nx.getNextLexicalUnit(); } colors.add(argb); positions.add(pos); @@ -780,7 +971,12 @@ private void parseRadialGradient(ScaledUnit background) { } - break; + // A length after 'at' is outside this parser's + // grammar. Leaving the switch without advancing + // param1 would spin the enclosing loop forever, + // so decline and let the extended parser take it. + reason = "Unsupported position after 'at' in radial-gradient. [4b]"; + return; } @@ -790,7 +986,17 @@ private void parseRadialGradient(ScaledUnit background) { return; } + break; } + default: + // Anything that is not a keyword -- most commonly the + // first colour of the ordinary radial-gradient(#fff, #000), + // or a length such as 'circle 40px' -- is not this + // parser's to read. It must return rather than fall out + // of the switch: nothing has advanced param1, and the + // loop condition would stay true forever. + reason = "Unsupported first argument for radial-gradient. [8b]"; + return; } } if (param2 == null) { @@ -1746,28 +1952,9 @@ String renderCSSProperty(String property, Map styles) { } } - public String getHtmlPreview() { - StringBuilder sb = new StringBuilder(); - sb.append("\n"); - for (String name : elements.keySet()) { - Element el = (Element)elements.get(name); - sb.append("

").append(name).append("

") - .append(el.getHtmlPreview()) - .append("

::Unselected

") - .append(el.getUnselected().getHtmlPreview()) - .append("

::Selected

") - .append(el.getSelected().getHtmlPreview()) - .append("

::Pressed

") - .append(el.getPressed().getHtmlPreview()) - .append("

::Disabled

") - .append(el.getDisabled().getHtmlPreview()) - .append("
"); - } - sb.append(""); - return sb.toString(); - } - - public boolean requiresCaptureHtml() { + /// True when at least one modified rule needs a generated image, either a + /// 9-piece border or a stretched background. + public boolean requiresRasterization() { for (String name : elements.keySet()) { if (!isModified(name)) { continue; @@ -1802,49 +1989,6 @@ public boolean requiresCaptureHtml() { return false; } - public String generateCaptureHtml() { - StringBuilder sb = new StringBuilder(); - sb.append("\n" - + ""); - for (String name : elements.keySet()) { - if (!isModified(name)) { - continue; - } - - - Element el = (Element)elements.get(name); - Map unselectedStyle = el.getUnselected().getFlattenedStyle(); - if (el.requiresBackgroundImageGeneration(unselectedStyle) || el.requiresImageBorder(unselectedStyle)) { - sb.append(el.getUnselected().getEmptyHtmlWithId(name, unselectedStyle)); - } - Map selectedStyle = el.getSelected().getFlattenedStyle(); - if (el.requiresBackgroundImageGeneration(selectedStyle) || el.requiresImageBorder(selectedStyle)) { - sb.append(el.getSelected().getEmptyHtmlWithId(name+".sel", selectedStyle)); - } - Map pressedStyle = el.getPressed().getFlattenedStyle(); - if (el.requiresBackgroundImageGeneration(pressedStyle) || el.requiresImageBorder(pressedStyle)) { - sb.append(el.getPressed().getEmptyHtmlWithId(name+".press", pressedStyle)); - } - // Keep the DOM IDs aligned with the hover processors registered by createImageBorders. - if (el.declaresHover()) { - Map hoverStyle = el.getHover().getFlattenedStyle(); - if (el.requiresBackgroundImageGeneration(hoverStyle) || el.requiresImageBorder(hoverStyle)) { - sb.append(el.getHover().getEmptyHtmlWithId(name+".hover", hoverStyle)); - } - } - Map disabledStyle = el.getDisabled().getFlattenedStyle(); - if (el.requiresBackgroundImageGeneration(disabledStyle) || el.requiresImageBorder(disabledStyle)) { - sb.append(el.getDisabled().getEmptyHtmlWithId(name+".dis", disabledStyle)); - } - - } - sb.append(""); - return sb.toString(); - } - public Map calculateSelectorChecksums() { Map out = new LinkedHashMap(); for (String id : elements.keySet()) { @@ -2361,7 +2505,12 @@ public void updateResources() { for (String id : modifiedIds) { for (String key : keys) { - if (isOwnedBy(key, id)) { + // createImageBorders has already run and stored the + // generated border or background under a key this UIID + // owns. Nothing below writes that key again for a rule + // that needs an image, so clearing it here would leave + // the images in the file and the theme not using them. + if (isOwnedBy(key, id) && !generatedImageKeys.contains(key)) { res.setThemeProperty(themeName, key, null); } } @@ -3314,8 +3463,7 @@ public Image getBackgroundImage(Map styles, ScaledUnit bgIma // file name so the theme references it by name; the runtime // com.codename1.generated.svg.SVGRegistry (when present) then // overrides the entry with the transcoded image during init(). - String lowerName = fileName.toLowerCase(); - if (lowerName.endsWith(".svg") || lowerName.endsWith(".json")) { + if (endsWithIgnoreCase(fileName, ".svg") || endsWithIgnoreCase(fileName, ".json")) { Image placeholder = registerSVGPlaceholder(fileName); if (placeholder != null) { loadedImages.put(url, placeholder); @@ -3498,318 +3646,307 @@ public void loadResourceFile() throws IOException { } } - public static interface WebViewProvider { - com.codename1.ui.BrowserComponent getWebView(); + private final Set importedFiles = new LinkedHashSet(); + + /// Every stylesheet pulled in through `@import`, directly or transitively, + /// in inclusion order. A caller deciding whether the output is stale, or + /// watching for edits, has to consider these as well as the root file. + public Set getImportedFiles() { + return java.util.Collections.unmodifiableSet(importedFiles); } + private static String currentId; - private void enforceNoCef() { + /// Theme keys [#createImageBorders()] stored a generated image under, which + /// [#updateResources()] must leave in place. + private final Set generatedImageKeys = new HashSet(); + + /// The state elements of one UIID in the order images are generated for + /// them, each with the suffix its generated image is reported under and + /// the prefix of its theme keys. + private static final String[] RASTER_STATE_LABELS = {"unselected", "selected", "pressed", "hover", "disabled"}; + private static final String[] RASTER_KEY_PREFIXES = {"", "sel#", "press#", "hover#", "dis#"}; + + private Element[] rasterStates(Element e) { + // No hover rule, no work: above all, no image generated under a hover# + // key nothing asked for. + return new Element[] { + e.getUnselected(), e.getSelected(), e.getPressed(), e.declaresHover() ? e.getHover() : null, e.getDisabled() + }; + } + + private void enforceNoRasterization() { List offenders = new ArrayList(); - String[] states = new String[] {"unselected", "selected", "pressed", "hover", "disabled"}; for (String id : elements.keySet()) { if (!isModified(id)) { continue; } Element e = (Element) elements.get(id); - Element[] stateElements = new Element[] { - e.getUnselected(), e.getSelected(), e.getPressed(), e.getHover(), e.getDisabled() - }; + Element[] stateElements = rasterStates(e); for (int i = 0; i < stateElements.length; i++) { + if (stateElements[i] == null) { + continue; + } Map styles = (Map) stateElements[i].getFlattenedStyle(); if (e.requiresImageBorder(styles)) { - offenders.add(id + "." + states[i] + " (image border)"); + offenders.add(id + "." + RASTER_STATE_LABELS[i] + " (image border)"); } else if (e.requiresBackgroundImageGeneration(styles)) { - offenders.add(id + "." + states[i] + " (background image)"); + offenders.add(id + "." + RASTER_STATE_LABELS[i] + " (background image)"); } } } if (!offenders.isEmpty()) { StringBuilder sb = new StringBuilder(); - sb.append("CSS rules require CEF-backed image rasterization, which is disabled "); - sb.append("in no-cef mode (native-themes build). Offending rules:\n"); + sb.append("CSS rules need a generated image, which this build does not allow "); + sb.append("(-no-raster). Offending rules:\n"); for (String o : offenders) { sb.append(" - ").append(o).append("\n"); } - sb.append("Fix: avoid box-shadow, border-radius combined with a visible border, "); - sb.append("mixed-side borders, filter, and complex gradients. "); - sb.append("Use cn1-round-border / cn1-pill-border or solid backgrounds instead. "); - sb.append("If the effect is genuinely required, extend the CSS compiler and/or "); - sb.append("the resource format with a native primitive rather than rasterizing."); + sb.append("An image is generated for cn1-9patch, cn1-background-type: cn1-image-border, "); + sb.append("a background-image or gradient the theme format cannot express combined with a "); + sb.append("border or radius, and a box-shadow that is blurred, inset, tinted or has no "); + sb.append("spread. Use a native primitive instead (cn1-round-border, cn1-pill-border, a "); + sb.append("plain border-radius, a supported gradient, an unblurred shadow), or extend the "); + sb.append("theme format with one."); throw new IllegalStateException(sb.toString()); } } - public void createImageBorders(WebViewProvider webviewProvider) { + /// Generates the image for every modified rule that has no native + /// equivalent: a 9-piece border where the box can stretch, one scaled + /// background image where it cannot (a percentage size, a bare gradient). + /// + /// Each box is painted in process by [CssBoxRasterizer], so this needs no + /// display and no browser. States are visited in a fixed order because the + /// generated images are numbered as they are stored. + public void createImageBorders() { if (res == null) { res = new EditableResourcesForCSS(resourceFile); } - if (strictNoCef) { - enforceNoCef(); + if (!rasterizationAllowed) { + enforceNoRasterization(); } ArrayList borders = new ArrayList(); - + ResourcesMutator resm = new ResourcesMutator(res, Display.DENSITY_VERY_HIGH, minDpi, maxDpi); resm.targetDensity = targetDensity; - - - List onComplete = new ArrayList(); + CssBoxRasterizer rasterizer = new CssBoxRasterizer(); + for (String id : elements.keySet()) { - try { - if (!isModified(id)) { + if (!isModified(id)) { + continue; + } + Element e = (Element) elements.get(id); + currentId = id; + Element[] stateElements = rasterStates(e); + for (int i = 0; i < stateElements.length; i++) { + if (stateElements[i] == null) { continue; } - Element e = (Element) elements.get(id); - - Element unselected = e.getUnselected(); - Map unselectedStyles = (Map) unselected.getFlattenedStyle(); - Border b = unselected.createBorder(unselectedStyles); - Border unselectedBorder = b; - currentId = id; - if (e.requiresImageBorder(unselectedStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id, (img) -> { - - Insets insets = unselected.getImageBorderInsets(unselectedStyles, img.getWidth(), img.getHeight()); - resm.targetDensity = getSourceDensity(unselectedStyles, resm.targetDensity); - com.codename1.ui.plaf.Border border = resm.create9PieceBorder(img, id, (int) insets.top, (int) insets.right, (int) insets.bottom, (int) insets.left); - resm.put(id + ".border", border); - unselectedBorder.border = border; - resm.targetDensity = targetDensity; - }); - } else { - onComplete.add(() -> { - resm.put(id + ".border", borders.get(borders.indexOf(unselectedBorder)).border); - }); - - } - } else if (e.requiresBackgroundImageGeneration(unselectedStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id, (img) -> { - int i = 1; - while (res.containsResource(id + "_" + i + ".png")) { - i++; - } - String prefix = id + "_" + i + ".png"; - resm.targetDensity = getSourceDensity(unselectedStyles, resm.targetDensity); - Image im = resm.storeImage(EncodedImage.create(ResourcesMutator.toPngOrJpeg(img)), prefix, false); - unselectedBorder.image = im; - resm.put(id + ".bgImage", im); - resm.targetDensity = targetDensity; - //resm.put(id+".press#bgType", Style.B) - }); - } else { - onComplete.add(() -> { - resm.put(id + ".bgImage", unselectedBorder.image); - }); - } + try { + createImageBorder(e, stateElements[i], id, RASTER_KEY_PREFIXES[i], borders, resm, rasterizer); + } catch (RuntimeException ex) { + throw new RuntimeException("Could not generate the image for the " + RASTER_STATE_LABELS[i] + + " style of " + id + ": " + ex.getMessage(), ex); } + } + } + } - Element selected = e.getSelected(); - Map selectedStyles = (Map) selected.getFlattenedStyle(); - b = selected.createBorder(selectedStyles); - Border selectedBorder = b; - if (e.requiresImageBorder(selectedStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id + ".sel", (img) -> { - Insets insets = selected.getImageBorderInsets(selectedStyles, img.getWidth(), img.getHeight()); - resm.targetDensity = getSourceDensity(selectedStyles, resm.targetDensity); - com.codename1.ui.plaf.Border border = resm.create9PieceBorder(img, id, (int) insets.top, (int) insets.right, (int) insets.bottom, (int) insets.left); - resm.put(id + ".sel#border", border); - selectedBorder.border = border; - resm.targetDensity = targetDensity; - }); - } else { - onComplete.add(() -> { - resm.put(id + ".sel#border", borders.get(borders.indexOf(selectedBorder)).border); - }); + private void createImageBorder(Element owner, Element state, String id, String keyPrefix, + List borders, ResourcesMutator resm, CssBoxRasterizer rasterizer) { + Map styles = (Map) state.getFlattenedStyle(); + boolean imageBorder = owner.requiresImageBorder(styles); + if (!imageBorder && !owner.requiresBackgroundImageGeneration(styles)) { + return; + } + Border b = state.createBorder(styles); + String key = id + "." + keyPrefix + (imageBorder ? "border" : "bgImage"); + + // Two states (or two UIIDs) that describe the same box share one image. + // The image has to be read from the Border that was rasterized, which + // is the one already in the list: `b` is a fresh, equal instance that + // has never been given an image. + int known = borders.indexOf(b); + if (known >= 0) { + Border first = borders.get(known); + Object shared = imageBorder ? (Object) first.border : (Object) first.image; + if (shared != null) { + resm.put(key, shared); + generatedImageKeys.add(key); + return; + } + } else { + borders.add(b); + } - } - } else if (e.requiresBackgroundImageGeneration(selectedStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id + ".sel", (img) -> { - int i = 1; - while (res.containsResource(id + "_" + i + ".png")) { - i++; - } - String prefix = id + "_" + i + ".png"; - - resm.targetDensity = getSourceDensity(selectedStyles, resm.targetDensity); - Image im = resm.storeImage(EncodedImage.create(ResourcesMutator.toPngOrJpeg(img)), prefix, false); - selectedBorder.image = im; - resm.put(id + ".sel#bgImage", im); - //resm.put(id+".press#bgType", Style.B) - resm.targetDensity = targetDensity; - }); - } else { - onComplete.add(() -> { - resm.put(id + ".sel#bgImage", selectedBorder.image); - }); - } + BufferedImage img = rasterizer.rasterize(state.toBoxStyle(styles)); + resm.targetDensity = getSourceDensity(styles, resm.targetDensity); + try { + if (imageBorder) { + Insets insets = state.getImageBorderInsets(styles, img.getWidth(), img.getHeight()); + com.codename1.ui.plaf.Border border = resm.create9PieceBorder(img, id, + (int) insets.top, (int) insets.right, (int) insets.bottom, (int) insets.left); + resm.put(key, border); + generatedImageKeys.add(key); + b.border = border; + if (known >= 0) { + borders.get(known).border = border; } - - Element pressed = e.getPressed(); - Map pressedStyles = (Map) pressed.getFlattenedStyle(); - - b = pressed.createBorder(pressedStyles); - Border pressedBorder = b; - if (e.requiresImageBorder(pressedStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id + ".press", (img) -> { - Insets insets = pressed.getImageBorderInsets(pressedStyles, img.getWidth(), img.getHeight()); - - resm.targetDensity = getSourceDensity(pressedStyles, resm.targetDensity); - com.codename1.ui.plaf.Border border = resm.create9PieceBorder(img, id, (int) insets.top, (int) insets.right, (int) insets.bottom, (int) insets.left); - - resm.put(id + ".press#border", border); - pressedBorder.border = border; - resm.targetDensity = targetDensity; - }); - } else { - onComplete.add(() -> { - resm.put(id + ".press#border", borders.get(borders.indexOf(pressedBorder)).border); - }); - - } - } else if (e.requiresBackgroundImageGeneration(pressedStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id + ".press", (img) -> { - int i = 1; - while (res.containsResource(id + "_" + i + ".png")) { - i++; - } - String prefix = id + "_" + i + ".png"; - resm.targetDensity = getSourceDensity(pressedStyles, resm.targetDensity); - Image im = resm.storeImage(EncodedImage.create(ResourcesMutator.toPngOrJpeg(img)), prefix, false); - pressedBorder.imageId = prefix; - resm.put(id + ".press#bgImage", im/*res.findId(im, true)*/); - resm.targetDensity = targetDensity; - //resm.put(id+".press#bgType", Style.B) - }); - } else { - onComplete.add(() -> { - resm.put(id + ".press#bgImage", res.findId(pressedBorder.imageId, true)); - }); - } + } else { + int i = 1; + while (res.containsResource(id + "_" + i + ".png")) { + i++; } + String name = id + "_" + i + ".png"; + Image im = resm.storeImage(EncodedImage.create(ResourcesMutator.toPngOrJpeg(img)), name, false); + resm.put(key, im); + generatedImageKeys.add(key); + b.image = im; + if (known >= 0) { + borders.get(known).image = im; + } + } + } finally { + resm.targetDensity = targetDensity; + } + } - // Same declaration guard as the property emission: no hover rule, no work, - // and above all no image generated under a .hover# key nothing asked for. - if (e.declaresHover()) { - Element hover = e.getHover(); - Map hoverStyles = (Map) hover.getFlattenedStyle(); - - b = hover.createBorder(hoverStyles); - Border hoverBorder = b; - if (e.requiresImageBorder(hoverStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id + ".hover", (img) -> { - Insets insets = hover.getImageBorderInsets(hoverStyles, img.getWidth(), img.getHeight()); + /// See [Element#getChecksum()]. + private static final int RASTER_GENERATION = 1; - resm.targetDensity = getSourceDensity(hoverStyles, resm.targetDensity); - com.codename1.ui.plaf.Border border = resm.create9PieceBorder(img, id, (int) insets.top, (int) insets.right, (int) insets.bottom, (int) insets.left); + /// Width of the box a rule is laid out in when it does not give one, and + /// the reference for a percentage width. + /// + /// The generated images were always measured on a 640x960 page: an element + /// with no `width` filled that page, and one with no `height` was given + /// [#RASTER_DEFAULT_CONTENT_HEIGHT]. The 9-piece insets are computed from + /// the image that results, so these are part of what a compiled theme + /// looks like and must not drift. + static final int RASTER_PAGE_WIDTH = 640; - resm.put(id + ".hover#border", border); - hoverBorder.border = border; - resm.targetDensity = targetDensity; - }); - } else { - onComplete.add(() -> { - resm.put(id + ".hover#border", borders.get(borders.indexOf(hoverBorder)).border); - }); + /// Height that goes with [#RASTER_PAGE_WIDTH]. + static final int RASTER_PAGE_HEIGHT = 960; - } - } else if (e.requiresBackgroundImageGeneration(hoverStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id + ".hover", (img) -> { - int i = 1; - while (res.containsResource(id + "_" + i + ".png")) { - i++; - } - String prefix = id + "_" + i + ".png"; - resm.targetDensity = getSourceDensity(hoverStyles, resm.targetDensity); - Image im = resm.storeImage(EncodedImage.create(ResourcesMutator.toPngOrJpeg(img)), prefix, false); - hoverBorder.imageId = prefix; - resm.put(id + ".hover#bgImage", im/*res.findId(im, true)*/); - resm.targetDensity = targetDensity; - //resm.put(id+".hover#bgType", Style.B) - }); - } else { - onComplete.add(() -> { - resm.put(id + ".hover#bgImage", res.findId(hoverBorder.imageId, true)); - }); - } - } - } + /// Content height of a box whose rule sets no `height`. + static final int RASTER_DEFAULT_CONTENT_HEIGHT = 100; - Element disabled = e.getDisabled(); - Map disabledStyles = (Map) disabled.getFlattenedStyle(); + /// CSS pixels per CSS millimetre (96 per inch). + private static final double CSS_PX_PER_MM = 96.0 / 25.4; - b = disabled.createBorder(disabledStyles); - Border disabledBorder = b; - if (e.requiresImageBorder(disabledStyles)) { - if (!borders.contains(b)) { + /// The length of `value` in the pixels of a generated image. + /// + /// This is deliberately not [ScaledUnit#getPixelValue()]. A physical unit + /// is first rescaled from the unit's own density to the 160dpi the page is + /// described in and only then converted at the CSS rate, which puts one + /// millimetre at about 11.3 image pixels where `getPixelValue()` answers + /// 18.9. The 9-piece insets are computed with `getPixelValue()`, so the two + /// disagree, and always have: every theme compiled so far has its image + /// borders drawn at this scale, and reconciling them would resize the + /// radius and border of each one. + static double rasterLength(LexicalUnit value, double percentBase) { + if (value == null) { + return 0; + } + double density = value instanceof ScaledUnit ? ((ScaledUnit) value).dpi : 160; + switch (value.getLexicalUnitType()) { + case LexicalUnit.SAC_PIXEL: + case LexicalUnit.SAC_INTEGER: + case LexicalUnit.SAC_REAL: + return numericValue(value); + case LexicalUnit.SAC_POINT: + return numericValue(value) * density / 160.0; + case LexicalUnit.SAC_MILLIMETER: + return numericValue(value) * density / 160.0 * CSS_PX_PER_MM; + case LexicalUnit.SAC_CENTIMETER: + return numericValue(value) * density / 160.0 * CSS_PX_PER_MM * 10; + case LexicalUnit.SAC_INCH: + return numericValue(value) * density / 160.0 * 96.0; + case LexicalUnit.SAC_PERCENTAGE: + return numericValue(value) / 100.0 * percentBase; + case LexicalUnit.SAC_EM: + return numericValue(value) * 16.0; + case LexicalUnit.SAC_IDENT: { + String keyword = value.getStringValue(); + if ("thin".equals(keyword)) { + return 1; + } + if ("medium".equals(keyword)) { + return 3; + } + if ("thick".equals(keyword)) { + return 5; + } + if ("none".equals(keyword) || "auto".equals(keyword)) { + return 0; + } + break; + } + default: { + String unit; + try { + unit = value.getDimensionUnitText(); + } catch (RuntimeException notADimension) { + unit = null; + } + if ("rem".equals(unit)) { + return numericValue(value) * 16.0; + } + if ("vw".equals(unit) || "vmin".equals(unit)) { + return numericValue(value) / 100.0 * RASTER_PAGE_WIDTH; + } + if ("vh".equals(unit) || "vmax".equals(unit)) { + return numericValue(value) / 100.0 * RASTER_PAGE_HEIGHT; + } + } + } + throw new IllegalArgumentException("Unsupported length " + value + " in a rule that needs a generated image"); + } - borders.add(b); - resm.addImageProcessor(id + ".dis", (img) -> { - Insets disabledInsets = disabled.getImageBorderInsets(disabledStyles, img.getWidth(), img.getHeight()); + private static double numericValue(LexicalUnit value) { + return value.getLexicalUnitType() == LexicalUnit.SAC_INTEGER ? value.getIntegerValue() : value.getFloatValue(); + } - resm.targetDensity = getSourceDensity(disabledStyles, resm.targetDensity); - com.codename1.ui.plaf.Border border = resm.create9PieceBorder(img, id, (int) disabledInsets.top, (int) disabledInsets.right, (int) disabledInsets.bottom, (int) disabledInsets.left); - disabledBorder.border = border; - resm.put(id + ".dis#border", border); - resm.targetDensity = targetDensity; - }); - } else { - onComplete.add(() -> { - resm.put(id + ".dis#border", borders.get(borders.indexOf(disabledBorder)).border); - }); + /// The colour of `value` as non-premultiplied ARGB, or `fallback` when the + /// property is not set. + private static int rasterColor(LexicalUnit value, int fallback) { + if (value == null) { + return fallback; + } + if (value.getLexicalUnitType() == LexicalUnit.SAC_IDENT) { + String keyword = value.getStringValue(); + if ("transparent".equals(keyword) || "none".equals(keyword)) { + return 0; + } + if ("currentColor".equalsIgnoreCase(keyword) || "inherit".equals(keyword)) { + return fallback; + } + } + Integer alpha = getColorAlphaInt(value); + int a = alpha == null ? 0xff : Math.max(0, Math.min(0xff, alpha.intValue())); + return (a << 24) | (getColorInt(value) & 0xffffff); + } - } - } else if (e.requiresBackgroundImageGeneration(disabledStyles)) { - if (!borders.contains(b)) { - borders.add(b); - resm.addImageProcessor(id + ".dis", (img) -> { - int i = 1; - while (res.containsResource(id + "_" + i + ".png")) { - i++; - } - String prefix = id + "_" + i + ".png"; - resm.targetDensity = getSourceDensity(disabledStyles, resm.targetDensity); - Image im = resm.storeImage(EncodedImage.create(ResourcesMutator.toPngOrJpeg(img)), prefix, false); - disabledBorder.image = im; - resm.put(id + ".dis#bgImage", im); - resm.targetDensity = targetDensity; - //resm.put(id+".press#bgType", Style.B) - }); - } else { - onComplete.add(() -> { - resm.put(id + ".dis#bgImage", disabledBorder.image); - }); - } + private BufferedImage readRasterImage(String url) { + try { + URL imgURL = url.startsWith("http://") || url.startsWith("https://") ? new URL(url) : new URL(baseURL, url); + InputStream in = imgURL.openStream(); + try { + BufferedImage img = javax.imageio.ImageIO.read(in); + if (img == null) { + throw new IllegalArgumentException(url + " is not an image the compiler can decode (PNG, JPEG, " + + "GIF or BMP). A vector image cannot be painted into a generated border or background."); } - } catch (Exception ex) { - throw new RuntimeException("An exception occurred while processing the image border for element "+id, ex); + return img; + } finally { + in.close(); } + } catch (IOException ex) { + throw new IllegalArgumentException("Could not read " + url + ": " + ex.getMessage(), ex); } - - if (requiresCaptureHtml()) { - resm.createScreenshots(webviewProvider.getWebView(), generateCaptureHtml(), this.baseURL.toExternalForm()); - } - for (Runnable r : onComplete) { - r.run(); - } - } - + public void save(File outputFile) throws IOException { DataOutputStream resFile = new DataOutputStream(new FileOutputStream(outputFile)); res.save(resFile); @@ -4280,7 +4417,13 @@ public class Element { public String getChecksum() { StringBuilder sb = new StringBuilder(); - sb.append("STYLE=").append(this.getFlattenedStyle()) + // The checksum decides whether the images already stored for this + // selector can be reused. It therefore has to cover how they were + // produced as well as what the rule says: bump the generation when + // a change to the rasterizer or its geometry should invalidate + // every cached image, including ones a browser rendered. + sb.append("RASTER=").append(RASTER_GENERATION) + .append(";STYLE=").append(this.getFlattenedStyle()) .append(";UNSELECTED=").append(this.getFlattenedUnselectedStyle()) .append(";SELECTED=").append(this.getFlattenedSelectedStyle()) .append(";PRESSED=").append(this.getFlattenedPressedStyle()) @@ -4320,67 +4463,209 @@ Insets getBoxShadowPadding(Map style) { return i; } - String generateBoxShadowPaddingString() { - StringBuilder sb = new StringBuilder(); - Map styles = new LinkedHashMap(); - styles.putAll(getFlattenedStyle()); - - return ""+getBoxShadowPadding(styles); + private BorderSide rasterBorderSide(Map styles, String side, int currentColor) { + LexicalUnit styleUnit = styles.get("border-" + side + "-style"); + BorderStyle style = BorderStyle.parse(styleUnit == null ? null : styleUnit.getStringValue()); + LexicalUnit widthUnit = styles.get("border-" + side + "-width"); + double width; + if (style == BorderStyle.NONE || style == BorderStyle.HIDDEN) { + // A width with no style draws nothing and takes no room. + width = 0; + } else if (widthUnit == null) { + // CSS's initial border-width is `medium`. + width = 3; + } else { + width = Math.max(0, rasterLength(widthUnit, 0)); + } + return new BorderSide(width, style, rasterColor(styles.get("border-" + side + "-color"), currentColor)); } - - String generateStyleCSS() { - Map styles = new LinkedHashMap(); - styles.putAll(getFlattenedStyle()); - try { - StringBuilder sb = new StringBuilder(); + private boolean isUnsetLength(LexicalUnit value) { + return value == null || (value.getLexicalUnitType() == LexicalUnit.SAC_IDENT + && ("auto".equals(value.getStringValue()) || "none".equals(value.getStringValue()))); + } - if (this.requiresImageBorder(styles)) { - if (styles.get("min-height") != null) { - styles.put("height", styles.get("min-height")); - } - if (styles.get("min-width") != null) { - styles.put("width", styles.get("min-width")); - } - } + /// Describes the box of one state for [CssBoxRasterizer]. + /// + /// Only what paints is carried over: backgrounds, borders, radii and the + /// shadow. Padding and margin are layout the runtime applies, opacity is + /// a native style property, and nothing else in a rule draws on an empty + /// box. The geometry -- a 640-wide page, a 100px default height, the + /// shadow margin of [#getBoxShadowPadding] -- is fixed, because the + /// 9-piece insets are derived from the size of the image it produces. + BoxStyle toBoxStyle(Map flattened) { + Map styles = new LinkedHashMap(flattened); + if (this.requiresImageBorder(styles)) { + // A 9-piece border is cut from the smallest box the rule allows. + if (styles.get("min-height") != null) { + styles.put("height", styles.get("min-height")); + } + if (styles.get("min-width") != null) { + styles.put("width", styles.get("min-width")); + } + } + + int currentColor = rasterColor(styles.get("color"), 0xff000000); + BorderSide top = rasterBorderSide(styles, "top", currentColor); + BorderSide right = rasterBorderSide(styles, "right", currentColor); + BorderSide bottom = rasterBorderSide(styles, "bottom", currentColor); + BorderSide left = rasterBorderSide(styles, "left", currentColor); + + Insets shadowPad = getBoxShadowPadding(styles); + int padTop = (int) shadowPad.top; + int padRight = (int) shadowPad.right; + int padBottom = (int) shadowPad.bottom; + int padLeft = (int) shadowPad.left; + + // width and height size the content box (the page is content-box and + // padding is never painted), so the borders are added on top. A box + // with no width fills the page between its shadow margins. + LexicalUnit widthUnit = styles.get("width"); + double boxWidth; + if (isUnsetLength(widthUnit)) { + boxWidth = RASTER_PAGE_WIDTH - padLeft - padRight; + } else if (widthUnit.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { + boxWidth = (int) (widthUnit.getFloatValue() / 100f * RASTER_PAGE_WIDTH) + left.getWidth() + right.getWidth(); + } else { + boxWidth = rasterLength(widthUnit, RASTER_PAGE_WIDTH) + left.getWidth() + right.getWidth(); + } + LexicalUnit heightUnit = styles.get("height"); + double boxHeight; + if (isUnsetLength(heightUnit)) { + boxHeight = RASTER_DEFAULT_CONTENT_HEIGHT + top.getWidth() + bottom.getWidth(); + } else if (heightUnit.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { + boxHeight = (int) (heightUnit.getFloatValue() / 100f * RASTER_PAGE_HEIGHT) + top.getWidth() + bottom.getWidth(); + } else { + boxHeight = rasterLength(heightUnit, RASTER_PAGE_HEIGHT) + top.getWidth() + bottom.getWidth(); + } + if (boxWidth < 1 || boxHeight < 1) { + throw new IllegalArgumentException("the box is " + boxWidth + "x" + boxHeight + + " pixels; a generated image needs a width and height of at least one pixel"); + } - if (styles.get("height") == null) { - styles.put("height", new ScaledUnit(new PixelUnit(100), 320, 640, 960)); - } - //styles.put("margin", new ScaledUnit(new PixelUnit(1), 144, 640, 960)); + String[] corners = {"top-left", "top-right", "bottom-right", "bottom-left"}; + double[] radii = new double[8]; + for (int c = 0; c < corners.length; c++) { + radii[c * 2] = Math.max(0, rasterLength(nullIfNone(styles.get("cn1-border-" + corners[c] + "-radius-x")), boxWidth)); + radii[c * 2 + 1] = Math.max(0, rasterLength(nullIfNone(styles.get("cn1-border-" + corners[c] + "-radius-y")), boxHeight)); + } - for (Object key : styles.keySet()) { - String property = (String) key; - LexicalUnit value = (LexicalUnit) styles.get(key); - String prop = renderCSSProperty(property, styles); - if (!prop.isEmpty()) { - sb.append(prop).append(";"); - } + BoxStyle.Builder box = BoxStyle.builder() + .borderBoxWidth(boxWidth).borderBoxHeight(boxHeight) + .padTop(padTop).padRight(padRight).padBottom(padBottom).padLeft(padLeft) + .backgroundColor(rasterColor(styles.get("background-color"), 0)) + .top(top).right(right).bottom(bottom).left(left) + .radii(radii); + LexicalUnit background = styles.get("background"); + while (background != null) { + if (isGradient(background)) { + box.gradient(CN1Gradient.describeForRaster((ScaledUnit) background, (int) boxWidth, (int) boxHeight)); + break; } + background = background.getNextLexicalUnit(); + } - Insets shadowInset = getBoxShadowPadding(styles); - if (shadowInset.top > 0) { - sb.append("margin-top: ").append(shadowInset.top).append("px !important;"); - } - if (shadowInset.left > 0) { - sb.append("margin-left: ").append(shadowInset.left).append("px !important;"); - } - if (shadowInset.right > 0) { - sb.append("margin-right: ").append(shadowInset.right).append("px !important;"); - } - if (shadowInset.bottom > 0) { - sb.append("margin-bottom: ").append(shadowInset.bottom).append("px !important;"); + LexicalUnit bgImage = styles.get("background-image"); + if (bgImage != null && bgImage.getLexicalUnitType() == LexicalUnit.SAC_URI) { + box.backgroundImage(rasterBackgroundImage(styles, bgImage, boxWidth, boxHeight)); + } + + LexicalUnit shadowH = styles.get("cn1-box-shadow-h"); + if (shadowH != null && !"none".equals(shadowH.getStringValue())) { + LexicalUnit inset = styles.get("cn1-box-shadow-inset"); + box.shadow(new Shadow( + rasterLength(shadowH, 0), + rasterLength(nullIfNone(styles.get("cn1-box-shadow-v")), 0), + Math.max(0, rasterLength(nullIfNone(styles.get("cn1-box-shadow-blur")), 0)), + rasterLength(nullIfNone(styles.get("cn1-box-shadow-spread")), 0), + rasterColor(styles.get("cn1-box-shadow-color"), currentColor), + inset != null && "inset".equals(inset.getStringValue()))); + } + + LexicalUnit borderImage = styles.get("border-image"); + if (borderImage != null && borderImage.getLexicalUnitType() == LexicalUnit.SAC_URI) { + box.borderImage(rasterBorderImage(styles, borderImage)); + } + return box.build(); + } + + private LexicalUnit nullIfNone(LexicalUnit value) { + return isNone(value) ? null : value; + } + + private BackgroundImage rasterBackgroundImage(Map styles, LexicalUnit bgImage, + double boxWidth, double boxHeight) { + BufferedImage image = readRasterImage(bgImage.getStringValue()); + BackgroundImage.Repeat repeat = BackgroundImage.Repeat.REPEAT; + LexicalUnit repeatUnit = styles.get("background-repeat"); + if (repeatUnit != null && repeatUnit.getStringValue() != null) { + String keyword = repeatUnit.getStringValue(); + if ("no-repeat".equals(keyword)) { + repeat = BackgroundImage.Repeat.NO_REPEAT; + } else if ("repeat-x".equals(keyword)) { + repeat = BackgroundImage.Repeat.REPEAT_X; + } else if ("repeat-y".equals(keyword)) { + repeat = BackgroundImage.Repeat.REPEAT_Y; + } + } + BackgroundImage.Size size = BackgroundImage.Size.AUTO; + double sizeW = -1; + double sizeH = -1; + LexicalUnit sizeUnit = styles.get("background-size"); + if (sizeUnit != null) { + String keyword = sizeUnit.getLexicalUnitType() == LexicalUnit.SAC_IDENT ? sizeUnit.getStringValue() : null; + if ("cover".equals(keyword)) { + size = BackgroundImage.Size.COVER; + } else if ("contain".equals(keyword)) { + size = BackgroundImage.Size.CONTAIN; + } else if (!"auto".equals(keyword) || sizeUnit.getNextLexicalUnit() != null) { + size = BackgroundImage.Size.EXPLICIT; + sizeW = "auto".equals(keyword) ? -1 : rasterLength(sizeUnit, boxWidth); + LexicalUnit second = sizeUnit.getNextLexicalUnit(); + if (second != null) { + boolean auto = second.getLexicalUnitType() == LexicalUnit.SAC_IDENT + && "auto".equals(second.getStringValue()); + sizeH = auto ? -1 : rasterLength(second, boxHeight); + } } + } + // background-position is not a property the compiler keeps, so the + // image sits at the CSS initial position, the top left corner. + BackgroundImage layer = new BackgroundImage(image).withRepeat(repeat).withPosition(0, true, 0, true); + return size == BackgroundImage.Size.EXPLICIT ? layer.withSize(sizeW, sizeH) : layer.withSize(size); + } - //sb.append("border-top-right-radius: 10px / 20px;"); - return sb.toString(); - } catch (Exception ex) { - System.err.println("Failed to generate style CSS for style: " + styles + ". Message was "+ex.getMessage()); - throw ex; + private BorderImage rasterBorderImage(Map styles, LexicalUnit borderImage) { + BufferedImage image = readRasterImage(borderImage.getStringValue()); + // CSS reads one to four slice values as top, right, bottom, left with + // the usual shorthand fill-in. Only percentages ever reached the + // page these images used to be measured on; any other value was + // dropped there and the slice fell back to its initial 100%. + double[] slices = {100, 100, 100, 100}; + int count = 0; + LexicalUnit slice = styles.get("border-image-slice"); + while (slice != null && count < 4) { + if (slice.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { + slices[count++] = slice.getFloatValue(); + } + slice = slice.getNextLexicalUnit(); + } + if (count == 1) { + slices[1] = slices[0]; } + if (count <= 2) { + slices[2] = slices[0]; + } + if (count <= 3) { + slices[3] = slices[1]; + } + int w = image.getWidth(); + int h = image.getHeight(); + return new BorderImage(image, slices[0] / 100.0 * h, slices[1] / 100.0 * w, + slices[2] / 100.0 * h, slices[3] / 100.0 * w, false, BorderImage.Mode.STRETCH); } - + void setParent(String name) { Element parentEl = getElementByName(name); Element self = this; @@ -4393,23 +4678,6 @@ void setParent(String name) { self.parent = parentEl; } - String getHtmlPreview() { - StringBuilder sb = new StringBuilder(); - sb.append("
Lorem Ipsum
"); - return sb.toString(); - } - - public String getEmptyHtmlWithId(String id, Map style) { - StringBuilder sb = new StringBuilder(); - String generateImage = (this.requiresBackgroundImageGeneration(style) || this.requiresImageBorder(style)) ? "true" : "false"; - - sb.append("
"); - return sb.toString(); - } - Map getFlattenedSelectedStyle() { Map out = new LinkedHashMap(); @@ -5046,8 +5314,12 @@ Insets getImageBorderInsets(Map styles, int width, int heigh } break; } - tmpUnit = tmpUnit.getNextLexicalUnit(); } + // Advance on every token. This used to sit inside the function + // test above, so a non-function token in the background (a + // colour ahead of a gradient, say) was never stepped past and + // the loop never ended. + tmpUnit = tmpUnit.getNextLexicalUnit(); } Insets boxShadowInsets = getBoxShadowPadding(styles); @@ -7403,6 +7675,26 @@ public void apply(Element style, String property, LexicalUnit value) { while (value != null) { + if (value.getLexicalUnitType() == LexicalUnit.SAC_IDENT) { + // The shorthand's repeat and position keywords. Every + // identifier used to fall through to "it may be a + // colour", so `background: url(x.png) no-repeat` + // recorded a background colour named no-repeat and + // failed the compile when that was read back. + String keyword = value.getStringValue(); + if ("repeat".equals(keyword) || "no-repeat".equals(keyword) + || "repeat-x".equals(keyword) || "repeat-y".equals(keyword)) { + style.put("background-repeat", value); + value = value.getNextLexicalUnit(); + continue; + } + if ("left".equals(keyword) || "right".equals(keyword) || "top".equals(keyword) + || "bottom".equals(keyword) || "center".equals(keyword)) { + // A position is not something a theme records. + value = value.getNextLexicalUnit(); + continue; + } + } switch (value.getLexicalUnitType()) { case LexicalUnit.SAC_IDENT: @@ -8757,6 +9049,17 @@ public static CSSTheme load(URL uri) throws IOException { InputStream stream = uri.openStream(); String stringContents = Util.readToString(stream); + // Inline @import before anything else looks at the text, so the + // rewrites below and the parser see one complete stylesheet. + Set importedFiles = new LinkedHashSet(); + if ("file".equals(uri.getProtocol())) { + try { + stringContents = CssImports.inline(new File(uri.toURI()), stringContents, importedFiles); + } catch (java.net.URISyntaxException ex) { + throw new IOException("Cannot resolve @import against " + uri, ex); + } + } + stringContents = transformDarkModeMediaQueries(stringContents); // The flute parser chokes on properties beginning with -- so we need to replace these with cn1 prefix @@ -8777,6 +9080,7 @@ public static CSSTheme load(URL uri) throws IOException { Parser parser = parserFactory.makeParser(); final CSSTheme theme = new CSSTheme(); theme.baseURL = uri; + theme.importedFiles.addAll(importedFiles); parser.setErrorHandler(new ErrorHandler() { @Override @@ -8836,7 +9140,11 @@ public void namespaceDeclaration(String string, String string1) throws CSSExcept @Override public void importStyle(String string, SACMediaList sacml, String string1) throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. + // Imports of a local stylesheet are inlined before the parse + // (CssImports), so the parser only reports one when the root + // is not a file. Dropping it would silently lose every rule + // of the imported sheet, so refuse instead. + throw new CSSException("@import of " + string + " is only supported from a stylesheet on the local filesystem"); } @Override diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CssImports.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CssImports.java new file mode 100644 index 00000000000..a8b8b21bf2f --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CssImports.java @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/// Resolves `@import` by textual inclusion, before the stylesheet reaches the +/// parser. +/// +/// The SAC parser reports an `@import` through a callback and reads nothing +/// itself, and that callback used to be empty: every rule of the imported +/// file was missing from the theme with no error and no warning. Inlining +/// the text ahead of the parse gives the imported rules exactly the position +/// CSS assigns them (where the `@import` stood, ahead of the importing file's +/// own rules) without a second, re-entrant parse into the same theme. +/// +/// A relative `url()` inside an imported file refers to something beside +/// *that* file, while the compiler resolves every `url()` against the one +/// root stylesheet. Imported text is therefore rewritten so its relative +/// URLs are expressed relative to the root file's directory. +public final class CssImports { + + private CssImports() { + } + + /// Returns `rootText` with every `@import` replaced by the text it names, + /// recursively. + /// + /// #### Parameters + /// + /// - `rootFile`: the stylesheet `rootText` was read from; relative imports + /// and rebased URLs are computed from its directory. + /// - `rootText`: the contents of `rootFile`. + /// - `imported`: receives every file pulled in, in inclusion order, so a + /// caller can watch them or compare their modification times. + /// + /// #### Throws + /// + /// - `IOException`: an imported file is missing or unreadable, an import + /// is circular, or an import names something other than a local file. + public static String inline(File rootFile, String rootText, Set imported) throws IOException { + File root = rootFile.getCanonicalFile(); + List stack = new ArrayList(); + stack.add(root); + return inline(root, rootText, stack, imported); + } + + /// Every file `css` imports, directly or transitively, in inclusion order. + /// + /// For a caller that needs to know what a stylesheet depends on without + /// compiling it: a staleness check, a file watcher. + public static Set collect(File css) throws IOException { + Set imported = new java.util.LinkedHashSet(); + inline(css, readFully(css), imported); + return imported; + } + + /// Expands the imports of `text`, the contents of `current`. Every `url()` + /// in the result is relative to the directory of `current`. + private static String inline(File current, String text, List stack, Set imported) + throws IOException { + StringBuilder out = new StringBuilder(text.length()); + int len = text.length(); + int i = 0; + int depth = 0; + while (i < len) { + char c = text.charAt(i); + if (c == '/' && i + 1 < len && text.charAt(i + 1) == '*') { + int end = text.indexOf("*/", i + 2); + end = end < 0 ? len : end + 2; + out.append(text, i, end); + i = end; + } else if (c == '"' || c == '\'') { + int end = skipString(text, i); + out.append(text, i, end); + i = end; + } else if (c == '{') { + depth++; + out.append(c); + i++; + } else if (c == '}') { + depth--; + out.append(c); + i++; + } else if (c == '@' && depth == 0 && text.regionMatches(true, i, "@import", 0, 7) + && i + 7 < len && !isIdentChar(text.charAt(i + 7))) { + int end = endOfStatement(text, i); + String statement = text.substring(i + 7, end).trim(); + out.append(resolve(current, statement, stack, imported)); + i = end < len ? end + 1 : len; + } else { + out.append(c); + i++; + } + } + return out.toString(); + } + + private static String resolve(File current, String statement, List stack, Set imported) + throws IOException { + String target; + String rest; + if (statement.regionMatches(true, 0, "url(", 0, 4)) { + int close = statement.indexOf(')'); + if (close < 0) { + throw new IOException("Malformed @import in " + current + ": " + statement); + } + target = unquote(statement.substring(4, close).trim()); + rest = statement.substring(close + 1).trim(); + } else if (statement.length() > 0 && (statement.charAt(0) == '"' || statement.charAt(0) == '\'')) { + int close = skipString(statement, 0); + target = unquote(statement.substring(0, close)); + rest = statement.substring(close).trim(); + } else { + throw new IOException("Malformed @import in " + current + ": " + statement); + } + if (target.length() == 0) { + throw new IOException("@import with no file name in " + current); + } + if (target.indexOf("://") > 0) { + throw new IOException("@import of " + target + " in " + current + + " is not supported: only local files can be imported."); + } + File file = new File(target); + if (!file.isAbsolute()) { + file = new File(current.getParentFile(), target); + } + if (!file.isFile()) { + throw new IOException("@import in " + current + " names a file that does not exist: " + file); + } + file = file.getCanonicalFile(); + if (stack.contains(file)) { + StringBuilder chain = new StringBuilder(); + for (File f : stack) { + chain.append(f.getName()).append(" -> "); + } + throw new IOException("Circular @import: " + chain + file.getName()); + } + imported.add(file); + String body; + stack.add(file); + try { + body = inline(file, readFully(file), stack, imported); + } finally { + stack.remove(stack.size() - 1); + } + // Only now, with the file's own imports expanded and already expressed + // relative to it, is everything moved up one level. Rebasing first + // would also rewrite the paths of the nested imports themselves. + body = rebaseUrls(body, file.getParentFile(), current.getParentFile()); + StringBuilder out = new StringBuilder(body.length() + 64); + out.append("\n/* @import ").append(file.getName()).append(" */\n"); + if (rest.length() > 0) { + // A media-qualified import applies its rules only under that media. + out.append("@media ").append(rest).append(" {\n").append(body).append("\n}\n"); + } else { + out.append(body).append('\n'); + } + return out.toString(); + } + + /// Rewrites each relative `url()` in `text` from "relative to `fromDir`" + /// to "relative to `toDir`". Absolute paths, URLs with a scheme and + /// `data:` URIs are left alone. + static String rebaseUrls(String text, File fromDir, File toDir) throws IOException { + if (fromDir.getCanonicalFile().equals(toDir.getCanonicalFile())) { + return text; + } + StringBuilder out = new StringBuilder(text.length() + 32); + int len = text.length(); + int i = 0; + while (i < len) { + char c = text.charAt(i); + if (c == '/' && i + 1 < len && text.charAt(i + 1) == '*') { + int end = text.indexOf("*/", i + 2); + end = end < 0 ? len : end + 2; + out.append(text, i, end); + i = end; + } else if (text.regionMatches(true, i, "url(", 0, 4) && (i == 0 || !isIdentChar(text.charAt(i - 1)))) { + int close = text.indexOf(')', i + 4); + if (close < 0) { + out.append(text, i, len); + break; + } + String raw = unquote(text.substring(i + 4, close).trim()); + out.append("url(\"").append(rebase(raw, fromDir, toDir)).append("\")"); + i = close + 1; + } else if (c == '"' || c == '\'') { + int end = skipString(text, i); + out.append(text, i, end); + i = end; + } else { + out.append(c); + i++; + } + } + return out.toString(); + } + + private static String rebase(String url, File fromDir, File toDir) throws IOException { + if (url.length() == 0 || url.charAt(0) == '/' || url.charAt(0) == '#' || url.indexOf(':') > 0) { + return url; + } + java.nio.file.Path base = toDir.getCanonicalFile().toPath(); + // Canonicalise only the directory: the file itself may not exist yet, + // and resolving it would also follow a link named by the stylesheet. + java.nio.file.Path resolved = fromDir.getCanonicalFile().toPath().resolve(url).normalize(); + try { + return base.relativize(resolved).toString().replace('\\', '/'); + } catch (IllegalArgumentException differentRoot) { + // No relative path exists between two filesystem roots. + return resolved.toUri().toString(); + } + } + + private static int skipString(String text, int start) { + char quote = text.charAt(start); + int i = start + 1; + int len = text.length(); + while (i < len) { + char c = text.charAt(i); + if (c == '\\') { + i += 2; + } else if (c == quote) { + return i + 1; + } else { + i++; + } + } + return len; + } + + private static int endOfStatement(String text, int start) { + int len = text.length(); + int i = start; + while (i < len) { + char c = text.charAt(i); + if (c == '"' || c == '\'') { + i = skipString(text, i); + } else if (c == ';') { + return i; + } else { + i++; + } + } + return len; + } + + private static String unquote(String value) { + int len = value.length(); + if (len >= 2) { + char first = value.charAt(0); + if ((first == '"' || first == '\'') && value.charAt(len - 1) == first) { + return value.substring(1, len - 1); + } + } + return value; + } + + private static boolean isIdentChar(char c) { + return c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + private static String readFully(File file) throws IOException { + InputStream in = new FileInputStream(file); + try { + ByteArrayOutputStream out = new ByteArrayOutputStream((int) Math.max(64, file.length())); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + return new String(out.toByteArray(), "UTF-8"); + } finally { + in.close(); + } + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java index 8b6cec9fb03..f2a2d455e30 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java @@ -7,10 +7,15 @@ import com.codename1.l10n.L10NManager; import com.codename1.ui.Component; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import javax.imageio.ImageIO; + /** * Minimal stub of {@link CodenameOneImplementation} the headless CSS * compiler installs into {@code Display.impl} via reflection before @@ -27,11 +32,15 @@ * implementation those calls NPE. * *

Rather than littering CN1 core with {@code if (impl == null)} - * fallbacks, the css-compiler module installs this stub at startup - * (NoCefCSSCLI.main). The pattern mirrors what the unit-test module + * fallbacks, the CSS compiler installs this stub at startup through + * {@link #install()}. The pattern mirrors what the unit-test module * does with {@code TestCodenameOneImplementation}: provide a minimal * subclass and inject it via reflection. * + *

This is the only implementation the compiler ever runs on. It needs + * no display: images are plain {@link BufferedImage}s decoded by ImageIO, + * which works under {@code java.awt.headless=true}. + * *

Most overrides return zero / null / -1 / false. The only methods * that need to do real work are the ones that the theme-build path * actually calls: @@ -41,15 +50,83 @@ * {@link #getFace(Object)} / {@link #getSize(Object)} / * {@link #getStyle(Object)} for the original face/style/size. * We round-trip via a small {@link Triple} carrier. - *

  • {@link #convertToPixels(int,boolean)} - returns 1:1 (1 mm = 1 pixel) - * so theme padding/margin serialization does not collapse to - * zero. The actual conversion happens at app runtime when a - * full implementation is loaded.
  • + *
  • {@link #convertToPixels(int,boolean)} and + * {@link #getDeviceDensity()} - pinned to the constants below. A few + * values the compiler serializes are computed from them, so they + * must not depend on the machine the build runs on.
  • + *
  • the image methods - {@code url()} images are decoded to read + * their size, scaled into the densities of a multi-image, and + * re-encoded, all through {@link BufferedImage}.
  • *
  • {@link #cleanup(Object)} - closes any closeable streams used * by Util.copy when serializing the resource.
  • * */ -final class HeadlessCssCompilerImplementation extends CodenameOneImplementation { +public final class HeadlessCssCompilerImplementation extends CodenameOneImplementation { + + /** + * Pixels per millimetre the compiler assumes wherever theme construction + * asks the display to convert a length. The value is the one application + * themes have always been compiled with: the compiler used to run on the + * simulator port with no skin, which on an ordinary (non-retina) display + * reports {@link com.codename1.ui.Display#DENSITY_MEDIUM} and converts at + * five pixels to the millimetre. That was measured, not derived: the same + * stylesheet compiled by that compiler and by this one produces the same + * theme entry for entry. Changing it changes serialized defaults (the + * shadow spread of a round border, for one), so it is a constant rather + * than something read from the host. + */ + public static final int PIXELS_PER_MILLIMETRE = 5; + + /** The density that goes with {@link #PIXELS_PER_MILLIMETRE}. */ + public static final int DEVICE_DENSITY = com.codename1.ui.Display.DENSITY_MEDIUM; + + /** + * See {@link #setNativeThemeUnits(boolean)}. + */ + private static boolean nativeThemeUnits; + + /** + * Selects the unit conversion the framework's own native themes are + * compiled with, in place of {@link #PIXELS_PER_MILLIMETRE}. + * + *

    Those themes have always been compiled on an implementation that + * answered zero pixels for any length under half a metre, so a round + * border in them carries no default shadow spread, where the same rule in + * an application theme carries the 10px / 1mm the constants above produce. + * That spread adds to a component's size, and the native themes are tuned + * and screenshot-tested to the pixel without it. Both sets of themes are + * shipped, so both conversions are kept rather than resizing one of them; + * the native-themes build asks for this one explicitly. + */ + public static void setNativeThemeUnits(boolean nativeThemeUnits) { + HeadlessCssCompilerImplementation.nativeThemeUnits = nativeThemeUnits; + } + + /** + * Installs this implementation into {@code Display} and {@code Util} + * unless an implementation is already present. Idempotent. + * + *

    {@code Display.impl} is package-private and there is no public + * installer, so the field is set reflectively; {@code Util} keeps its own + * reference, which has a public setter. + */ + public static void install() { + try { + Class displayCls = Class.forName("com.codename1.ui.Display"); + java.lang.reflect.Field implField = displayCls.getDeclaredField("impl"); + implField.setAccessible(true); + Object current = implField.get(null); + if (current == null) { + current = new HeadlessCssCompilerImplementation(); + implField.set(null, current); + } + if (current instanceof HeadlessCssCompilerImplementation) { + com.codename1.io.Util.setImplementation((CodenameOneImplementation) current); + } + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException("Could not install the headless CSS compiler implementation", ex); + } + } private static final class Triple { final int face, style, size; @@ -69,8 +146,86 @@ private static final class Triple { return nativeFont instanceof Triple ? ((Triple) nativeFont).size : 0; } @Override public int convertToPixels(int dipCount, boolean horizontal) { - // 1:1 - the real device DPI is only needed at app runtime. - return Math.round(dipCount / 1000f); + if (nativeThemeUnits) { + // The argument is in thousandths of a millimetre and the caller + // divides the answer by a thousand again, so this is zero for any + // length a theme uses. See setNativeThemeUnits. + return Math.round(dipCount / 1000f); + } + return dipCount * PIXELS_PER_MILLIMETRE; + } + @Override public int getDeviceDensity() { + if (nativeThemeUnits) { + // What a display of no size has always been classified as. + return com.codename1.ui.Display.DENSITY_VERY_LOW; + } + return DEVICE_DENSITY; + } + + // ---- Images: BufferedImage is the native image. ---- + + private static BufferedImage toArgb(BufferedImage src) { + if (src == null) { + return null; + } + if (src.getType() == BufferedImage.TYPE_INT_ARGB) { + return src; + } + return HeadlessImages.toArgb(src); + } + + @Override public Object createImage(byte[] bytes, int offset, int len) { + try { + return toArgb(ImageIO.read(new ByteArrayInputStream(bytes, offset, len))); + } catch (IOException ex) { + // The caller (EncodedImage) reports a null image as "create image + // failed", which is the right outcome for bytes that are not an image. + return null; + } + } + @Override public Object createImage(InputStream i) throws IOException { + return toArgb(ImageIO.read(i)); + } + @Override public Object createImage(String path) throws IOException { + InputStream in = new FileInputStream(path); + try { + return toArgb(ImageIO.read(in)); + } finally { + in.close(); + } + } + @Override public Object createImage(int[] rgb, int width, int height) { + BufferedImage out = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + out.setRGB(0, 0, width, height, rgb, 0, width); + return out; + } + @Override public Object createMutableImage(int width, int height, int fillColor) { + BufferedImage out = new BufferedImage(Math.max(1, width), Math.max(1, height), BufferedImage.TYPE_INT_ARGB); + if (fillColor != 0) { + int[] row = new int[out.getWidth()]; + java.util.Arrays.fill(row, fillColor); + for (int y = 0; y < out.getHeight(); y++) { + out.setRGB(0, y, row.length, 1, row, 0, row.length); + } + } + return out; + } + @Override public int getImageWidth(Object i) { + return i instanceof BufferedImage ? ((BufferedImage) i).getWidth() : 0; + } + @Override public int getImageHeight(Object i) { + return i instanceof BufferedImage ? ((BufferedImage) i).getHeight() : 0; + } + @Override public void getRGB(Object nativeImage, int[] arr, int offset, int x, int y, int width, int height) { + if (nativeImage instanceof BufferedImage) { + ((BufferedImage) nativeImage).getRGB(x, y, width, height, arr, offset, width); + } + } + @Override public Object scale(Object nativeImage, int width, int height) { + if (!(nativeImage instanceof BufferedImage)) { + return nativeImage; + } + return HeadlessImages.scale((BufferedImage) nativeImage, Math.max(1, width), Math.max(1, height)); } @Override public void cleanup(Object o) { if (o instanceof java.io.Closeable) { @@ -87,15 +242,6 @@ private static final class Triple { @Override public void editString(Component cmp, int maxSize, int constraint, String text, int initiatingKeycode) {} @Override public void flushGraphics(int x, int y, int width, int height) {} @Override public void flushGraphics() {} - @Override public void getRGB(Object nativeImage, int[] arr, int offset, int x, int y, int width, int height) {} - @Override public Object createImage(int[] rgb, int width, int height) { return null; } - @Override public Object createImage(String path) throws IOException { return null; } - @Override public Object createImage(InputStream i) throws IOException { return null; } - @Override public Object createMutableImage(int width, int height, int fillColor) { return null; } - @Override public Object createImage(byte[] bytes, int offset, int len) { return null; } - @Override public int getImageWidth(Object i) { return 0; } - @Override public int getImageHeight(Object i) { return 0; } - @Override public Object scale(Object nativeImage, int width, int height) { return nativeImage; } @Override public int getSoftkeyCount() { return 0; } @Override public int[] getSoftkeyCode(int index) { return new int[0]; } @Override public int getClearKeyCode() { return 0; } diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessImages.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessImages.java new file mode 100644 index 00000000000..8e974e0849b --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessImages.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; + +/// Image operations the CSS compiler needs, on plain [BufferedImage]s. +/// +/// Everything here is class-library code that runs without a display, so it +/// is safe under `java.awt.headless=true`. +final class HeadlessImages { + + private HeadlessImages() { + } + + /// Copies an image of any type into `TYPE_INT_ARGB`, the one layout the + /// compiler works in. + static BufferedImage toArgb(BufferedImage src) { + BufferedImage out = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = out.createGraphics(); + g.drawImage(src, 0, 0, null); + g.dispose(); + return out; + } + + /// Scales to exactly `targetWidth` x `targetHeight`. + /// + /// Downscaling halves repeatedly rather than jumping to the target in one + /// step: a single bicubic pass samples too few source pixels once the ratio + /// passes two and aliases visibly, which matters because every lower + /// density of a multi-image is produced this way. + static BufferedImage scale(BufferedImage img, int targetWidth, int targetHeight) { + BufferedImage ret = img; + int w = img.getWidth(); + int h = img.getHeight(); + do { + if (w > targetWidth) { + w /= 2; + if (w < targetWidth) { + w = targetWidth; + } + } else { + w = targetWidth; + } + if (h > targetHeight) { + h /= 2; + if (h < targetHeight) { + h = targetHeight; + } + } else { + h = targetHeight; + } + BufferedImage tmp = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2 = tmp.createGraphics(); + g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2.drawImage(ret, 0, 0, w, h, null); + g2.dispose(); + ret = tmp; + } while (w != targetWidth || h != targetHeight); + return ret; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/MissingNativeBrowserException.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/MissingNativeBrowserException.java deleted file mode 100644 index bf94d6ff035..00000000000 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/MissingNativeBrowserException.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.designer.css; - -public class MissingNativeBrowserException extends RuntimeException { - -} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/NoCefCSSCLI.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/NoCefCSSCLI.java deleted file mode 100644 index c14055125bc..00000000000 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/NoCefCSSCLI.java +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -/* - * Minimal CLI entry point for compiling a CSS theme to a .res file with the - * strictNoCef flag enabled. Unlike CN1CSSCLI (in the Designer module), - * this class does NOT - * pull in any JavaSE port classes or initialize a BrowserComponent host -- - * it is designed to live in a thin "css-compiler" jar that depends only on - * codenameone-core, flute, and sac. - * - * The intended consumer is the native-themes build, which generates the - * shipped platform themes from CSS source. Any CSS rule that would require - * CEF-backed image rasterization fails the compile (see CSSTheme.enforceNoCef). - * - * Usage: - * java -jar codenameone-css-compiler-jar-with-dependencies.jar \ - * -input path/to/theme.css \ - * -output path/to/Theme.res - */ -package com.codename1.designer.css; - -import java.io.File; -import java.net.URL; - -public class NoCefCSSCLI { - - public static void main(String[] args) throws Exception { - // Headless theme build: install a minimal CodenameOneImplementation - // before any CSSTheme code touches Display / Font / Util. Avoids - // peppering CN1 core with `if (impl == null)` fallbacks - the - // unit-test module follows the same pattern with - // TestCodenameOneImplementation. - installHeadlessImplementation(); - if (hasArg(args, "help") || hasArg(args, "h") || args.length == 0) { - printUsage(); - return; - } - String inputPath = getArg(args, "input", "i"); - String outputPath = getArg(args, "output", "o"); - if (inputPath == null || outputPath == null) { - printUsage(); - System.exit(1); - } - - File inputFile = new File(inputPath); - File outputFile = new File(outputPath); - if (!inputFile.exists()) { - System.err.println("Input CSS file does not exist: " + inputFile.getAbsolutePath()); - System.exit(2); - } - File parent = outputFile.getAbsoluteFile().getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) { - System.err.println("Could not create output directory: " + parent.getAbsolutePath()); - System.exit(3); - } - - CSSTheme.strictNoCef = true; - System.out.println("Compiling " + inputFile.getName() + " -> " + outputFile.getName() + " (no-cef)"); - - URL url = inputFile.toURI().toURL(); - CSSTheme theme = CSSTheme.load(url); - if (theme == null) { - System.err.println("CSSTheme.load returned null for " + inputFile - + " - parser probably failed to initialize. See stderr above for details."); - System.exit(4); - } - theme.cssFile = inputFile; - theme.resourceFile = outputFile; - - // createImageBorders walks every rule. With strictNoCef=true it - // throws an IllegalStateException listing any rule that would need - // CEF rasterization before the unreachable webview path would run. - // The provider below is a safety net: if a post-enforceNoCef code - // path still asks for a WebView, fail loud instead of NPE. - theme.createImageBorders(new CSSTheme.WebViewProvider() { - @Override - public com.codename1.ui.BrowserComponent getWebView() { - throw new IllegalStateException( - "CSS compile in no-cef mode must not request a WebView. " - + "enforceNoCef should have rejected the offending rule; " - + "please report this bug."); - } - }); - - theme.updateResources(); - theme.save(outputFile); - System.out.println("Wrote " + outputFile.getAbsolutePath()); - } - - private static boolean hasArg(String[] args, String... names) { - return getArg(args, names) != null; - } - - private static String getArg(String[] args, String... names) { - for (int i = 0; i < args.length; i++) { - String a = args[i]; - if (a == null) { - continue; - } - if (a.startsWith("-")) { - String key = a.substring(1); - while (key.startsWith("-")) { - key = key.substring(1); - } - int eq = key.indexOf('='); - String value; - if (eq >= 0) { - value = key.substring(eq + 1); - key = key.substring(0, eq); - } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) { - value = args[i + 1]; - } else { - value = "true"; - } - for (String n : names) { - if (n.equals(key)) { - return value; - } - } - } - } - return null; - } - - private static void installHeadlessImplementation() throws Exception { - // Display.impl is package-private and there is no public installer. - // Reflect into the field once at startup; mirrors the pattern used - // by the unit-test DisplayContext helper. Util keeps its own copy of - // the implementation reference (Util.implInstance) which is normally - // pushed via Util.setImplementation - that one is public so we use - // it directly. - HeadlessCssCompilerImplementation stub = new HeadlessCssCompilerImplementation(); - Class displayCls = Class.forName("com.codename1.ui.Display"); - java.lang.reflect.Field implField = displayCls.getDeclaredField("impl"); - implField.setAccessible(true); - if (implField.get(null) == null) { - implField.set(null, stub); - } - com.codename1.io.Util.setImplementation(stub); - } - - private static void printUsage() { - System.out.println("Codename One CSS Compiler (no-cef, native-themes build)"); - System.out.println(); - System.out.println("Usage:"); - System.out.println(" java -jar codenameone-css-compiler--jar-with-dependencies.jar \\"); - System.out.println(" -input \\"); - System.out.println(" -output "); - System.out.println(); - System.out.println("Any CSS rule requiring CEF-backed image rasterization (box-shadow,"); - System.out.println("border-radius combined with visible border, filter, complex gradients)"); - System.out.println("fails the compile with the list of offending rules."); - } -} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/ResourcesMutator.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/ResourcesMutator.java index 375f6a55ba5..4568ed871e2 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/ResourcesMutator.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/ResourcesMutator.java @@ -25,32 +25,14 @@ -import com.codename1.io.JSONParser; -import com.codename1.io.Log; -import com.codename1.io.Util; -import com.codename1.processing.Result; -import com.codename1.ui.BrowserComponent; -import com.codename1.ui.CN; import com.codename1.ui.Display; -import com.codename1.ui.events.ActionEvent; -import com.codename1.ui.events.ActionListener; import com.codename1.ui.util.EditableResources; -import java.awt.Graphics2D; -import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; -import java.io.StringReader; -import java.lang.reflect.Field; -import java.net.URL; -import java.util.HashMap; import java.util.HashSet; import java.util.Hashtable; -import java.util.Map; import java.util.Set; -import java.util.logging.Level; -import java.util.logging.Logger; import javax.imageio.ImageIO; @@ -62,7 +44,6 @@ public class ResourcesMutator { public static final int DEFAULT_TARGET_DENSITY = com.codename1.ui.Display.DENSITY_VERY_HIGH; int targetDensity = DEFAULT_TARGET_DENSITY; - private Map imageProcessors = new HashMap<>(); private String themeName = "Theme"; public ResourcesMutator(EditableResources res, int targetDensity, double minDpi, double maxDpi) { @@ -94,11 +75,6 @@ public ResourcesMutator(EditableResources res, int targetDensity, double minDpi, } } - public void addImageProcessor(String id, ImageProcessor proc) { - imageProcessors.put(id, proc); - } - - public com.codename1.ui.EncodedImage storeImage(com.codename1.ui.EncodedImage img, String prefix) { return storeImage(img, prefix, true); } @@ -356,49 +332,8 @@ public static EditableResources.MultiImage scaleMultiImage(int fromDPI, int toDP //return null; } - private static BufferedImage getScaledInstance(BufferedImage img, - int targetWidth, - int targetHeight) - { - BufferedImage ret = (BufferedImage)img; - int w, h; - // Use multi-step technique: start with original size, then - // scale down in multiple passes with drawImage() - // until the target size is reached - w = img.getWidth(); - h = img.getHeight(); - - do { - if (w > targetWidth) { - w /= 2; - if (w < targetWidth) { - w = targetWidth; - } - } else { - w = targetWidth; - } - - if (h > targetHeight) { - h /= 2; - if (h < targetHeight) { - h = targetHeight; - } - } else { - h = targetHeight; - } - - - BufferedImage tmp = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); - Graphics2D g2 = tmp.createGraphics(); - g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); - g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); - g2.drawImage(ret, 0, 0, w, h, null); - g2.dispose(); - - ret = tmp; - } while (w != targetWidth || h != targetHeight); - - return ret; + private static BufferedImage getScaledInstance(BufferedImage img, int targetWidth, int targetHeight) { + return HeadlessImages.scale(img, targetWidth, targetHeight); } public com.codename1.ui.plaf.Border createRoundBorder(int arcWidth, int arcHeight, int color, boolean outline) { @@ -414,6 +349,17 @@ public com.codename1.ui.plaf.Border create9PieceBorder(BufferedImage img, String //bg2d.dispose(); //img = buff; + // The slices come from a rule (cn1-9patch) as often as from arithmetic, so + // check them here: getSubimage would otherwise report a bad one as a + // RasterFormatException about a rectangle, with nothing to say which + // rule it came from. + if (top < 1 || right < 1 || bottom < 1 || left < 1 + || left + right >= img.getWidth() || top + bottom >= img.getHeight()) { + throw new IllegalArgumentException("The 9-piece slices of " + prefix + " (top " + top + ", right " + right + + ", bottom " + bottom + ", left " + left + ") do not fit its " + img.getWidth() + "x" + + img.getHeight() + " image. Each slice must be at least 1 pixel, and opposite slices must " + + "leave at least 1 pixel between them."); + } BufferedImage topLeft = img.getSubimage(0, 0, left, top); BufferedImage topRight = img.getSubimage(img.getWidth() - right, 0, right, top); BufferedImage bottomLeft = img.getSubimage(0, img.getHeight() - bottom, left, bottom); @@ -464,247 +410,6 @@ public com.codename1.ui.plaf.Border create9PieceBorder(BufferedImage img, String return b; } - private BrowserComponent web; - private boolean screenshotsComplete; - private final Object screenshotsLock = new Object(); - - - /** - * A callback function triggered inside capture.js Javascript file to take a screenshot - * of a particular rectangular region in the web view. This would generally be the bounds - * of an element that we are generating a screenshot for. - * @param id THe ID of the element; - * @param x The x coordinate in browser space - * @param y The y coordinate in browser space - * @param w The width - * @param h The height - */ - public void createScreenshotCallback(String id, int x, int y, int w, int h) { - //System.out.println("In createScreenshotsCallback("+id+","+x+","+y+","+w+","+h); - // There are 3 possibilities: - // 1. There is no registered image processor with ID=id -> call captureScreenshots() - // 2. There is a registered image processor with ID=id, but image generate fails -> call captureScreenshots() - // 3. There is a registered image proessor with ID=id. -> call captureScreenshots() - // - // Notice in all cases we just call captureScreenshots() again. The capture.js page will automatically - // increment to the next element in the list. When done, it will fire finishedCaptureScreenshotsCallback(). - CN.callSerially(()->{ - if (imageProcessors.containsKey(id)) { - double ratio = 1.0; - try { - // Create a Snapshotter to snap a screenshot of the given bounds - // in the webview. - WebviewSnapshotter snapper = new WebviewSnapshotter(web); - snapper.setBounds(x, y, w, h); - snapper.snapshot(()-> { - BufferedImage img = snapper.getImage(); - - imageProcessors.get(id).process(img); - CN.callSerially(()-> { - web.execute("window.captureScreenshots()"); - }); - - }); - } catch (Throwable t) { - Log.p("Failed to create snapshot for UIID "+id+": "+t.getMessage()); - Log.e(t); - CN.callSerially(()-> { - web.execute("window.captureScreenshots()"); - }); - } - } else { - CN.callSerially(()-> { - web.execute("window.captureScreenshots()"); - }); - } - - }); - } - - public void finishedCaptureScreenshotsCallback() { - //System.out.println("In finished screen cap"); - screenshotsComplete = true; - synchronized(screenshotsLock) { - screenshotsLock.notifyAll(); - } - } - - private SimpleWebServer webServer; - - private void startWebServer(File docRoot) throws IOException { - if (webServer != null) { - throw new IllegalStateException("Cannot start webserver. It is already running"); - } - - webServer = new SimpleWebServer(0, docRoot); - webServer.start(); - } - - ActionListener loadListener; - - /** - * Creates screenshots for the given HTML which was generated by {@link CSSTheme#generateCaptureHtml() }. - * @param web The web View used to render the HTML - * @param html The HTML to render in the webview. Generated by {@link CSSTheme#generateCaptureHtml() } - * @param baseURL The BaseURL - general points to the CSS file location. Used for loading resources from relative paths. - */ - public void createScreenshots(BrowserComponent web, String html, String baseURL) { - //System.out.println("in createScreenshots"); - try { - File baseURLFile = new File(new URL(baseURL).toURI()); - if (!baseURLFile.isDirectory()) { - baseURLFile = baseURLFile.getParentFile(); - } - startWebServer(baseURLFile); - boolean waitForServerResult[] = new boolean[1]; - CN.invokeAndBlock(new Runnable() { - public void run() { - waitForServerResult[0] = webServer.waitForServer(2000); - - } - }); - if (!waitForServerResult[0]) { - throw new RuntimeException("Failed to start webserver after 2 seconds"); - } - - } catch (Exception ex) { - throw new RuntimeException("Failed to start local webserver for creating screenshots", ex); - } - long timeout = 50000; - //String captureSrc = this.getClass().getResource("capture.js").toExternalForm(); - String captureJS = null; - try { - captureJS = Util.readToString(this.getClass().getResourceAsStream("capture.js")); - } catch (IOException ex) { - throw new RuntimeException("Failed to read capture.js file.", ex); - } - //final String modifiedHtml = html.replace("", /*""); - final String modifiedHtml = html.replace("", /*""); - this.web = web; - screenshotsComplete = false; - CN.callSerially(() -> { - - loadListener = (ActionEvent evt) -> { - if (webServer != null) { - webServer.stop(); - webServer = null; - } - web.removeWebEventListener(BrowserComponent.onLoad, loadListener); - try { - //System.out.println("In onLoad event"); - // Use reflection to retrieve the WebEngine's private 'page' field. - web.addJSCallback("window.app = window.app || {}; window.app.createScreenshotCallback = function(id, x, y, w, h) {" - + "callback.onSuccess(JSON.stringify({id:id, x:x, y:y, w:w, h:h}));" - + "};", res -> { - try { - Result data = Result.fromContent(new StringReader(res.toString()), Result.JSON); - - createScreenshotCallback( - data.getAsString("id"), - data.getAsInteger("x"), - data.getAsInteger("y"), - data.getAsInteger("w"), - data.getAsInteger("h")); - } catch (Exception ex) { - Log.p("Failed to parse input to createScreenshotsCallback"); - Log.e(ex); - } - - }); - web.addJSCallback("window.app.finishedCaptureScreenshotsCallback = function() {" - + "callback.onSuccess(null);" - + "};", res -> { - finishedCaptureScreenshotsCallback(); - }); - web.execute("$(document).ready(function(){ captureScreenshots();});"); - //web.getEngine().executeScript("window.onload = function(){window.app.ready()};"); - } catch (IllegalArgumentException ex) { - Logger.getLogger(ResourcesMutator.class.getName()).log(Level.SEVERE, null, ex); - } catch (SecurityException ex) { - Logger.getLogger(ResourcesMutator.class.getName()).log(Level.SEVERE, null, ex); - } - - }; - - - - web.addWebEventListener(BrowserComponent.onLoad, loadListener); - //CN.setProperty("cef.setPage.useDataURI", "true"); - webServer.route("/index.html", ()->{ - try { - return modifiedHtml.getBytes("UTF-8"); - } catch (Exception ex) { - Log.e(ex); - try { - return ("Error: "+ex.getMessage()).getBytes("UTF-8"); - } catch (Exception ex2) { - Log.e(ex2); - return new byte[0]; - } - - } - }); - web.setURL("http://localhost:"+webServer.getPort()+"/index.html"); - - }); - long startTime = System.currentTimeMillis(); - while (!screenshotsComplete && System.currentTimeMillis() - startTime < timeout) { - CN.invokeAndBlock(new Runnable() { - public void run() { - Util.wait(screenshotsLock, 50); - } - }); - - } - - - if (!screenshotsComplete) { - throw new RuntimeException("Failed to create screenshots for HTML "+html+". Timeout reached. Likely there was a problem initializing the browser component."); - } - this.web = null; - this.loadListener = null; - - } - - BufferedImage createHtmlScreenshot(BrowserComponent web, String html) { - final boolean[] complete = new boolean[1]; - final Object lock = new Object(); - final BufferedImage[] img = new BufferedImage[1]; - - - Runnable webpageLoadedCallback = () -> { - img[0] = (BufferedImage)web.captureScreenshot().get().getImage(); - complete[0] = true; - synchronized(lock) { - lock.notify(); - } - - }; - CN.callSerially(()->{ - web.setPage(html, ""); - }); - - - while (!complete[0]) { - synchronized(lock) { - try { - lock.wait(); - } catch (InterruptedException ex) { - Logger.getLogger(ResourcesMutator.class.getName()).log(Level.SEVERE, null, ex); - } - } - } - - return img[0]; - - } - - public static interface ImageProcessor { - public void process(BufferedImage img); - } - public void put(String property, Object value) { res.setThemeProperty(themeName, property, value); diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/SimpleWebServer.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/SimpleWebServer.java deleted file mode 100644 index 74119a1930d..00000000000 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/SimpleWebServer.java +++ /dev/null @@ -1,333 +0,0 @@ -/* - * Copyright (C) 2014 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.codename1.designer.css; - -//import android.content.res.AssetManager; -//import android.text.TextUtils; -//import android.util.Log; - -import com.codename1.io.Log; -import com.codename1.io.Util; -import com.codename1.ui.CN; -import java.io.BufferedReader; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.PrintStream; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.SocketException; -import java.net.URL; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Implementation of a very basic HTTP server. The contents are loaded from the assets folder. This - * server handles one request at a time. It only supports GET method. - */ -class SimpleWebServer implements Runnable { - - private static final String TAG = "SimpleWebServer"; - - private Map routers = new HashMap(); - private List sniffers = new ArrayList(); - - - interface Router { - public byte[] getContent(); - } - - interface Sniffer { - public String getMimetype(String fileName); - } - - public SimpleWebServer route(String fileName, Router router) { - routers.put(fileName, router); - return this; - } - - public SimpleWebServer sniff(Sniffer sniffer) { - sniffers.add(sniffer); - return this; - } - - /** - * The port number we listen to - */ - private int mPort; - - private final File docRoot; - - - public boolean isRunning() { - return mIsRunning; - } - - /** - * True if the server is running. - */ - private boolean mIsRunning; - - /** - * The {@link java.net.ServerSocket} that we listen to. - */ - private ServerSocket mServerSocket; - - /** - * WebServer constructor. - */ - public SimpleWebServer(int port, File docRoot) { - mPort = port; - this.docRoot = docRoot; - route("/ping", () -> { - return "OK".getBytes(); - }); - - } - - /** - * This method starts the web server listening to the specified port. - */ - public void start() { - mIsRunning = true; - new Thread(this).start(); - } - - public boolean waitForServer(int timeout) { - long start = System.currentTimeMillis(); - long expires = start + timeout; - while (System.currentTimeMillis() < expires) { - if (ping()) { - return true; - } - if (CN.isEdt()) { - CN.invokeAndBlock(()->{ - - Util.sleep(100); - }); - } else { - Util.sleep(100); - } - } - return false; - } - - public boolean ping() { - if (!mIsRunning) { - return false; - } - if (mServerSocket == null) { - return false; - } - int port = mServerSocket.getLocalPort(); - try { - URL u = new URL("http://localhost:"+port+"/ping"); - InputStream stream = u.openStream(); - byte[] buf = new byte[128]; - StringBuilder sb = new StringBuilder(); - int len = 0; - while ((len = stream.read(buf)) >= 0) { - if (len > 0) { - sb.append(new String(buf, 0, len)); - } - } - return sb.toString().trim().endsWith("OK"); - } catch (Exception ex) { - return false; - } - } - - /** - * This method stops the web server - */ - public void stop() { - try { - mIsRunning = false; - if (null != mServerSocket) { - mServerSocket.close(); - mServerSocket = null; - } - } catch (IOException e) { - Log.p("Error closing the server socket."); - Log.e(e); - } - } - - public int getPort() { - return mPort; - } - - @Override - public void run() { - try { - mServerSocket = new ServerSocket(mPort); - if (mPort <= 0) { - mPort = mServerSocket.getLocalPort(); - } - while (mIsRunning) { - Socket socket = mServerSocket.accept(); - handle(socket); - socket.close(); - } - } catch (SocketException e) { - // The server was stopped; ignore. - } catch (IOException e) { - Log.p("Web server error."); - Log.e(e); - - } - } - - private static class TextUtils { - static boolean isEmpty(String str) { - return str == null || str.isEmpty(); - } - } - - /** - * Respond to a request from a client. - * - * @param socket The client socket. - * @throws IOException - */ - private void handle(Socket socket) throws IOException { - BufferedReader reader = null; - PrintStream output = null; - try { - String route = null; - - // Read HTTP headers and parse out the route. - reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); - String line; - while (!TextUtils.isEmpty(line = reader.readLine())) { - if (line.startsWith("GET /")) { - int start = line.indexOf('/') + 1; - int end = line.indexOf(' ', start); - route = line.substring(start, end); - break; - } - } - - // Output stream that we send the response to - output = new PrintStream(socket.getOutputStream()); - // Prepare the content to send. - if (null == route) { - writeServerError(output); - return; - } - byte[] bytes = loadContent(route); - if (null == bytes) { - writeServerError(output); - return; - } - - // Send out the content. - output.println("HTTP/1.0 200 OK"); - output.println("Content-Type: " + detectMimeType(route)); - output.println("Content-Length: " + bytes.length); - output.println(); - output.write(bytes); - output.flush(); - } finally { - if (null != output) { - output.close(); - } - if (null != reader) { - reader.close(); - } - } - } - - /** - * Writes a server error response (HTTP/1.0 500) to the given output stream. - * - * @param output The output stream. - */ - private void writeServerError(PrintStream output) { - output.println("HTTP/1.0 500 Internal Server Error"); - output.flush(); - } - - /** - * Loads all the content of {@code fileName}. - * - * @param fileName The name of the file. - * @return The content of the file. - * @throws IOException - */ - private byte[] loadContent(String fileName) throws IOException { - String absPath = fileName; - if (!absPath.startsWith("/")) { - absPath = "/" + absPath; - } - if (routers.containsKey(absPath)) { - return routers.get(absPath).getContent(); - } - InputStream input = null; - try { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - File file = new File(docRoot, fileName); - input = new FileInputStream(file); - byte[] buffer = new byte[1024]; - int size; - while (-1 != (size = input.read(buffer))) { - output.write(buffer, 0, size); - } - output.flush(); - return output.toByteArray(); - } catch (FileNotFoundException e) { - return null; - } finally { - if (null != input) { - input.close(); - } - } - } - - /** - * Detects the MIME type from the {@code fileName}. - * - * @param fileName The name of the file. - * @return A MIME type. - */ - private String detectMimeType(String fileName) { - for (Sniffer sniffer : sniffers) { - String mime = sniffer.getMimetype(fileName); - if (mime != null) { - return mime; - } - } - if (TextUtils.isEmpty(fileName)) { - return null; - } else if (fileName.endsWith(".html")) { - return "text/html"; - } else if (fileName.endsWith(".js")) { - return "application/javascript"; - } else if (fileName.endsWith(".css")) { - return "text/css"; - } else { - return "application/octet-stream"; - } - } - -} \ No newline at end of file diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/WebviewSnapshotter.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/WebviewSnapshotter.java deleted file mode 100644 index ce05412accd..00000000000 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/WebviewSnapshotter.java +++ /dev/null @@ -1,218 +0,0 @@ -/* - * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.designer.css; - -import com.codename1.io.Log; -import com.codename1.processing.Result; -import com.codename1.ui.BrowserComponent; -import com.codename1.ui.CN; -import com.codename1.ui.Image; -import java.awt.Graphics2D; -import java.awt.image.BufferedImage; -import java.util.LinkedList; -import java.util.logging.Level; -import java.util.logging.Logger; - - -public class WebviewSnapshotter { - private int x, y, w, h; - private BrowserComponent web; - final LinkedList eventQueue = new LinkedList(); - Thread eventThread; - Runnable onDone; - boolean done; - BufferedImage image; - Graphics2D imageGraphics; - - - public WebviewSnapshotter(BrowserComponent web) { - this.web = web; - } - - public void setBounds(int x, int y, int w, int h) { - this.x = x; - this.y =y; - this.w = w; - this.h = h; - } - - private void fireJSSnap(int x, int y, int w, int h) { - //System.out.println("[WebviewSnapshotter::fireJSSnap("+x+","+y+","+w+","+h); - if (!CN.isEdt()) { - CN.callSerially(()-> { - fireJSSnap(x, y, w, h); - }); - return; - } - - web.execute("window.snapper = window.snapper || {}; " - + "window.snapper.handleJSSnap = function(scrollX, scrollY, x, y, w, h) {" - + " callback.onSuccess(JSON.stringify({scrollX:scrollX, scrollY:scrollY, x:x, y:y, w:w, h:h}));" - + "}; window.getRectSnapshot("+x+","+y+","+w+","+h+");", res -> { - try { - Result data = Result.fromContent(res.toString(), Result.JSON); - handleJSSnap( - data.getAsInteger("scrollX"), - data.getAsInteger("scrollY"), - data.getAsInteger("x"), - data.getAsInteger("y"), - data.getAsInteger("w"), - data.getAsInteger("h") - ); - } catch (Exception ex) { - Log.p("Failed to parse callback in fireJSSnap"); - Log.e(ex); - } - }); - } - - private boolean isEventThread() { - return Thread.currentThread() == eventThread; - } - - private void runLater(Runnable r) { - synchronized(eventQueue) { - eventQueue.add(r); - eventQueue.notify(); - } - } - - public void log(String msg) { - System.out.println(msg); - } - - public final void handleJSSnap(int scrollX, int scrollY, int x, int y, int w, int h) { - //System.out.println("[WebviewSnapshotter::handleJSSnap("+scrollX+","+scrollY+","+x+","+y+","+w+","+h+")"); - if (!isEventThread()) { - runLater(()-> { - handleJSSnap(scrollX, scrollY, x, y, w, h); - }); - return; - } - CN.callSerially(()-> { - Image wi = web.captureScreenshot().get(); - BufferedImage img = (BufferedImage)wi.getImage(); - - runLater(()-> { - int remw = Math.min(w, 320); - int remh = Math.min(h, 480); - //remw = Math.min(remw, 320); - //remh = Math.min(remh, 480); - BufferedImage img2 = img.getSubimage(20,20, remw, remh); - //System.out.println("Writing image at "+(x-20)+", "+(y-20)+" with width "+remw+" and height "+remh); - imageGraphics.drawImage(img2, null, x-20, y-20); - if (x+remw < this.x+this.w || y+remh < this.y+this.h) { - if (x+remw < this.x+this.w) { - fireJSSnap(x+remw, y, w, h); - } else { - fireJSSnap(this.x, y+remh, w, h); - } - } else { - fireDone(); - } - }); - - }); - - } - - public final void fireDone() { - //System.out.println("[WebViewSnapshotter::fireDone()]"); - if (!isEventThread()) { - runLater(()-> { - fireDone(); - }); - return; - } - //System.out.println("In fireDone()"); - imageGraphics.dispose(); - - // Now do the fireDone - if (onDone != null) { - onDone.run(); - } - done = true; - } - - public void snapshot(Runnable onDone) { - //System.out.println("[WebviewSnapshotter::snapshot()]"); - if (eventThread != null) { - throw new RuntimeException("Snapshot event thread already created"); - } - eventThread = new Thread(()-> { - while (!done) { - Runnable evt = null; - synchronized(eventQueue) { - if (eventQueue.isEmpty()) { - try { - eventQueue.wait(); - } catch (InterruptedException ex) { - Logger.getLogger(WebviewSnapshotter.class.getName()).log(Level.SEVERE, null, ex); - } - } - evt = eventQueue.remove(); - } - evt.run(); - } - }); - this.onDone = onDone; - int x = this.x; - int y = this.y; - int width = Math.min(this.w, 320); - int height = Math.min(this.h, 480); - this.image = new BufferedImage(this.w, this.h, BufferedImage.TYPE_INT_ARGB); - this.imageGraphics = this.image.createGraphics(); - fireJSSnap(x, y, width, height); - eventThread.start(); - - } - - public void snapshotSync() { - Object lock = new Object(); - boolean[] complete = new boolean[1]; - snapshot(()-> { - complete[0] = true; - synchronized(lock) { - lock.notify(); - } - }); - CN.invokeAndBlock(new Runnable() { - public void run() { - while (!complete[0]) { - synchronized(lock) { - try { - lock.wait(); - } catch (InterruptedException ex) { - Logger.getLogger(WebviewSnapshotter.class.getName()).log(Level.SEVERE, null, ex); - } - } - } - } - }); - - } - - public BufferedImage getImage() { - return image; - } -} diff --git a/maven/css-compiler/src/main/java/com/codename1/ui/EditorFont.java b/maven/css-compiler/src/main/java/com/codename1/ui/EditorFont.java index 2852a5566ec..632e9b3139c 100644 --- a/maven/css-compiler/src/main/java/com/codename1/ui/EditorFont.java +++ b/maven/css-compiler/src/main/java/com/codename1/ui/EditorFont.java @@ -38,7 +38,6 @@ import java.io.ByteArrayInputStream; import java.util.HashMap; import java.util.Map; -import javax.swing.JOptionPane; /** * Font object for creation within the Theme Creator which stores all the fallback @@ -94,7 +93,7 @@ public EditorFont(Font systemFont, byte[] truetypeFont, String lookupFont, boole return; } catch(Exception err) { err.printStackTrace(); - JOptionPane.showMessageDialog(java.awt.Frame.getFrames()[0], "Error creating font: " + err, "TTF Error", JOptionPane.ERROR_MESSAGE); + com.codename1.ui.util.ResourceEditorUi.get().reportError("TTF Error", "Error creating font: " + err); } } diff --git a/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java index f41f02034ea..0a4a2fa308c 100644 --- a/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java +++ b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java @@ -80,9 +80,6 @@ import java.util.Map; import javax.imageio.ImageIO; import javax.swing.Icon; -import javax.swing.JFrame; -import javax.swing.JOptionPane; -import javax.swing.JPasswordField; import javax.swing.event.TreeModelEvent; import javax.swing.event.TreeModelListener; import javax.swing.tree.TreeModel; @@ -96,12 +93,6 @@ public class EditableResources extends Resources implements TreeModel { private static final short MINOR_VERSION = 16; private static final short MAJOR_VERSION = 1; - private static final boolean IS_MAC; - static { - String osName = System.getProperty("os.name", ""); - IS_MAC = osName.toLowerCase().contains("mac"); - } - private boolean modified; private boolean loadingMode = false; private boolean xmlUI; @@ -325,13 +316,12 @@ public static void setCurrentPassword(String password) { private static byte[] key; private static String currentPassword; void checkKey(String id) { - JPasswordField password = new JPasswordField(); - if(currentPassword != null) { - password.setText(currentPassword); - } - int v = JOptionPane.showConfirmDialog(java.awt.Frame.getFrames()[0], password, "Enter Password", JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE); - if(v == JOptionPane.OK_OPTION) { - currentPassword = password.getText(); + // Asking is the editor's job. With no editor attached (the CSS compiler, + // a test) there is nobody to ask, the answer is null, and the file is + // rejected by the superclass exactly as if the prompt had been cancelled. + String entered = ResourceEditorUi.get().promptPassword(currentPassword); + if(entered != null) { + currentPassword = entered; setPassword(currentPassword); try { key = currentPassword.getBytes("UTF-8"); @@ -343,8 +333,7 @@ void checkKey(String id) { //keyOffset = 0; if(l != 'l' || w != 'w') { // incorrect password! - JOptionPane.showMessageDialog(java.awt.Frame.getFrames()[0], - "Incorrect Password!", "Error", JOptionPane.ERROR_MESSAGE); + ResourceEditorUi.get().reportError("Error", "Incorrect Password!"); throw new IllegalStateException("Incorrect password"); } return; @@ -1695,17 +1684,7 @@ private void updateModified() { overrideResource.updateModified(); return; } - if(IS_MAC) { - for(java.awt.Window w : java.awt.Frame.getWindows()) { - if(w instanceof JFrame) { - if(modified) { - ((JFrame)w).getRootPane().putClientProperty("Window.documentModified", Boolean.TRUE); - } else { - ((JFrame)w).getRootPane().putClientProperty("Window.documentModified", Boolean.FALSE); - } - } - } - } + ResourceEditorUi.get().modifiedChanged(modified); } public void setModified() { @@ -1984,7 +1963,7 @@ public Hashtable getTheme(String id) { themeLoadingErrors = new HashSet(); } if (!themeLoadingErrors.contains(e.getMessage())) { - JOptionPane.showMessageDialog(java.awt.Frame.getFrames()[0], e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + ResourceEditorUi.get().reportError("Error", e.getMessage()); themeLoadingErrors.add(e.getMessage()); } } @@ -3410,8 +3389,7 @@ public void refreshThemeMultiImages() { if(img != null) { String id = findId(img); if(id == null) { - JOptionPane.showMessageDialog(java.awt.Frame.getFrames()[0], - "Missing image from border: " + key, "Error", JOptionPane.ERROR_MESSAGE); + ResourceEditorUi.get().reportError("Error", "Missing image from border: " + key); continue; } if(isMultiImage(id)) { diff --git a/maven/css-compiler/src/main/java/com/codename1/ui/util/ResourceEditorUi.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/ResourceEditorUi.java new file mode 100644 index 00000000000..5a42409113f --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/ui/util/ResourceEditorUi.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.util; + +/// The few things [EditableResources] needs from a user interface. +/// +/// `EditableResources` is both the resource editor's document model and the +/// writer the CSS compiler saves a theme with. It used to open Swing dialogs +/// itself, which made a `HeadlessException` reachable from a build that has +/// no display. The dialogs now live behind this class: the default answers +/// without a user interface, and the resource editor installs a Swing +/// implementation when it starts. +/// +/// One instance serves the whole JVM, like the password state it mediates. +public class ResourceEditorUi { + + private static ResourceEditorUi instance = new ResourceEditorUi(); + + /// The installed implementation; never null. + public static ResourceEditorUi get() { + return instance; + } + + /// Replaces the implementation. Passing null restores the default. + public static void install(ResourceEditorUi ui) { + instance = ui == null ? new ResourceEditorUi() : ui; + } + + /// Asks for the password of a protected resource file. + /// + /// #### Parameters + /// + /// - `current`: the password used last, or null; an editor may offer it. + /// + /// #### Returns + /// + /// the password, or null when none is available -- the user cancelled, or + /// there is no user to ask, which is the case here. + public String promptPassword(String current) { + return null; + } + + /// Reports a problem that does not stop the operation in progress. The + /// default writes it to standard error. + public void reportError(String title, String message) { + System.err.println(title + ": " + message); + } + + /// Called whenever the "has unsaved changes" state of a resource file is + /// set. The default does nothing. + public void modifiedChanged(boolean modified) { + } +} diff --git a/maven/css-compiler/src/main/resources/com/codename1/designer/css/capture.js b/maven/css-compiler/src/main/resources/com/codename1/designer/css/capture.js deleted file mode 100644 index 5b91474ccb3..00000000000 --- a/maven/css-compiler/src/main/resources/com/codename1/designer/css/capture.js +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -/*! jQuery v2.1.4 | (c) 2005, 2015 jQuery Foundation, Inc. | jquery.org/license */ -!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l=a.document,m="2.1.4",n=function(a,b){return new n.fn.init(a,b)},o=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,p=/^-ms-/,q=/-([\da-z])/gi,r=function(a,b){return b.toUpperCase()};n.fn=n.prototype={jquery:m,constructor:n,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=n.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return n.each(this,a,b)},map:function(a){return this.pushStack(n.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},n.extend=n.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||n.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(a=arguments[h]))for(b in a)c=g[b],d=a[b],g!==d&&(j&&d&&(n.isPlainObject(d)||(e=n.isArray(d)))?(e?(e=!1,f=c&&n.isArray(c)?c:[]):f=c&&n.isPlainObject(c)?c:{},g[b]=n.extend(j,f,d)):void 0!==d&&(g[b]=d));return g},n.extend({expando:"jQuery"+(m+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===n.type(a)},isArray:Array.isArray,isWindow:function(a){return null!=a&&a===a.window},isNumeric:function(a){return!n.isArray(a)&&a-parseFloat(a)+1>=0},isPlainObject:function(a){return"object"!==n.type(a)||a.nodeType||n.isWindow(a)?!1:a.constructor&&!j.call(a.constructor.prototype,"isPrototypeOf")?!1:!0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(a){var b,c=eval;a=n.trim(a),a&&(1===a.indexOf("use strict")?(b=l.createElement("script"),b.text=a,l.head.appendChild(b).parentNode.removeChild(b)):c(a))},camelCase:function(a){return a.replace(p,"ms-").replace(q,r)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=s(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(o,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(s(Object(a))?n.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){return null==b?-1:g.call(b,a,c)},merge:function(a,b){for(var c=+b.length,d=0,e=a.length;c>d;d++)a[e++]=b[d];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=s(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(c=a[b],b=a,a=c),n.isFunction(a)?(e=d.call(arguments,2),f=function(){return a.apply(b||this,e.concat(d.call(arguments)))},f.guid=a.guid=a.guid||n.guid++,f):void 0},now:Date.now,support:k}),n.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function s(a){var b="length"in a&&a.length,c=n.type(a);return"function"===c||n.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var t=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N=M.replace("w","w#"),O="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+N+"))|)"+L+"*\\]",P=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+O+")*)|.*)\\)|)",Q=new RegExp(L+"+","g"),R=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),S=new RegExp("^"+L+"*,"+L+"*"),T=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),U=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),V=new RegExp(P),W=new RegExp("^"+N+"$"),X={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M.replace("w","w*")+")"),ATTR:new RegExp("^"+O),PSEUDO:new RegExp("^"+P),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,aa=/[+~]/,ba=/'|\\/g,ca=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),da=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ea=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(fa){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],k=b.nodeType,"string"!=typeof a||!a||1!==k&&9!==k&&11!==k)return d;if(!e&&p){if(11!==k&&(f=_.exec(a)))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return H.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName)return H.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=1!==k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(ba,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+ra(o[l]);w=aa.test(a)&&pa(b.parentNode)||b,x=o.join(",")}if(x)try{return H.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function pa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=g.documentElement,e=g.defaultView,e&&e!==e.top&&(e.addEventListener?e.addEventListener("unload",ea,!1):e.attachEvent&&e.attachEvent("onunload",ea)),p=!f(g),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(g.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(g.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!g.getElementsByName||!g.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(ca,da);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(ca,da);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(g.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){var b=g.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",P)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===g||a.ownerDocument===v&&t(v,a)?-1:b===g||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,h=[a],i=[b];if(!e||!f)return a===g?-1:b===g?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?la(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},g):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(ca,da),a[3]=(a[3]||a[4]||a[5]||"").replace(ca,da),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(ca,da).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(Q," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(ca,da),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return W.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(ca,da).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:oa(function(){return[0]}),last:oa(function(a,b){return[b-1]}),eq:oa(function(a,b,c){return[0>c?c+b:c]}),even:oa(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:oa(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:oa(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:oa(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function sa(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function ta(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ua(a,b,c){for(var d=0,e=b.length;e>d;d++)ga(a,b[d],c);return c}function va(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function wa(a,b,c,d,e,f){return d&&!d[u]&&(d=wa(d)),e&&!e[u]&&(e=wa(e,f)),ia(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ua(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:va(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=va(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=va(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function xa(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=sa(function(a){return a===b},h,!0),l=sa(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[sa(ta(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return wa(i>1&&ta(m),i>1&&ra(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&xa(a.slice(i,e)),f>e&&xa(a=a.slice(e)),f>e&&ra(a))}m.push(c)}return ta(m)}function ya(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=F.call(i));s=va(s)}H.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&ga.uniqueSort(i)}return k&&(w=v,j=t),r};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=xa(b[c]),f[u]?d.push(f):e.push(f);f=A(a,ya(e,d)),f.selector=a}return f},i=ga.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(ca,da),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(ca,da),aa.test(j[0].type)&&pa(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&ra(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,aa.test(a)&&pa(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);n.find=t,n.expr=t.selectors,n.expr[":"]=n.expr.pseudos,n.unique=t.uniqueSort,n.text=t.getText,n.isXMLDoc=t.isXML,n.contains=t.contains;var u=n.expr.match.needsContext,v=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,w=/^.[^:#\[\.,]*$/;function x(a,b,c){if(n.isFunction(b))return n.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return n.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(w.test(b))return n.filter(b,a,c);b=n.filter(b,a)}return n.grep(a,function(a){return g.call(b,a)>=0!==c})}n.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?n.find.matchesSelector(d,a)?[d]:[]:n.find.matches(a,n.grep(b,function(a){return 1===a.nodeType}))},n.fn.extend({find:function(a){var b,c=this.length,d=[],e=this;if("string"!=typeof a)return this.pushStack(n(a).filter(function(){for(b=0;c>b;b++)if(n.contains(e[b],this))return!0}));for(b=0;c>b;b++)n.find(a,e[b],d);return d=this.pushStack(c>1?n.unique(d):d),d.selector=this.selector?this.selector+" "+a:a,d},filter:function(a){return this.pushStack(x(this,a||[],!1))},not:function(a){return this.pushStack(x(this,a||[],!0))},is:function(a){return!!x(this,"string"==typeof a&&u.test(a)?n(a):a||[],!1).length}});var y,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=n.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||y).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof n?b[0]:b,n.merge(this,n.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:l,!0)),v.test(c[1])&&n.isPlainObject(b))for(c in b)n.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}return d=l.getElementById(c[2]),d&&d.parentNode&&(this.length=1,this[0]=d),this.context=l,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):n.isFunction(a)?"undefined"!=typeof y.ready?y.ready(a):a(n):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),n.makeArray(a,this))};A.prototype=n.fn,y=n(l);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};n.extend({dir:function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&n(a).is(c))break;d.push(a)}return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),n.fn.extend({has:function(a){var b=n(a,this),c=b.length;return this.filter(function(){for(var a=0;c>a;a++)if(n.contains(this,b[a]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=u.test(a)||"string"!=typeof a?n(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&n.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?n.unique(f):f)},index:function(a){return a?"string"==typeof a?g.call(n(a),this[0]):g.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(n.unique(n.merge(this.get(),n(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){while((a=a[b])&&1!==a.nodeType);return a}n.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return n.dir(a,"parentNode")},parentsUntil:function(a,b,c){return n.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return n.dir(a,"nextSibling")},prevAll:function(a){return n.dir(a,"previousSibling")},nextUntil:function(a,b,c){return n.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return n.dir(a,"previousSibling",c)},siblings:function(a){return n.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return n.sibling(a.firstChild)},contents:function(a){return a.contentDocument||n.merge([],a.childNodes)}},function(a,b){n.fn[a]=function(c,d){var e=n.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=n.filter(d,e)),this.length>1&&(C[a]||n.unique(e),B.test(a)&&e.reverse()),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return n.each(a.match(E)||[],function(a,c){b[c]=!0}),b}n.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):n.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(b=a.memory&&l,c=!0,g=e||0,e=0,f=h.length,d=!0;h&&f>g;g++)if(h[g].apply(l[0],l[1])===!1&&a.stopOnFalse){b=!1;break}d=!1,h&&(i?i.length&&j(i.shift()):b?h=[]:k.disable())},k={add:function(){if(h){var c=h.length;!function g(b){n.each(b,function(b,c){var d=n.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&g(c)})}(arguments),d?f=h.length:b&&(e=c,j(b))}return this},remove:function(){return h&&n.each(arguments,function(a,b){var c;while((c=n.inArray(b,h,c))>-1)h.splice(c,1),d&&(f>=c&&f--,g>=c&&g--)}),this},has:function(a){return a?n.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],f=0,this},disable:function(){return h=i=b=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,b||k.disable(),this},locked:function(){return!i},fireWith:function(a,b){return!h||c&&!i||(b=b||[],b=[a,b.slice?b.slice():b],d?i.push(b):j(b)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!c}};return k},n.extend({Deferred:function(a){var b=[["resolve","done",n.Callbacks("once memory"),"resolved"],["reject","fail",n.Callbacks("once memory"),"rejected"],["notify","progress",n.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return n.Deferred(function(c){n.each(b,function(b,f){var g=n.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&n.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?n.extend(a,d):d}},e={};return d.pipe=d.then,n.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&n.isFunction(a.promise)?e:0,g=1===f?a:n.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&n.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;n.fn.ready=function(a){return n.ready.promise().done(a),this},n.extend({isReady:!1,readyWait:1,holdReady:function(a){a?n.readyWait++:n.ready(!0)},ready:function(a){(a===!0?--n.readyWait:n.isReady)||(n.isReady=!0,a!==!0&&--n.readyWait>0||(H.resolveWith(l,[n]),n.fn.triggerHandler&&(n(l).triggerHandler("ready"),n(l).off("ready"))))}});function I(){l.removeEventListener("DOMContentLoaded",I,!1),a.removeEventListener("load",I,!1),n.ready()}n.ready.promise=function(b){return H||(H=n.Deferred(),"complete"===l.readyState?setTimeout(n.ready):(l.addEventListener("DOMContentLoaded",I,!1),a.addEventListener("load",I,!1))),H.promise(b)},n.ready.promise();var J=n.access=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===n.type(c)){e=!0;for(h in c)n.access(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,n.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(n(a),c)})),b))for(;i>h;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f};n.acceptData=function(a){return 1===a.nodeType||9===a.nodeType||!+a.nodeType};function K(){Object.defineProperty(this.cache={},0,{get:function(){return{}}}),this.expando=n.expando+K.uid++}K.uid=1,K.accepts=n.acceptData,K.prototype={key:function(a){if(!K.accepts(a))return 0;var b={},c=a[this.expando];if(!c){c=K.uid++;try{b[this.expando]={value:c},Object.defineProperties(a,b)}catch(d){b[this.expando]=c,n.extend(a,b)}}return this.cache[c]||(this.cache[c]={}),c},set:function(a,b,c){var d,e=this.key(a),f=this.cache[e];if("string"==typeof b)f[b]=c;else if(n.isEmptyObject(f))n.extend(this.cache[e],b);else for(d in b)f[d]=b[d];return f},get:function(a,b){var c=this.cache[this.key(a)];return void 0===b?c:c[b]},access:function(a,b,c){var d;return void 0===b||b&&"string"==typeof b&&void 0===c?(d=this.get(a,b),void 0!==d?d:this.get(a,n.camelCase(b))):(this.set(a,b,c),void 0!==c?c:b)},remove:function(a,b){var c,d,e,f=this.key(a),g=this.cache[f];if(void 0===b)this.cache[f]={};else{n.isArray(b)?d=b.concat(b.map(n.camelCase)):(e=n.camelCase(b),b in g?d=[b,e]:(d=e,d=d in g?[d]:d.match(E)||[])),c=d.length;while(c--)delete g[d[c]]}},hasData:function(a){return!n.isEmptyObject(this.cache[a[this.expando]]||{})},discard:function(a){a[this.expando]&&delete this.cache[a[this.expando]]}};var L=new K,M=new K,N=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,O=/([A-Z])/g;function P(a,b,c){var d;if(void 0===c&&1===a.nodeType)if(d="data-"+b.replace(O,"-$1").toLowerCase(),c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:N.test(c)?n.parseJSON(c):c}catch(e){}M.set(a,b,c)}else c=void 0;return c}n.extend({hasData:function(a){return M.hasData(a)||L.hasData(a)},data:function(a,b,c){ -return M.access(a,b,c)},removeData:function(a,b){M.remove(a,b)},_data:function(a,b,c){return L.access(a,b,c)},_removeData:function(a,b){L.remove(a,b)}}),n.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=M.get(f),1===f.nodeType&&!L.get(f,"hasDataAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=n.camelCase(d.slice(5)),P(f,d,e[d])));L.set(f,"hasDataAttrs",!0)}return e}return"object"==typeof a?this.each(function(){M.set(this,a)}):J(this,function(b){var c,d=n.camelCase(a);if(f&&void 0===b){if(c=M.get(f,a),void 0!==c)return c;if(c=M.get(f,d),void 0!==c)return c;if(c=P(f,d,void 0),void 0!==c)return c}else this.each(function(){var c=M.get(this,d);M.set(this,d,b),-1!==a.indexOf("-")&&void 0!==c&&M.set(this,a,b)})},null,b,arguments.length>1,null,!0)},removeData:function(a){return this.each(function(){M.remove(this,a)})}}),n.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=L.get(a,b),c&&(!d||n.isArray(c)?d=L.access(a,b,n.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=n.queue(a,b),d=c.length,e=c.shift(),f=n._queueHooks(a,b),g=function(){n.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return L.get(a,c)||L.access(a,c,{empty:n.Callbacks("once memory").add(function(){L.remove(a,[b+"queue",c])})})}}),n.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthx",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var U="undefined";k.focusinBubbles="onfocusin"in a;var V=/^key/,W=/^(?:mouse|pointer|contextmenu)|click/,X=/^(?:focusinfocus|focusoutblur)$/,Y=/^([^.]*)(?:\.(.+)|)$/;function Z(){return!0}function $(){return!1}function _(){try{return l.activeElement}catch(a){}}n.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.get(a);if(r){c.handler&&(f=c,c=f.handler,e=f.selector),c.guid||(c.guid=n.guid++),(i=r.events)||(i=r.events={}),(g=r.handle)||(g=r.handle=function(b){return typeof n!==U&&n.event.triggered!==b.type?n.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(E)||[""],j=b.length;while(j--)h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o&&(l=n.event.special[o]||{},o=(e?l.delegateType:l.bindType)||o,l=n.event.special[o]||{},k=n.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&n.expr.match.needsContext.test(e),namespace:p.join(".")},f),(m=i[o])||(m=i[o]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,p,g)!==!1||a.addEventListener&&a.addEventListener(o,g,!1)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),n.event.global[o]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,o,p,q,r=L.hasData(a)&&L.get(a);if(r&&(i=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=Y.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=n.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,m=i[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&q!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||n.removeEvent(a,o,r.handle),delete i[o])}else for(o in i)n.event.remove(a,o+b[j],c,d,!0);n.isEmptyObject(i)&&(delete r.handle,L.remove(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,m,o,p=[d||l],q=j.call(b,"type")?b.type:b,r=j.call(b,"namespace")?b.namespace.split("."):[];if(g=h=d=d||l,3!==d.nodeType&&8!==d.nodeType&&!X.test(q+n.event.triggered)&&(q.indexOf(".")>=0&&(r=q.split("."),q=r.shift(),r.sort()),k=q.indexOf(":")<0&&"on"+q,b=b[n.expando]?b:new n.Event(q,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=r.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+r.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:n.makeArray(c,[b]),o=n.event.special[q]||{},e||!o.trigger||o.trigger.apply(d,c)!==!1)){if(!e&&!o.noBubble&&!n.isWindow(d)){for(i=o.delegateType||q,X.test(i+q)||(g=g.parentNode);g;g=g.parentNode)p.push(g),h=g;h===(d.ownerDocument||l)&&p.push(h.defaultView||h.parentWindow||a)}f=0;while((g=p[f++])&&!b.isPropagationStopped())b.type=f>1?i:o.bindType||q,m=(L.get(g,"events")||{})[b.type]&&L.get(g,"handle"),m&&m.apply(g,c),m=k&&g[k],m&&m.apply&&n.acceptData(g)&&(b.result=m.apply(g,c),b.result===!1&&b.preventDefault());return b.type=q,e||b.isDefaultPrevented()||o._default&&o._default.apply(p.pop(),c)!==!1||!n.acceptData(d)||k&&n.isFunction(d[q])&&!n.isWindow(d)&&(h=d[k],h&&(d[k]=null),n.event.triggered=q,d[q](),n.event.triggered=void 0,h&&(d[k]=h)),b.result}},dispatch:function(a){a=n.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(L.get(this,"events")||{})[a.type]||[],k=n.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=n.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,c=0;while((g=f.handlers[c++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(g.namespace))&&(a.handleObj=g,a.data=g.data,e=((n.event.special[g.origType]||{}).handle||g.handler).apply(f.elem,i),void 0!==e&&(a.result=e)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!==this;i=i.parentNode||this)if(i.disabled!==!0||"click"!==a.type){for(d=[],c=0;h>c;c++)f=b[c],e=f.selector+" ",void 0===d[e]&&(d[e]=f.needsContext?n(e,this).index(i)>=0:n.find(e,this,null,[i]).length),d[e]&&d.push(f);d.length&&g.push({elem:i,handlers:d})}return h]*)\/>/gi,ba=/<([\w:]+)/,ca=/<|&#?\w+;/,da=/<(?:script|style|link)/i,ea=/checked\s*(?:[^=]|=\s*.checked.)/i,fa=/^$|\/(?:java|ecma)script/i,ga=/^true\/(.*)/,ha=/^\s*\s*$/g,ia={option:[1,""],thead:[1,"","
    "],col:[2,"","
    "],tr:[2,"","
    "],td:[3,"","
    "],_default:[0,"",""]};ia.optgroup=ia.option,ia.tbody=ia.tfoot=ia.colgroup=ia.caption=ia.thead,ia.th=ia.td;function ja(a,b){return n.nodeName(a,"table")&&n.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function ka(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function la(a){var b=ga.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function ma(a,b){for(var c=0,d=a.length;d>c;c++)L.set(a[c],"globalEval",!b||L.get(b[c],"globalEval"))}function na(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(L.hasData(a)&&(f=L.access(a),g=L.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;d>c;c++)n.event.add(b,e,j[e][c])}M.hasData(a)&&(h=M.access(a),i=n.extend({},h),M.set(b,i))}}function oa(a,b){var c=a.getElementsByTagName?a.getElementsByTagName(b||"*"):a.querySelectorAll?a.querySelectorAll(b||"*"):[];return void 0===b||b&&n.nodeName(a,b)?n.merge([a],c):c}function pa(a,b){var c=b.nodeName.toLowerCase();"input"===c&&T.test(a.type)?b.checked=a.checked:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}n.extend({clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=n.contains(a.ownerDocument,a);if(!(k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||n.isXMLDoc(a)))for(g=oa(h),f=oa(a),d=0,e=f.length;e>d;d++)pa(f[d],g[d]);if(b)if(c)for(f=f||oa(a),g=g||oa(h),d=0,e=f.length;e>d;d++)na(f[d],g[d]);else na(a,h);return g=oa(h,"script"),g.length>0&&ma(g,!i&&oa(a,"script")),h},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,k=b.createDocumentFragment(),l=[],m=0,o=a.length;o>m;m++)if(e=a[m],e||0===e)if("object"===n.type(e))n.merge(l,e.nodeType?[e]:e);else if(ca.test(e)){f=f||k.appendChild(b.createElement("div")),g=(ba.exec(e)||["",""])[1].toLowerCase(),h=ia[g]||ia._default,f.innerHTML=h[1]+e.replace(aa,"<$1>")+h[2],j=h[0];while(j--)f=f.lastChild;n.merge(l,f.childNodes),f=k.firstChild,f.textContent=""}else l.push(b.createTextNode(e));k.textContent="",m=0;while(e=l[m++])if((!d||-1===n.inArray(e,d))&&(i=n.contains(e.ownerDocument,e),f=oa(k.appendChild(e),"script"),i&&ma(f),c)){j=0;while(e=f[j++])fa.test(e.type||"")&&c.push(e)}return k},cleanData:function(a){for(var b,c,d,e,f=n.event.special,g=0;void 0!==(c=a[g]);g++){if(n.acceptData(c)&&(e=c[L.expando],e&&(b=L.cache[e]))){if(b.events)for(d in b.events)f[d]?n.event.remove(c,d):n.removeEvent(c,d,b.handle);L.cache[e]&&delete L.cache[e]}delete M.cache[c[M.expando]]}}}),n.fn.extend({text:function(a){return J(this,function(a){return void 0===a?n.text(this):this.empty().each(function(){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&(this.textContent=a)})},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=ja(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=ja(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?n.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||n.cleanData(oa(c)),c.parentNode&&(b&&n.contains(c.ownerDocument,c)&&ma(oa(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(n.cleanData(oa(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return n.clone(this,a,b)})},html:function(a){return J(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!da.test(a)&&!ia[(ba.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(aa,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(n.cleanData(oa(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,n.cleanData(oa(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,m=this,o=l-1,p=a[0],q=n.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&ea.test(p))return this.each(function(c){var d=m.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(c=n.buildFragment(a,this[0].ownerDocument,!1,this),d=c.firstChild,1===c.childNodes.length&&(c=d),d)){for(f=n.map(oa(c,"script"),ka),g=f.length;l>j;j++)h=c,j!==o&&(h=n.clone(h,!0,!0),g&&n.merge(f,oa(h,"script"))),b.call(this[j],h,j);if(g)for(i=f[f.length-1].ownerDocument,n.map(f,la),j=0;g>j;j++)h=f[j],fa.test(h.type||"")&&!L.access(h,"globalEval")&&n.contains(i,h)&&(h.src?n._evalUrl&&n._evalUrl(h.src):n.globalEval(h.textContent.replace(ha,"")))}return this}}),n.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){n.fn[a]=function(a){for(var c,d=[],e=n(a),g=e.length-1,h=0;g>=h;h++)c=h===g?this:this.clone(!0),n(e[h])[b](c),f.apply(d,c.get());return this.pushStack(d)}});var qa,ra={};function sa(b,c){var d,e=n(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:n.css(e[0],"display");return e.detach(),f}function ta(a){var b=l,c=ra[a];return c||(c=sa(a,b),"none"!==c&&c||(qa=(qa||n("