diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index 7393eaee936..e4a0423550c 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -50,8 +50,13 @@ jobs: docs: - 'docs/developer-guide/**' - 'docs/demos/common/src/main/snippets/**' - - 'scripts/developer-guide/migrate-inline-guide-snippets.py' - - 'scripts/developer-guide/validate-guide-snippets.py' + # The whole directory, not two named files. on.pull_request.paths + # already triggers on scripts/developer-guide/**, so a change to any + # other script here started the workflow while leaving this filter + # false -- which skipped the very steps that script governs. A + # change to compare-screenshots.py could have merged without the + # screenshot check ever running it. + - 'scripts/developer-guide/**' - '.github/workflows/developer-guide-docs.yml' # Triggering the workflow is not enough on its own: the HTML and PDF # build and the steps beside it are gated on this filter, so the @@ -122,6 +127,7 @@ jobs: run: | set -euo pipefail GENERATED_DIR="$RUNNER_TEMP/pre-advanced-theming-screenshots" + echo "GUIDE_GENERATED_SCREENSHOTS=$GENERATED_DIR" >> "$GITHUB_ENV" rm -rf "$GENERATED_DIR" mkdir -p "$GENERATED_DIR" xvfb-run -a mvn -B -ntp \ @@ -131,23 +137,25 @@ jobs: -P guide-screenshot-generator \ -Dguide.screenshot.output="$GENERATED_DIR" \ verify - COUNT="$(find "$GENERATED_DIR" -maxdepth 1 -type f -name '*.png' | wc -l | tr -d ' ')" - if [ "$COUNT" != "24" ]; then - echo "::error::Expected 24 generated pre-Advanced Theming screenshots, found $COUNT" - exit 1 - fi - for generated in "$GENERATED_DIR"/*.png; do - name="$(basename "$generated")" - committed="docs/developer-guide/img/$name" - if [ ! -f "$committed" ]; then - echo "::error::Generated screenshot has no committed counterpart: $name" - exit 1 - fi - if ! cmp -s "$generated" "$committed"; then - echo "::error::Committed screenshot is stale: $name" - exit 1 - fi - done + pip install --user --quiet Pillow + # Byte equality is still the rule; a figure only gets a bounded + # difference if it carries a .tolerance sidecar saying why. + python3 scripts/developer-guide/compare-screenshots.py \ + --generated "$GENERATED_DIR" \ + --committed docs/developer-guide/img \ + --expected-count 24 + + # When the byte compare fails, the message names the file but not what it + # actually rendered, which leaves no way to tell a real regression from an + # environment difference. Publishing what this runner produced makes the + # two distinguishable without adding a debugging round trip to CI. + - name: Upload generated screenshots when they do not match + if: failure() && env.GUIDE_GENERATED_SCREENSHOTS != '' + uses: actions/upload-artifact@v7 + with: + name: guide-generated-screenshots + path: ${{ env.GUIDE_GENERATED_SCREENSHOTS }} + if-no-files-found: warn - name: Verify developer guide images are referenced if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true' diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/PreAdvancedThemingScreenshots.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/PreAdvancedThemingScreenshots.java index b168677eabf..effa4dc28ae 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/PreAdvancedThemingScreenshots.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/screenshots/PreAdvancedThemingScreenshots.java @@ -1,3 +1,25 @@ +/* + * 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.codenameone.developerguide.screenshots; import com.codename1.components.SpanLabel; @@ -5,6 +27,7 @@ import com.codename1.ui.Button; import com.codename1.ui.Component; import com.codename1.ui.Container; +import com.codename1.ui.Display; import com.codename1.ui.CN; import com.codename1.ui.FontImage; import com.codename1.ui.Font; @@ -12,6 +35,7 @@ import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.Label; +import com.codename1.ui.TextArea; import com.codename1.ui.TextField; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; @@ -40,10 +64,33 @@ public final class PreAdvancedThemingScreenshots { private static final int BLUE = 0x0b57d0; private static final int GREEN = 0x06a806; private static final int WHITE = 0xffffff; - private static final Font TITLE_FONT = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE) - .derive(35, Font.STYLE_PLAIN); - private static final Font BLOCK_FONT = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE) - .derive(24, Font.STYLE_PLAIN); + private static final Font TITLE_FONT = screenshotFont(35); + private static final Font BLOCK_FONT = screenshotFont(24); + /// Sized to match the height the theme's own default font had (13px), so + /// pinning the face on components the block styling does not touch keeps + /// their layout exactly where it was. + private static final Font FIELD_FONT = screenshotFont(11); + + /// Loads a figure font from the port's bundled Roboto rather than from the host. + /// + /// `Font.createSystemFont` resolves through `JavaSEPort.fontFaceSystem`, which is + /// "Arial" on macOS and Linux alike. Arial exists on a developer's Mac and not on a + /// stock CI runner, so AWT silently substitutes a different face and every glyph in + /// every figure changes -- which is why none of these screenshots could be + /// regenerated outside CI and byte-compared against what was committed. The + /// `native:` scheme reads `/com/codename1/impl/javase/Roboto-*.ttf` off the + /// classpath, so the result does not depend on what the machine happens to have + /// installed. It is also what this project's font rule requires everywhere. + private static Font screenshotFont(int pixelSize) { + Font font = Font.createTrueTypeFont("native:MainRegular", "native:MainRegular"); + if (font == null) { + // Falling back to a host font would quietly restore the very + // non-determinism this exists to remove, so refuse instead. + throw new IllegalStateException( + "the native font scheme is unavailable, so figures would render with a host font"); + } + return font.derive(pixelSize, Font.STYLE_PLAIN); + } private PreAdvancedThemingScreenshots() { } @@ -53,6 +100,32 @@ public interface ScreenshotSink { } public static void generate(ScreenshotSink sink) throws IOException { + // JavaSEPort.loadTrueTypeFont has an earlier branch for native: fonts: + // when isIOS is set -- which loadSkinFile does for any skin whose + // systemFontFamily contains "helvetica" -- it resolves to the first + // INSTALLED SF or Helvetica family and never reaches the bundled Roboto. + // This generator never loads a skin, so that branch is not taken, and the + // measurement agrees: figures rendered on a Mac match the Linux runner + // byte for byte, which could not happen if one side were using Helvetica + // Neue. Guard it anyway, because a future change that loads a skin here + // would put the host's fonts back into the output without any other + // symptom. + String platform = Display.getInstance().getPlatformName(); + if ("ios".equals(platform)) { + throw new IllegalStateException( + "an iOS skin is active, so native: fonts would resolve to installed " + + "system faces instead of the bundled ones and these figures would " + + "stop being reproducible off this machine"); + } + + // MigLayout picks its default gaps from PlatformDefaults, which reads + // System.getProperty("os.name") and returns MAC_OSX, GNOME or WINDOWS_XP. + // The gaps differ per platform, so mig-layout.png came out with macOS + // spacing on a Mac and GNOME spacing on the Linux runner -- a 12.8% pixel + // difference that has nothing to do with fonts. Pin it so the figure shows + // the same thing wherever it is generated. + com.codename1.ui.layouts.mig.PlatformDefaults.setPlatform( + com.codename1.ui.layouts.mig.PlatformDefaults.GNOME); write(sink, "flow-layout.png", flow("Flow Layout", Component.LEFT, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT); write(sink, "flow-layout-center.png", flow("Flow Layout", Component.CENTER, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT); write(sink, "flow-layout-right.png", flow("Flow Layout", Component.RIGHT, Component.TOP), PORTRAIT_WIDTH, PORTRAIT_HEIGHT); @@ -328,6 +401,22 @@ private static void applyScreenshotStyle(Form form) { private static void applyBlockStyleToContent(Container container) { for (int i = 0; i < container.getComponentCount(); i++) { Component component = container.getComponentAt(i); + // Pin the font on EVERY component rather than on the types that also + // get block colours. Anything left on the theme's default font + // resolves through the host, and that is what made + // guibuilder-2-insets-3.png -- the only figure containing a + // TextField -- differ between a Mac and CI while the other 23 + // matched byte for byte. Enumerating the types that carry text + // would leave the next one to be added broken in the same way. + component.getAllStyles().setFont(FIELD_FONT); + if (component instanceof TextArea) { + // The hint is painted by a Label that is not in the component + // tree, so the walk above never reaches it. + Label hint = ((TextArea) component).getHintLabel(); + if (hint != null) { + hint.getAllStyles().setFont(FIELD_FONT); + } + } if (component instanceof Label || component instanceof Button) { styleBlock(component); } diff --git a/docs/developer-guide/img/border-layout-RTL.png b/docs/developer-guide/img/border-layout-RTL.png index 95e79675f1f..edf98faecda 100644 Binary files a/docs/developer-guide/img/border-layout-RTL.png and b/docs/developer-guide/img/border-layout-RTL.png differ diff --git a/docs/developer-guide/img/border-layout-center.png b/docs/developer-guide/img/border-layout-center.png index 398b2658b15..aec5b063013 100644 Binary files a/docs/developer-guide/img/border-layout-center.png and b/docs/developer-guide/img/border-layout-center.png differ diff --git a/docs/developer-guide/img/border-layout.png b/docs/developer-guide/img/border-layout.png index b88ae544390..b31ed1a2f83 100644 Binary files a/docs/developer-guide/img/border-layout.png and b/docs/developer-guide/img/border-layout.png differ diff --git a/docs/developer-guide/img/box-layout-x-no-grow.png b/docs/developer-guide/img/box-layout-x-no-grow.png index df53706a6c4..e6daaeaab7b 100644 Binary files a/docs/developer-guide/img/box-layout-x-no-grow.png and b/docs/developer-guide/img/box-layout-x-no-grow.png differ diff --git a/docs/developer-guide/img/box-layout-x.png b/docs/developer-guide/img/box-layout-x.png index a676564e83f..df40eb73e99 100644 Binary files a/docs/developer-guide/img/box-layout-x.png and b/docs/developer-guide/img/box-layout-x.png differ diff --git a/docs/developer-guide/img/box-layout-y.png b/docs/developer-guide/img/box-layout-y.png index 6c5aa6e082b..aff6e8fa8ba 100644 Binary files a/docs/developer-guide/img/box-layout-y.png and b/docs/developer-guide/img/box-layout-y.png differ diff --git a/docs/developer-guide/img/flow-layout-center-middle.png b/docs/developer-guide/img/flow-layout-center-middle.png index f67dfa2281b..291aff0a991 100644 Binary files a/docs/developer-guide/img/flow-layout-center-middle.png and b/docs/developer-guide/img/flow-layout-center-middle.png differ diff --git a/docs/developer-guide/img/flow-layout-center.png b/docs/developer-guide/img/flow-layout-center.png index abb0f26a28a..1f1e88ca095 100644 Binary files a/docs/developer-guide/img/flow-layout-center.png and b/docs/developer-guide/img/flow-layout-center.png differ diff --git a/docs/developer-guide/img/flow-layout-right.png b/docs/developer-guide/img/flow-layout-right.png index 333ffa7c2b2..7bd6e9838dc 100644 Binary files a/docs/developer-guide/img/flow-layout-right.png and b/docs/developer-guide/img/flow-layout-right.png differ diff --git a/docs/developer-guide/img/flow-layout.png b/docs/developer-guide/img/flow-layout.png index 609f84de599..20825fa27fb 100644 Binary files a/docs/developer-guide/img/flow-layout.png and b/docs/developer-guide/img/flow-layout.png differ diff --git a/docs/developer-guide/img/grid-layout-2x2.png b/docs/developer-guide/img/grid-layout-2x2.png index ba2329cce9b..898c1124e72 100644 Binary files a/docs/developer-guide/img/grid-layout-2x2.png and b/docs/developer-guide/img/grid-layout-2x2.png differ diff --git a/docs/developer-guide/img/grid-layout-2x4.png b/docs/developer-guide/img/grid-layout-2x4.png index af3242b504a..34f340e6257 100644 Binary files a/docs/developer-guide/img/grid-layout-2x4.png and b/docs/developer-guide/img/grid-layout-2x4.png differ diff --git a/docs/developer-guide/img/grid-layout-autofit-landscape.png b/docs/developer-guide/img/grid-layout-autofit-landscape.png index 4cf353d5626..e01088fc2ed 100644 Binary files a/docs/developer-guide/img/grid-layout-autofit-landscape.png and b/docs/developer-guide/img/grid-layout-autofit-landscape.png differ diff --git a/docs/developer-guide/img/grid-layout-autofit-portrait.png b/docs/developer-guide/img/grid-layout-autofit-portrait.png index 7e62c765a54..4404b3936a5 100644 Binary files a/docs/developer-guide/img/grid-layout-autofit-portrait.png and b/docs/developer-guide/img/grid-layout-autofit-portrait.png differ diff --git a/docs/developer-guide/img/gridbag-layout.png b/docs/developer-guide/img/gridbag-layout.png index e9c6b977d5f..651217b3ded 100644 Binary files a/docs/developer-guide/img/gridbag-layout.png and b/docs/developer-guide/img/gridbag-layout.png differ diff --git a/docs/developer-guide/img/group-layout.png b/docs/developer-guide/img/group-layout.png index 9a886156db2..c1f087123ca 100644 Binary files a/docs/developer-guide/img/group-layout.png and b/docs/developer-guide/img/group-layout.png differ diff --git a/docs/developer-guide/img/guibuilder-2-insets-1.png b/docs/developer-guide/img/guibuilder-2-insets-1.png index 179a9cebcea..257fd9e0448 100644 Binary files a/docs/developer-guide/img/guibuilder-2-insets-1.png and b/docs/developer-guide/img/guibuilder-2-insets-1.png differ diff --git a/docs/developer-guide/img/guibuilder-2-insets-2.png b/docs/developer-guide/img/guibuilder-2-insets-2.png index e3051918c76..939d38580af 100644 Binary files a/docs/developer-guide/img/guibuilder-2-insets-2.png and b/docs/developer-guide/img/guibuilder-2-insets-2.png differ diff --git a/docs/developer-guide/img/guibuilder-2-insets-3.png b/docs/developer-guide/img/guibuilder-2-insets-3.png index 3aa0bfbf478..a200270d4b4 100644 Binary files a/docs/developer-guide/img/guibuilder-2-insets-3.png and b/docs/developer-guide/img/guibuilder-2-insets-3.png differ diff --git a/docs/developer-guide/img/layered-layout.png b/docs/developer-guide/img/layered-layout.png index af04164ae34..2c8aa052ecd 100644 Binary files a/docs/developer-guide/img/layered-layout.png and b/docs/developer-guide/img/layered-layout.png differ diff --git a/docs/developer-guide/img/layered-layout.tolerance b/docs/developer-guide/img/layered-layout.tolerance new file mode 100644 index 00000000000..4f1e24ed098 --- /dev/null +++ b/docs/developer-guide/img/layered-layout.tolerance @@ -0,0 +1,15 @@ +# This figure contains a FontImage material glyph. Measured against the CI +# runner's own output, the glyph lands at exactly the same size and position -- +# a 55x49 bounding box at the same origin -- and differs only in antialiased +# edge coverage: 946 fully-white pixels against 916, 173 differing pixels in +# total, 0.113% of the image. Java2D rasterizes the same glyph from the same +# bundled font at the same size slightly differently on macOS and Linux. +# +# The two bounds are independent and both bind. maxMismatchPercent limits how +# much of the image may change AT ALL -- every differing pixel counts, not only +# those past the delta -- so a wholesale recolour cannot slip through by moving +# each channel a little at a time. maxChannelDelta then caps how far any single +# pixel may move: 160 clears the measured worst of 141 with room, while a solid +# overwrite still fails on it. +maxChannelDelta=160 +maxMismatchPercent=0.3 diff --git a/docs/developer-guide/img/mig-layout.png b/docs/developer-guide/img/mig-layout.png index 910e722133c..4f57360b62f 100644 Binary files a/docs/developer-guide/img/mig-layout.png and b/docs/developer-guide/img/mig-layout.png differ diff --git a/docs/developer-guide/img/table-layout-2x2.png b/docs/developer-guide/img/table-layout-2x2.png index 7a066de29dc..dae9bdd4053 100644 Binary files a/docs/developer-guide/img/table-layout-2x2.png and b/docs/developer-guide/img/table-layout-2x2.png differ diff --git a/docs/developer-guide/img/table-layout-constraints.png b/docs/developer-guide/img/table-layout-constraints.png index c9afd65acc6..8ef75925dd6 100644 Binary files a/docs/developer-guide/img/table-layout-constraints.png and b/docs/developer-guide/img/table-layout-constraints.png differ diff --git a/docs/developer-guide/img/table-layout-enclose.png b/docs/developer-guide/img/table-layout-enclose.png index 0bce1e15513..90eb64903a3 100644 Binary files a/docs/developer-guide/img/table-layout-enclose.png and b/docs/developer-guide/img/table-layout-enclose.png differ diff --git a/scripts/developer-guide/compare-screenshots.py b/scripts/developer-guide/compare-screenshots.py new file mode 100755 index 00000000000..a57efcaca40 --- /dev/null +++ b/scripts/developer-guide/compare-screenshots.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Compare generated guide figures against the committed ones. + +Byte equality is the default and is what nearly every figure achieves: with the +font loaded from the port's bundled Roboto and MigLayout's platform pinned, 23 of +the 24 render identically on macOS and on the Linux runner. + +The exception is a figure containing a `FontImage` material glyph. Measured +against the runner's own output, the glyph lands at exactly the same size and +position -- a 55x49 bounding box at the same origin -- and differs only in +antialiased edge coverage, 946 fully-white pixels against 916. That is Java2D +rasterizing the same glyph from the same font at the same size slightly +differently on the two platforms, and no amount of pinning on our side changes +it. Demanding byte equality there would mean either deleting legitimate content +from the figure or carrying a permanently red check. + +So a figure may carry a `.tolerance` sidecar, in the same key=value shape +the CN1SS screenshot suites already use, and only then is a bounded difference +accepted. Everything without a sidecar must still match exactly. + +The two bounds are applied independently, which is where this differs from the +CN1SS comparator: `maxMismatchPercent` limits how much of the image may change at +all, and `maxChannelDelta` caps how far any single pixel may move. Counting only +the pixels that exceed the delta would let an unlimited number of sub-threshold +changes through. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +try: + from PIL import Image +except ImportError: # pragma: no cover - the workflow installs Pillow + print("Pillow is required to compare screenshots", file=sys.stderr) + raise + + +def read_tolerance(path: Path) -> tuple[int, float] | None: + if not path.exists(): + return None + max_delta, max_percent = 0, 0.0 + for line in path.read_text(encoding="utf-8").split("\n"): + line = line.split("#", 1)[0].strip() + if not line or "=" not in line: + continue + key, _, value = line.partition("=") + if key.strip() == "maxChannelDelta": + max_delta = int(value) + elif key.strip() == "maxMismatchPercent": + max_percent = float(value) + return max_delta, max_percent + + +def compare(generated: Path, committed: Path, tolerance: tuple[int, float] | None) -> str | None: + """Return None when the pair is acceptable, else a description of the failure.""" + if generated.read_bytes() == committed.read_bytes(): + return None + if tolerance is None: + return "differs and has no tolerance sidecar" + max_delta, max_percent = tolerance + # RGBA, not RGB: these figures are saved with an alpha channel, and dropping + # it would make a change that touches only transparency invisible here -- + # including one that turned the whole figure see-through while leaving every + # colour channel intact. + a = Image.open(generated).convert("RGBA") + b = Image.open(committed).convert("RGBA") + if a.size != b.size: + return f"size changed: generated {a.size[0]}x{a.size[1]}, committed {b.size[0]}x{b.size[1]}" + pa, pb = a.load(), b.load() + width, height = a.size + changed = 0 + worst = 0 + for y in range(height): + for x in range(width): + first, second = pa[x, y], pb[x, y] + if first == second: + continue + # Every changed pixel counts toward the area budget, and the channel + # delta is a separate ceiling. Counting only the pixels that EXCEED + # the delta -- which is what the CN1SS comparator does -- leaves an + # unbounded hole: with maxChannelDelta=160, recolouring these figures' + # green #06a806 to #a608a6 moves every channel by exactly 160, so not + # one pixel would be counted and a completely different image would + # pass. + changed += 1 + worst = max(worst, max(abs(first[i] - second[i]) for i in range(4))) + percent = 100.0 * changed / (width * height) + if percent > max_percent: + return f"{percent:.3f}% of pixels changed (allowed {max_percent}%)" + if worst > max_delta: + return f"worst channel delta {worst} exceeds maxChannelDelta={max_delta}" + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--generated", required=True, type=Path) + parser.add_argument("--committed", required=True, type=Path) + parser.add_argument("--expected-count", type=int, required=True) + args = parser.parse_args() + + produced = sorted(args.generated.glob("*.png")) + if len(produced) != args.expected_count: + print( + f"::error::Expected {args.expected_count} generated figures, found {len(produced)}", + file=sys.stderr, + ) + return 1 + + failures = 0 + tolerated = 0 + for image in produced: + committed = args.committed / image.name + if not committed.exists(): + print(f"::error::Generated figure has no committed counterpart: {image.name}", file=sys.stderr) + failures += 1 + continue + sidecar = args.committed / (image.stem + ".tolerance") + tolerance = read_tolerance(sidecar) + problem = compare(image, committed, tolerance) + if problem: + print(f"::error::{image.name}: {problem}", file=sys.stderr) + failures += 1 + elif tolerance is not None and image.read_bytes() != committed.read_bytes(): + tolerated += 1 + print(f"{image.name}: within its tolerance sidecar") + + if failures: + print(f"\n{failures} figure(s) do not match.", file=sys.stderr) + return 1 + print(f"All {len(produced)} figures match ({tolerated} within a tolerance sidecar).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())