diff --git a/.github/workflows/_build-mac-port.yml b/.github/workflows/_build-mac-port.yml index 084963a7a65..9d72e92efa8 100644 --- a/.github/workflows/_build-mac-port.yml +++ b/.github/workflows/_build-mac-port.yml @@ -98,11 +98,12 @@ jobs: # triggers on, and scripts-macos.yml triggers on maven/** -- so naming # codenameone-maven-plugin and svg-transcoder left every other module # able to start this job and then hit an exact cache. build-mac-port.sh - # runs `-pl designer -am` and `-pl mac -am`, so the modules it actually + # runs `-pl css-cli -am` and `-pl mac -am`, so the modules it actually # rebuilds are the transitive dependencies of those two: core, - # css-compiler, javase, factory and the rest. A CSS compiler change is - # the concrete case -- the script rebuilds designer precisely so it - # reaches the test app, and the cache key was cancelling that rebuild. + # css-compiler, project-model, factory and the rest. A CSS compiler + # change is the concrete case -- the script reinstalls css-cli precisely + # so it reaches the test app, and the cache key was cancelling that + # rebuild. # Listing them would be the same mistake one module later, so the # subtree is hashed and the list is gone. # @@ -120,9 +121,9 @@ jobs: # null-aware sort. # CodenameOneDesigner/src is here because maven/designer is a pom and # nothing else -- it sets ../../CodenameOneDesigner/src, - # so the module the build script rebuilds by name compiles a tree that - # lives outside maven/ entirely. Hashing maven/ alone would have looked - # complete and still missed it. + # so a module setup-workspace.sh installs into the cached repository + # compiles a tree that lives outside maven/ entirely. Hashing maven/ + # alone would have looked complete and still missed it. SRC_HASH=$(find CodenameOne/src Ports/MacPort Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ CodenameOneDesigner/src \ maven \ diff --git a/.github/workflows/archetype-smoke.yml b/.github/workflows/archetype-smoke.yml index 9cb832acda0..424545f63ce 100644 --- a/.github/workflows/archetype-smoke.yml +++ b/.github/workflows/archetype-smoke.yml @@ -1,7 +1,7 @@ name: Archetype simulator smoke # Smoke-tests that an archetype-generated project can run `cn1:css` (the -# CN1CSSCLI subprocess that boots JavaSEPort) with `cn1.autoDefaultResourceBundle` +# CN1CSSCLI subprocess, which runs headless) with `cn1.autoDefaultResourceBundle` # forced on. This reproduces the user-side preference state that masked # issue #4850 in CI for six months: the bug was deterministic on the user # code path but invisible to CI runners with the default-false preference. @@ -17,6 +17,8 @@ on: - 'CodenameOneDesigner/**' - 'CodenameOne/src/com/codename1/ui/plaf/UIManager.java' - 'maven/codenameone-maven-plugin/**' + - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'maven/build-engine/**' - 'maven/project-model/**' - 'maven/cn1app-archetype/**' @@ -42,6 +44,8 @@ on: - 'CodenameOneDesigner/**' - 'CodenameOne/src/com/codename1/ui/plaf/UIManager.java' - 'maven/codenameone-maven-plugin/**' + - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'maven/build-engine/**' - 'maven/project-model/**' - 'maven/cn1app-archetype/**' @@ -96,6 +100,11 @@ jobs: # them need those headers to compile at all: without them this failed on # "'curl/curl.h' file not found" after translating perfectly well. The same # four packages, for the same reason, are in parparvm-tests.yml. + # + # xvfb is for the minimal-layout step, which runs cn1:test in the + # simulator's runner, and is left around the archetype smoke as it was. + # Compiling a project's CSS needs no display: the framework build and + # the staged-jar step run without one, and the latter is the guard. - name: Install xvfb and the native backend toolchain run: | bash scripts/ci/apt-get-install.sh xvfb clang \ @@ -116,12 +125,12 @@ jobs: # -Darchetype.test.skip=true keeps the smoke fast — the per-tag # ITs already run in the release workflow. cd maven - bash ../scripts/ci/retry.sh xvfb-run -a mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true + bash ../scripts/ci/retry.sh mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true - name: Refresh archetype catalog run: | cd maven - xvfb-run -a mvn archetype:update-local-catalog -Plocal-dev-javase - xvfb-run -a mvn archetype:crawl -Plocal-dev-javase + mvn archetype:update-local-catalog -Plocal-dev-javase + mvn archetype:crawl -Plocal-dev-javase - name: Run archetype simulator smoke (auto-bundle pref forced on) env: # The packaging check must not be skipped on a runner that has clang. @@ -135,8 +144,13 @@ jobs: - name: Check the staged upload jar of every build target run: | cd maven/integration-tests - # The project's CSS compile opens an AWT frame, so it needs a display. - xvfb-run -a bash cn1app-staged-jar-test.sh + # DISPLAY and WAYLAND_DISPLAY are cleared on purpose, and this must + # never be wrapped in xvfb-run: the script generates a project and + # builds every target, which compiles the project's theme.css each + # time, so passing here proves the CSS compile needs no display. A + # compiler change that reaches for a window fails this step instead + # of the first developer who builds over ssh or in a container. + env -u DISPLAY -u WAYLAND_DISPLAY bash cn1app-staged-jar-test.sh # The archetype's default layouts: an app with no platform modules, whose # common module builds, tests and stages every platform, and a backend-only # project. Nothing is submitted and the simulator is never launched. diff --git a/.github/workflows/designer.yml b/.github/workflows/designer.yml index f8851d4bdbc..f52c5aaf9e9 100644 --- a/.github/workflows/designer.yml +++ b/.github/workflows/designer.yml @@ -47,7 +47,7 @@ jobs: cache: 'maven' - name: Install build dependencies - run: bash scripts/ci/apt-get-install.sh xvfb unzip + run: bash scripts/ci/apt-get-install.sh unzip - name: Cache cn1-binaries uses: actions/cache@v5 @@ -69,7 +69,7 @@ jobs: rm -rf maven/target/cn1-binaries cp -R ../cn1-binaries maven/target/cn1-binaries - - name: Build Designer (pulls in codenameone-css-compiler as dep) + - name: Build Designer (pulls in codenameone-css-compiler and codenameone-css-cli as deps) env: CN1_BINARIES: ${{ github.workspace }}/maven/target/cn1-binaries run: | @@ -97,7 +97,8 @@ jobs: # CN1CSSCLI lives in the thin codenameone-css-cli module and is launched # on a resolved classpath (see AbstractCN1Mojo.getCssCliClasspath and the # simulator's CSSWatcher), not as a shaded `java -jar`. Exercise it the - # same way here. + # same way here, with java.awt.headless set and DISPLAY cleared: the + # compiler opens no window and must keep working where there is none. cd maven bash $GITHUB_WORKSPACE/scripts/ci/retry.sh mvn -B -q -pl css-cli -Plocal-dev-javase \ -Dmdep.outputFile="$PWD/css-cli-cp.txt" dependency:build-classpath @@ -116,14 +117,14 @@ jobs: cat <<'EOF' > "$l10n_dir/Strings.properties" greeting=Hello from CLI EOF - xvfb-run -a java -Dcli=true -cp "$cp" com.codename1.designer.css.CN1CSSCLI \ - -css -stateless -input "$css_file" -output "$output_file" -localization "$l10n_dir" + env -u DISPLAY -u WAYLAND_DISPLAY java -Djava.awt.headless=true -cp "$cp" com.codename1.designer.css.CN1CSSCLI \ + -input "$css_file" -output "$output_file" -localization "$l10n_dir" test -s "$output_file" - - name: Verify native-themes CEF-free build + - name: Verify native-themes build run: | cd maven - bash $GITHUB_WORKSPACE/scripts/ci/retry.sh mvn -B -pl css-compiler -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase + bash $GITHUB_WORKSPACE/scripts/ci/retry.sh mvn -B -pl css-cli -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase cd .. ./scripts/build-native-themes.sh test -f Themes/iOSModernTheme.res || { echo "missing Themes/iOSModernTheme.res"; exit 1; } diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index 34e25e31db1..c59dc002c23 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -36,12 +36,13 @@ on: # The themed figures are rendered against the compiled native themes, so # what those themes look like is an input to this guide exactly the way the # build hint sources above are. build-native-themes.sh compiles - # native-themes//theme.css with maven/css-compiler, so a change to - # any of the three changes the pictures without touching docs/ at all -- - # and the committed figures would then go stale silently until some later - # run failed against them. + # native-themes//theme.css with maven/css-compiler through its + # command line in maven/css-cli, so a change to any of them changes the + # pictures without touching docs/ at all -- and the committed figures + # would then go stale silently until some later run failed against them. - 'native-themes/**' - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'scripts/build-native-themes.sh' # The figures are rendered by the framework itself, so a change to how a # component paints, how a layout measures, or how the JavaSE port @@ -109,6 +110,7 @@ jobs: # comparison step is gated on this filter too. - 'native-themes/**' - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'scripts/build-native-themes.sh' # Same reason, and the same two-lists rule: the figure comparison # step is gated on this filter as well as on the trigger. @@ -167,7 +169,9 @@ jobs: mkdir -p "$HOME/.codenameone" touch "$HOME/.codenameone/guibuilder.jar" cp maven/CodeNameOneBuildClient.jar "$HOME/.codenameone/CodeNameOneBuildClient.jar" - xvfb-run -a mvn -B -ntp -Dgenerate-gui-sources-done=true -Dcodename1.platform=javase -f docs/demos/pom.xml process-classes + # No virtual display: process-classes compiles each demo's CSS, which + # is headless. The screenshot generator below is the step that needs one. + mvn -B -ntp -Dgenerate-gui-sources-done=true -Dcodename1.platform=javase -f docs/demos/pom.xml process-classes - name: Install demo common module for screenshot generation if: github.event_name != 'pull_request' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.demos == 'true' || steps.changes.outputs.workflow == 'true' diff --git a/.github/workflows/gradle-smoke.yml b/.github/workflows/gradle-smoke.yml index 2a5e1601b07..86a3d139c46 100644 --- a/.github/workflows/gradle-smoke.yml +++ b/.github/workflows/gradle-smoke.yml @@ -31,6 +31,8 @@ on: - 'maven/codenameone-gradle-plugin/**' - 'maven/codenameone-gradle-plugin-marker/**' - 'maven/codenameone-maven-plugin/**' + - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'maven/build-engine/**' - 'maven/project-model/**' - 'maven/cn1app-archetype/**' @@ -53,6 +55,8 @@ on: - 'maven/codenameone-gradle-plugin/**' - 'maven/codenameone-gradle-plugin-marker/**' - 'maven/codenameone-maven-plugin/**' + - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'maven/build-engine/**' - 'maven/project-model/**' - 'maven/cn1app-archetype/**' @@ -108,9 +112,12 @@ jobs: java-version: '8' distribution: 'temurin' cache: 'maven' - # xvfb for the CSS compiler, which opens an AWT frame. clang and the - # libraries the generated backend links (see archetype-smoke.yml) for - # backendPackage, which the backend-only test otherwise skips. + # xvfb for the steps that run cn1Test in the simulator's runner or open + # a desktop tool. Building a project, compiling its CSS included, needs + # no display, so no build-only step below is wrapped in xvfb-run and + # none should be. clang and the libraries the generated backend links + # (see archetype-smoke.yml) for backendPackage, which the backend-only + # test otherwise skips. - name: Install xvfb and the native backend toolchain run: | bash scripts/ci/apt-get-install.sh xvfb clang \ @@ -122,21 +129,26 @@ jobs: RETRY_DELAY_SECONDS: '60' run: | cd maven - bash ../scripts/ci/retry.sh xvfb-run -a mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true + bash ../scripts/ci/retry.sh mvn install -Plocal-dev-javase -DskipTests -Darchetype.test.skip=true -Dmaven.javadoc.skip=true - name: Refresh archetype catalog run: | cd maven - xvfb-run -a mvn archetype:update-local-catalog -Plocal-dev-javase - xvfb-run -a mvn archetype:crawl -Plocal-dev-javase + mvn archetype:update-local-catalog -Plocal-dev-javase + mvn archetype:crawl -Plocal-dev-javase - name: Gradle application working-directory: maven/integration-tests run: xvfb-run -a bash gradle-app-test.sh - name: Maven and Gradle upload parity working-directory: maven/integration-tests - run: xvfb-run -a bash gradle-maven-parity-test.sh + run: bash gradle-maven-parity-test.sh - name: Gradle cn1lib, consumed by Gradle and Maven working-directory: maven/integration-tests - run: xvfb-run -a bash gradle-cn1lib-test.sh + # DISPLAY and WAYLAND_DISPLAY are cleared on purpose, and this must + # never be wrapped in xvfb-run: the script runs cn1Css and stages + # Android and iOS builds for several Gradle apps without starting the + # simulator, so passing here proves the Gradle CSS compile needs no + # display. archetype-smoke.yml holds the same guard for Maven. + run: env -u DISPLAY -u WAYLAND_DISPLAY bash gradle-cn1lib-test.sh - name: Migration to Gradle working-directory: maven/integration-tests run: xvfb-run -a bash gradle-migrate-test.sh @@ -145,13 +157,13 @@ jobs: env: # A skip reads like a pass; this runner has clang. CN1_BACKEND_PACKAGE_REQUIRED: '1' - run: xvfb-run -a bash gradle-initializr-test.sh + run: bash gradle-initializr-test.sh # The desktop tools are separate Maven projects under scripts/; Settings # stands in for all four, which start through the same launcher. - name: Build Codename One Settings working-directory: scripts/settings - # Its own CSS compile opens an AWT frame, so it needs a display too. - run: JAVA_HOME="$GRADLE_JAVA_HOME" xvfb-run -a mvn -B -ntp install -DskipTests -Pexecutable-jar -Dcodename1.platform=javase + # Build only, tests skipped: nothing here opens a window. + run: JAVA_HOME="$GRADLE_JAVA_HOME" mvn -B -ntp install -DskipTests -Pexecutable-jar -Dcodename1.platform=javase - name: Desktop tools launch from Maven and Gradle working-directory: maven/integration-tests env: diff --git a/.github/workflows/guibuilder.yml b/.github/workflows/guibuilder.yml index cf0e9aa5653..7601b14d1ee 100644 --- a/.github/workflows/guibuilder.yml +++ b/.github/workflows/guibuilder.yml @@ -23,6 +23,7 @@ on: - 'CodenameOne/src/**' - 'Ports/JavaSE/src/**' - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' - 'maven/build-engine/src/main/java/com/codename1/maven/**' push: @@ -40,6 +41,7 @@ on: - 'CodenameOne/src/**' - 'Ports/JavaSE/src/**' - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/**' - 'maven/build-engine/src/main/java/com/codename1/maven/**' diff --git a/.github/workflows/linux-build-run.yml b/.github/workflows/linux-build-run.yml index 1af97d42cc2..2883dbd9408 100644 --- a/.github/workflows/linux-build-run.yml +++ b/.github/workflows/linux-build-run.yml @@ -100,13 +100,9 @@ jobs: - name: Check out repository uses: actions/checkout@v6 - - name: Install Xvfb - run: | - set -e - # The CN1 CSS compiler renders via CEF/AWT and throws HeadlessException - # without a display, so the common build runs under a virtual X server. - bash scripts/ci/apt-get-install.sh xvfb - + # No virtual display in this job: it only builds, and the css goal the + # common build runs compiles theme.css headless. The jobs that run the + # app start their own Xvfb. - name: Set up JDK 8 uses: actions/setup-java@v5 with: { distribution: 'temurin', java-version: '8' } @@ -134,9 +130,6 @@ jobs: run: | set -e export JAVA_HOME="$JDK_17_HOME" - Xvfb :99 -screen 0 1600x1200x24 >/tmp/xvfb-build.log 2>&1 & - export DISPLAY=:99 - sleep 2 mkdir -p "$HOME/.codenameone" [ -f "$HOME/.codenameone/guibuilder.jar" ] || : > "$HOME/.codenameone/guibuilder.jar" bash scripts/ci/retry.sh \ diff --git a/.github/workflows/native-themes-sync.yml b/.github/workflows/native-themes-sync.yml index 29ea0786b5e..a00a30a3542 100644 --- a/.github/workflows/native-themes-sync.yml +++ b/.github/workflows/native-themes-sync.yml @@ -19,6 +19,7 @@ on: - 'scripts/ci/retry.sh' - 'native-themes/**' - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'scripts/build-native-themes.sh' - '.github/workflows/native-themes-sync.yml' @@ -60,10 +61,10 @@ jobs: restore-keys: | ${{ runner.os }}-m2 - - name: Build css-compiler + - name: Build the CSS compiler CLI run: | cd maven - bash $GITHUB_WORKSPACE/scripts/ci/retry.sh mvn -B -pl css-compiler -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase + bash $GITHUB_WORKSPACE/scripts/ci/retry.sh mvn -B -pl css-cli -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase - name: Rebuild native themes env: diff --git a/.github/workflows/port-status-nightly.yml b/.github/workflows/port-status-nightly.yml index 7b0a36d8cef..eebdf41c3d0 100644 --- a/.github/workflows/port-status-nightly.yml +++ b/.github/workflows/port-status-nightly.yml @@ -49,12 +49,10 @@ jobs: distribution: temurin java-version: '17' cache: maven - - name: Install Xvfb - run: bash scripts/ci/apt-get-install.sh xvfb - name: Set up workspace - run: xvfb-run ./scripts/setup-workspace.sh -q -DskipTests + run: ./scripts/setup-workspace.sh -q -DskipTests - name: Build compliance application for JavaScript - run: SKIP_PARPARVM_BUILD=1 xvfb-run ./scripts/build-javascript-port-hellocodenameone.sh "${GITHUB_WORKSPACE}/hellocodenameone-javascript-port.zip" + run: SKIP_PARPARVM_BUILD=1 ./scripts/build-javascript-port-hellocodenameone.sh "${GITHUB_WORKSPACE}/hellocodenameone-javascript-port.zip" - uses: actions/upload-artifact@v4 with: name: nightly-javascript-app diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a8906d677c2..d943bba4775 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -232,11 +232,11 @@ jobs: - name: Run push service-worker contract if: ${{ matrix.java-version == 8 }} run: node tests/push-e2e/service-worker-contract.mjs - - name: Build CSS compiler and smoke native-themes + - name: Build CSS compiler CLI and smoke native-themes if: ${{ matrix.java-version == 8 }} run: | cd maven - mvn -B -pl css-compiler -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase + mvn -B -pl css-cli -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase cd .. ./scripts/build-native-themes.sh test -f Themes/iOSModernTheme.res || { echo "missing Themes/iOSModernTheme.res"; exit 1; } @@ -583,11 +583,11 @@ jobs: -Dcn1.binaries="${CN1_BINARIES}" \ -pl codenameone-maven-plugin -am -Plocal-dev-javase -DskipTests install) cd scripts/settings - # Under xvfb: the module's POM binds cn1:css at process-classes, and - # the CSS compiler opens a JFrame -- in this container that is - # java.awt.HeadlessException before a test runs. Same X server for the - # whole retry sequence. - JAVA_HOME="${JAVA_HOME_17}" xvfb-run bash ../../scripts/ci/retry.sh \ + # No xvfb, and DISPLAY cleared: the module's POM binds cn1:css at + # process-classes, the CSS compiler is headless, and none of these + # tests initialises Display. A test that starts needing a window + # should fail here rather than borrow one. + JAVA_HOME="${JAVA_HOME_17}" env -u DISPLAY -u WAYLAND_DISPLAY bash ../../scripts/ci/retry.sh \ mvn -B -pl common test -Dcn1.settings.skipTests=false - name: Run Certificate Wizard tests if: ${{ matrix.java-version == 8 }} @@ -601,6 +601,9 @@ jobs: run: | set -euo pipefail cd scripts/certificatewizard + # Under xvfb, unlike the Settings step: CertificateWizardStructureHarness + # calls Display.init and builds real forms on the JavaSE port, which + # needs a display. The cn1:css goal this build also runs does not. JAVA_HOME="${JAVA_HOME_17}" xvfb-run bash ../../scripts/ci/retry.sh \ mvn -B -pl common test -Dcn1.certificatewizard.skipTests=false - name: Generate static analysis HTML summaries diff --git a/.github/workflows/scripts-fidelity-desktop.yml b/.github/workflows/scripts-fidelity-desktop.yml index 27bfbda5690..21920fd1d99 100644 --- a/.github/workflows/scripts-fidelity-desktop.yml +++ b/.github/workflows/scripts-fidelity-desktop.yml @@ -63,6 +63,7 @@ name: Desktop theme fidelity # Sync's GITHUB_TOKEN commit does not trigger another workflow. Score generator # changes from the original master push; this job rebuilds CSS before scoring. - 'maven/css-compiler/**' + - 'maven/css-cli/**' - 'scripts/build-native-themes.sh' - 'scripts/fidelity-app/desktop-runner/**' - 'scripts/fidelity-app/common/src/main/resources/fidelity-tests.yaml' @@ -164,11 +165,9 @@ jobs: java-version: '8' architecture: ${{ matrix.platform == 'windows' && 'x64' || '' }} - # Needed by the BUILD as well as by the run. The fidelity app is a Codename One - # application, so its build runs the cn1 css goal, which rasterizes through CEF and - # needs a display -- it fails with "An error occurred while compiling the CSS files" - # on a bare runner. Installed before the build for that reason, not only before the - # simulator starts. + # Needed by the run only: run-desktop-fidelity-tests.sh starts the simulator under + # xvfb-run to render the tiles. The build below needs no display -- the cn1 css goal + # the fidelity app's build runs is headless. - name: Install display and native reference font if: matrix.platform == 'gnome' run: bash scripts/ci/apt-get-install.sh xvfb fonts-cantarell @@ -195,11 +194,7 @@ jobs: # needs, so this half runs on the 21 installed first ("invalid target release: # 17" otherwise). Two JDKs in one job is the reason both setup-java steps exist. cd ../scripts/fidelity-app - XVFB=() - if [ "$(uname -s)" = "Linux" ]; then - XVFB=(xvfb-run -a) - fi - JAVA_HOME="$JDK21" "${XVFB[@]}" ./mvnw -B -q -pl common,desktop-runner -DskipTests install + JAVA_HOME="$JDK21" ./mvnw -B -q -pl common,desktop-runner -DskipTests install - name: Regenerate the desktop themes from CSS shell: bash diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index b5a55733160..5cfb2a650ea 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -290,11 +290,11 @@ jobs: - name: Vault bridge in a real browser (IndexedDB, Web Crypto, WebAuthn PRF) run: node scripts/test-javascript-vault-bridge.mjs - - name: Install Xvfb for headless Java AWT - run: bash scripts/ci/apt-get-update.sh && sudo apt-get install -y xvfb - + # No virtual display for the Maven steps below: they only build, and the + # css goal compiles theme.css headless. The browsers the tests drive are + # launched headless by their own scripts. - name: Setup workspace - run: xvfb-run ./scripts/setup-workspace.sh -q -DskipTests + run: ./scripts/setup-workspace.sh -q -DskipTests - name: Build HelloCodenameOne JavaScript port bundle run: | @@ -305,8 +305,7 @@ jobs: echo "Using JAVA_HOME=$JAVA_HOME" java -version - # xvfb-run is required for CSS compilation which uses java.awt - SKIP_PARPARVM_BUILD=1 xvfb-run ./scripts/build-javascript-port-hellocodenameone.sh "${GITHUB_WORKSPACE}/artifacts/javascript-ui-tests/hellocodenameone-javascript-port.zip" + SKIP_PARPARVM_BUILD=1 ./scripts/build-javascript-port-hellocodenameone.sh "${GITHUB_WORKSPACE}/artifacts/javascript-ui-tests/hellocodenameone-javascript-port.zip" # What the screenshot suite cannot see. Its capture reads the app's canvas back, which # suspends the DOM text layer, so it never looks at the page a browser shows -- and @@ -319,7 +318,7 @@ jobs: set -euo pipefail export JAVA_HOME="${JAVA_HOME_17_X64}" export PATH="$JAVA_HOME/bin:$PATH" - (cd scripts/javascript/composited-app && xvfb-run mvn -B package -DskipTests \ + (cd scripts/javascript/composited-app && mvn -B package -DskipTests \ -Dcodename1.platform=javascript -Dcodename1.buildTarget=local-javascript) bundle_dir="${RUNNER_TEMP}/composited-app" rm -rf "$bundle_dir" && mkdir -p "$bundle_dir" diff --git a/.github/workflows/starter-canary.yml b/.github/workflows/starter-canary.yml index efc75e3fa83..3d29b75a661 100644 --- a/.github/workflows/starter-canary.yml +++ b/.github/workflows/starter-canary.yml @@ -94,10 +94,14 @@ jobs: java-version: '17' distribution: 'temurin' - # The Maven CSS goal initializes Java AWT while compiling theme.css. - # GitHub's Linux runner has no display, so without Xvfb a healthy starter - # fails locally before it can submit its cloud build. Windows already has - # a desktop session and must not try to install or invoke xvfb-run. + # The starter builds with the PUBLISHED Maven plugin its pom pins, not + # with this tree, and a published plugin's CSS goal may initialize Java + # AWT while compiling theme.css. GitHub's Linux runner has no display, + # so without Xvfb a healthy starter can fail locally before it submits + # its cloud build. The plugin in this tree compiles CSS headless; this + # stays for as long as a starter can pin a plugin that does not. + # Windows already has a desktop session and must not try to install or + # invoke xvfb-run. - name: Install Xvfb for CSS compilation if: runner.os == 'Linux' run: bash scripts/ci/apt-get-install.sh xvfb diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml index 99112dc6fa9..e41683829f5 100644 --- a/.github/workflows/website-docs.yml +++ b/.github/workflows/website-docs.yml @@ -202,13 +202,12 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} - # The Initializr/Playground/Skin Designer JavaScript bundles are built by - # scripts/website/build.sh through xvfb-run: the Codename One Maven - # plugin's CSS/theme compile instantiates JavaSEPort, which needs a - # display on the headless runner. (xvfb formerly also served the Skin - # Designer screenshot generation step, which has been removed -- the - # screenshots are now committed under docs/developer-guide/img/skin-designer.) - - name: Set up xvfb for headless JavaScript bundle builds + # For the Playground rendering check further down, which drives Firefox: + # it needs an X display to initialize Mesa WebGL even in headless mode. + # The Initializr/Playground/Skin Designer bundle builds in + # scripts/website/build.sh do not use it here -- this workflow builds + # them with the workspace plugin, whose CSS compile is headless. + - name: Set up xvfb for the Playground rendering check run: | set -euo pipefail bash scripts/ci/apt-get-update.sh diff --git a/.github/workflows/windows-cross-build-run.yml b/.github/workflows/windows-cross-build-run.yml index 46976964dcf..dc3ad571469 100644 --- a/.github/workflows/windows-cross-build-run.yml +++ b/.github/workflows/windows-cross-build-run.yml @@ -81,15 +81,15 @@ jobs: set -e # No explicit index refresh here: apt-get-install.sh does one itself, and doing it # twice doubles the time a stalling mirror can take before anything is installed. - # xvfb: the CN1 CSS compiler (codenameone-maven-plugin css goal) renders - # via CEF/AWT and throws HeadlessException without a display, so the - # hellocodenameone-common build below runs under a virtual X server. + # No xvfb: nothing in this job opens a window. The sample build's css + # goal compiles theme.css headless, and the PE this job produces is + # run by the Windows jobs downstream, not here. # Every network call here is bounded and retried. On 2026-08-19 this step sat in # its first line for a full hour and was cancelled having installed nothing, which # then failed the screenshot-comment job downstream for a missing artifact. That is # the same hang apt-get-install.sh was written for -- a hung mirror should cost one # attempt, not the job -- and this workflow had never adopted it. - bash scripts/ci/apt-get-install.sh cmake ninja-build unzip xvfb + bash scripts/ci/apt-get-install.sh cmake ninja-build unzip # apt.llvm.org is a single host with no mirror, and llvm.sh both adds its repo and # installs from it, so it is the likeliest thing in this step to stall forever. RETRY_ATTEMPTS=3 bash scripts/ci/retry.sh \ @@ -199,10 +199,6 @@ jobs: run: | set -e export JAVA_HOME="$JDK_17_HOME" - # Headful virtual display for the CSS compiler's CEF/AWT rendering. - Xvfb :99 -screen 0 1600x1200x24 >/tmp/xvfb.log 2>&1 & - export DISPLAY=:99 - sleep 2 mkdir -p "$HOME/.codenameone" [ -f "$HOME/.codenameone/guibuilder.jar" ] || : > "$HOME/.codenameone/guibuilder.jar" RETRY_ONLY_MATCHING=transient bash scripts/ci/retry.sh \ diff --git a/CLAUDE.md b/CLAUDE.md index cdc8c665f87..fc26e59b1ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,6 +279,50 @@ scripts/check-build-hint-catalog.sh # every hint the code reads is de means a hint went in undeclared. Do not re-run `tools/build-hint-bootstrap/`; it seeded the catalog once and would overwrite hand edits. +### The CSS compiler is headless, and has to stay that way + +Compiling `css/theme.css` to `theme.res` opens no window, boots no JavaSE port +and starts no browser. `maven/css-compiler` is the engine and `maven/css-cli` +(`com.codename1.designer.css.CN1CSSCLI`) its command line; neither depends on +`codenameone-javase`. Every build forks that CLI -- the Maven and Gradle css +goals, and the simulator's live reload through `cn1.css.cli.classpath`. The +Designer is a resource editor and has no part in a build. + +- **CSS with no native equivalent is painted in process** by the pure-Java + rasterizer in `com.codename1.designer.css.raster`, into a 9-piece border or a + stretched background image. Do not reach for AWT windows, CEF or a web view to + render anything there. +- **Both modules' tests run with `-Djava.awt.headless=true`.** A test that needs + a display is a regression in the compiler, not a test to exclude. +- **Native themes are built with `-no-raster -native-theme-units`**, by + `scripts/build-native-themes.sh` and `scripts/verify-native-theme-split.sh` + and nothing else. `-no-raster` fails the build, naming the rule, when a rule + would need a generated image: a shipped theme must stay resolution + independent. `-native-theme-units` is the unit conversion those themes are + built with. Application builds pass neither. +- **Never wrap a build step in `xvfb-run` for CSS.** `archetype-smoke.yml` + (`cn1app-staged-jar-test.sh`) and `gradle-smoke.yml` (`gradle-cn1lib-test.sh`) + run with `DISPLAY` and `WAYLAND_DISPLAY` cleared, precisely so a compiler + change that reaches for a display fails there. xvfb belongs only to steps + that start the simulator, run `cn1:test`/`cn1Test` or `@CodenameOneTest` + classes, take screenshots or drive a browser. + +The one place a display is still supplied for a CSS compile is a build that +runs a **published** plugin instead of this tree's: `starter-canary.yml`, and +`scripts/website/build.sh` when it is not bootstrapping the workspace snapshots. + +The runnable jar is +`maven/css-cli/target/codenameone-css-cli--jar-with-dependencies.jar`, +built by `mvn -pl css-cli -am`: + +```bash +java -Djava.awt.headless=true -cp com.codename1.designer.css.CN1CSSCLI \ + -input a.css[,b.css] -output theme.res [-merge file] [-l l10n-dir] [-watch] +``` + +Exit status is 0, 1 for a stylesheet that failed to compile, 2 for a bad +command line. + ### Never rely on ClassCastException **ParparVM's `CHECKCAST` is unchecked.** `BC_CHECKCAST` expands to nothing and the diff --git a/CodenameOneDesigner/src/com/codename1/designer/CSSImageImporter.java b/CodenameOneDesigner/src/com/codename1/designer/CSSImageImporter.java index aea83fd5427..5d345e57f1a 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/CSSImageImporter.java +++ b/CodenameOneDesigner/src/com/codename1/designer/CSSImageImporter.java @@ -207,16 +207,13 @@ public void doImportAndExit() throws IOException { .append(" cn1-source-dpi: ").append(sourceDpi).append(";\n") .append("}\n"); appendCSS(css.toString()); - try { - CN1CSSCLI.main(new String[]{ - getCSSFile().getAbsolutePath(), - getResourceFile().getAbsolutePath() - }); - } catch (Exception ex) { - if (ex instanceof IOException) { - throw (IOException)ex; - } - throw new RuntimeException("Failed to compile CSS file.", ex); + // run(), not main(): main ends the JVM, and this is the editor's own. + // The stylesheets of the project's libraries are compiled in ahead + // of its own, so the theme written here still has what they define. + int status = CN1CSSCLI.run(CN1CSSCLI.legacyArguments( + getCSSFile().getAbsolutePath(), getResourceFile().getAbsolutePath())); + if (status != 0) { + throw new IOException("Failed to compile CSS file " + getCSSFile() + ". See the console for the reason."); } diff --git a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java index cb5d33ccca4..482bcfb99a9 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java +++ b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java @@ -35,8 +35,6 @@ import com.codename1.ui.EditorTTFFont; import com.codename1.ui.Font; import com.codename1.designer.css.CN1CSSCLI; -import com.codename1.designer.css.MissingNativeBrowserException; -import com.codename1.impl.javase.CN1Bootstrap; //import com.codename1.impl.javase.JavaFXLoader; import com.codename1.ui.plaf.Style; import com.codename1.ui.resource.util.QuitAction; @@ -256,17 +254,7 @@ public static ResourceEditorApp getApplication() { } public static void main(String[] args) throws Exception { - try { - // First try to run without adding CEF to classpath - _main(args); - } catch (MissingNativeBrowserException ex) { - // If the CSS compiler tried to run and requires CEF because - // some CSS needs a browser component to generate 9-piece border - // it throws the MissingNativeBrowserException - // Catch it here and try to add CEF to the classpath, then run again. - System.out.println("Looks like this CSS file needs CEF on classpath... adding it now, and retrying..."); - CN1Bootstrap.run(ResourceEditorApp.class, args); - } + _main(args); } /** @@ -281,6 +269,15 @@ public static void _main(String[] args) throws Exception { // creates a deadlock between FX, Swing and CN1. Horrible horrible deadlock... JavaSEPortWithSVGSupport.blockNativeBrowser = true; + // EditableResources is shared with the CSS compiler, which has no + // display, so its dialogs are supplied from here. This comes before + // the command modes below: -style, -img, -mimg and the rest open a + // resource file too, and one that is password protected has to be + // able to ask. Only -css stays without it, because it must not need + // a display. + if (args.length == 0 || !args[0].equalsIgnoreCase("-css")) { + com.codename1.ui.util.ResourceEditorUi.install(new SwingResourceEditorUi()); + } if(args.length > 0) { if(args[0].equalsIgnoreCase("-buildVersion")) { Properties p = new Properties(); @@ -568,9 +565,17 @@ public void actionPerformed(ActionEvent e) { List cssArgs = new ArrayList(Arrays.asList(args)); cssArgs.remove(0); - CN1CSSCLI.main(cssArgs.toArray(new String[cssArgs.size()])); - //CN1Bootstrap.run(cssArgs.toArray(new String[cssArgs.size()])); - + // The compiler proper lives in codenameone-css-cli and needs no + // display; this entry point only keeps `-css` working for + // whoever still launches the editor jar to compile a stylesheet. + // `-css theme.css theme.res` is how a build script written for + // this jar names its two files, and expects the stylesheets of + // the project's libraries to be compiled in with its own. + if (cssArgs.size() == 2 && !cssArgs.get(0).startsWith("-") && !cssArgs.get(1).startsWith("-")) { + cssArgs = new ArrayList(Arrays.asList( + CN1CSSCLI.legacyArguments(cssArgs.get(0), cssArgs.get(1)))); + } + System.exit(CN1CSSCLI.run(cssArgs.toArray(new String[cssArgs.size()]))); return; } 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/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompileTask.java b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompileTask.java deleted file mode 100644 index c8cca16c8c4..00000000000 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompileTask.java +++ /dev/null @@ -1,296 +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 java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.PrintWriter; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Scanner; -import java.util.logging.Level; -import java.util.logging.Logger; -import org.apache.tools.ant.BuildException; -import org.apache.tools.ant.Task; -import org.apache.tools.ant.taskdefs.Java; -import org.apache.tools.ant.types.Commandline.Argument; -import org.apache.tools.ant.types.Path; - -public class CN1CSSCompileTask extends Task { - - @Override - public void execute() throws BuildException { - - String srcDirPath = getProject().getProperty("src.dir"); - if (srcDirPath == null || "".equals(srcDirPath)) { - srcDirPath = "src"; - } - - File cssDir = new File(getProject().getBaseDir(), "css"); - if (!cssDir.exists()) { - log("Skipping CSS task because no 'css' directory found in project."); - return; - } - - File srcDir = new File(getProject().getBaseDir(), srcDirPath); - //System.out.println(getProject().getProperties()); - - String runClasspath = getProject().getProperty("run.classpath"); - if (runClasspath == null || "".equals(runClasspath)) { - runClasspath = "JavaSE.jar"; - } - String[] classPath = runClasspath.split(":"); - String javaSEJarPath = null; - for (String path : classPath) { - if (path.endsWith("JavaSE.jar")) { - javaSEJarPath = path; - break; - } - } - - String codenameOneTempPath = System.getProperty("user.home") + File.separator + ".codenameone"; - - String designerJarPath = cssDir.getAbsolutePath() + File.separator + "designer_1.jar"; - File designerJar = new File(designerJarPath); - if (!designerJar.exists()) { - designerJarPath = codenameOneTempPath + File.separator + "designer_1.jar"; - - designerJar = new File(designerJarPath); - if (!designerJar.exists()) { - throw new BuildException("Could not find designer_1.jar file at path "+designerJar); - } - } - - File javaSEJar = new File(getProject().getBaseDir(), javaSEJarPath); - if (!javaSEJar.exists()) { - throw new BuildException("Could not find JavaSE.jar file at path "+javaSEJar); - } - - - File cssJar = new File(codenameOneTempPath, "cn1css.jar"); - - - if (!cssJar.exists()) { - try { - exportResource("cn1css.jar", cssJar); - } catch (IOException ex) { - throw new BuildException("Failed to export cn1css.jar to temp directory.", ex); - } - } - - try { - String jarChecksum = getMD5Checksum(cssJar.getAbsolutePath()); - String newChecksum = getResourceAsString(getClass(), "cn1css.jar.MD5"); - if (!jarChecksum.equals(newChecksum)) { - exportResource("cn1css.jar", cssJar); - } - } catch (IOException ex) { - throw new BuildException("Failed to get checksum for "+cssJar, ex); - } - - - - String[] cssFiles = cssDir.list((dir, name) -> { - return name.endsWith(".css"); - - }); - - Map checksums = loadChecksums(); - - for (String cssFile : cssFiles) { - try { - File f = new File(cssDir, cssFile); - File destFile = new File(srcDir, cssFile + ".res"); - if (destFile.exists() && f.lastModified() < destFile.lastModified()) { - log("Not compiling " + f + " because " + destFile + " has a newer modification time."); - - continue; - } - - if (destFile.exists()) { - String checksum = getMD5Checksum(destFile.getAbsolutePath()); - - String lastChecksum = checksums.get(cssFile); - - if (lastChecksum != null && !lastChecksum.equals(checksum)) { - throw new BuildException("The file "+destFile+" has been modified since it was last generated. To avoid overwriting manual changes, please, delete this file and try to recompile."); - } - - - } - - Java javaTask = (Java)getProject().createTask("java"); - Path cp = javaTask.createClasspath(); - //cp.add(new Path(getProject(), javaSEJar.getAbsolutePath())); - cp.add(new Path(getProject(), designerJar.getAbsolutePath())); - cp.add(new Path(getProject(), cssJar.getAbsolutePath())); - - javaTask.setClasspath(cp); - javaTask.setFork(true); - javaTask.setClassname("com.codename1.ui.css.CN1CSSCLI"); - javaTask.setFailonerror(true); - String maxMemory = getProject().getProperty("cn1css.max.memory"); - if (maxMemory != null) { - javaTask.setMaxmemory("4096m"); - } else { - javaTask.setMaxmemory(maxMemory); - } - - Argument arg = javaTask.createArg(); - arg.setValue(f.getAbsolutePath()); - - Argument destArg = javaTask.createArg(); - destArg.setValue(destFile.getAbsolutePath()); - - Argument swPipeline = javaTask.createJvmarg(); - swPipeline.setValue("-Dprism.order=sw"); - - javaTask.execute(); - - String checksum = getMD5Checksum(destFile.getAbsolutePath()); - checksums.put(cssFile, checksum); - saveChecksums(checksums); - - - } catch (Exception ex) { - Logger.getLogger(CN1CSSCompileTask.class.getName()).log(Level.SEVERE, null, ex); - throw new BuildException(ex.getMessage()); - } - - } - - super.execute(); //To change body of generated methods, choose Tools | Templates. - } - - - private static byte[] createChecksum(String filename) throws IOException { - try { - InputStream fis = new FileInputStream(filename); - - byte[] buffer = new byte[1024]; - MessageDigest complete = MessageDigest.getInstance("MD5"); - int numRead; - - do { - numRead = fis.read(buffer); - if (numRead > 0) { - complete.update(buffer, 0, numRead); - } - } while (numRead != -1); - - fis.close(); - return complete.digest(); - } catch (NoSuchAlgorithmException ex) { - throw new RuntimeException(ex); - } - } - - // see this How-to for a faster way to convert - // a byte array to a HEX string - private static String getMD5Checksum(String filename) throws IOException { - byte[] b = createChecksum(filename); - String result = ""; - - for (int i=0; i < b.length; i++) { - result += Integer.toString( ( b[i] & 0xff ) + 0x100, 16).substring( 1 ); - } - return result; - } - - private Map loadChecksums() { - File checkSums = getChecksumsFile(); - if (!checkSums.exists()){ - return new HashMap(); - } - HashMap out = new HashMap(); - try { - Scanner scanner = new Scanner(checkSums); - - //now read the file line by line... - int lineNum = 0; - while (scanner.hasNextLine()) { - String line = scanner.nextLine(); - lineNum++; - String[] parts = line.split(":"); - if (parts.length == 2) { - out.put(parts[0], parts[1]); - } - } - return out; - } catch(Exception e) { - //handle this - return out; - } - - } - - private File getChecksumsFile() { - return new File(getProject().getBaseDir(), ".cn1_css_checksums"); - } - - private void saveChecksums(Map map) throws IOException { - try (PrintWriter out = new PrintWriter(new FileOutputStream(getChecksumsFile()))) { - for (String key : map.keySet()) { - out.println(key+":"+map.get(key)); - } - } - - } - - static void exportResource(String resourceName, File targetFile) throws IOException { - InputStream stream = null; - OutputStream resStreamOut = null; - try { - stream = CN1CSSCompileTask.class.getResourceAsStream(resourceName);//note that each / is a directory down in the "jar tree" been the jar the root of the tree - if(stream == null) { - throw new Exception("Cannot get resource \"" + resourceName + "\" from Jar file."); - } - - int readBytes; - byte[] buffer = new byte[4096]; - resStreamOut = new FileOutputStream(targetFile); - while ((readBytes = stream.read(buffer)) > 0) { - resStreamOut.write(buffer, 0, readBytes); - } - } catch (Exception ex) { - throw new IOException(ex); - } finally { - stream.close(); - resStreamOut.close(); - } - - } - - static String getResourceAsString(Class cls, String resourceName) throws IOException { - return new Scanner(cls.getResourceAsStream(resourceName), "UTF-8").useDelimiter("\\A").next(); - } -} diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompiler.java b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompiler.java deleted file mode 100644 index d272b2cdb14..00000000000 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompiler.java +++ /dev/null @@ -1,830 +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.impl.javase.JavaSEPort; -import com.codename1.ui.BrowserComponent; -import com.codename1.ui.CN; -import com.codename1.ui.Display; -import com.codename1.ui.util.EditableResources; -import com.codename1.ui.util.EditableResourcesEditor; -import java.awt.Graphics2D; -import java.awt.RenderingHints; -import java.awt.image.BufferedImage; -import org.w3c.css.sac.*; -import org.w3c.css.sac.helpers.*; -import java.net.*; -import java.io.*; -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Hashtable; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.logging.Level; -import java.util.logging.Logger; - -import javax.imageio.ImageIO; -import javax.swing.JFrame; - - - -public class CN1CSSCompiler implements DocumentHandler { - - - - private Map selectors = new HashMap(); - - class NinePieceBorder { - int top, left, bottom, right, width, height; - - - } - - - - - - - - public static final int DEFAULT_TARGET_DENSITY = com.codename1.ui.Display.DENSITY_VERY_HIGH; - - int targetDensity = DEFAULT_TARGET_DENSITY; - Set includedDensities = new HashSet(); - - File inputFile; - - File outputFile; - - //Properties props; - Hashtable theme; - SelectorList currSelectors; - EditableResources res; - - static BrowserComponent web; - static Runnable webpageLoadedCallback; - static JFrame frm; - - /** - * @param args the command line arguments - */ - public static void main(String[] args) throws Exception { - System.setProperty("testfx.robot", "glass"); - System.setProperty("testfx.headless", "true"); - System.setProperty("prism.order", "sw"); - System.setProperty("prism.text", "t2k"); - System.setProperty("java.awt.headless", "true"); - try { - //Display.init(null); - JavaSEPort.setShowEDTViolationStacks(false); - JavaSEPort.setShowEDTWarnings(false); - frm = new JFrame("Placeholder"); - frm.setVisible(false); - Display.init(frm.getContentPane()); - System.setProperty("org.w3c.css.sac.parser", "org.w3c.flute.parser.Parser"); - - //Platform.runLater(new Runnable() { - - // @Override - // public void run() { - InputStream stream = null; - - InputSource source = new InputSource(); - URL uri = CN1CSSCompiler.class.getResource("test.css"); - stream = uri.openStream(); - source.setByteStream(stream); - source.setURI(uri.toString()); - ParserFactory parserFactory = new ParserFactory(); - Parser parser = parserFactory.makeParser(); - parser.setDocumentHandler(new CN1CSSCompiler()); - parser.parseStyleSheet(source); - stream.close(); - } finally { - - frm.dispose(); - System.exit(0); - //Display.getInstance().exitApplication(); - //Display.deinitialize(); - } - - - - - - - } - - static final Set defaultDensities = new HashSet(); - static { - defaultDensities.add(Display.DENSITY_LOW); - defaultDensities.add(Display.DENSITY_MEDIUM); - defaultDensities.add(Display.DENSITY_HIGH); - defaultDensities.add(Display.DENSITY_VERY_HIGH); - - } - - public CN1CSSCompiler() { - inputFile = new File("test.css"); - outputFile = new File("test.css.res"); - - - - - - - } - - - @Override - public void startDocument(InputSource is) throws CSSException { - //props = new Properties(); - theme = new Hashtable(); - res = new EditableResourcesEditor(); - includedDensities.clear(); - includedDensities.addAll(defaultDensities); - } - - - - @Override - public void endDocument(InputSource is) throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - - /* - try { - - File tmpProps = File.createTempFile("theme", "properties"); - FileOutputStream fos = null; - try { - fos = new FileOutputStream(tmpProps); - props.store(fos, "Updating properties"); - } catch (IOException ex) { - throw new RuntimeException(ex); - } finally { - if (fos != null) { - try { - fos.close(); - } catch (Exception ex) { - - } - } - } - /(props); - //CodenameOneTask cn1 = new CodenameOneTask(); - //cn1.setDest(outputFile); - - ThemeTask theme = new ThemeTask(); - theme.setDescription("Theme for "+inputFile.getName()); - theme.setFile(tmpProps); - theme.setName(inputFile.getName()); - //cn1.addTheme(theme); - //cn1.execute(); - - EditableResources output = new EditableResourcesEditor(); - - theme.addToResources(output); - System.out.println(output.getTheme(inputFile.getName())); - - - DataOutputStream resFile = new DataOutputStream(new FileOutputStream(outputFile)); - output.save(resFile); - resFile.close(); - System.out.println("Tmp props: "+tmpProps); - //tmpProps.delete(); - - } catch (IOException ex) { - throw new RuntimeException(ex); - } - */ - - - /* - EditableResources output = new EditableResourcesEditor(); - Hashtable theme = new Hashtable(); - for (String key : props.stringPropertyNames()) { - theme.put(key, props.get(key)); - } - - output.setTheme("test.css", theme); - */ - try { - - String html = "" - + "
Hello
"; - BufferedImage testImage = createHtmlScreenshot(html); - //BufferedImage testImage2 = new BufferedImage(500, 500, BufferedImage.TYPE_INT_ARGB); - //Graphics2D g = testImage2.createGraphics(); - //g.setColor(java.awt.Color.RED); - //g.fillRect(0, 0, 50, 500); - //g.dispose(); - - create9PieceBorder(testImage, "testborder", 20, 20, 20, 20); - - - - theme.put("uninitialized", true); - //System.out.println(theme); - res.setTheme("test", theme); - //for (Object key : theme.keySet()) { - // res.setThemeProperty("test.css", (String)key, theme.get(key)); - //} - - //res.setThemeProperties("test.css", theme); - //res.getTheme("test.css").putAll(theme); - //System.out.println(res.getTheme("test")); - DataOutputStream resFile = new DataOutputStream(new FileOutputStream(outputFile)); - res.save(resFile); - //res.saveXML(outputFile); - - resFile.close(); - } catch (Exception ex) { - throw new RuntimeException(ex); - } - - - } - - @Override - public void comment(String string) throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - } - - @Override - public void ignorableAtRule(String string) throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - } - - @Override - public void namespaceDeclaration(String string, String string1) throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - } - - @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. - } - - @Override - public void startMedia(SACMediaList sacml) throws CSSException { - - } - - @Override - public void endMedia(SACMediaList sacml) throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - } - - @Override - public void startPage(String string, String string1) throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - } - - @Override - public void endPage(String string, String string1) throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - } - - @Override - public void startFontFace() throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - } - - @Override - public void endFontFace() throws CSSException { - //throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates. - } - - @Override - public void startSelector(SelectorList sl) throws CSSException { - currSelectors = sl; - int len = sl.getLength(); - for (int i=0; i getMargin(Selector sel) { - List out = new ArrayList(); - String marginStr = (String)theme.get(convertSelector(sel, "margin"));//, "0,0,0,0").split(","); - if (marginStr == null) { - marginStr = "0,0,0,0"; - } - String[] marginArr = marginStr.split(","); - for (String v : marginArr) { - out.add(Integer.parseInt(v)); - } - return out; - - } - - - - private byte[] getMarginUnits(Selector sel) { - byte[] units = (byte[])theme.get(convertSelector(sel, "marUnit")); - if (units == null) { - units = new byte[]{0,0,0,0}; - } - return units; - } - - private void setBorder(Selector sel, LexicalUnit lu) { - selectors.get(convertSelector(sel, null)); - } - - private void setMargin(Selector sel, LexicalUnit lu) { - List units = new ArrayList(); - List vals = new ArrayList(); - UnitVal cssUnit = new UnitVal(); - UnitVal cn1Unit = new UnitVal(); - do { - cssUnit.unit = lu.getLexicalUnitType(); - cssUnit.val = lu.getFloatValue(); - //System.out.println("Flval is "+lu.getFloatValue()); - //System.out.println("Val is "+cssUnit.val+ " for "+lu); - convertUnitToCN1(cssUnit, cn1Unit); - vals.add(Math.round(cn1Unit.val)); - units.add(cn1Unit.unit); - } while ((lu = lu.getNextLexicalUnit()) != null ); - - StringBuilder valsStr = new StringBuilder(); - //StringBuilder unitsStr = new StringBuilder(); - byte[] u = new byte[]{0,0,0,0}; - - switch (vals.size()) { - case 1: - for (int i=0; i<4; i++) { - valsStr.append(vals.get(0)).append(","); - u[i] = (byte)(int)units.get(0); - } - valsStr.setLength(valsStr.length()-1); - //unitsStr.setLength(unitsStr.length()-1); - break; - - - } - - theme.put(convertSelector(sel, "margin"), valsStr.toString()); - theme.put(convertSelector(sel, "marUnit"), u); - } - - private class UnitVal { - int unit; - float val; - } - - private void convertUnitToCN1(UnitVal in, UnitVal out) { - switch (in.unit) { - case LexicalUnit.SAC_MILLIMETER: - out.unit = 2; - out.val = in.val; - break; - case LexicalUnit.SAC_PIXEL: - out.unit = 0; - out.val = in.val; - break; - case LexicalUnit.SAC_POINT: - out.unit = 2; - out.val = in.val / 72; - break; - case LexicalUnit.SAC_PERCENTAGE: - out.unit = 1; - out.val = in.val; - break; - default: - throw new RuntimeException("Unit type not handled "+in.unit); - } - } - - /** - * Converts a selector from a CSS selector to a property in the CN1 theme - * format. - * @param sel The selector - * @param key The property key (cn1 specific) - * @return The cn1 property name. - */ - private String convertSelector(Selector sel, String key) { - StringBuilder out = new StringBuilder(); - switch (sel.getSelectorType()) { - case Selector.SAC_ELEMENT_NODE_SELECTOR: - return ((ElementSelector)sel).getLocalName() + "." + key; - /* - case Selector.SAC_CONDITIONAL_SELECTOR: { - ConditionalSelector csel = (ConditionalSelector)sel; - switch (csel.getCondition().getConditionType()) { - case Condition.SAC_PSEUDO_CLASS_CONDITION: - AttributeCondition acond = (AttributeCondition)csel.getCondition(); - String name = acond.getLocalName(); - if ("disabled".equals(name)) { - return convertSelector(csel.getSimpleSelector())+".disabled" - } - } - }*/ - - } - return ""; - } - - - - private void create9PieceBorder(BufferedImage img, String prefix, int top, int right, int bottom, int left) { - //BufferedImage buff = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_ARGB); - //Graphics2D bg2d = buff.createGraphics(); - //bg2d.drawImage(img.getSubimage(get(cropLeft), get(cropTop), img.getWidth() - get(cropLeft) - get(cropRight), - // img.getHeight() - get(cropTop) - get(cropBottom)), get(cropLeft), get(cropTop), null); - - //bg2d.dispose(); - //img = buff; - 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); - BufferedImage bottomRight = img.getSubimage(img.getWidth() - right, img.getHeight() - bottom, right, bottom); - BufferedImage center = img.getSubimage(left, top, img.getWidth() - right - left, img.getHeight() - bottom - top); - BufferedImage topImage = img.getSubimage(left, 0, img.getWidth() - left - right, top); - BufferedImage bottomImage = img.getSubimage(left, img.getHeight() - bottom, img.getWidth() - left - right, bottom); - BufferedImage leftImage = img.getSubimage(0, top, left, img.getHeight() - top - bottom); - BufferedImage rightImage = img.getSubimage(img.getWidth() - right, top, right, img.getHeight() - top - bottom); - - // optimize the size of the center/top/left/bottom/right images which is a HUGE performance deterant - if(center.getWidth() < 10 || center.getHeight() < 10) { - center = getScaledInstance(center, Math.max(20, center.getWidth()), Math.max(20, center.getHeight())); - topImage = getScaledInstance(topImage, Math.max(20, topImage.getWidth()), topImage.getHeight()); - leftImage = getScaledInstance(leftImage, leftImage.getWidth(), Math.max(20, leftImage.getHeight())); - rightImage = getScaledInstance(rightImage, rightImage.getWidth(), Math.max(20, rightImage.getHeight())); - bottomImage = getScaledInstance(bottomImage, Math.max(20, bottomImage.getWidth()), bottomImage.getHeight()); - } - - com.codename1.ui.EncodedImage topLeftCodenameOne = com.codename1.ui.EncodedImage.create(toPng(topLeft)); - com.codename1.ui.EncodedImage topRightCodenameOne = com.codename1.ui.EncodedImage.create(toPng(topRight)); - com.codename1.ui.EncodedImage bottomLeftCodenameOne = com.codename1.ui.EncodedImage.create(toPng(bottomLeft)); - com.codename1.ui.EncodedImage bottomRightCodenameOne = com.codename1.ui.EncodedImage.create(toPng(bottomRight)); - com.codename1.ui.EncodedImage centerCodenameOne = com.codename1.ui.EncodedImage.create(toPng(center)); - com.codename1.ui.EncodedImage topImageCodenameOne = com.codename1.ui.EncodedImage.create(toPng(topImage)); - com.codename1.ui.EncodedImage bottomImageCodenameOne = com.codename1.ui.EncodedImage.create(toPng(bottomImage)); - com.codename1.ui.EncodedImage leftImageCodenameOne = com.codename1.ui.EncodedImage.create(toPng(leftImage)); - com.codename1.ui.EncodedImage rightImageCodenameOne = com.codename1.ui.EncodedImage.create(toPng(rightImage)); - //String prefix = (String)applies.getAppliesTo().getModel().getElementAt(0); - topLeftCodenameOne = storeImage(topLeftCodenameOne, prefix +"TopL"); - topRightCodenameOne = storeImage(topRightCodenameOne, prefix +"TopR"); - bottomLeftCodenameOne = storeImage(bottomLeftCodenameOne, prefix +"BottomL"); - bottomRightCodenameOne = storeImage(bottomRightCodenameOne, prefix +"BottomR"); - centerCodenameOne = storeImage(centerCodenameOne, prefix + "Center"); - topImageCodenameOne = storeImage(topImageCodenameOne, prefix + "Top"); - bottomImageCodenameOne = storeImage(bottomImageCodenameOne, prefix + "Bottom"); - leftImageCodenameOne = storeImage(leftImageCodenameOne, prefix + "Left"); - rightImageCodenameOne = storeImage(rightImageCodenameOne, prefix + "Right"); - com.codename1.ui.plaf.Border b = com.codename1.ui.plaf.Border.createImageBorder(topImageCodenameOne, bottomImageCodenameOne, leftImageCodenameOne, - rightImageCodenameOne, topLeftCodenameOne, topRightCodenameOne, - bottomLeftCodenameOne, bottomRightCodenameOne, centerCodenameOne); - //Hashtable newTheme = new Hashtable(res.getTheme(theme)); - //for(int i = 0 ; i < applies.getAppliesTo().getModel().getSize() ; i++) { - // newTheme.put(applies.getAppliesTo().getModel().getElementAt(i), b); - //} - //((DefaultListModel)applies.getAppliesTo().getModel()).removeAllElements(); - //res.setTheme(theme, newTheme); - } - - public 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 com.codename1.ui.EncodedImage storeImage(com.codename1.ui.EncodedImage img, String prefix) { - int i = 1; - while(res.containsResource(prefix + "_" + i + ".png")) { - i++; - } - - float ratioWidth = 0; - int multiVal = DEFAULT_TARGET_DENSITY; - switch(multiVal) { - // Generate RGB Image - case 0: - res.setImage(prefix + "_" + i + ".png", img); - return img; - - // Generate Medium Resolution MultiImage - case com.codename1.ui.Display.DENSITY_MEDIUM: - //multiVal = com.codename1.ui.Display.DENSITY_MEDIUM; - ratioWidth = 320; - break; - - // Generate High Resolution MultiImage - case com.codename1.ui.Display.DENSITY_HIGH: - ratioWidth = 480; - //multiVal = com.codename1.ui.Display.DENSITY_HIGH; - break; - - // Generate Very High Resolution MultiImage - case com.codename1.ui.Display.DENSITY_VERY_HIGH: - ratioWidth = 640; - //multiVal = com.codename1.ui.Display.DENSITY_VERY_HIGH; - break; - - // Generate HD Resolution MultiImage - case com.codename1.ui.Display.DENSITY_HD: - ratioWidth = 1080; - //multiVal = com.codename1.ui.Display.DENSITY_HD; - break; - - // Generate HD560 Resolution MultiImage - case com.codename1.ui.Display.DENSITY_560: - ratioWidth = 1500; - //multiVal = com.codename1.ui.Display.DENSITY_560; - break; - - // Generate HD2 Resolution MultiImage - case com.codename1.ui.Display.DENSITY_2HD: - ratioWidth = 2000; - //multiVal = com.codename1.ui.Display.DENSITY_2HD; - break; - - // Generate 4k Resolution MultiImage - case com.codename1.ui.Display.DENSITY_4K: - ratioWidth = 2500; - //multiVal = com.codename1.ui.Display.DENSITY_4K; - break; - } - EditableResources.MultiImage multi = new EditableResources.MultiImage(); - multi.setDpi(new int[] {multiVal}); - multi.setInternalImages(new com.codename1.ui.EncodedImage[] {img}); - if(includedDensities.contains(Display.DENSITY_LOW)) { - float ratio = 240.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_LOW, w, h, multi); - } - - if(includedDensities.contains(Display.DENSITY_VERY_LOW)) { - float ratio = 176.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_VERY_LOW, w, h, multi); - } - - if(includedDensities.contains(Display.DENSITY_MEDIUM)) { - float ratio = 320.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_MEDIUM, w, h, multi); - } - - if(includedDensities.contains(Display.DENSITY_HIGH)) { - float ratio = 480.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_HIGH, w, h, multi); - } - - if(includedDensities.contains(Display.DENSITY_VERY_HIGH)) { - float ratio = 640.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_VERY_HIGH, w, h, multi); - } - - if(includedDensities.contains(Display.DENSITY_HD)) { - float ratio = 1080.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_HD, w, h, multi); - } - - if(includedDensities.contains(Display.DENSITY_560)) { - float ratio = 1500.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_560, w, h, multi); - } - - if(includedDensities.contains(Display.DENSITY_2HD)) { - float ratio = 2000.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_2HD, w, h, multi); - } - - if(includedDensities.contains(Display.DENSITY_4K)) { - float ratio = 2500.0f / ratioWidth; - int w = Math.max((int)(img.getWidth() * ratio), 1); - int h = Math.max((int)(img.getHeight() * ratio), 1); - multi = scaleMultiImage(multiVal, com.codename1.ui.Display.DENSITY_4K, w, h, multi); - } - - res.setMultiImage(prefix + "_" + i + ".png", multi); - return multi.getBest(); - } - - - public static byte[] toPng(BufferedImage b) { - try { - ByteArrayOutputStream bo = new ByteArrayOutputStream(); - ImageIO.write(b, "png", bo); - bo.close(); - return bo.toByteArray(); - } catch (IOException ex) { - ex.printStackTrace(); - return null; - } - } - - public static EditableResources.MultiImage scaleMultiImage(int fromDPI, int toDPI, int scaledWidth, int scaledHeight, EditableResources.MultiImage multi) { - try { - int[] dpis = multi.getDpi(); - com.codename1.ui.EncodedImage[] imgs = multi.getInternalImages(); - int fromOffset = -1; - int toOffset = -1; - for (int iter = 0; iter < dpis.length; iter++) { - if (dpis[iter] == fromDPI) { - fromOffset = iter; - } - if (dpis[iter] == toDPI) { - toOffset = iter; - } - } - if (fromOffset == -1) { - return null; - } - EditableResources.MultiImage newImage = new EditableResources.MultiImage(); - if (toOffset == -1) { - com.codename1.ui.EncodedImage[] newImages = new com.codename1.ui.EncodedImage[imgs.length + 1]; - System.arraycopy(imgs, 0, newImages, 0, imgs.length); - toOffset = imgs.length; - int[] newDpis = new int[dpis.length + 1]; - System.arraycopy(dpis, 0, newDpis, 0, dpis.length); - newDpis[toOffset] = toDPI; - newImage.setDpi(newDpis); - newImage.setInternalImages(newImages); - } else { - com.codename1.ui.EncodedImage[] newImages = new com.codename1.ui.EncodedImage[imgs.length]; - System.arraycopy(multi.getInternalImages(), 0, newImages, 0, imgs.length); - newImage.setDpi(dpis); - newImage.setInternalImages(newImages); - } - com.codename1.ui.Image sourceImage = newImage.getInternalImages()[fromOffset]; - BufferedImage buffer = new BufferedImage(sourceImage.getWidth(), sourceImage.getHeight(), BufferedImage.TYPE_INT_ARGB); - buffer.setRGB(0, 0, sourceImage.getWidth(), sourceImage.getHeight(), sourceImage.getRGB(), 0, sourceImage.getWidth()); - sourceImage.getRGB(); - sourceImage.getWidth(); - BufferedImage scaled = getScaledInstance(buffer, scaledWidth, scaledHeight); - ByteArrayOutputStream output = new ByteArrayOutputStream(); - ImageIO.write(scaled, "png", output); - output.close(); - byte[] bytes = output.toByteArray(); - com.codename1.ui.EncodedImage encoded = com.codename1.ui.EncodedImage.create(bytes); - newImage.getInternalImages()[toOffset] = encoded; - return newImage; - } catch (IOException ex) { - ex.printStackTrace(); - - } - return null; - } - - BufferedImage createHtmlScreenshot(String html) { - final boolean[] complete = new boolean[1]; - final Object lock = new Object(); - final BufferedImage[] img = new BufferedImage[1]; - - webpageLoadedCallback = new Runnable() { - - @Override - public void run() { - - com.codename1.ui.Image wi = web.toImage(); - - img[0] = (BufferedImage)wi.getImage(); - complete[0] = true; - synchronized(lock) { - lock.notify(); - } - } - - }; - CN.callSerially(new Runnable() { - - @Override - public void run() { - web.setPage(html, ""); - } - - }); - - - while (!complete[0]) { - synchronized(lock) { - try { - lock.wait(); - } catch (InterruptedException ex) { - Logger.getLogger(CN1CSSCompiler.class.getName()).log(Level.SEVERE, null, ex); - } - } - } - - return img[0]; - - } - -} diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSInstallerCLI.java b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSInstallerCLI.java deleted file mode 100644 index ef309ca7879..00000000000 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSInstallerCLI.java +++ /dev/null @@ -1,96 +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.impl.javase.JavaSEPort; -import com.codename1.ui.Display; -import com.codename1.ui.util.EditableResources; -import com.codename1.ui.util.EditableResourcesEditor; -import java.awt.EventQueue; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.logging.Level; -import java.util.logging.Logger; -import javax.swing.JFrame; - -/** - * Installs CSS on top of an existing Designer theme by setting the theme's - * {@code @OverlayThemes} constant to the stylesheet's name, so the CSS styles - * override the ones already in the .res file. - * - *

This is a standalone migration tool, invoked by hand. Nothing in the build - * calls it: a Maven project's CSS is compiled by - * {@code CompileCSSMojo}, which runs the CSS CLI with {@code -output} pointed - * straight at the build's theme.res and merges dependency CSS into that same - * output. That path produces one resource and never sets an overlay constant, - * so do not describe overlays as how compiled CSS normally reaches a theme. - */ -public class CN1CSSInstallerCLI { - private static void install(String[] args) throws Exception { - File cssFile = new File(args[1]); - File resFile = new File(args[2]); - EventQueue.invokeLater(()->{ - try { - JavaSEPort.setShowEDTViolationStacks(false); - - JavaSEPort.setShowEDTWarnings(false); - JFrame frm = new JFrame("Placeholder"); - frm.setVisible(false); - Display.init(frm.getContentPane()); - JavaSEPort.setBaseResourceDir(resFile.getParentFile()); - EditableResources res = new EditableResourcesEditor(); - res.openFile(new FileInputStream(resFile)); - String mainTheme = res.getThemeResourceNames()[0]; - res.setThemeProperty(mainTheme, "@OverlayThemes", cssFile.getName()); - System.out.println("Setting @OverlayThemes constant in "+mainTheme+" theme of "+resFile.getPath()+" to "+cssFile.getName()+" so that the CSS styles will override styles in the default theme."); - DataOutputStream dos = new DataOutputStream(new FileOutputStream(resFile)); - res.save(dos); - dos.close(); - Display.getInstance().exitApplication(); - } catch (IOException ex) { - Logger.getLogger(CN1CSSInstallerCLI.class.getName()).log(Level.SEVERE, null, ex); - } - - - }); - } - - - public static void main(String[] args) throws Exception { - - if (args.length != 3 || !"install".equals(args[0])) { - System.err.println("Invalid parameters. Expected install "); - System.exit(1); - } - System.setProperty("testfx.robot", "glass"); - System.setProperty("testfx.headless", "true"); - System.setProperty("prism.order", "sw"); - System.setProperty("prism.text", "t2k"); - //System.setProperty("java.awt.headless", "true"); - - install(args); - } -} diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/test.css b/CodenameOneDesigner/src/com/codename1/designer/css/test.css deleted file mode 100644 index fdb525868cd..00000000000 --- a/CodenameOneDesigner/src/com/codename1/designer/css/test.css +++ /dev/null @@ -1,111 +0,0 @@ -/*Button { - border-radius: 5px; - border: 2px solid #ccc; - padding: 20px; - margin: 30px; - background : red; - color: white; - box-shadow: 0 10px 10px rgba(0, 0, 0, .5); - -} - -Button.pressed { - box-shadow: inset 0 10px 10px rgba(0, 0, 0, .5); -} - -Label { - background: gray; - color: blue; -}*/ - -@font-face { - font-family: fontawesome; - src: url(https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.4.0/fonts/fontawesome-webfont.ttf); - -} - -@font-face { - font-family: bangers; - src: url(https://raw.githubusercontent.com/google/fonts/master/ofl/bangers/Bangers.ttf); -} - -@font-face { - font-family: alegreya; - src: url(https://raw.githubusercontent.com/google/fonts/master/ofl/alegreya/Alegreya-Black.ttf) -} - -Button.selected { - padding: 20px 20px 30px 10px; - border: 30px solid red; -} - -Tech { - background-image: url(http://solutions.weblite.ca/images/technologies.jpg); -} - -FooImage { - padding: 10px; - - cn1-image-id: "Foobar.jpg"; - /**/ - background: radial-gradient( - yellow, - #f06d06 - ); - background-size: cover; - /*background-repeat: repeat-x;*/ - /*border: 1px solid black; - border-radius: 20px;*/ - background-color: rgba(0,0,0,100); - width: 100%; - height: 100%; - - font: 10mm italic; - font-family: /*"bangers";*/ "alegreya"; - text-decoration: cn1-3d; - cn1-derive: Button; - opacity: 0.4; - -} - -SearchField { - cn1-derive : TextField; - background: linear-gradient(#ccc, #eaeaea); - opacity: 0.5; - border-radius: 10px; - min-height: 40px; - min-width: 40px; -} -/* -Label, Button { - padding: 1px 5px 10px 20px; - margin: 2px; - padding-top: 21px; - background: radial-gradient(farthest-side at 60% 55%,blue,green,yellow,black); -} - - - -Label.selected, Button.selected { - border: 2px solid black; - background:url(https://upload.wikimedia.org/wikipedia/commons/f/fc/Yosemite_Valley_from_Wawona_Tunnel_view,_vista_point..JPG); - padding: 1px 5px 10px 20px; - margin: 4px; - -} - -Button { - border: 1px solid #ccc; - border-radius: 10px; -} - -Button.pressed { - box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); -} - -Gradient { - background: linear-gradient(red, blue); - border-radius: 10px; - border: 1px solid rgb(0,0,0); -} -*/ diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java b/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java index 74e3bf7d652..9e21723f662 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/CSSWatcher.java @@ -22,6 +22,7 @@ */ package com.codename1.impl.javase; +import com.codename1.project.BuildSystem; import com.codename1.project.ProjectLayout; import com.codename1.ui.CN; import com.codename1.ui.Display; @@ -29,17 +30,16 @@ import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.Resources; import java.io.BufferedReader; -import java.io.BufferedWriter; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; import java.net.ServerSocket; import java.net.Socket; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; import java.util.Properties; import java.util.logging.Level; @@ -53,17 +53,51 @@ public class CSSWatcher implements Runnable { private volatile Process childProcess; private volatile boolean closing; private final Thread shutdownHook; - private static final int MIN_DESIGNER_VERSION=6; /** - * Classpath of the headless CSS compiler CLI, published by the Maven plugin's - * PrepareSimulatorClasspathMojo into target/codenameone/simulator.properties and + * Classpath of the headless CSS compiler CLI, published by the Maven and Gradle + * plugins into the build directory's codenameone/simulator.properties and * loaded into the System properties by {@link Simulator}. Kept in sync with - * AbstractCN1Mojo.CSS_CLI_CLASSPATH_PROPERTY / CSS_CLI_MAIN_CLASS -- the port - * cannot see the plugin's constants. + * SimulatorSupport.CSS_CLI_CLASSPATH_PROPERTY / CssCompiler.CSS_CLI_MAIN_CLASS + * -- the port cannot see the build engine's constants. + * + *

This is the only way the compiler is found. There is no fallback to the + * Resource Editor ("designer") jar: a launch that does not supply the classpath + * gets no live CSS reload, and says so once.

*/ - private static final String CSS_CLI_CLASSPATH_PROPERTY = "cn1.css.cli.classpath"; - private static final String CSS_CLI_MAIN_CLASS = "com.codename1.designer.css.CN1CSSCLI"; + static final String CSS_CLI_CLASSPATH_PROPERTY = "cn1.css.cli.classpath"; + static final String CSS_CLI_MAIN_CLASS = "com.codename1.designer.css.CN1CSSCLI"; + + /** What the build compiled, as SimulatorSupport names them. */ + static final String CSS_INPUT_PROPERTY = "codename1.css.compiler.args.input"; + static final String CSS_OUTPUT_PROPERTY = "codename1.css.compiler.args.output"; + static final String CSS_MERGE_PROPERTY = "codename1.css.compiler.args.merge"; + + static final String UNAVAILABLE_NOTICE = "Live CSS reload is unavailable: this build did not supply " + + "the CSS compiler (" + CSS_CLI_CLASSPATH_PROPERTY + "). Launch the simulator through the " + + "Codename One Maven or Gradle plugin."; + + /** + * Set once the notice above has been printed. A system property rather than a + * static field because a simulator reload loads this class again in a new + * class loader, and one watcher is created per theme prefix: the notice is + * about the launch, so it is printed once per JVM. + */ + static final String NOTICE_PRINTED_PROPERTY = "cn1.css.reload.noticePrinted"; + + /** + * A compiler that dies is started again, but not for ever: this many exits + * inside {@link #RAPID_EXIT_WINDOW_MILLIS} means it cannot run at all (a + * stylesheet or bundle it crashes on, a broken classpath), and restarting it + * only repeats the same failure. See JavaSEPort.findLocalizationDirectory for + * the time an unbounded restart turned one bad bundle into an endless loop + * of JVM launches. + */ + static final int MAX_RAPID_EXITS = 3; + static final long RAPID_EXIT_WINDOW_MILLIS = 10000L; + + /** How much of the compiler's error output is kept to explain a failure. */ + private static final int ERROR_TAIL_LINES = 20; private final String themePrefix; @@ -109,52 +143,66 @@ public void stop() { } /** - * Checks whether the CSS watcher is supported currently. This will check the - * update project's properties to see the current version of the designer. - * CSS watcher is only supported for designer version 5 or higher. - * - *

This can be overridden by setting {@literal csswatcher.enabled=true|false} in - * the codenameone_settings.properties file

- * @return + * Checks whether the CSS watcher is supported currently: the launch supplied + * the CSS compiler's classpath ({@literal cn1.css.cli.classpath}), which the + * Maven and Gradle plugins do and an Ant launch does not. + * + *

Setting {@literal csswatcher.enabled=false} in the + * codenameone_settings.properties file switches it off. Any other value leaves + * the answer to the classpath: the setting cannot conjure a compiler the launch + * did not supply.

*/ public static boolean isSupported() { - File userHome = new File(System.getProperty("user.home")); - File cn1Home = new File(userHome, ".codenameone"); - File updateStatusProps = new File(cn1Home, "UpdateStatus.properties"); - + return !isDisabledInSettings() && cssCliClasspath() != null; + } + + /** + * Says, once per simulator run, that live CSS reload is not available because + * the launch did not supply the compiler. Silent when the project does not use + * CSS or switched the watcher off itself: neither is missing anything. + */ + static void reportIfUnavailable() { + if (cssCliClasspath() != null || isDisabledInSettings() || !isCssInUse()) { + return; + } + if (System.getProperty(NOTICE_PRINTED_PROPERTY) != null) { + return; + } + System.setProperty(NOTICE_PRINTED_PROPERTY, "true"); + System.out.println(UNAVAILABLE_NOTICE); + } + + /** The CSS compiler's classpath, or null when the launch supplied none. */ + private static String cssCliClasspath() { + String classpath = System.getProperty(CSS_CLI_CLASSPATH_PROPERTY, null); + return classpath == null || classpath.trim().isEmpty() ? null : classpath; + } + + /** The project's settings, empty when it has none or they cannot be read. */ + private static Properties projectSettings() { + Properties cn1Properties = new Properties(); File cn1Props = JavaSEPort.projectSettingsFile(); if (cn1Props.exists()) { - java.util.Properties cn1Properties = new Properties(); try (InputStream input = new FileInputStream(cn1Props)) { cn1Properties.load(input); - String cssWatcherEnabled = cn1Properties.getProperty("csswatcher.enabled", null); - if (cssWatcherEnabled != null) { - return "true".equals(cssWatcherEnabled.toLowerCase()); - } - } catch (IOException ex) { - Logger.getLogger(CSSWatcher.class.getName()).log(Level.SEVERE, null, ex); - } - - } - - - if (updateStatusProps.exists()) { - java.util.Properties updateStatusProperties = new Properties(); - try (InputStream input = new FileInputStream(updateStatusProps)){ - updateStatusProperties.load(input); - String designerVersionStr = updateStatusProperties.getProperty("designer", "0"); - Double designerVersionDbl = Double.parseDouble(designerVersionStr); - if (designerVersionDbl >= MIN_DESIGNER_VERSION) { - return true; - } } catch (IOException ex) { Logger.getLogger(CSSWatcher.class.getName()).log(Level.SEVERE, null, ex); } } - - return false; + return cn1Properties; } - + + /** An explicit csswatcher.enabled that is anything but "true" switches the watcher off. */ + private static boolean isDisabledInSettings() { + String cssWatcherEnabled = projectSettings().getProperty("csswatcher.enabled", null); + return cssWatcherEnabled != null && !"true".equalsIgnoreCase(cssWatcherEnabled); + } + + private static boolean isCssInUse() { + String cssTheme = projectSettings().getProperty("codename1.cssTheme", null); + return cssTheme != null && "true".equalsIgnoreCase(cssTheme.trim()); + } + /** * Resolves the first existing localization directory to pass to the CSS compiler. @@ -217,8 +265,7 @@ void addLocalizationCandidates(File cssFile, List out) { } /** - * Appends `-l ` to the designer command arguments when a localization directory exists. - *

Keeping this in one helper ensures both watch invocation branches stay consistent.

+ * Appends `-l ` to the CSS compiler arguments when a localization directory exists. */ void addLocalizationArgument(List args, File srcFile, String overrideInputs) { File localizationDirectory = findLocalizationDirectory(srcFile, overrideInputs); @@ -252,24 +299,235 @@ public String unescapeXSI(final String s) throws IOException { return sb.toString(); } + /** The three file arguments of one compiler run. */ + static final class CompilerArgs { + final String input; + final String output; + final String merge; + + CompilerArgs(String input, String output, String merge) { + this.input = input; + this.output = output; + this.merge = merge; + } + } + + /** + * The last lines the compiler wrote to its error stream, so that a compiler + * that cannot stay up can be reported with its own explanation. + */ + private static final class ErrorTail { + private final ArrayDeque lines = new ArrayDeque(); + + synchronized void add(String line) { + if (lines.size() == ERROR_TAIL_LINES) { + lines.removeFirst(); + } + lines.addLast(line); + } + + /** + * The last line that says something. A crashing JVM ends its output with + * the stack frames, so the literal last line is "at ...main(...)"; the + * line above the frames is the exception and its message. Falls back to + * the literal last line when everything kept is a frame. + */ + synchronized String lastMeaningfulLine() { + String last = null; + Iterator it = lines.descendingIterator(); + while (it.hasNext()) { + String line = it.next().trim(); + if (line.isEmpty()) { + continue; + } + if (last == null) { + last = line; + } + if (!line.startsWith("at ") && !line.startsWith("...")) { + return line; + } + } + return last; + } + } + + /** Prints a line about the launch once per JVM, however many watchers find it true. */ + private static void printOnce(String key, String message) { + if (System.getProperty(key) == null) { + System.setProperty(key, "true"); + System.out.println(message); + } + } + + private static String launchProperty(String name) { + String value = System.getProperty(name, null); + // A pom that forwards -Dname=${name} while the property is undefined hands + // over the unexpanded placeholder, which is not a value. + return value == null || value.contains("${") ? null : value; + } + + /** + * Where the build writes theme.res. Maven and Gradle compile it into the + * resources the simulator runs from (target/classes, build/resources/main); + * an Ant project keeps it beside its sources. + */ + static File derivedOutput(ProjectLayout layout) { + if (layout.buildSystem() == BuildSystem.ANT) { + return new File(layout.resourcesDir(), "theme.res"); + } + return new File(layout.resourcesOutputDir(), "theme.res"); + } + + /** + * Where the build writes the merged stylesheet: css/theme.css under the build + * directory, which is what CssCompiler, PrepareSimulatorClasspathMojo and the + * Gradle plugin's run task all name. Deliberately not + * ProjectLayout.cssMergeFile() for those two: that answers theme.css.merged, + * and a watcher writing a second merge file would not share the build's + * up-to-date state. Ant has no such directory and does use it. + */ + static File derivedMerge(ProjectLayout layout) { + if (layout.buildSystem() == BuildSystem.ANT) { + return layout.cssMergeFile(layout.themeCss()); + } + return new File(new File(layout.buildDir(), "css"), "theme.css"); + } + + /** + * What to compile for this watcher's theme prefix, or null -- after saying + * why -- when there is nothing to watch. + * + *

The build passes the unprefixed theme's files as + * codename1.css.compiler.args.*. A launch that carries the compiler's + * classpath but not those (simulator.properties was loaded, the pom's + * arguments were not: an IDE run configuration) gets the same values from + * the project layout instead. The one thing the layout cannot supply is the + * stylesheets of the project's cn1libs, which the build resolves from its + * dependencies and merges first, so a derived input is the application's + * own theme.css alone.

+ * + *

Every file takes the theme prefix, the merge file included: the build + * names it [prefix]theme.css too, and two watchers sharing one merge file + * would overwrite each other's.

+ */ + CompilerArgs resolveCompilerArgs() { + String input = launchProperty(CSS_INPUT_PROPERTY); + String output = launchProperty(CSS_OUTPUT_PROPERTY); + String merge = launchProperty(CSS_MERGE_PROPERTY); + if (input == null || output == null || merge == null) { + ProjectLayout layout = SimulatorProject.current(); + if (layout == null) { + printOnce("cn1.css.reload.noArgsNoticePrinted", "Live CSS reload is unavailable: the launch " + + "passed no CSS compiler arguments (" + CSS_INPUT_PROPERTY + ", " + CSS_OUTPUT_PROPERTY + + ", " + CSS_MERGE_PROPERTY + ") and no Codename One project was found around " + + JavaSEPort.getCWD().getAbsolutePath() + " to derive them from."); + return null; + } + if (input == null) { + File themeCss = layout.themeCss(); + if (!prefixFile(themePrefix, themeCss).exists()) { + System.out.println("CSS file " + prefixFile(themePrefix, themeCss) + + " does not exist. Not activating CSS watcher for prefix " + themePrefix); + return null; + } + input = themeCss.getAbsolutePath(); + } + if (output == null) { + output = derivedOutput(layout).getAbsolutePath(); + } + if (merge == null) { + merge = derivedMerge(layout).getAbsolutePath(); + } + } + if (input.trim().isEmpty() || output.trim().isEmpty() || merge.trim().isEmpty()) { + // The Maven plugin passes all three blank for a project without a theme.css. + System.out.println("CSS file " + input + " does not exist. Not activating CSS watcher for prefix " + + themePrefix); + return null; + } + return new CompilerArgs(prefixInputs(themePrefix, input), + prefixFile(themePrefix, new File(output)).getPath(), + prefixFile(themePrefix, new File(merge)).getPath()); + } + + /** + * The command line of the CSS compiler in watch mode. + * + *

The compiler is headless: it opens no window and needs no display, and + * java.awt.headless keeps the child from showing up as a second application + * (a Dock icon on macOS) or failing where there is no display at all.

+ */ + List buildCommand(String cssCliClasspath, int parentPort, CompilerArgs compilerArgs) { + File javaBin = new File(System.getProperty("java.home"), "bin/java"); + List args = new ArrayList(); + args.add(javaBin.getAbsolutePath()); + args.add("-Djava.awt.headless=true"); + args.add("-Dcli=true"); + args.add("-Dparent.port=" + parentPort); + // A build that moved its build directory told the simulator where; the + // compiler keeps its checksums there and cannot find it from the + // directory tree either. + String buildDir = System.getProperty(SimulatorProject.BUILD_DIR_PROPERTY); + if (buildDir != null && buildDir.length() > 0) { + args.add("-D" + SimulatorProject.BUILD_DIR_PROPERTY + "=" + buildDir); + } + args.add("-cp"); + args.add(cssCliClasspath); + args.add(CSS_CLI_MAIN_CLASS); + args.add("-input"); + args.add(compilerArgs.input); + args.add("-output"); + args.add(compilerArgs.output); + args.add("-merge"); + args.add(compilerArgs.merge); + addLocalizationArgument(args, null, compilerArgs.input); + args.add("-watch"); + return args; + } + + /** Starts the compiler. A seam so tests can supply a process without forking a JVM. */ + Process startChild(List command) throws IOException { + return new ProcessBuilder(command).start(); + } + + /** A monotonic clock, in milliseconds. A seam for the restart bound's tests. */ + long nowMillis() { + return System.nanoTime() / 1000000L; + } + + private boolean simulatorReloaded() { + int reloadVersion = Integer.parseInt(System.getProperty("reload.simulator.count", "0")); + return reloadVersion != simulatorReloadVersion; + } + private void watch() throws IOException { if (closing) { return; } + String cssCliClasspath = cssCliClasspath(); + if (cssCliClasspath == null) { + reportIfUnavailable(); + return; + } + final CompilerArgs compilerArgs = resolveCompilerArgs(); + if (compilerArgs == null) { + return; + } + System.out.println("Watching CSS files for changes: ["+compilerArgs.input+"]"); if (pulseSocket == null || pulseSocket.isClosed()) { // If the the Simulator is killed then the shutdown hook doesn't run - // so we need an alternative way for the ResourceEditorApp to know that + // so we need an alternative way for the CSS compiler to know that // the parent is dead so that it will close itself. - // So we create a ServerSocket to serve as a "pulse". - // We pass the port to the ResourceEditorApp so that it can connect. - // When the socket is disconnected for any reason, the ResourceEditor app will exit. + // So we create a ServerSocket to serve as a "pulse". + // We pass the port to the CSS compiler so that it can connect. + // When the socket is disconnected for any reason, the compiler will exit. pulseSocket = new ServerSocket(0); pulseThread = new Thread(new Runnable() { public void run() { while (!closing) { try { Socket clientSocket = pulseSocket.accept(); - + } catch (IOException ex) { Logger.getLogger(CSSWatcher.class.getName()).log(Level.SEVERE, null, ex); } @@ -278,186 +536,133 @@ public void run() { } }); pulseThread.setDaemon(true); - - } - File javaBin = new File(System.getProperty("java.home"), "bin/java"); - // Maven and Gradle launches both pass codename1.css.compiler.args.* - // (read below), so this relative css/ is only ever Ant's. - final File srcFile = new File("css", themePrefix + "theme.css"); - String overrideInputs = System.getProperty("codename1.css.compiler.args.input", null); - if (overrideInputs != null) { - overrideInputs = prefixInputs(themePrefix, overrideInputs); - } - if (!srcFile.exists() && overrideInputs == null) { - //System.out.println("No theme.css file found. CSSWatcher canceled"); - return; - } else { - if (overrideInputs == null) { - System.out.println("Found "+themePrefix+"theme.css file. Watching for changes..."); - } else { - if (overrideInputs.trim().isEmpty()) { - System.out.println("CSS file "+overrideInputs+" does not exist. Not activating CSS watcher for prefix " + themePrefix); - return; - } - System.out.println("Watching CSS files for changes: ["+overrideInputs+"]"); - } - } - File resDir = JavaSEPort.getSourceResourcesDir(); - - File destFile = new File(resDir, themePrefix + "theme.res"); - - String overrideOutputs = System.getProperty("codename1.css.compiler.args.output", null); - if (overrideOutputs != null) { - overrideOutputs = prefixInputs(themePrefix, overrideOutputs); - destFile = new File(overrideOutputs); - } - // Preferred path: PrepareSimulatorClasspathMojo resolves the thin - // codenameone-css-cli module plus its dependencies and hands the classpath - // over through simulator.properties, which Simulator.java loads into the - // System properties. Launching CN1CSSCLI with -cp means neither the plugin - // nor the simulator needs the designer's 43MB shaded jar to compile CSS. - String cssCliClasspath = System.getProperty(CSS_CLI_CLASSPATH_PROPERTY, null); - ProcessBuilder pb; - if (cssCliClasspath != null && !cssCliClasspath.trim().isEmpty()) { - pb = new ProcessBuilder( - javaBin.getAbsolutePath(), - "-Dcli=true", - "-Dparent.port=" + pulseSocket.getLocalPort(), - "-cp", cssCliClasspath, - CSS_CLI_MAIN_CLASS, - "-css" - ); - } else { - // Fallback for projects generated against an older plugin, whose - // javase/pom.xml only forwards -Dcodename1.designer.jar. - File userHome = new File(System.getProperty("user.home")); - File cn1Home = new File(userHome, ".codenameone"); - File designerJar = new File(cn1Home, "designer_1.jar"); - // Generated javase/pom.xml files forward -Dcodename1.designer.jar - // unconditionally, so with the property undefined this arrives blank (or as - // a literal unexpanded ${...}). Treat anything that isn't a real file as unset. - String designerJarProperty = System.getProperty("codename1.designer.jar", null); - if (designerJarProperty != null && !designerJarProperty.trim().isEmpty() - && !designerJarProperty.contains("${")) { - designerJar = new File(designerJarProperty); - } else { - // Any CN1 mojo invocation pulls the version-pinned designer jar into m2. - // Prefer that over ~/.codenameone/designer_1.jar (which is managed by - // UpdateCodenameOne and routinely lags behind the plugin version, - // producing confusing CSS failures). - File m2Designer = com.codename1.impl.javase.util.MavenUtils.findDesignerJarInM2(); - if (m2Designer != null) { - designerJar = m2Designer; - } else if (designerJar.exists()) { - System.err.println("[CSSWatcher] Warning: neither " + CSS_CLI_CLASSPATH_PROPERTY - + " nor codename1.designer.jar is set, and no version-pinned designer was " - + "found in the local Maven repository; falling back to " - + designerJar.getAbsolutePath() + ". This file is managed by " - + "UpdateCodenameOne and may be older than the CN1 plugin in use. " - + "If CSS compilation fails, launch the simulator via the Maven cn1:run goal."); - } - } - // JVM options must precede -jar, and the jar path must follow it - // immediately. The pre-existing ordering put -Dcli=true where the - // jar belongs, so the JVM treated that as the jar name. - pb = new ProcessBuilder( - javaBin.getAbsolutePath(), - "-Dcli=true", - "-Dparent.port=" + pulseSocket.getLocalPort(), - "-jar", designerJar.getAbsolutePath(), - "-css" - ); - } - List args = pb.command(); - if (overrideInputs != null) { - args.add("-input"); - args.add(overrideInputs); - args.add("-output"); - args.add(overrideOutputs); - args.add("-merge"); - args.add(System.getProperty("codename1.css.compiler.args.merge", null)); - addLocalizationArgument(args, srcFile, overrideInputs); - args.add("-watch"); - } else { - args.add(srcFile.getAbsolutePath()); - args.add(destFile.getAbsolutePath()); - addLocalizationArgument(args, srcFile, null); - args.add("-watch"); - args.add("-Dprism.order=sw"); } + List args = buildCommand(cssCliClasspath, pulseSocket.getLocalPort(), compilerArgs); System.out.println("Running CSS watch with args " + args); - if (closing) { - stop(); - return; - } - Process previousProcess = childProcess; - if (previousProcess != null && previousProcess.isAlive()) { + // When the compiler last exited, as a ring: with MAX_RAPID_EXITS entries + // recorded, the slot about to be overwritten is the oldest of them. + long[] exitTimes = new long[MAX_RAPID_EXITS]; + int exits = 0; + while (!closing) { + if (simulatorReloaded()) { + stop(); + return; + } + Process previousProcess = childProcess; + if (previousProcess != null && previousProcess.isAlive()) { + try { + previousProcess.destroyForcibly(); + } catch (Throwable t){} + } + Process p = startChild(args); + childProcess = p; + if (closing) { + stop(); + return; + } + ErrorTail errors = new ErrorTail(); + Thread errorPump = pumpErrors(p, errors); + pumpOutput(p, compilerArgs); + + // Output has ended, so the compiler is gone or going. Waiting on the + // process (rather than polling it) costs nothing while it lives and + // is ended by stop(), which destroys it. + int exitCode; try { - previousProcess.destroyForcibly(); - } catch (Throwable t){} - } - Process p = pb.start(); - childProcess = p; - if (closing) { - stop(); - return; + exitCode = p.waitFor(); + // Its last words may still be in the pipe; bounded, because a + // grandchild holding the stream open must not hold this up. + errorPump.join(2000L); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + p.destroyForcibly(); + return; + } + if (closing) { + return; + } + long now = nowMillis(); + exitTimes[exits % MAX_RAPID_EXITS] = now; + exits++; + if (exits >= MAX_RAPID_EXITS && now - exitTimes[exits % MAX_RAPID_EXITS] <= RAPID_EXIT_WINDOW_MILLIS) { + String lastError = errors.lastMeaningfulLine(); + System.err.println("Live CSS reload stopped for " + themePrefix + "theme.css: the CSS compiler exited " + + MAX_RAPID_EXITS + " times within " + (RAPID_EXIT_WINDOW_MILLIS / 1000L) + + " seconds (last exit code " + exitCode + "). Its last error output: " + + (lastError == null ? "(none)" : lastError)); + return; + } } - String line; - - OutputStream stdin = p.getOutputStream(); - final InputStream stderr = p.getErrorStream(); - final BufferedReader errorReader = new BufferedReader(new InputStreamReader(stderr)); - new Thread(new Runnable() { + } + + /** Echoes the compiler's error stream and keeps its tail. */ + private Thread pumpErrors(Process p, final ErrorTail errors) { + final BufferedReader errorReader = new BufferedReader(new InputStreamReader(p.getErrorStream())); + Thread errorPump = new Thread(new Runnable() { public void run() { while (true) { - int reloadVersion = Integer.parseInt(System.getProperty("reload.simulator.count", "0")); - if (reloadVersion != simulatorReloadVersion) { + if (simulatorReloaded()) { stop(); break; } try { String l = errorReader.readLine(); if (l != null) { + errors.add(l); System.err.println("CSS> "+l); } else { break; } } catch (IOException ex) { - Logger.getLogger(CSSWatcher.class.getName()).log(Level.SEVERE, null, ex); + // The stream is closed under the reader when the process + // is destroyed; either way there is nothing more to read. + if (!closing) { + Logger.getLogger(CSSWatcher.class.getName()).log(Level.SEVERE, null, ex); + } + break; } } } - }).start(); - - InputStream stdout = p.getInputStream(); - - - BufferedReader reader = new BufferedReader(new InputStreamReader(stdout)); - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(stdin)); - final File fDestFile = destFile; - final String fOverrideInputs = overrideInputs; + }); + errorPump.setDaemon(true); + errorPump.start(); + return errorPump; + } + + /** + * Reads the compiler's output until it ends, reloading the theme on every + * "::refresh::" the compiler prints after a successful recompile. + */ + private void pumpOutput(Process p, final CompilerArgs compilerArgs) { + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + final File fDestFile = new File(compilerArgs.output); while (true) { + if (simulatorReloaded()) { + stop(); + return; + } + String l; try { - int reloadVersion = Integer.parseInt(System.getProperty("reload.simulator.count", "0")); - if (reloadVersion != simulatorReloadVersion) { - stop(); - return; - } - String l = reader.readLine(); - if (l == null) { - if (!p.isAlive()) { - if (!closing) { - watchThread = null; - start(); - } - break; - } + l = reader.readLine(); + } catch (IOException ex) { + if (!closing) { + ex.printStackTrace(); } - System.out.println("CSS> "+l); - if ("::refresh::".equals(l)) { + // Nothing more can be read from this process, so a live one is + // of no further use; ending it hands the decision to restart + // back to watch(), where it is bounded. + p.destroyForcibly(); + return; + } + if (l == null) { + return; + } + System.out.println("CSS> "+l); + if ("::refresh::".equals(l)) { + try { Display.getInstance().callSerially(new Runnable() { @Override public void run() { @@ -465,11 +670,7 @@ public void run() { return; } try { - if (fOverrideInputs != null) { - System.out.println("CSS File "+fOverrideInputs+" has been updated. Reloading styles from "+fDestFile); - } else { - System.out.println("CSS File "+srcFile+" has been updated. Reloading styles from "+fDestFile); - } + System.out.println("CSS File "+compilerArgs.input+" has been updated. Reloading styles from "+fDestFile); Resources res = Resources.open(new FileInputStream(fDestFile)); UIManager.getInstance().addThemeProps(res.getTheme(res.getThemeResourceNames()[0])); Form f = CN.getCurrentForm(); @@ -482,24 +683,14 @@ public void run() { } } }); - } - } catch (Throwable t) { - if (closing) { - return; - } - t.printStackTrace(); - - if (!p.isAlive()) { - if (!closing) { - watchThread = null; - start(); + } catch (Throwable t) { + if (closing) { + return; } - break; + t.printStackTrace(); } } } - - } @Override @@ -548,14 +739,32 @@ private static File getCSSSourceDirectory() { private static String prefixInputs(String themePrefix, String inputs) { - StringBuilder sb = new StringBuilder(); + StringBuilder all = new StringBuilder(); + StringBuilder present = new StringBuilder(); for (String part : inputs.split(",")) { - if (sb.length() > 0) { - sb.append(","); + File prefixed = prefixFile(themePrefix, new File(part)); + if (all.length() > 0) { + all.append(","); + } + all.append(prefixed.getPath()); + if (prefixed.exists()) { + if (present.length() > 0) { + present.append(","); + } + present.append(prefixed.getPath()); } - sb.append(prefixFile(themePrefix, new File(part)).getPath()); } - return sb.toString(); + // The inputs are the ordinary theme of every library and of the + // application, renamed. A library need not have a dark theme because + // the application has one, and the compiler refuses an input that is + // not there, so the ones that are missing are left out. With none + // there at all the list is kept whole, for the compiler to say so. + // A library with a prefixed theme and no ordinary one is not in the + // list to begin with, and is only compiled by the build. That is + // how live reload has always found a prefixed theme's inputs: the + // simulator is handed the ordinary theme's list and nothing else, + // and renames it here. + return themePrefix.length() > 0 && present.length() > 0 ? present.toString() : all.toString(); } private static File prefixFile(String themePrefix, File file) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java b/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java index 6a7e3159b6c..a7e57aa5d88 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/Executor.java @@ -312,8 +312,13 @@ public void run() { timer.schedule(tt, 2000); } + } else { + // A project that uses CSS, launched without the + // compiler (an Ant project, a hand-rolled launch): + // one line, here rather than per theme prefix. + CSSWatcher.reportIfUnavailable(); } - + //if (isDebug && usingHotswapAgent) { // A Gradle launch may carry none of the system // properties isRunningInMaven() keys on; the project diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java b/Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java index 2bcdc82a4a7..ecb3b109c62 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/util/MavenUtils.java @@ -22,7 +22,10 @@ */ package com.codename1.impl.javase.util; +import com.codename1.impl.javase.SimulatorProject; import com.codename1.io.Log; +import com.codename1.project.BuildSystem; +import com.codename1.project.ProjectLayout; import com.codename1.ui.Display; import java.io.File; import java.io.FileInputStream; @@ -39,15 +42,49 @@ public class MavenUtils { private static boolean isRunningInMaven; private static boolean isRunningInJDKChecked; private static boolean isRunningInMavenChecked; + /** + * Whether the simulator was launched for a Maven project. + * + *

A generated project forks the simulator with {@code exec:exec}, so neither + * {@code maven.home} nor {@code cn1.library.path} reaches it. For a long time the + * only thing that made this true there was the {@code -Dcodename1.designer.jar} + * argument the project's javase pom forwarded (blank, more often than not). + * New projects no longer forward it, so the project layout is asked as well: + * a Maven build system found around the working directory is the same answer + * without depending on what a pom happens to pass. The property is still read + * because projects generated earlier keep passing it.

+ * + *

The layout answers Maven only for a Maven project: a Gradle or an Ant + * project reports its own build system, so neither becomes "Maven" here. Gradle + * callers ask {@link SimulatorProject#isGradle()} beside this method.

+ */ public static boolean isRunningInMaven() { if (!isRunningInMavenChecked) { isRunningInMavenChecked = true; - + isRunningInMaven = System.getProperty("cn1.library.path", null) != null || System.getProperty("maven.home", null) != null || System.getProperty("codename1.designer.jar", null) != null; } - return isRunningInMaven; + // Not cached with the properties: the Executor moves user.dir into the + // project, and SimulatorProject already caches the layout per directory. + return isRunningInMaven || isMavenProjectLayout(); + } + + private static boolean isMavenProjectLayout() { + try { + ProjectLayout layout = SimulatorProject.current(); + return layout != null && layout.buildSystem() == BuildSystem.MAVEN; + } catch (RuntimeException ex) { + // An unreadable directory tree is not a Maven project. + return false; + } + } + + /** Test seam: forgets the cached system property answer. */ + static void reset() { + isRunningInMavenChecked = false; + isRunningInMaven = false; } public static File findJavac() { @@ -150,8 +187,8 @@ static File findDesignerJarInM2(File coreJar) { // designer_1.jar and re-zips it, so this file is a zip wrapper containing // a single designer_1.jar entry with no top-level Main-Class manifest. // AbstractCN1Mojo.getDesignerJar (in the maven plugin) unzips it on demand - // and returns the inner jar; we mirror that here so the CSSWatcher - // fallback path receives a path that `java -jar` can actually launch. + // and returns the inner jar; we mirror that here so the Component + // Inspector receives a path that `java -jar` can actually launch. File wrapperZip = new File(designerVersionDir, "codenameone-designer-" + version + "-jar-with-dependencies.jar"); if (!wrapperZip.isFile()) { return null; diff --git a/docs/demos/javase/pom.xml b/docs/demos/javase/pom.xml index 470c6bf8e08..0b0e016460f 100644 --- a/docs/demos/javase/pom.xml +++ b/docs/demos/javase/pom.xml @@ -491,11 +491,6 @@ - - -Dcodename1.designer.jar=${codename1.designer.jar} - @@ -592,11 +587,6 @@ - - -Dcodename1.designer.jar=${codename1.designer.jar} - @@ -686,11 +676,6 @@ -Xmx1024M - - -Dcodename1.designer.jar=${codename1.designer.jar} - @@ -792,13 +777,6 @@ are set in the prepare-simulator-classpath goal of the codenameone-maven-plugin which was run during the initialize phase. --> - - - codename1.designer.jar - ${codename1.designer.jar} - - -Dcodename1.designer.jar=${codename1.designer.jar} - @@ -549,11 +544,6 @@ - - -Dcodename1.designer.jar=${codename1.designer.jar} - @@ -643,11 +633,6 @@ -Xmx1024M - - -Dcodename1.designer.jar=${codename1.designer.jar} - @@ -749,13 +734,6 @@ are set in the prepare-simulator-classpath goal of the codenameone-maven-plugin which was run during the initialize phase. --> - - - codename1.designer.jar - ${codename1.designer.jar} - com.codenameone @@ -45,10 +39,6 @@ com.codenameone codenameone-css-compiler - - com.codenameone - codenameone-javase - com.codenameone @@ -61,4 +51,65 @@ test + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + -Djava.awt.headless=true + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.3.0 + + + build-jar-with-dependencies + package + + single + + + + + com.codename1.designer.css.CN1CSSCLI + + + + 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 + + + + + + + + diff --git a/maven/css-cli/src/main/java/com/codename1/designer/css/CN1CSSCLI.java b/maven/css-cli/src/main/java/com/codename1/designer/css/CN1CSSCLI.java index 30e0ba44415..42e3d0fc88c 100644 --- a/maven/css-cli/src/main/java/com/codename1/designer/css/CN1CSSCLI.java +++ b/maven/css-cli/src/main/java/com/codename1/designer/css/CN1CSSCLI.java @@ -22,126 +22,136 @@ */ package com.codename1.designer.css; - - -import com.codename1.impl.javase.JavaSEPort; -import com.codename1.ui.Display; -import com.codename1.designer.css.CSSTheme.WebViewProvider; -import com.codename1.impl.javase.CN1Bootstrap; -import com.codename1.io.Log; -import com.codename1.io.Util; -import com.codename1.project.BuildSystem; import com.codename1.project.ProjectLayout; import com.codename1.project.ProjectLayouts; import com.codename1.tools.resourcebuilder.PropertiesUtil; -import com.codename1.ui.BrowserComponent; -import com.codename1.ui.CN; -import com.codename1.ui.Component; -import com.codename1.ui.ComponentSelector; -import com.codename1.ui.ComponentSelector.ComponentClosure; -import com.codename1.ui.Form; -import com.codename1.ui.Label; -import com.codename1.ui.events.ActionEvent; -import com.codename1.ui.events.ActionListener; -import com.codename1.ui.layouts.BoxLayout; -import com.codename1.ui.plaf.Border; -import com.codename1.ui.util.Resources; -import java.awt.BorderLayout; -import java.awt.Container; -import java.awt.Dimension; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.PrintWriter; import java.io.RandomAccessFile; import java.io.UncheckedIOException; import java.math.BigInteger; -import java.net.MalformedURLException; import java.net.Socket; -import java.net.URL; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; -import java.nio.file.CopyOption; -import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.nio.file.StandardWatchEventKinds; -import java.nio.file.WatchEvent; -import java.nio.file.WatchKey; -import java.nio.file.WatchService; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Properties; -import java.util.Scanner; -import java.util.logging.Level; -import java.util.logging.Logger; -import java.util.regex.Pattern; - -import javax.swing.JFrame; -import javax.swing.JPanel; -//import org.apache.tools.ant.types.Path; +import java.util.Set; +/// The CSS compiler's command line: turns a project's CSS into a `theme.res`. +/// +/// The Maven `css` goal, the Gradle `cn1Css` task and the simulator's live +/// CSS reload all fork this class. It runs without a display: the theme is +/// built on [HeadlessCssCompilerImplementation], and a rule that has no native +/// equivalent is painted in process rather than in a browser. `main` sets +/// `java.awt.headless` so that stays true even if the caller forgot to. +/// +/// Around the compile itself it provides what a project build needs: merging +/// the stylesheets of installed libraries ahead of the application's own, +/// bundling localization `.properties` files, skipping a compile whose inputs +/// are older than its output, and a watch mode that recompiles on change and +/// tells the simulator to reload. public class CN1CSSCLI { - public static String version = "7.0"; - static Object lock = new Object(); - static BrowserComponent web; - - public void start() throws Exception { - //Platform.setImplicitExit(false); - startImpl(); - //stage.hide(); - - } - - private static void startImpl() throws Exception { - web = new BrowserComponent(); - web.addWebEventListener(BrowserComponent.onError, new ActionListener() { - @Override - public void actionPerformed(ActionEvent evt) { - System.out.println("Received exception: "+evt.getSource()); + + /// Exit status when the stylesheet could not be compiled. + static final int EXIT_COMPILE_FAILED = 1; + + /// Exit status when the command line could not be understood. + static final int EXIT_USAGE = 2; + + /// Printed on standard output, on a line of its own, after a recompile in + /// watch mode. The simulator's CSS watcher reads this process's output and + /// reloads the theme when it sees the line. + static final String REFRESH_SIGNAL = "::refresh::"; + + /// The system property a build passes when it has moved the project's build + /// directory (Gradle's layout.buildDirectory), which detection by the + /// directory tree cannot see. + static final String BUILD_DIR_PROPERTY = "cn1.buildDir"; + + /// Name of the file, in the project's CSS state directory, that is locked + /// for the length of a compile. A build and the simulator's watcher can + /// both be compiling the same output, and the incremental state must not be + /// read by one while the other is writing it. The file holds nothing; it + /// keeps the name it had when it also stored checksums, so two versions of + /// the compiler still exclude each other. + private static final String LOCK_FILE_NAME = ".cn1_css_checksums"; + + private static final class Options { + File[] inputFiles; + File outputFile; + File mergeFile; + File localizationDir; + boolean watch; + boolean noRaster; + boolean nativeThemeUnits; + + static Options parse(String[] args) { + Options o = new Options(); + String input = getArgByName(args, "i", "input"); + if (input == null || "true".equals(input)) { + throw new IllegalArgumentException("an input stylesheet is required. Use -input . " + + "The positional form ` []` is no longer accepted."); } - - }); - web.setPreferredW(400); - web.setPreferredH(800); - - //Scene scene = new Scene(web, 400, 800, Color.web("#666670")); - //stage.setScene(scene); - //stage.show(); - synchronized(lock) { - lock.notify(); + o.inputFiles = getInputFiles(input); + if (o.inputFiles.length == 0) { + throw new IllegalArgumentException("-input names no file"); + } + for (File f : o.inputFiles) { + if (!f.isFile()) { + throw new IllegalArgumentException("the input stylesheet does not exist: " + f.getAbsolutePath()); + } + } + String output = getArgByName(args, "o", "output"); + if (output == null || "true".equals(output)) { + throw new IllegalArgumentException("an output file is required. Use -output "); + } + o.outputFile = new File(output); + String merge = getArgByName(args, "m", "merge"); + if ("true".equals(merge)) { + throw new IllegalArgumentException("-merge needs a file path"); + } + if (merge != null) { + o.mergeFile = new File(merge); + } else if (o.inputFiles.length > 1) { + throw new IllegalArgumentException("several input files need somewhere to be merged. " + + "Use -merge "); + } + String localization = getArgByName(args, "l", "localization"); + if ("true".equals(localization)) { + throw new IllegalArgumentException("-localization needs a directory"); + } + o.localizationDir = localization == null ? null : new File(localization); + o.watch = hasFlag(args, "w", "watch"); + o.noRaster = hasFlag(args, "no-raster"); + o.nativeThemeUnits = hasFlag(args, "native-theme-units"); + return o; } } - - private static void relaunch() throws Exception { - //Stage stage = new Stage(); - startImpl(); + + private static boolean hasFlag(String[] args, String... names) { + List namesList = Arrays.asList(names); + for (String arg : args) { + if (arg.length() > 1 && arg.charAt(0) == '-' && namesList.contains(arg.substring(1))) { + return true; + } + } + return false; } - - - // It's getting to be a bit of a gong show in here with the CSS compiler trying to - // work nicely with the project files - e.g. automatically saving the theme.res in the - // src directory. - // Stateless mode is an attempt to make it simpler. - // Stateless mode can be invoked by adding "-stateless" to the parameters. - public static boolean statelessMode; - public static boolean mergeMode; - public static boolean watchmode; - private static Thread watchThread; - private static File localizationDir; - - - + private static String getArgByName(String[] args, String... names) { int len = args.length; List namesList = Arrays.asList(names); @@ -167,124 +177,503 @@ private static String getArgByName(String[] args, String... names) { return null; } - - private static String getInputFile(String[] args) { - if (statelessMode) { - return getArgByName(args, "i", "input"); - + private static void printUsage() { + System.out.println("Codename One CSS Compiler"); + System.out.println("Usage:\n"); + System.out.println(" java -cp " + CN1CSSCLI.class.getName() + + " -input -output OPTIONS...\n"); + System.out.println("Options:"); + System.out.println(" -i, -input Input CSS file path. Multiple files separated by commas."); + System.out.println(" -o, -output Output res file path."); + System.out.println(" -m, -merge Path of the file the inputs are merged into. Required with several inputs."); + System.out.println(" -l, -localization Directory containing Java resource bundle .properties files to include."); + System.out.println(" -w, -watch Run in watch mode."); + System.out.println(" Watches input files for changes and automatically recompiles."); + System.out.println(" -no-raster Fail, listing the offending rules, if any rule needs a generated image"); + System.out.println(" (a 9-piece border or a stretched background) instead of a native primitive."); + System.out.println(" -native-theme-units Convert lengths the way the framework's own native themes are built:"); + System.out.println(" a round border gets no default shadow spread. For those themes only;"); + System.out.println(" an application theme compiled with it changes size."); + System.out.println("\nSystem Properties:"); + System.out.println(" parent.port A local port to connect to in watch mode. The compiler exits when the"); + System.out.println(" connection closes, so it does not outlive the process that started it."); + System.out.println(" " + BUILD_DIR_PROPERTY + " The project's build directory, when the build has moved it."); + } + + public static void main(String[] args) { + // Before anything can load an AWT class. The compiler uses AWT only for + // what needs no display (images, font metadata); stating that here turns + // an accidental use of a window or a dialog into an immediate + // HeadlessException instead of a build that works on a developer's + // machine and fails on a server. + System.setProperty("java.awt.headless", "true"); + System.exit(run(args)); + } + + /// The arguments that compile `css` to `res` the way the two-file form + /// of the command line used to: ` `, with nothing + /// else said. + /// + /// That form looked at the project around the stylesheet. For a + /// `theme.css` in a project whose settings say `codename1.cssTheme=true` + /// it compiled the stylesheets of the project's libraries, one + /// `theme.css` in each directory of the library CSS directory, ahead of + /// the application's own, through a merged file. A caller that still + /// names its files that way gets the same theme from these arguments. + public static String[] legacyArguments(String css, String res) { + List out = new ArrayList(); + File input = new File(css); + ProjectLayout layout = "theme.css".equals(input.getName()) ? getLayout(input.getAbsoluteFile()) : null; + StringBuilder inputs = new StringBuilder(); + if (layout != null && layout.settingsFile().isFile()) { + Properties settings = new Properties(); + try (InputStream in = new FileInputStream(layout.settingsFile())) { + settings.load(in); + } catch (IOException ex) { + settings.clear(); + } + File[] libraries = "true".equals(settings.getProperty("codename1.cssTheme")) + ? layout.libraryCssDir().listFiles() : null; + if (libraries != null) { + // By name, so the same project gives the same theme on + // every machine. + Arrays.sort(libraries); + for (File library : libraries) { + File theme = new File(library, "theme.css"); + // A list of inputs is split at its commas. + if (library.isDirectory() && theme.isFile() && theme.getPath().indexOf(',') < 0) { + inputs.append(theme.getPath()).append(','); + } + } + } } - if (args.length > 0) { - return args[0]; - } else { - return "test.css"; + out.add("-input"); + out.add(inputs + css); + out.add("-output"); + out.add(res); + if (inputs.length() > 0) { + out.add("-merge"); + // Among the libraries in every layout, where Ant used to keep + // it beside the stylesheet: the inputs' directories are copied + // in next to the merged file, and a copy of the stylesheet's + // directory cannot go inside that directory. + out.add(new File(layout.libraryCssDir(), input.getName() + ".merged").getPath()); + } + return out.toArray(new String[0]); + } + + /// Runs the compiler and reports the outcome as an exit status instead of + /// ending the JVM, for callers in the same process. + /// + /// #### Returns + /// + /// 0 when the output is up to date, [#EXIT_COMPILE_FAILED] when the + /// stylesheet could not be compiled, [#EXIT_USAGE] for a bad command line. + /// In watch mode it returns only once the parent process has gone. + public static int run(String[] args) { + if (hasFlag(args, "help", "h")) { + printUsage(); + return 0; + } + Options options; + try { + options = Options.parse(args); + } catch (IllegalArgumentException ex) { + System.err.println("CSS compile failed: " + ex.getMessage()); + System.err.println("Run with -help for the options."); + return EXIT_USAGE; + } + HeadlessCssCompilerImplementation.install(); + HeadlessCssCompilerImplementation.setNativeThemeUnits(options.nativeThemeUnits); + if (options.watch) { + return watch(options); + } + try { + if (compileOnce(options)) { + System.out.println("CSS file successfully compiled. " + options.outputFile); + } + return 0; + } catch (Throwable t) { + report(t); + return EXIT_COMPILE_FAILED; } } - - private static Properties loadProjectProperties(File projectDir) throws IOException { - - Properties out = new Properties(); - try (FileInputStream fis = new FileInputStream(new File(projectDir, "codenameone_settings.properties"))) { - out.load(fis); - }; - return out; + + private static void report(Throwable t) { + // One line a build log can be searched for, then the detail. + System.err.println("CSS compile failed: " + (t.getMessage() == null ? t.toString() : t.getMessage())); + t.printStackTrace(); } - - private static boolean isStatelessMode(String[] args) { - for (String arg : args) { - if ("-stateless".equals(arg)) { + + /// Brings the output up to date. + /// + /// #### Returns + /// + /// true when the output was written, false when it was already newer than + /// everything it is built from. + private static boolean compileOnce(Options options) throws IOException { + return compileOnce(options, false, false); + } + + /// As above; with `changed` the output is written whatever the times + /// say. Watch mode knows a file changed because its time moved, either + /// way: a checkout that puts an older file back, or an edit within the + /// same second as the last build, is a change no comparison of times + /// with the output would call one. + /// + /// With `assets` as well, what changed was not a stylesheet: an image + /// or a font. The rules already built are then not reused. They are + /// known by their CSS alone, which an image put back with an older + /// time leaves as it was, and so would be answered with the old image. + private static boolean compileOnce(Options options, boolean changed, boolean assets) throws IOException { + File css = options.mergeFile != null ? options.mergeFile : options.inputFiles[0]; + File outputDir = options.outputFile.getAbsoluteFile().getParentFile(); + if (outputDir != null && !outputDir.isDirectory() && !outputDir.mkdirs()) { + throw new IOException("Could not create the output directory " + outputDir); + } + // The project is found from where the stylesheet is, which is known + // before a merged one has been written for the first time. + ProjectLayout layout = getLayout(css.isFile() ? css : css.getAbsoluteFile().getParentFile()); + if (layout == null && options.mergeFile != null) { + // A build directory can be outside the project, and the merged + // stylesheet with it. The stylesheets it is merged from are not. + layout = getLayout(options.inputFiles[0]); + } + if (layout == null) { + // Not inside a project (the framework's own theme build, a one-off + // compile). There is no state directory to keep anything in, so the + // compile is unconditional and leaves nothing behind but its output. + if (options.mergeFile != null) { + updateMergeFile(options.inputFiles, options.mergeFile); + } + compile(css, options, false); + return true; + } + File stateDir = layout.cssChecksumDir(); + if (!stateDir.isDirectory() && !stateDir.mkdirs()) { + throw new IOException("Could not create " + stateDir); + } + RandomAccessFile lockFile = new RandomAccessFile(new File(stateDir, LOCK_FILE_NAME), "rw"); + try { + FileChannel channel = lockFile.getChannel(); + FileLock lock = channel.lock(); + try { + // The merged stylesheet is rewritten under the lock. A build + // and the simulator's watcher compile the same theme, and one + // must not replace the file the other is parsing. + if (options.mergeFile != null) { + updateMergeFile(options.inputFiles, options.mergeFile); + } + if (!changed && isUpToDate(css, options)) { + System.out.println("File has not changed since last compile."); + return false; + } + compile(css, options, !assets); return true; + } finally { + lock.release(); } + } finally { + lockFile.close(); } - return false; } - - private static boolean isMergeMode(String[] args) { - String inputFile = getInputFile(args); - File f = new File(inputFile); - if (!"theme.css".equals(f.getName())) { + + /// Everything, besides the stylesheet handed to the parser, that the + /// output depends on: the files it imports and the localization bundles. + private static List dependencies(File css, Options options) { + List out = new ArrayList(); + // A broken import is the compile's error to report, with its message. + // The file it names is still a dependency: it appearing is a change. + out.addAll(CssImports.collectReachable(css)); + try { + // The images and fonts too. An edit to one changes the theme and + // no stylesheet; in a merged build the copy in the mirror is what + // the merged file names, and refreshing it moves its time. + out.addAll(CssImports.assets(css)); + } catch (IOException ex) { + // As above: the compile reports it. + } + if (options.localizationDir != null && options.localizationDir.isDirectory()) { + collectLocalizationFiles(options.localizationDir, out, new HashSet()); + } + return out; + } + + /// `seen` is every directory looked in, by where it really is: one + /// linked back to itself or to something it is in is looked in once. + private static void collectLocalizationFiles(File dir, List out, Set seen) { + try { + if (!seen.add(dir.getCanonicalFile())) { + return; + } + } catch (IOException ex) { + return; + } + // The directory too: adding or removing a bundle changes its own + // modification time and nothing else. + out.add(dir); + File[] children = dir.listFiles(); + if (children == null) { + return; + } + for (File child : children) { + if (child.isDirectory()) { + collectLocalizationFiles(child, out, seen); + } else if (child.getName().endsWith(".properties")) { + out.add(child); + } + } + } + + private static boolean isUpToDate(File css, Options options) { + if (options.noRaster || options.nativeThemeUnits) { + // Modification times cannot tell which mode an existing output + // was built in. -no-raster is a check as much as a compile, and + // skipping it would report a stylesheet clean without looking; + // -native-theme-units changes the numbers in the result. Both + // belong to the native theme build, which always compiles. + return false; + } + // The output is what this compiler builds and nothing else: it is + // overwritten whenever the stylesheet is newer, and no copy of the old + // one is kept. A resource file edited by hand belongs under another + // name; one that shares the output's name is replaced by the next + // compile, which is also what a build directory has always done. + File output = options.outputFile; + if (!output.exists()) { + return false; + } + long built = output.lastModified(); + if (css.lastModified() > built) { + return false; + } + if (!new File(css.getAbsoluteFile().getParentFile(), css.getName() + ".checksums").exists()) { + // Every compile this check follows leaves that file, except one + // in a native theme mode, which removes it. An output without it + // was built with other units, or by something else altogether. return false; } try { - File projectDir = getProjectDir(f); - Properties props = loadProjectProperties(projectDir); - return "true".equals(props.getProperty("codename1.cssTheme")); - + CssImports.collect(css); } catch (IOException ex) { + // An import that cannot be followed -- a file deleted since the + // last build -- is for the compile to report. Calling the old + // output current would hide it. return false; } - - } - - private static String prefixUrls(String contents, String prefix) { - - contents = contents.replaceAll("url\\(\"(.*?)\"\\)", "url($1)"); - contents = contents.replaceAll("url\\('(.*?)'\\)", "url($1)"); - contents = contents.replaceAll("url\\((.*?://.*?)\\)", "url(\"$1\")"); - contents = contents.replaceAll("url\\((/.*?)\\)", "url(\"$1\")"); - //contents = contents.replaceAll("url\\(((?!(.*://)).*?)?\\)", "url("+prefix+"$1)"); - contents = contents.replaceAll("url\\(([^\\\"\'].*?)\\)", "url(\""+prefix+"$1\")"); - return contents; + for (File dependency : dependencies(css, options)) { + // One that is missing is for the compile to report. + if (!dependency.exists() || dependency.lastModified() > built) { + return false; + } + } + return true; } - - private static String getRelativePath(File file, File relativeTo) throws IOException { - File projectDir = getProjectDir(file); - if (!relativeTo.getAbsolutePath().startsWith(projectDir.getAbsolutePath())) { - throw new IllegalArgumentException("Relative file not in project: "+relativeTo); + + private static void compile(File css, Options options, boolean incremental) throws IOException { + File outputFile = options.outputFile; + CSSTheme theme = CSSTheme.load(css.toURI().toURL()); + if (theme == null) { + throw new IOException("The CSS parser could not be started for " + css); + } + theme.cssFile = css; + theme.resourceFile = outputFile; + theme.setRasterizationAllowed(!options.noRaster); + + // The per-selector cache lets a recompile reuse the generated images of + // rules that did not change. + File cacheFile = new File(css.getAbsoluteFile().getParentFile(), css.getName() + ".checksums"); + // The cache knows a rule by its CSS alone. It cannot tell that an + // image the rule names was edited, nor which units or which raster + // policy the existing output was built with, so in each of those + // cases nothing is reused and every rule is built again. + boolean nativeThemeMode = options.noRaster || options.nativeThemeUnits; + if (nativeThemeMode && cacheFile.exists() && !cacheFile.delete()) { + throw new IOException("Could not delete " + cacheFile); + } + if (incremental && !nativeThemeMode && outputFile.exists() && cacheFile.exists() + && !assetsNewerThan(css, cacheFile.lastModified())) { + theme.loadResourceFile(); + theme.loadSelectorCacheStatus(cacheFile); } - if (!file.getAbsolutePath().startsWith(projectDir.getAbsolutePath())) { - throw new IllegalArgumentException("File not in project: "+file); + + Map>> localizationBundles = + loadLocalizationBundles(options.localizationDir); + + theme.createImageBorders(); + theme.updateResources(); + // Also with none: the output this may have been built on can + // hold a bundle whose files are gone. + theme.applyLocalizationBundles(localizationBundles); + theme.save(outputFile); + if (incremental && !nativeThemeMode) { + theme.saveSelectorChecksums(cacheFile); } - - List fileAncestors = new ArrayList(); - File tmp = file; - while (tmp.getParentFile() != null) { - tmp = tmp.getParentFile(); - fileAncestors.add(tmp.getAbsolutePath()); - if (tmp.getAbsolutePath().equals(projectDir.getAbsolutePath())) { - break; + } + + private static boolean assetsNewerThan(File css, long time) { + try { + for (File asset : CssImports.assets(css)) { + // One that has gone is a change too: the rule that names + // it must be compiled again to say so, not answered from + // what it compiled to while the file was there. + if (!asset.exists() || asset.lastModified() > time) { + return true; + } } - + return false; + } catch (IOException ex) { + // Cannot tell, so nothing is assumed unchanged. + return true; } - List relativeToAncestors = new ArrayList(); - tmp = relativeTo; - while (tmp.getParentFile() != null) { - tmp = tmp.getParentFile(); - relativeToAncestors.add(tmp.getAbsolutePath()); - if (tmp.getAbsolutePath().equals(projectDir.getAbsolutePath())) { - break; + } + + /// Watch mode: compile, then recompile whenever a source changes, until the + /// process that started this one goes away. + private static int watch(Options options) { + startParentPulse(Thread.currentThread()); + long reported = options.outputFile.lastModified(); + boolean first = true; + // Whether something other than a stylesheet is what changed last. + boolean assetsMoved = false; + while (!Thread.currentThread().isInterrupted()) { + // Snapshot the sources before compiling, not after: an edit made + // while the compile is running then shows up as a change on the + // next poll instead of being absorbed into the baseline. + PollingFileWatcher watcher = new PollingFileWatcher(watchedFiles(options), 1000); + boolean failed = false; + boolean written = false; + try { + written = compileOnce(options, !first, assetsMoved); + if (written && first) { + System.out.println("CSS file successfully compiled. " + options.outputFile); + } + } catch (Throwable t) { + // A stylesheet that does not compile is the normal state of one + // being edited. Report it and keep watching. + report(t); + failed = true; } - - } - String commonAncestor = null; - for (String filePath : fileAncestors) { - int index = relativeToAncestors.indexOf(filePath); - if (index > -1) { - commonAncestor = filePath; + // Not after a failure: one that struck while the theme was being + // written leaves a file that cannot be loaded. What was last + // reported is left as it was, so the compile that does succeed + // is announced even when it writes nothing new. + if (!failed) { + long now = options.outputFile.lastModified(); + // Written by this compile, too, whatever its time says: on a + // file system that keeps times to the second a theme written + // twice in one has the same time and is not the same theme. + if (!first && (written || now != reported)) { + // Whoever wrote it: a build running beside the simulator can + // get there first, and the theme on screen is stale either way. + System.out.println(REFRESH_SIGNAL); + System.out.flush(); + } + reported = now; + } + first = false; + try { + watcher.poll(); + } catch (InterruptedException parentGone) { break; } + assetsMoved = false; + for (File moved : watcher.changed()) { + String name = moved.getName(); + boolean sheet = name.regionMatches(true, Math.max(0, name.length() - 4), ".css", 0, 4); + assetsMoved = assetsMoved || !sheet; + } + System.out.println("Change detected in " + Arrays.toString(options.inputFiles) + ". Recompiling"); } - if (commonAncestor == null) { - throw new IOException("No common ancestors found between "+file+" and "+relativeTo+". FileAncestors: "+fileAncestors+", relativeToAncestors:"+relativeToAncestors); + return 0; + } + + private static File[] watchedFiles(Options options) { + Set files = new LinkedHashSet(Arrays.asList(options.inputFiles)); + for (File input : options.inputFiles) { + files.addAll(dependencies(input, options)); } - int distanceUpToCommonAncestor = relativeTo.getAbsolutePath().substring(commonAncestor.length()+1).split(Pattern.quote(File.separator)).length-1; - StringBuilder sb = new StringBuilder(); - for (int i=0; i= 0) { + } + } finally { + sock.close(); + } + } catch (IOException ex) { + // Refused or reset: the parent is gone, which is the event + // this thread exists to notice. + } catch (NumberFormatException ex) { + System.err.println("Ignoring parent.port=" + parentPort + ": not a port number"); + return; + } + watchThread.interrupt(); + } + }, "css-parent-pulse"); + pulseThread.setDaemon(true); + pulseThread.start(); } - + + private static String prefixUrls(String contents, final String prefix) throws IOException { + return CssImports.rewriteUrls(contents, new CssImports.UrlRewriter() { + @Override + public String rewrite(String url) { + // Only a path relative to the stylesheet moves with it. + return CssImports.isRelativeUrl(url) ? prefix + url : url; + } + }); + } + + /// Deletes a file, or a directory and everything in it. private static void delTree(File dir) { - for(File f : dir.listFiles()) { - if(f.isDirectory()) { + File[] children = dir.listFiles(); + if (children != null) { + for (File f : children) { delTree(f); - } else { - f.delete(); } } + dir.delete(); } private static void syncDirectories(File srcDir, File destDir) throws IOException { + syncDirectories(srcDir, destDir, new HashSet()); + } + + /// `seen` holds the directories being copied, by where they really + /// are. One that is come to again is reached through a link back to + /// itself or to something it is in, and following it would copy the + /// same files a level deeper each time, without end. + private static void syncDirectories(File srcDir, File destDir, Set seen) throws IOException { + if (!seen.add(srcDir.getCanonicalFile())) { + return; + } + try { + syncSeen(srcDir, destDir, seen); + } finally { + seen.remove(srcDir.getCanonicalFile()); + } + } + + private static void syncSeen(File srcDir, File destDir, Set seen) throws IOException { File canonicalSrc = srcDir.getCanonicalFile(); File canonicalDest = destDir.getCanonicalFile(); @@ -314,11 +703,11 @@ private static void syncDirectories(File srcDir, File destDir) throws IOExceptio if (child.isDirectory()) { if (destChild.isDirectory()) { - syncDirectories(child, destChild); + syncDirectories(child, destChild, seen); } else { destChild.delete(); destChild.mkdir(); - syncDirectories(child, destChild); + syncDirectories(child, destChild, seen); } } else { if (destChild.isDirectory()) { @@ -327,7 +716,12 @@ private static void syncDirectories(File srcDir, File destDir) throws IOExceptio } else { long destMtime = destChild.lastModified(); long srcMTime = child.lastModified(); - if (destMtime < srcMTime) { + // Newer, or not what the copy holds: a file put back + // by a checkout is older than its copy and another + // file all the same. + if (destMtime < srcMTime || destChild.length() != child.length() + || !Arrays.equals(Files.readAllBytes(destChild.toPath()), + Files.readAllBytes(child.toPath()))) { Files.copy(child.toPath(), destChild.toPath(), StandardCopyOption.REPLACE_EXISTING); } } @@ -336,14 +730,23 @@ private static void syncDirectories(File srcDir, File destDir) throws IOExceptio if (child.isDirectory()) { destChild.mkdir(); - syncDirectories(child, destChild); + syncDirectories(child, destChild, seen); } else { Files.copy(child.toPath(), destChild.toPath(), StandardCopyOption.REPLACE_EXISTING); } } + destChildren.remove(childName); + } + // What is left was copied from a file that has since been deleted. + // Kept, it would go on being compiled into the theme, and a url() + // that no longer names anything would never be reported. The + // directory of assets copied in from outside is not a copy of + // anything in the source and is left alone. + for (String leftover : destChildren) { + if (!OUTSIDE_ASSETS_DIR.equals(leftover)) { + delTree(new File(destDir, leftover)); + } } - - } private static String getMd5(String input) @@ -395,150 +798,132 @@ private static boolean contains(File directory1, File directory2) throws IOExcep // a direct parent was ever detected: contains(/a, /a/b/c) answered false. return contains(directory1, parent2); } - - /** - * Updates the given merged CSS file with the contents of the given input files. This is only used when - * running in stateless mode. - * @param inputFiles List of input CSS files - * @param mergedFile Output CSS file - * @throws IOException If there is a problem writing the merged file. - */ - private static void updateMergeFileStateless(File[] inputFiles, File mergedFile) throws IOException { - boolean changed = false; - - long mergedFileLastModified = mergedFile.exists() ? mergedFile.lastModified() : 0l; - for (File inputFile : inputFiles) { - if (mergedFileLastModified < inputFile.lastModified()) { - changed = true; - break; - } - } - - if (!changed) { - return; + + /// Writes the one stylesheet the compiler reads when there are several + /// inputs: each library's CSS, then the application's, in the order given. + /// + /// Every input's directory is mirrored under `cn1-merged-files/` beside the + /// merged file and its `url()`s are pointed there, so an image or font keeps + /// resolving once the text has moved. An `@import` is expanded first, while + /// the path it is relative to is still known. + /// What an imported stylesheet outside that directory refers to is copied + /// in beside it, see [#mirrorOutsideAssets]. + /// + /// The file is rewritten only when its content changes. Its modification + /// time is what decides whether the theme is stale, so it has to move when + /// an input or anything an input imports does, and stay put otherwise. + private static void updateMergeFile(File[] inputFiles, File mergedFile) throws IOException { + File mergeRoot = mergedFile.getAbsoluteFile().getParentFile(); + if (!mergeRoot.isDirectory() && !mergeRoot.mkdirs()) { + throw new IOException("Could not create " + mergeRoot); } - - File mergeRoot = mergedFile.getParentFile(); File incDir = new File(mergeRoot, "cn1-merged-files"); incDir.mkdir(); - - StringBuilder buf = new StringBuilder(); - - //File libCSSDir = getLibCSSDirectory(inputFile); - //String relativePathToLibCSSDir = getRelativePath(libCSSDir, mergedFile); - for (File f : inputFiles) { - File canonicalFile = f.getCanonicalFile(); String md5 = getMd5(canonicalFile.getAbsolutePath()); File destDir = new File(incDir, md5); syncDirectories(canonicalFile.getParentFile(), destDir); - - String contents; - try (FileInputStream fis = new FileInputStream(f)) { - byte[] buffer = new byte[(int)f.length()]; - fis.read(buffer); - contents = new String(buffer, "UTF-8"); - } - + String contents = new String(Files.readAllBytes(f.toPath()), "UTF-8"); + contents = CssImports.inline(canonicalFile, contents, new LinkedHashSet()); + contents = mirrorOutsideAssets(contents, canonicalFile.getParentFile(), destDir); contents = prefixUrls(contents, "cn1-merged-files/"+md5+"/"); buf.append("\n/* "+f.getAbsolutePath()+" */\n").append(contents).append("\n/* end "+f.getAbsolutePath()+"*/\n"); } - - try (FileOutputStream fos = new FileOutputStream(mergedFile)) { - fos.write(buf.toString().getBytes("UTF-8")); - } - - - } - - private static void updateMergeFile(File[] inputFiles, File mergedFile) throws IOException { - if (statelessMode) { - updateMergeFileStateless(inputFiles, mergedFile); - return; - } - File inputFile = inputFiles[0]; - List libCSSFiles = findLibCSSFiles(inputFile); - boolean changed = false; - if (!mergedFile.exists() || mergedFile.lastModified() < inputFile.lastModified()) { - changed = true; - } - if (!changed) { - for (File f : libCSSFiles) { - if (mergedFile.lastModified() < f.lastModified()) { - changed = true; - break; - } - } - } - if (!changed) { + byte[] merged = buf.toString().getBytes("UTF-8"); + if (mergedFile.isFile() && Arrays.equals(merged, Files.readAllBytes(mergedFile.toPath()))) { return; } - StringBuilder buf = new StringBuilder(); - - File libCSSDir = getLibCSSDirectory(inputFile); - String relativePathToLibCSSDir = getRelativePath(libCSSDir, mergedFile); - - for (File f : libCSSFiles) { - String contents; - try (FileInputStream fis = new FileInputStream(f)) { - byte[] buffer = new byte[(int)f.length()]; - fis.read(buffer); - contents = new String(buffer, "UTF-8"); - } - contents = prefixUrls(contents, relativePathToLibCSSDir+"/"+f.getParentFile().getName()+"/"); - buf.append("\n/* "+f.getAbsolutePath()+" */\n").append(contents).append("\n/* end "+f.getAbsolutePath()+"*/\n"); - } - - try (FileInputStream fis = new FileInputStream(inputFile)) { - buf.append("\n/* ").append(inputFile.getAbsolutePath()).append(" */\n"); - byte[] buffer = new byte[(int)inputFile.length()]; - fis.read(buffer); - String fileContents = new String(buffer, "UTF-8"); - if (!mergedFile.getParentFile().getAbsolutePath().equals(inputFile.getParentFile().getAbsolutePath())) { - // Merged file is in a different directory than the theme.css file. - // We need to update the relative URL paths. - String relativePathToInputFileDir = getRelativePath(inputFile.getParentFile(), mergedFile); - fileContents = prefixUrls(fileContents, relativePathToInputFileDir+"/"); - - } - buf.append(fileContents); - buf.append("\n/* End ").append(inputFile.getAbsolutePath()).append(" */\n"); - } - + System.out.println("Updating merge file " + mergedFile); try (FileOutputStream fos = new FileOutputStream(mergedFile)) { - fos.write(buf.toString().getBytes("UTF-8")); + fos.write(merged); } - - - - - } - - - - /// The directory holding the project's `codenameone_settings.properties`: - /// the project root under Ant and Gradle, `common/` under Maven. Null - /// when `start` is not inside a Codename One project. - private static File getProjectDir(File start) { - ProjectLayout layout = getLayout(start); - return layout == null ? null : layout.projectDir(); } - /// The layout of the project containing `start`, or null. + /// Where files from outside an input's directory are copied inside its + /// mirror. Named so that it cannot plausibly collide with a directory of + /// the stylesheet's own. + private static final String OUTSIDE_ASSETS_DIR = "cn1-imported-assets"; + + /// Copies into `mirrorDir` every file a `url()` of `contents` names that + /// lies outside `inputDir`, and points the `url()` at the copy. A + /// directory, which is how a multi-image is named, is copied with what + /// is in it. /// - /// [ProjectLayouts#detect(File)] answers with canonical paths, while this - /// class compares paths as the caller spelled them (`getRelativePath` uses - /// `startsWith` on absolute paths). On macOS `/tmp` is a link to - /// `/private/tmp`, so a canonical project dir would no longer prefix an - /// input given under `/tmp`. The detected directories are therefore - /// re-expressed as ancestors of `start` in its own spelling. - /// The system property a build passes when it has moved the project's build - /// directory (Gradle's layout.buildDirectory), which detection by the - /// directory tree cannot see. - static final String BUILD_DIR_PROPERTY = "cn1.buildDir"; + /// An input's directory is mirrored whole, which covers everything the + /// input and the stylesheets beside it refer to. A stylesheet imported + /// from somewhere else -- `@import "../shared/base.css"` -- brings + /// `url()`s that, once expressed relative to the input, climb out of that + /// directory, and so out of its mirror: prefixed with the mirror's path + /// they would name a file nothing copied. Each such file is copied on its + /// own, under a directory named for where it came from, so two imports + /// with an `img/logo.png` each do not overwrite one another. + /// + /// A `url()` naming something that does not exist is left as written, for + /// the compiler to report. + private static String mirrorOutsideAssets(String contents, File inputDir, final File mirrorDir) + throws IOException { + final java.nio.file.Path base = inputDir.getCanonicalFile().toPath(); + return CssImports.rewriteUrls(contents, new CssImports.UrlRewriter() { + @Override + public String rewrite(String url) throws IOException { + if (!CssImports.isRelativeUrl(url)) { + return url; + } + java.nio.file.Path target; + try { + target = base.resolve(url).normalize(); + } catch (java.nio.file.InvalidPathException ex) { + return url; + } + if (target.startsWith(base)) { + return url; + } + if (Files.isDirectory(target) && target.getParent() != null && target.getFileName() != null) { + // A multi-image: a directory holding one picture for + // each density. It goes over whole, under its own name, + // which is the name of the image. + String from = getMd5(target.getParent().toString()); + String name = target.getFileName().toString(); + syncDirectories(target.toFile(), + new File(new File(new File(mirrorDir, OUTSIDE_ASSETS_DIR), from), name)); + return OUTSIDE_ASSETS_DIR + "/" + from + "/" + name; + } + if (!Files.isRegularFile(target)) { + return url; + } + String origin = getMd5(target.getParent().toString()); + File copy = new File(new File(new File(mirrorDir, OUTSIDE_ASSETS_DIR), origin), + target.getFileName().toString()); + File source = target.toFile(); + // Also one of the same size that is not newer: a checkout + // that puts an older file back changes what is in it and + // moves its time the wrong way. What is in the two is what + // says whether the copy still stands. + if (!copy.isFile() || copy.lastModified() < source.lastModified() + || copy.length() != source.length() + || !Arrays.equals(Files.readAllBytes(copy.toPath()), Files.readAllBytes(target))) { + File parent = copy.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Could not create " + parent); + } + Files.copy(target, copy.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return OUTSIDE_ASSETS_DIR + "/" + origin + "/" + copy.getName(); + } + }); + } + /// The layout of the project containing `start`, or null when `start` is + /// not inside a Codename One project. + /// + /// [ProjectLayouts#detect(File)] answers with canonical paths. On macOS + /// `/tmp` is a link to `/private/tmp`, so a canonical project directory is + /// spelled differently from an input given under `/tmp`; the detected + /// directories are therefore re-expressed as ancestors of `start` in its + /// own spelling, which keeps every path this class prints or derives + /// recognisable to the caller. static ProjectLayout getLayout(File start) { ProjectLayout layout = detectLayout(start); String buildDir = System.getProperty(BUILD_DIR_PROPERTY); @@ -578,72 +963,6 @@ private static File sameSpellingAncestor(File from, File canonicalTarget) { return null; } - private static File getLibCSSDirectory(File inputFile) throws IOException { - if (System.getProperty("cn1.libCSSDir", null) != null) { - return new File(System.getProperty("cn1.libCSSDir")); - } - ProjectLayout layout = getLayout(inputFile); - if (layout == null) { - // Outside any project: the Ant-relative path this always fell back to. - return new File("lib" + File.separator + "impl" + File.separator + "css"); - } - // Ant: lib/impl/css; Maven: common/target/css; Gradle: build/css. - return layout.libraryCssDir(); - } - - private static List findLibCSSFiles(File inputFile) throws IOException { - ArrayList out = new ArrayList<>(); - File cssDir = getLibCSSDirectory(inputFile); - if (cssDir.exists() && cssDir.isDirectory()) { - for (File child : cssDir.listFiles()) { - if (child.isDirectory()) { - File themeCss = new File(child, "theme.css"); - if (themeCss.exists()) { - out.add(themeCss); - } - } - } - } - - return out; - - } - - /// True when the project's CSS output lives in a build directory (Maven's - /// `target/`, Gradle's `build/`) rather than beside the sources as under - /// Ant. Only Ant keeps the merged file next to `theme.css`, backs up a - /// hand-edited `.res`, and stores checksums beside the CSS. - private static boolean usesBuildDir(File inputFile) throws IOException { - ProjectLayout layout = getLayout(inputFile); - return layout != null && layout.buildSystem() != BuildSystem.ANT; - } - - private static String getMergedFile(String inputPath) throws IOException { - if (System.getProperty("cn1.cssMergeFile") != null) { - // Was a bare expression whose value was discarded, so the override was read - // and thrown away -- callers setting cn1.cssMergeFile silently got the - // derived path instead. - return System.getProperty("cn1.cssMergeFile"); - } - File inputFile = new File(inputPath); - if (usesBuildDir(inputFile)) { - // ProjectLayout.cssMergeFile() is libraryCssDir()/.merged here; - // resolved through getLibCSSDirectory so cn1.libCSSDir still moves it. - return new File(getLibCSSDirectory(inputFile), inputFile.getName()+".merged").getAbsolutePath(); - } - // Ant: beside the input, spelled as the caller spelled it. - return inputPath + ".merged"; - } - - private static boolean checkWatchMode(String[] args) { - for (int i=0; i out = new ArrayList(); if (inputPath.contains(",")) { @@ -662,458 +981,6 @@ private static File[] getInputFiles(String inputPath) { } return out.toArray(new File[out.size()]); } - - - - public static void main(String[] args) throws Exception { - if (getArgByName(args, "help") != null) { - System.out.println("Codename One CSS Compiler\nversion "+version); - System.out.println("Usage Instructions:\n"); - System.out.println(" java -jar designer.jar -Dcli=true -css -input [inputfile.css] -output [outputfile.res] OPTIONS...\n"); - System.out.println("Options:"); - System.out.println(" -i, -input Input CSS file path. Multiple files separated by commas."); - System.out.println(" -o, -output Output res file path."); - System.out.println(" -m, -merge Path to merge file, used in case there are multipl input files."); - System.out.println(" -l, -localization Directory containing Java resource bundle .properties files to include."); - System.out.println(" -w, -watch Run in watch mode."); - System.out.println(" Watches input files for changes and automatically recompiles."); - System.out.println(" -no-cef Disallow any CSS feature that would trigger CEF-backed image"); - System.out.println(" rasterization (9-piece borders, complex gradients, shadows, filters)."); - System.out.println(" The compile fails listing offending rules instead of falling back."); - System.out.println(" Used by the framework native-themes build."); - System.out.println("\nSystem Properties:"); - System.out.println(" cn1.jcef.installDir Optional JCEF Maven installation directory."); - System.out.println(" cn1.jcef.mirrors Optional comma-separated JCEF download mirrors."); - System.out.println(" parent.port The port number to connect to the parent process for watch mode so that it knows "); - System.out.println(" to exit if the parent process ends."); - return; - - - } - if (getArgByName(args, "no-cef") != null) { - CSSTheme.strictNoCef = true; - System.out.println("CSS compiler running in no-cef mode: any rule requiring CEF rasterization will fail the build."); - } - statelessMode = getArgByName(args, "i", "input") != null; - String localizationPath = getArgByName(args, "l", "localization"); - if (localizationPath != null) { - if ("true".equals(localizationPath)) { - throw new IllegalArgumentException("Localization path is required when using -l or -localization"); - } - localizationDir = new File(localizationPath); - } else { - localizationDir = null; - } - String inputPath; - String outputPath; - String mergedFile; - if (statelessMode) { - System.out.println("Using stateless mode"); - inputPath = getArgByName(args, "i", "input"); - outputPath = getArgByName(args, "o", "output"); - if (outputPath == null) { - throw new IllegalArgumentException("Output path is required. Use -o [filepath] or -output [filepath]"); - } - watchmode = "true".equals(getArgByName(args, "watch")); - if (watchmode && !CN1Bootstrap.isBootstrapped() && !CN1Bootstrap.isCEFLoaded()) { - // In watch mode we require CEF to be loaded - throw new MissingNativeBrowserException(); - } - mergedFile = getArgByName(args, "m", "merge"); - - mergeMode = inputPath.contains(",") || mergedFile != null; - if (mergeMode && mergedFile == null) { - throw new IllegalArgumentException("When compiling multiple files, you must specify a merge path. Use -m [filepath] or -merge [filepath]"); - } - - - } else { - System.out.println("Using stateful mode. Use -help flag to see options for new stateless mode."); - // We don't want media queries while editing CSS because we need to be - // editing the rules raw. - if (checkWatchMode(args) && !CN1Bootstrap.isBootstrapped() && !CN1Bootstrap.isCEFLoaded()) { - // In watch mode we require - throw new MissingNativeBrowserException(); - } - Resources.setEnableMediaQueries(false); - mergeMode = isMergeMode(args); - inputPath = getInputFile(args); - - mergedFile = mergeMode ? getMergedFile(inputPath) : inputPath; - - - outputPath = inputPath+".res"; - - if (args.length > 1) { - if ("-watch".equals(args[1])) { - watchmode = true; - File tmpF = new File(inputPath).getParentFile().getParentFile(); - tmpF = new File(tmpF, "src"); - tmpF = new File(tmpF, new File(inputPath).getName()+".res"); - outputPath = tmpF.getAbsolutePath(); - } else { - outputPath = args[1]; - } - } else { - File tmpF = new File(inputPath).getParentFile().getParentFile(); - tmpF = new File(tmpF, "src"); - tmpF = new File(tmpF, new File(inputPath).getName()+".res"); - outputPath = tmpF.getAbsolutePath(); - - } - if (args.length > 2 && "-watch".equals(args[2])) { - watchmode = true; - } - } - - - if (!Display.isInitialized()) { - JavaSEPort.setShowEDTViolationStacks(false); - JavaSEPort.blockMonitors(); - JavaSEPort.setShowEDTWarnings(false); - JFrame f = new JFrame(); - Display.init(f.getContentPane()); - int w = 640; - int h = 480; - f.getContentPane().setPreferredSize(new java.awt.Dimension(w, h)); - f.getContentPane().setMinimumSize(new java.awt.Dimension(w, h)); - f.getContentPane().setMaximumSize(new java.awt.Dimension(w, h)); - f.pack(); - //f.setVisible(true); - - } - - File[] inputFiles = getInputFiles(inputPath); - if (inputFiles.length == 0) { - throw new IllegalArgumentException("CSS Compiler requires at least one input file"); - } - if (mergeMode) { - System.out.println("Updating merge file "+mergedFile); - updateMergeFile(inputFiles, new File(mergedFile)); - } - - File outputFile = new File(outputPath); - if (watchmode && watchThread == null) { - System.out.println("Starting watch thread to watch "+Arrays.toString(inputFiles)); - watchThread = new Thread(new Runnable() { - - @Override - public void run() { - - // When run in watch mode, a parent process can provide a port - // number that the cli compiler can connect to in order to detect - // if the parent process is "dead". If this socket disconnects - // for any reason, we know the parent process is dead and we can - // exit. - final String parentPort = System.getProperty("parent.port", null); - if (parentPort != null) { - Thread pulseThread = new Thread(new Runnable() { - - @Override - public void run() { - try { - Socket sock = new Socket("127.0.0.1", Integer.parseInt(parentPort)); - sock.setKeepAlive(true); - InputStream is = sock.getInputStream(); - while (is.read() >= 0) { - // Still alive - } - - } catch (IOException ex) { - Logger.getLogger(CN1CSSCLI.class.getName()).log(Level.SEVERE, null, ex); - } - System.exit(0); - } - - }); - pulseThread.setDaemon(true); - pulseThread.start(); - } - - - PollingFileWatcher watcher = new PollingFileWatcher(inputFiles, 1000); - while (true) { - try { - watcher.poll(); - try { - System.out.println("Changed detected in "+Arrays.toString(inputFiles)+". Recompiling"); - if (mergeMode) { - updateMergeFile(inputFiles, new File(mergedFile)); - compile(new File(mergedFile), outputFile); - } else { - compile(inputFiles[0], outputFile); - } - - System.out.println("::refresh::"); // Signal to CSSWatcher in Simulator that it should refresh - } catch (Throwable t) { - System.err.println("Compile of "+Arrays.toString(inputFiles)+" failed"); - t.printStackTrace(); - } - } catch (InterruptedException ex) { - Logger.getLogger(CN1CSSCLI.class.getName()).log(Level.SEVERE, null, ex); - } - - } - - - - } - - }); - - } - - try { - if (mergeMode) { - System.out.println("Compiling "+mergedFile+" to "+outputFile); - compile(new File(mergedFile), outputFile); - } else { - compile(inputFiles[0], outputFile); - } - System.out.println("CSS file successfully compiled. "+outputFile); - - } catch (Throwable t) { - if (!CN1Bootstrap.isBootstrapped() && t instanceof MissingNativeBrowserException) { - // Rethrow MissingNativeBrowserException so that it can be handled in main - // to attempt to re-add - throw t; - } - t.printStackTrace(); - if (!watchmode) { - System.exit(1); - } - } - - if (!watchmode) { - System.exit(0); - } else { - if (watchThread != null && !watchThread.isAlive()) { - watchThread.start(); - watchThread.join(); - } - - } - - } - - - - - private static void compile(File inputFile, File outputFile) throws IOException { - File baseDir = inputFile.getParentFile().getParentFile(); - File checksumsFile = getChecksumsFile(baseDir); - if (!checksumsFile.exists()) { - saveChecksums(baseDir, new HashMap()); - } - if (!checksumsFile.exists()) { - throw new RuntimeException("Failed to create checksums file"); - } - FileChannel channel = new RandomAccessFile(checksumsFile, "rw").getChannel(); - FileLock lock = channel.lock(); - try { - Map checksums = loadChecksums(baseDir); - if (outputFile.exists() && !usesBuildDir(inputFile)) { - String outputFileChecksum = getMD5Checksum(outputFile.getAbsolutePath()); - String previousChecksum = checksums.get(inputFile.getName()); - if (previousChecksum == null || !previousChecksum.equals(outputFileChecksum)) { - File backups = new File(inputFile.getParentFile(), ".backups"); - backups.mkdirs(); - File bak = new File(backups, outputFile.getName()+"."+System.currentTimeMillis()+".bak"); - Files.copy(outputFile.toPath(), bak.toPath(), StandardCopyOption.REPLACE_EXISTING); - System.out.println(outputFile+" has been modified since it was last compiled. Making copy at "+bak); - outputFile.delete(); - } - - } - - if (outputFile.exists() && inputFile.lastModified() <= outputFile.lastModified()) { - System.out.println("File has not changed since last compile."); - return; - } - - - try { - URL url = inputFile.toURI().toURL(); - //CSSTheme theme = CSSTheme.load(CSSTheme.class.getResource("test.css")); - CSSTheme theme = CSSTheme.load(url); - theme.cssFile = inputFile; - theme.resourceFile = outputFile; - JavaSEPort.setBaseResourceDir(outputFile.getParentFile()); - WebViewProvider webViewProvider = new WebViewProvider() { - - @Override - public BrowserComponent getWebView() { - if (web == null) { - if (!CN1Bootstrap.isCEFLoaded()/* && !CN1Bootstrap.isJavaFXLoaded()*/) { - // In theory having JavaFX should work, but I'm having problems getting the snapshotter - // to output the correct bounds so we are killing FX support. CEF only. - throw new MissingNativeBrowserException(); - } - if (!CN.isEdt()) { - CN.callSerially(()->{ - getWebView(); - }); - int counter = 0; - while (web == null && counter++ < 50) { - Util.sleep(100); - } - return web; - } - web = new BrowserComponent(); - ComponentSelector.select("*", web).add(web, true).selectAllStyles() - .setBgTransparency(0) - .setMargin(0) - .setPadding(0) - .setBorder(Border.createEmpty()) - .each(new ComponentClosure() { - @Override - public void call(Component c) { - c.setOpaque(false); - } - - - }); - web.setOpaque(false); - Form f = new Form(); - f.getContentPane().getStyle().setBgColor(0xff0000); - f.getContentPane().getStyle().setBgTransparency(0xff); - if (f.getToolbar() == null) { - f.setToolbar(new com.codename1.ui.Toolbar()); - } - f.getToolbar().hideToolbar(); - f.setLayout(new com.codename1.ui.layouts.BorderLayout()); - f.add(CN.CENTER, web); - f.show(); - - - - - } - - return web; - } - - }; - - - File cacheFile = new File(theme.cssFile.getParentFile(), theme.cssFile.getName()+".checksums"); - if (outputFile.exists() && cacheFile.exists()) { - theme.loadResourceFile(); - theme.loadSelectorCacheStatus(cacheFile); - } - - Map>> localizationBundles = loadLocalizationBundles(localizationDir); - - theme.createImageBorders(webViewProvider); - theme.updateResources(); - if (!localizationBundles.isEmpty()) { - theme.applyLocalizationBundles(localizationBundles); - } - theme.save(outputFile); - - theme.saveSelectorChecksums(cacheFile); - - String checksum = getMD5Checksum(outputFile.getAbsolutePath()); - checksums.put(inputFile.getName(), checksum); - saveChecksums(baseDir, checksums); - - } catch (MalformedURLException ex) { - Logger.getLogger(CN1CSSCLI.class.getName()).log(Level.SEVERE, null, ex); - } - } finally { - if (lock != null) { - lock.release(); - } - if (channel != null) { - channel.close(); - } - - - } - } - - private static byte[] createChecksum(String filename) throws IOException { - try { - InputStream fis = new FileInputStream(filename); - - byte[] buffer = new byte[1024]; - MessageDigest complete = MessageDigest.getInstance("MD5"); - int numRead; - - do { - numRead = fis.read(buffer); - if (numRead > 0) { - complete.update(buffer, 0, numRead); - } - } while (numRead != -1); - - fis.close(); - return complete.digest(); - } catch (NoSuchAlgorithmException ex) { - throw new RuntimeException(ex); - } - } - // see this How-to for a faster way to convert - // a byte array to a HEX string - private static String getMD5Checksum(String filename) throws IOException { - byte[] b = createChecksum(filename); - String result = ""; - - for (int i=0; i < b.length; i++) { - result += Integer.toString( ( b[i] & 0xff ) + 0x100, 16).substring( 1 ); - } - return result; - } - - private static Map loadChecksums(File baseDir) { - File checkSums = getChecksumsFile(baseDir); - if (!checkSums.exists()){ - return new HashMap(); - } - HashMap out = new HashMap(); - try (FileInputStream fis = new FileInputStream(checkSums)){ - Scanner scanner = new Scanner(fis); - - //now read the file line by line... - int lineNum = 0; - while (scanner.hasNextLine()) { - String line = scanner.nextLine(); - lineNum++; - String[] parts = line.split(":"); - if (parts.length == 2) { - out.put(parts[0], parts[1]); - } - } - return out; - } catch(Exception e) { - //handle this - return out; - } - - } - - private static File getChecksumsFile(File baseDir) { - try { - ProjectLayout layout = getLayout(baseDir); - if (layout != null) { - return new File(layout.cssChecksumDir(), ".cn1_css_checksums"); - } - } catch (Exception ex) { - Log.e(ex); - } - // No project around the input: next to the CSS directory, where an Ant - // project (whose root this is) has always kept them. - return new File(baseDir, ".cn1_css_checksums"); - } - - private static void saveChecksums(File baseDir, Map map) throws IOException { - try (PrintWriter out = new PrintWriter(new FileOutputStream(getChecksumsFile(baseDir)))) { - for (String key : map.keySet()) { - out.println(key+":"+map.get(key)); - } - } - - } private static Map>> loadLocalizationBundles(File localizationDirectory) throws IOException { Map>> bundles = new LinkedHashMap<>(); diff --git a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIEndToEndTest.java b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIEndToEndTest.java new file mode 100644 index 00000000000..84c29354208 --- /dev/null +++ b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIEndToEndTest.java @@ -0,0 +1,874 @@ +/* + * 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 com.codename1.ui.util.Resources; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.awt.GraphicsEnvironment; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.PrintStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Hashtable; +import java.util.TreeSet; + +import javax.imageio.ImageIO; + +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Runs the compiler the way a build does -- through [CN1CSSCLI#run] with a +/// command line -- and reads the `.res` it wrote. +/// +/// The whole class runs with `java.awt.headless=true` (the module's surefire +/// configuration), so a compile that reached for a window or a dialog would +/// fail here on any machine, not only on a server without a display. +class CN1CSSCLIEndToEndTest { + + private PrintStream realOut; + private PrintStream realErr; + private ByteArrayOutputStream out; + private ByteArrayOutputStream err; + + @BeforeEach + void captureOutput() throws Exception { + realOut = System.out; + realErr = System.err; + out = new ByteArrayOutputStream(); + err = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out, true, "UTF-8")); + System.setErr(new PrintStream(err, true, "UTF-8")); + } + + @AfterEach + void restoreOutput() { + System.setOut(realOut); + System.setErr(realErr); + System.clearProperty("parent.port"); + } + + private String stdout() throws Exception { + System.out.flush(); + synchronized (out) { + return out.toString("UTF-8"); + } + } + + private String stderr() throws Exception { + System.err.flush(); + return err.toString("UTF-8"); + } + + private static File write(File f, String content) throws Exception { + f.getParentFile().mkdirs(); + Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return f; + } + + private static File png(File f, int w, int h, int argb) throws Exception { + f.getParentFile().mkdirs(); + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + img.setRGB(x, y, argb); + } + } + assertTrue(ImageIO.write(img, "png", f)); + return f; + } + + private static Resources open(File res) throws Exception { + InputStream in = new FileInputStream(res); + try { + return Resources.open(in); + } finally { + in.close(); + } + } + + /// A colour read back through the runtime loader is lower-case hex with + /// no leading zeros, which is why the expectations below look as they do. + private static Hashtable theme(File res) throws Exception { + Resources r = open(res); + Hashtable theme = r.getTheme("Theme"); + assertNotNull(theme, "the compiled file holds a theme named Theme"); + return theme; + } + + /// A Maven project: the only layout in which the compiler keeps state + /// (the lock, the selector cache) and skips an up-to-date compile. + private static File mavenProject(File root) throws Exception { + write(new File(root, "pom.xml"), ""); + File common = new File(root, "common"); + write(new File(common, "pom.xml"), ""); + write(new File(common, "codenameone_settings.properties"), "codename1.cssTheme=true\n"); + return common; + } + + @Test + void theTestJvmIsHeadless() { + assertTrue(GraphicsEnvironment.isHeadless(), + "these tests prove nothing about a display unless they run with java.awt.headless=true"); + } + + @Test + void compilesAStylesheetIntoAThemeWithNoSideFiles(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File css = write(new File(dir, "theme.css"), "Label { color: #ff0000; }\n"); + File res = new File(dir, "out/theme.res"); + + assertEquals(0, CN1CSSCLI.run(new String[]{"-input", css.getPath(), "-output", res.getPath()}), stderr()); + + assertEquals("ff0000", theme(res).get("Label.fgColor")); + // Outside a project there is nowhere to keep state, so there must be none: + // the framework's own themes are compiled this way and committed. + assertEquals(new TreeSet(Arrays.asList("out", "theme.css")), + new TreeSet(Arrays.asList(dir.list())), "nothing but the input and the output directory"); + assertArrayEquals(new String[]{"theme.res"}, new File(dir, "out").list()); + + byte[] first = Files.readAllBytes(res.toPath()); + assertEquals(0, CN1CSSCLI.run(new String[]{"-input", css.getPath(), "-output", res.getPath()}), stderr()); + assertArrayEquals(first, Files.readAllBytes(res.toPath()), "the same input compiles to the same bytes"); + } + + @Test + void theLegacyArgumentsOlderCallersPassAreIgnored(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File css = write(new File(dir, "theme.css"), "Label { color: #00ff00; }\n"); + File res = new File(dir, "theme.res"); + + // An older simulator or plugin still leads with -css, and older scripts + // pass -stateless and -no-cef. None of them means anything any more, but + // rejecting them would break a mixed-version build. + assertEquals(0, CN1CSSCLI.run(new String[]{"-css", "-stateless", "-no-cef", + "-input", css.getPath(), "-output", res.getPath()}), stderr()); + assertEquals("ff00", theme(res).get("Label.fgColor")); + } + + @Test + void mergesLibraryCssAheadOfTheApplicationAndCarriesItsImages(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File lib = write(new File(dir, "lib/theme.css"), + "LibBadge { background-image: url(img/a.png); cn1-source-dpi: 160; }\nShared { color: #111111; }\n"); + png(new File(dir, "lib/img/a.png"), 8, 6, 0xff336699); + File app = write(new File(dir, "app/theme.css"), "Shared { color: #222222; }\n"); + File merged = new File(dir, "work/theme.css.merged"); + File res = new File(dir, "work/theme.res"); + + assertEquals(0, CN1CSSCLI.run(new String[]{"-input", lib.getPath() + "," + app.getPath(), + "-output", res.getPath(), "-merge", merged.getPath()}), stderr()); + + String mergedText = new String(Files.readAllBytes(merged.toPath()), StandardCharsets.UTF_8); + File[] mirrors = new File(dir, "work/cn1-merged-files").listFiles(); + assertNotNull(mirrors); + File libMirror = null; + for (File mirror : mirrors) { + if (new File(mirror, "img/a.png").isFile()) { + libMirror = mirror; + } + } + assertNotNull(libMirror, "the library's directory is mirrored beside the merged file"); + assertTrue(mergedText.contains("url(\"cn1-merged-files/" + libMirror.getName() + "/img/a.png\")"), + "the library's url() points at the mirror, was: " + mergedText); + + Resources r = open(res); + assertTrue(Arrays.asList(r.getImageResourceNames()).contains("a.png"), + "the library's image is in the theme: " + Arrays.toString(r.getImageResourceNames())); + assertEquals(8, r.getImage("a.png").getWidth(), "and it decodes to its real size"); + // The application's rule comes last, so it wins. + assertEquals("222222", r.getTheme("Theme").get("Shared.fgColor")); + } + + @Test + void aMergedBuildCarriesTheImagesOfAnImportFromOutsideTheInputDirectory(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + write(new File(dir, "shared/base.css"), + "SharedBadge { background-image: url(img/b.png); cn1-source-dpi: 160; }\n"); + png(new File(dir, "shared/img/b.png"), 10, 4, 0xff993366); + File app = write(new File(dir, "app/css/theme.css"), + "@import \"../../shared/base.css\";\nLabel { color: #222222; }\n"); + File merged = new File(dir, "work/theme.css.merged"); + File res = new File(dir, "work/theme.res"); + + assertEquals(0, CN1CSSCLI.run(new String[]{"-input", app.getPath(), + "-output", res.getPath(), "-merge", merged.getPath()}), stderr()); + + String mergedText = new String(Files.readAllBytes(merged.toPath()), StandardCharsets.UTF_8); + assertFalse(mergedText.contains("../"), "no url() climbs out of the mirror, was: " + mergedText); + Resources r = open(res); + assertTrue(Arrays.asList(r.getImageResourceNames()).contains("b.png"), + "the imported stylesheet's image is in the theme: " + Arrays.toString(r.getImageResourceNames())); + assertEquals(10, r.getImage("b.png").getWidth(), "and it decodes to its real size"); + } + + @Test + void aMergedBuildCarriesAMultiImageOfAnImportFromOutsideTheInputDirectory(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + write(new File(dir, "shared/base.css"), "SharedLogo { background-image: url(img/logo); }\n"); + png(new File(dir, "shared/img/logo/2hd.png"), 40, 16, 0xff993366); + png(new File(dir, "shared/img/logo/4k.png"), 80, 32, 0xff993366); + File app = write(new File(dir, "app/css/theme.css"), + "@import \"../../shared/base.css\";\nLabel { color: #222222; }\n"); + File merged = new File(dir, "work/theme.css.merged"); + File res = new File(dir, "work/theme.res"); + + assertEquals(0, CN1CSSCLI.run(new String[]{"-input", app.getPath(), + "-output", res.getPath(), "-merge", merged.getPath()}), stderr()); + + String mergedText = new String(Files.readAllBytes(merged.toPath()), StandardCharsets.UTF_8); + assertFalse(mergedText.contains("../"), "no url() climbs out of the mirror, was: " + mergedText); + assertTrue(Arrays.asList(open(res).getImageResourceNames()).contains("logo"), + "the multi-image is in the theme: " + Arrays.toString(open(res).getImageResourceNames())); + } + + @Test + void aMergedBuildEndsWhenADirectoryLinksBackToItself(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File app = write(new File(dir, "app/theme.css"), + "Badge { background-image: url(img/a.png); cn1-source-dpi: 160; }\n"); + png(new File(dir, "app/img/a.png"), 8, 6, 0xff336699); + try { + Files.createSymbolicLink(new File(dir, "app/img/again").toPath(), new File(dir, "app").toPath()); + } catch (UnsupportedOperationException | java.io.IOException ex) { + org.junit.jupiter.api.Assumptions.assumeTrue(false, "no symbolic links here"); + } + File merged = new File(dir, "work/theme.css.merged"); + File res = new File(dir, "work/theme.res"); + + int status = org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(60), + () -> CN1CSSCLI.run(new String[]{"-input", app.getPath(), + "-output", res.getPath(), "-merge", merged.getPath()})); + assertEquals(0, status, stderr()); + assertEquals(8, open(res).getImage("a.png").getWidth()); + } + + @Test + void aMergedBuildTakesAnOutsideImagePutBackWithAnOlderTime(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + write(new File(dir, "shared/base.css"), "Badge { background-image: url(img/a.png); cn1-source-dpi: 160; }\n"); + File image = png(new File(dir, "shared/img/a.png"), 8, 6, 0xff336699); + File app = write(new File(dir, "app/css/theme.css"), "@import \"../../shared/base.css\";\n"); + File merged = new File(dir, "work/theme.css.merged"); + File res = new File(dir, "work/theme.res"); + String[] args = {"-input", app.getPath(), "-output", res.getPath(), "-merge", merged.getPath()}; + assertEquals(0, CN1CSSCLI.run(args), stderr()); + long length = image.length(); + + // The same size, another colour, and older than everything built. + png(image, 8, 6, 0xff996633); + org.junit.jupiter.api.Assumptions.assumeTrue(image.length() == length, "the two pictures are the same size"); + assertTrue(image.setLastModified(res.lastModified() - 600000)); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertEquals(0xff996633, open(res).getImage("a.png").getRGB()[0], "the picture that is there now"); + } + + @Test + void anEditToAnImageAloneRecompilesAMergedBuild(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File app = write(new File(dir, "app/theme.css"), + "Badge { background-image: url(img/a.png); cn1-source-dpi: 160; }\n"); + File image = png(new File(dir, "app/img/a.png"), 8, 6, 0xff336699); + File merged = new File(dir, "work/theme.css.merged"); + File res = new File(dir, "work/theme.res"); + String[] args = {"-input", app.getPath(), "-output", res.getPath(), "-merge", merged.getPath()}; + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertEquals(8, open(res).getImage("a.png").getWidth()); + + png(image, 20, 6, 0xff336699); + assertTrue(image.setLastModified(res.lastModified() + 5000)); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + + assertEquals(20, open(res).getImage("a.png").getWidth(), "the new image is in the theme"); + } + + @Test + void theTwoFileFormOfAnAntProjectCompilesItsLibrariesIn(@TempDir Path tmp) throws Exception { + File project = tmp.toFile(); + write(new File(project, "build.xml"), ""); + write(new File(project, "codenameone_settings.properties"), "codename1.cssTheme=true\n"); + File css = write(new File(project, "css/theme.css"), "Shared { color: #222222; }\n"); + write(new File(project, "lib/impl/css/zlib/theme.css"), "Shared { color: #111111; }\nFromZ { color: #000003; }\n"); + write(new File(project, "lib/impl/css/alib/theme.css"), "FromA { color: #000004; }\n"); + write(new File(project, "lib/impl/css/notalib.txt"), "x"); + File res = new File(project, "src/theme.res"); + + String[] args = CN1CSSCLI.legacyArguments(css.getPath(), res.getPath()); + assertEquals("-input", args[0]); + assertTrue(args[1].endsWith("alib" + File.separator + "theme.css," + + new File(project, "lib/impl/css/zlib/theme.css").getPath() + "," + css.getPath()), args[1]); + assertEquals("-merge", args[4]); + assertEquals(new File(project, "lib/impl/css/theme.css.merged").getAbsolutePath(), + new File(args[5]).getAbsolutePath()); + + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertEquals("3", theme(res).get("FromZ.fgColor")); + assertEquals("4", theme(res).get("FromA.fgColor")); + assertEquals("222222", theme(res).get("Shared.fgColor"), "the application's own rule comes last"); + + File loose = write(new File(tmp.toFile(), "elsewhere/other.css"), "Label { color: #000001; }\n"); + assertArrayEquals(new String[] {"-input", loose.getPath(), "-output", "o.res"}, + CN1CSSCLI.legacyArguments(loose.getPath(), "o.res")); + } + + @Test + void anImportNamedWithAQueryIsReadFromTheFileBeforeIt(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File css = write(new File(dir, "theme.css"), + "@import \"base.css?v=1\";\n@import url(more.css#top);\nLabel { color: #000001; }\n"); + write(new File(dir, "base.css"), "Base { color: #000002; }\n"); + write(new File(dir, "more.css"), "More { color: #000003; }\n"); + File res = new File(dir, "theme.res"); + assertEquals(0, CN1CSSCLI.run(new String[] {"-input", css.getPath(), "-output", res.getPath()}), stderr()); + assertEquals("2", theme(res).get("Base.fgColor")); + assertEquals("3", theme(res).get("More.fgColor")); + } + + @Test + void anImageNamedWithAQueryDoesNotCompile(@TempDir Path tmp) throws Exception { + // Which is why nothing tracks the file behind such an address: no + // theme is built from one. + File dir = tmp.toFile(); + File css = write(new File(dir, "theme.css"), + "Badge { background-image: url(img/a.png?v=1); cn1-source-dpi: 160; }\n"); + png(new File(dir, "img/a.png"), 8, 6, 0xff336699); + assertEquals(CN1CSSCLI.EXIT_COMPILE_FAILED, CN1CSSCLI.run(new String[] { + "-input", css.getPath(), "-output", new File(dir, "theme.res").getPath()})); + assertTrue(stderr().contains("query"), stderr()); + } + + @Test + void anImportIsCompiledAndAnEditToItRecompiles(@TempDir Path tmp) throws Exception { + File common = mavenProject(tmp.toFile()); + File css = write(new File(common, "src/main/css/theme.css"), + "@import \"parts/colors.css\";\nLabel { color: #000001; }\n"); + File part = write(new File(common, "src/main/css/parts/colors.css"), "Imported { color: #abcdef; }\n"); + File res = new File(common, "target/classes/theme.res"); + String[] args = {"-input", css.getPath(), "-output", res.getPath()}; + + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertEquals("abcdef", theme(res).get("Imported.fgColor"), "the imported rule is in the theme"); + assertEquals("1", theme(res).get("Label.fgColor")); + + long built = res.lastModified(); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertTrue(stdout().contains("File has not changed since last compile."), stdout()); + assertEquals(built, res.lastModified(), "an up-to-date theme is left alone"); + + write(part, "Imported { color: #fedcba; }\n"); + assertTrue(part.setLastModified(built + 5000)); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertEquals("fedcba", theme(res).get("Imported.fgColor"), + "an edit to an imported file is an edit to the theme"); + } + + @Test + void anEditedImageIsRepaintedIntoARuleWhoseCssDidNotChange(@TempDir Path tmp) throws Exception { + File common = mavenProject(tmp.toFile()); + File css = write(new File(common, "src/main/css/theme.css"), + "Banner { width: 50%; height: 10%; background-image: url(img/a.png);" + + " box-shadow: 0 0 4px black; }\n"); + File image = png(new File(common, "src/main/css/img/a.png"), 8, 8, 0xffff0000); + File res = new File(common, "target/classes/theme.res"); + String[] args = {"-input", css.getPath(), "-output", res.getPath()}; + + assertEquals(0, CN1CSSCLI.run(args), stderr()); + int[] before = open(res).getImage("Banner_1.png").getRGB(); + assertEquals(0xffff0000, before[before.length / 2], "the image fills the box"); + + png(image, 8, 8, 0xff0000ff); + assertTrue(image.setLastModified(System.currentTimeMillis() + 5000)); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + + int[] after = open(res).getImage("Banner_1.png").getRGB(); + assertEquals(0xff0000ff, after[after.length / 2], "the rule was painted again from the new image"); + } + + @Test + void aBuildDirectoryOutsideTheProjectIsStillAProjectBuild(@TempDir Path tmp) throws Exception { + File common = mavenProject(new File(tmp.toFile(), "project")); + File css = write(new File(common, "src/main/css/theme.css"), "Label { color: #000001; }\n"); + File merged = new File(tmp.toFile(), "elsewhere/theme.css.merged"); + File res = new File(tmp.toFile(), "elsewhere/theme.res"); + String[] args = {"-input", css.getPath(), "-output", res.getPath(), "-merge", merged.getPath()}; + + assertEquals(0, CN1CSSCLI.run(args), stderr()); + long built = res.lastModified(); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + + assertTrue(stdout().contains("File has not changed since last compile."), stdout()); + assertEquals(built, res.lastModified(), "the second compile found the project and skipped"); + } + + @Test + void anImportNamedInAQuotedUrlMayHoldAParenthesis(@TempDir Path tmp) throws Exception { + File css = write(new File(tmp.toFile(), "theme.css"), "@import url(\"base).css\");\n"); + write(new File(tmp.toFile(), "base).css"), "Imported { color: #abcdef; }\n"); + File res = new File(tmp.toFile(), "theme.res"); + + assertEquals(0, CN1CSSCLI.run(new String[] {"-input", css.getPath(), "-output", res.getPath()}), stderr()); + + assertEquals("abcdef", theme(res).get("Imported.fgColor")); + } + + @Test + void aWatcherNoticesAFileThatIsNotTheNewestGoingAway(@TempDir Path tmp) throws Exception { + File newest = write(new File(tmp.toFile(), "theme.css"), "A { color: red; }\n"); + final File older = write(new File(tmp.toFile(), "part.css"), "B { color: red; }\n"); + assertTrue(older.setLastModified(newest.lastModified() - 60000)); + final PollingFileWatcher watcher = new PollingFileWatcher(new File[] {newest, older}, 50); + final boolean[] woke = {false}; + Thread poller = new Thread(new Runnable() { + @Override + public void run() { + try { + watcher.poll(); + woke[0] = true; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + }); + poller.start(); + assertTrue(older.delete()); + poller.join(10000); + boolean returned = !poller.isAlive(); + watcher.stop(); + poller.join(10000); + + assertTrue(returned && woke[0], "deleting the older file woke the watcher"); + } + + @Test + void anImageThatIsNotThereYetIsStillSomethingTheStylesheetDependsOn(@TempDir Path tmp) throws Exception { + File css = write(new File(tmp.toFile(), "theme.css"), + "A { background-image: url(img/later.png); }\n"); + + java.util.Set found = CssImports.assets(css); + + assertTrue(found.contains(new File(tmp.toFile().getCanonicalFile(), "img/later.png")), found.toString()); + } + + @Test + void anImageDeletedFromAMergedBuildIsReportedInsteadOfKept(@TempDir Path tmp) throws Exception { + File common = mavenProject(tmp.toFile()); + File css = write(new File(common, "src/main/css/theme.css"), + "Badge { background-image: url(img/a.png); cn1-source-dpi: 160; }\n"); + File image = png(new File(common, "src/main/css/img/a.png"), 8, 6, 0xff336699); + File merged = new File(common, "target/css/theme.css"); + File res = new File(common, "target/classes/theme.res"); + String[] args = {"-input", css.getPath(), "-output", res.getPath(), "-merge", merged.getPath()}; + assertEquals(0, CN1CSSCLI.run(args), stderr()); + + assertTrue(image.delete()); + + assertEquals(CN1CSSCLI.EXIT_COMPILE_FAILED, CN1CSSCLI.run(args), + "the image is gone and the stylesheet still names it: " + stdout()); + String said = stdout() + stderr(); + assertTrue(said.contains("a.png"), "the failure names the image: " + said); + assertTrue(!said.contains("NullPointerException"), said); + assertTrue(!new File(common, "target/css/cn1-merged-files").exists() + || !containsFile(new File(common, "target/css/cn1-merged-files"), "a.png"), + "and the copy of it is gone from the mirror"); + + // Put back, the build recovers. + png(image, 8, 6, 0xff336699); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + } + + private static boolean containsFile(File dir, String name) { + File[] children = dir.listFiles(); + if (children == null) { + return false; + } + for (File child : children) { + if (child.isDirectory() ? containsFile(child, name) : child.getName().equals(name)) { + return true; + } + } + return false; + } + + @Test + void aMissingImportIsStillSomethingTheStylesheetDependsOn(@TempDir Path tmp) throws Exception { + File css = write(new File(tmp.toFile(), "theme.css"), + "@import \"first.css\";\n@import \"absent.css\";\n"); + File first = write(new File(tmp.toFile(), "first.css"), "A { color: red; }\n"); + + java.util.Set found = CssImports.collectReachable(css); + + assertTrue(found.contains(first.getCanonicalFile()), found.toString()); + assertTrue(found.contains(new File(tmp.toFile(), "absent.css").getCanonicalFile()) + || found.contains(new File(tmp.toFile(), "absent.css")), found.toString()); + } + + @Test + void aMergedBuildKeepsAUrlWithAParenthesisInItsName(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File app = write(new File(dir, "app/theme.css"), + "Badge { background-image: url(\"img/a(1).png\"); cn1-source-dpi: 160; }\n"); + png(new File(dir, "app/img/a(1).png"), 8, 6, 0xff336699); + File res = new File(dir, "work/theme.res"); + String[] args = {"-input", app.getPath(), "-output", res.getPath(), + "-merge", new File(dir, "work/theme.css.merged").getPath()}; + + assertEquals(0, CN1CSSCLI.run(args), stderr()); + + assertEquals(8, open(res).getImage("a(1).png").getWidth()); + } + + @Test + void bundlesLocalizationAndRecompilesWhenOnlyABundleChanges(@TempDir Path tmp) throws Exception { + File common = mavenProject(tmp.toFile()); + File css = write(new File(common, "src/main/css/theme.css"), "Label { color: #000001; }\n"); + File l10n = new File(common, "src/main/l10n"); + File bundle = write(new File(l10n, "Messages.properties"), "greeting=Hello\n"); + write(new File(l10n, "Messages_fr.properties"), "greeting=Bonjour\n"); + File res = new File(common, "target/classes/theme.res"); + String[] args = {"-input", css.getPath(), "-output", res.getPath(), "-l", l10n.getPath()}; + + assertEquals(0, CN1CSSCLI.run(args), stderr()); + Resources r = open(res); + assertTrue(Arrays.asList(r.getL10NResourceNames()).contains("Messages"), + Arrays.toString(r.getL10NResourceNames())); + assertEquals("Bonjour", r.getL10N("Messages", "fr").get("greeting")); + assertEquals("Hello", r.getL10N("Messages", "").get("greeting")); + + // The stylesheet has not changed. The theme still has to be rebuilt: the + // bundle is compiled into the same file. + long built = res.lastModified(); + write(bundle, "greeting=Hi\n"); + assertTrue(bundle.setLastModified(built + 5000)); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertFalse(stdout().contains("File has not changed since last compile."), stdout()); + assertEquals("Hi", open(res).getL10N("Messages", "").get("greeting")); + + // A bundle whose files are deleted goes from the theme, though the + // theme is rebuilt on top of the one that held it. + File other = write(new File(l10n, "Other.properties"), "k=v\n"); + assertTrue(l10n.setLastModified(built + 10000)); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertTrue(Arrays.asList(open(res).getL10NResourceNames()).contains("Other")); + assertTrue(other.delete()); + assertTrue(bundle.delete()); + assertTrue(new File(l10n, "Messages_fr.properties").delete()); + assertTrue(l10n.setLastModified(built + 20000)); + assertEquals(0, CN1CSSCLI.run(args), stderr()); + assertEquals(0, open(res).getL10NResourceNames().length, + Arrays.toString(open(res).getL10NResourceNames())); + } + + @Test + void aBadCommandLineIsAUsageErrorThatSaysWhatIsMissing(@TempDir Path tmp) throws Exception { + File css = write(new File(tmp.toFile(), "theme.css"), "Label { color: red; }\n"); + + assertEquals(CN1CSSCLI.EXIT_USAGE, CN1CSSCLI.run(new String[0])); + assertTrue(stderr().contains("-input "), stderr()); + + err.reset(); + // The form Ant builds used. It is refused by name rather than + // misread as an option-less compile. + assertEquals(CN1CSSCLI.EXIT_USAGE, CN1CSSCLI.run(new String[]{css.getPath(), "theme.res"})); + assertTrue(stderr().contains("positional form"), stderr()); + + err.reset(); + assertEquals(CN1CSSCLI.EXIT_USAGE, CN1CSSCLI.run(new String[]{"-input", css.getPath()})); + assertTrue(stderr().contains("-output "), stderr()); + + err.reset(); + File missing = new File(tmp.toFile(), "nope.css"); + assertEquals(CN1CSSCLI.EXIT_USAGE, + CN1CSSCLI.run(new String[]{"-input", missing.getPath(), "-output", "x.res"})); + assertTrue(stderr().contains("does not exist") && stderr().contains("nope.css"), stderr()); + } + + @Test + void aStylesheetThatCannotBeCompiledFailsWithTheReason(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File css = write(new File(dir, "theme.css"), "@import \"gone.css\";\nLabel { color: red; }\n"); + File res = new File(dir, "theme.res"); + + assertEquals(CN1CSSCLI.EXIT_COMPILE_FAILED, + CN1CSSCLI.run(new String[]{"-input", css.getPath(), "-output", res.getPath()})); + String message = stderr(); + assertTrue(message.contains("CSS compile failed: "), message); + assertTrue(message.contains("names a file that does not exist") && message.contains("gone.css"), message); + assertFalse(res.exists(), "a failed compile writes no theme"); + } + + @Test + void aRuleWithNoNativeEquivalentBecomesANinePieceBorder(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + File css = write(new File(dir, "theme.css"), + "Card { background-color: #ffffff; border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.5); }\n"); + File res = new File(dir, "theme.res"); + + assertEquals(0, CN1CSSCLI.run(new String[]{"-input", css.getPath(), "-output", res.getPath()}), stderr()); + + Resources r = open(res); + Object border = r.getTheme("Theme").get("Card.border"); + assertTrue(border instanceof com.codename1.ui.plaf.Border, "Card.border is " + border); + java.util.List images = Arrays.asList(r.getImageResourceNames()); + for (String piece : new String[]{"TopL", "Top", "TopR", "Left", "Center", "Right", "BottomL", "Bottom", "BottomR"}) { + assertTrue(images.contains("Card" + piece + "_1.png"), "missing the " + piece + " slice in " + images); + } + + // The same rule under -no-raster is a build failure that names it. + err.reset(); + File strict = new File(dir, "strict.res"); + assertEquals(CN1CSSCLI.EXIT_COMPILE_FAILED, CN1CSSCLI.run(new String[]{"-input", css.getPath(), + "-output", strict.getPath(), "-no-raster"})); + assertTrue(stderr().contains("Card.unselected (image border)"), stderr()); + assertFalse(strict.exists()); + } + + @Test + void watchModeRecompilesOnAnEditAndStopsWhenTheParentGoes(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + final File css = write(new File(dir, "theme.css"), "Label { color: #000001; }\n"); + final File res = new File(dir, "theme.res"); + + ServerSocket parent = new ServerSocket(0); + try { + System.setProperty("parent.port", String.valueOf(parent.getLocalPort())); + final int[] status = {-1}; + Thread watcher = new Thread(new Runnable() { + @Override + public void run() { + status[0] = CN1CSSCLI.run(new String[]{"-input", css.getPath(), "-output", res.getPath(), "-watch"}); + } + }, "css-watch-under-test"); + watcher.start(); + Socket child = parent.accept(); + + awaitStdout("CSS file successfully compiled."); + assertEquals("1", theme(res).get("Label.fgColor")); + assertFalse(stdout().contains(CN1CSSCLI.REFRESH_SIGNAL), "the first compile is not a reload"); + + write(css, "Label { color: #000002; }\n"); + assertTrue(css.setLastModified(System.currentTimeMillis() + 5000)); + awaitStdout(CN1CSSCLI.REFRESH_SIGNAL); + assertEquals("2", theme(res).get("Label.fgColor"), "the reload signal follows the new theme"); + + // The parent going away is the only thing that ends watch mode. + child.close(); + watcher.join(20000); + assertFalse(watcher.isAlive(), "watch mode ends when the parent's socket closes"); + assertEquals(0, status[0]); + } finally { + parent.close(); + } + } + + @Test + void watchModeRecompilesAnEditWhoseTimeMovedBack(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + final File css = write(new File(dir, "theme.css"), "Label { color: #000001; }\n"); + final File res = new File(dir, "theme.res"); + + ServerSocket parent = new ServerSocket(0); + try { + System.setProperty("parent.port", String.valueOf(parent.getLocalPort())); + Thread watcher = new Thread(new Runnable() { + @Override + public void run() { + CN1CSSCLI.run(new String[]{"-input", css.getPath(), "-output", res.getPath(), "-watch"}); + } + }, "css-watch-under-test"); + watcher.start(); + Socket child = parent.accept(); + awaitStdout("CSS file successfully compiled."); + + // An older file put back, as a checkout does: a change, though + // the stylesheet is now older than the theme built from it. + write(css, "Label { color: #000002; }\n"); + assertTrue(css.setLastModified(res.lastModified() - 60000)); + awaitStdout(CN1CSSCLI.REFRESH_SIGNAL); + assertEquals("2", theme(res).get("Label.fgColor")); + + child.close(); + watcher.join(20000); + assertFalse(watcher.isAlive()); + } finally { + parent.close(); + } + } + + @Test + void watchModeTakesAnImagePutBackWithAnOlderTime(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + final File css = write(new File(dir, "theme.css"), + "Badge { background-image: url(img/a.png); cn1-source-dpi: 160; }\n"); + File image = png(new File(dir, "img/a.png"), 8, 6, 0xff336699); + final File res = new File(dir, "theme.res"); + + ServerSocket parent = new ServerSocket(0); + try { + System.setProperty("parent.port", String.valueOf(parent.getLocalPort())); + Thread watcher = new Thread(new Runnable() { + @Override + public void run() { + CN1CSSCLI.run(new String[]{"-input", css.getPath(), "-output", res.getPath(), "-watch"}); + } + }, "css-watch-under-test"); + watcher.start(); + Socket child = parent.accept(); + awaitStdout("CSS file successfully compiled."); + assertEquals(0xff336699, open(res).getImage("a.png").getRGB()[0]); + + // Another picture, older than everything built from the first. + png(image, 8, 6, 0xff996633); + assertTrue(image.setLastModified(res.lastModified() - 600000)); + awaitStdout(CN1CSSCLI.REFRESH_SIGNAL); + assertEquals(0xff996633, open(res).getImage("a.png").getRGB()[0], "the picture that is there now"); + + child.close(); + watcher.join(20000); + assertFalse(watcher.isAlive()); + } finally { + parent.close(); + } + } + + @Test + void aFailedRecompileInWatchModeIsNotAReload(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + final File css = write(new File(dir, "theme.css"), "Label { color: #000001; }\n"); + final File res = new File(dir, "theme.res"); + + ServerSocket parent = new ServerSocket(0); + try { + System.setProperty("parent.port", String.valueOf(parent.getLocalPort())); + Thread watcher = new Thread(new Runnable() { + @Override + public void run() { + CN1CSSCLI.run(new String[]{"-input", css.getPath(), "-output", res.getPath(), "-watch"}); + } + }, "css-watch-under-test"); + watcher.start(); + Socket child = parent.accept(); + awaitStdout("CSS file successfully compiled."); + + // The theme file changes and the compile fails: what a failure + // halfway through writing it looks like from outside. + assertTrue(res.setLastModified(res.lastModified() + 60000)); + write(css, "@import \"gone.css\";\nLabel { color: #000002; }\n"); + assertTrue(css.setLastModified(System.currentTimeMillis() + 5000)); + long deadline = System.nanoTime() + 60L * 1000 * 1000 * 1000; + while (!stderr().contains("CSS compile failed: ")) { + assertTrue(System.nanoTime() < deadline, "timed out waiting for the failure:\n" + stdout()); + Thread.sleep(100); + } + assertFalse(stdout().contains(CN1CSSCLI.REFRESH_SIGNAL), "nothing to reload after a failure"); + + write(css, "Label { color: #000003; }\n"); + assertTrue(css.setLastModified(System.currentTimeMillis() + 10000)); + awaitStdout(CN1CSSCLI.REFRESH_SIGNAL); + assertEquals("3", theme(res).get("Label.fgColor")); + + child.close(); + watcher.join(20000); + assertFalse(watcher.isAlive()); + } finally { + parent.close(); + } + } + + /// Forks the compiler as a build does and lists the classes it loaded. + /// + /// `java.awt.headless=true` already turns showing a window into an error. + /// This is stricter: the window, frame and Swing component classes must + /// not even load, on a stylesheet that exercises every part of the + /// compiler that touches AWT -- a decoded image, a generated 9-piece + /// border, an embedded font's metadata. A model interface such as + /// `javax.swing.tree.TreeModel` is allowed: the resource file's document + /// model implements it, and an interface draws nothing. + @Test + void aForkedCompileLoadsNoWindowOrSwingComponentClass(@TempDir Path tmp) throws Exception { + File dir = tmp.toFile(); + png(new File(dir, "img/a.png"), 16, 16, 0xff112233); + File css = write(new File(dir, "theme.css"), + "Pic { background-image: url(img/a.png); cn1-source-dpi: 160; }\n" + + "Card { background-color: #ffffff; border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.5); }\n"); + File res = new File(dir, "theme.res"); + + String javaBin = new File(new File(System.getProperty("java.home"), "bin"), "java").getPath(); + ProcessBuilder pb = new ProcessBuilder(javaBin, "-Djava.awt.headless=true", "-verbose:class", + "-cp", System.getProperty("java.class.path"), CN1CSSCLI.class.getName(), + "-input", css.getPath(), "-output", res.getPath()); + pb.redirectErrorStream(true); + // No display to fall back on, whatever the machine running the test has. + pb.environment().remove("DISPLAY"); + pb.environment().remove("WAYLAND_DISPLAY"); + Process process = pb.start(); + ByteArrayOutputStream log = new ByteArrayOutputStream(); + InputStream in = process.getInputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) >= 0) { + log.write(buffer, 0, read); + } + int status = process.waitFor(); + String output = log.toString("UTF-8"); + assertEquals(0, status, output.length() > 4000 ? output.substring(output.length() - 4000) : output); + assertTrue(res.isFile(), "the forked compile wrote the theme"); + + java.util.List offenders = new java.util.ArrayList(); + java.util.regex.Matcher m = java.util.regex.Pattern.compile( + "(javax\\.swing\\.J[A-Za-z]+|java\\.awt\\.(?:Frame|Window|Dialog)\\b)").matcher(output); + while (m.find()) { + if (!offenders.contains(m.group(1))) { + offenders.add(m.group(1)); + } + } + assertTrue(offenders.isEmpty(), "a headless compile loaded window or Swing component classes: " + offenders); + assertTrue(output.contains("com.codename1.designer.css.raster.CssBoxRasterizer"), + "the stylesheet did reach the rasterizer, so the list above means something"); + } + + private void awaitStdout(String text) throws Exception { + long deadline = System.nanoTime() + 60L * 1000 * 1000 * 1000; + final Object tick = new Object(); + while (!stdout().contains(text)) { + assertTrue(System.nanoTime() < deadline, "timed out waiting for \"" + text + "\" in:\n" + stdout() + + "\nstderr:\n" + stderr()); + synchronized (tick) { + tick.wait(100); + } + } + } +} diff --git a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLILogicTest.java b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLILogicTest.java index a4ebe7a20e1..4e5188500ca 100644 --- a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLILogicTest.java +++ b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLILogicTest.java @@ -35,9 +35,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Regression tests for two long-standing logic bugs in CN1CSSCLI, surfaced by review - * when the class moved into this module. Both predate the move: the file was relocated - * with a zero-line content diff. + * Regression tests for the path helpers CN1CSSCLI merges stylesheets with. */ class CN1CSSCLILogicTest { @@ -47,27 +45,6 @@ private static Object invoke(String name, Class[] types, Object... args) thro return m.invoke(null, args); } - /** - * getMergedFile() read the cn1.cssMergeFile override into a bare expression and - * discarded it, so callers setting the property silently got the derived path. - */ - @Test - void honoursTheCssMergeFileOverride() throws Exception { - String previous = System.getProperty("cn1.cssMergeFile"); - try { - System.setProperty("cn1.cssMergeFile", "/tmp/explicit-merge-target.css"); - Object result = invoke("getMergedFile", new Class[]{String.class}, "/somewhere/theme.css"); - assertEquals("/tmp/explicit-merge-target.css", result, - "cn1.cssMergeFile must win over the path derived from the input"); - } finally { - if (previous == null) { - System.clearProperty("cn1.cssMergeFile"); - } else { - System.setProperty("cn1.cssMergeFile", previous); - } - } - } - /** * contains() recursed with directory2 as the first argument, dropping directory1 from * the comparison after one level -- so it only ever detected a direct parent, and the diff --git a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIProjectLayoutTest.java b/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIProjectLayoutTest.java deleted file mode 100644 index 2ee25a5b34b..00000000000 --- a/maven/css-cli/src/test/java/com/codename1/designer/css/CN1CSSCLIProjectLayoutTest.java +++ /dev/null @@ -1,135 +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. - */ -package com.codename1.designer.css; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.File; -import java.lang.reflect.Method; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -/// Where CN1CSSCLI puts the library CSS, the merged stylesheet and its -/// checksums for each build tool. The Ant and Maven answers are the ones the -/// CLI gave before it resolved projects through codenameone-project-model. -class CN1CSSCLIProjectLayoutTest { - - private static Object invoke(String name, Class[] types, Object... args) throws Exception { - Method m = CN1CSSCLI.class.getDeclaredMethod(name, types); - m.setAccessible(true); - return m.invoke(null, args); - } - - private static File libCssDir(File input) throws Exception { - return (File) invoke("getLibCSSDirectory", new Class[]{File.class}, input); - } - - private static String mergedFile(File input) throws Exception { - return (String) invoke("getMergedFile", new Class[]{String.class}, input.getPath()); - } - - private static File checksums(File baseDir) throws Exception { - return (File) invoke("getChecksumsFile", new Class[]{File.class}, baseDir); - } - - private static String relativePath(File file, File relativeTo) throws Exception { - return (String) invoke("getRelativePath", new Class[]{File.class, File.class}, file, relativeTo); - } - - private static File write(File f, String content) throws Exception { - f.getParentFile().mkdirs(); - Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8)); - return f; - } - - @Test - void antKeepsEverythingBesideTheSources(@TempDir Path tmp) throws Exception { - File root = tmp.toFile(); - write(new File(root, "codenameone_settings.properties"), "codename1.cssTheme=true\n"); - File theme = write(new File(root, "css/theme.css"), ""); - - assertEquals(new File(root, "lib/impl/css"), libCssDir(theme)); - assertEquals(theme.getPath() + ".merged", mergedFile(theme)); - assertEquals(new File(root, ".cn1_css_checksums"), checksums(theme.getParentFile().getParentFile())); - } - - @Test - void mavenWritesUnderCommonTarget(@TempDir Path tmp) throws Exception { - File root = tmp.toFile(); - write(new File(root, "pom.xml"), ""); - File common = new File(root, "common"); - write(new File(common, "pom.xml"), ""); - write(new File(common, "codenameone_settings.properties"), "codename1.cssTheme=true\n"); - File theme = write(new File(common, "src/main/css/theme.css"), ""); - - File lib = new File(common, "target/css"); - assertEquals(lib, libCssDir(theme)); - File merged = new File(lib, "theme.css.merged"); - assertEquals(merged.getAbsolutePath(), mergedFile(theme)); - assertEquals(new File(common, "target/.cn1_css_checksums"), - checksums(theme.getParentFile().getParentFile())); - // The temp dir is a symlinked path on macOS (/var -> /private/var); the - // project dir must stay in the caller's spelling or this throws. - assertEquals("../../src/main/css", relativePath(theme.getParentFile(), merged)); - } - - @Test - void gradleWritesUnderBuild(@TempDir Path tmp) throws Exception { - File root = tmp.toFile(); - write(new File(root, "settings.gradle.kts"), "rootProject.name = \"app\"\n"); - write(new File(root, "build.gradle.kts"), "plugins { id(\"com.codenameone\") }\n"); - write(new File(root, "codenameone_settings.properties"), "codename1.cssTheme=true\n"); - File theme = write(new File(root, "src/main/css/theme.css"), ""); - - File lib = new File(root, "build/css"); - assertEquals(lib, libCssDir(theme)); - assertEquals(new File(lib, "theme.css.merged").getAbsolutePath(), mergedFile(theme)); - assertEquals(new File(root, "build/.cn1_css_checksums"), - checksums(theme.getParentFile().getParentFile())); - } - - @Test - void libCssDirOverrideStillMovesTheMergedFile(@TempDir Path tmp) throws Exception { - File root = tmp.toFile(); - write(new File(root, "settings.gradle.kts"), "plugins { id(\"com.codenameone\") }\n"); - write(new File(root, "codenameone_settings.properties"), ""); - File theme = write(new File(root, "src/main/css/theme.css"), ""); - File override = new File(root, "elsewhere"); - String previous = System.getProperty("cn1.libCSSDir"); - try { - System.setProperty("cn1.libCSSDir", override.getPath()); - assertEquals(override, libCssDir(theme)); - assertEquals(new File(override, "theme.css.merged").getAbsolutePath(), mergedFile(theme)); - } finally { - if (previous == null) { - System.clearProperty("cn1.libCSSDir"); - } else { - System.setProperty("cn1.libCSSDir", previous); - } - } - } -} diff --git a/maven/css-cli/src/test/java/com/codename1/designer/css/CSSLocalizationTest.java b/maven/css-cli/src/test/java/com/codename1/designer/css/CSSLocalizationTest.java index b34440ae2f2..1c8b4d8eec7 100644 --- a/maven/css-cli/src/test/java/com/codename1/designer/css/CSSLocalizationTest.java +++ b/maven/css-cli/src/test/java/com/codename1/designer/css/CSSLocalizationTest.java @@ -50,8 +50,8 @@ void testLoadLocalizationBundlesUtf8() throws Exception { Path tempDir = Files.createTempDirectory("cn1-css-localization-utf8"); try { Path localizationRoot = Files.createDirectory(tempDir.resolve("l10n")); - // The exact string from issue #4883 — UTF-8 encoded Italian with accented à. - String value = "Non ci sono ancora attività. Usa il pulsante flottante per aggiungere la prima routine."; + // The exact string from issue #4883 -- UTF-8 encoded Italian with accented a-grave. + String value = "Non ci sono ancora attivit\u00e0. Usa il pulsante flottante per aggiungere la prima routine."; Files.write(localizationRoot.resolve("Bundle_it.properties"), ("home.empty=" + value + System.lineSeparator()).getBytes(StandardCharsets.UTF_8)); @@ -71,7 +71,7 @@ void testLoadLocalizationBundlesLatin1Fallback() throws Exception { try { Path localizationRoot = Files.createDirectory(tempDir.resolve("l10n")); // Pre-Java-9 native2ascii-style file: ISO-8859-1 with a literal accented byte. - String value = "café"; + String value = "caf\u00e9"; Files.write(localizationRoot.resolve("Bundle_fr.properties"), ("greeting=" + value + System.lineSeparator()).getBytes(StandardCharsets.ISO_8859_1)); @@ -97,7 +97,7 @@ void testLoadLocalizationBundlesUnicodeEscape() throws Exception { Map>> bundles = loadLocalizationBundles(localizationRoot.toFile()); Map> bundle = bundles.get("Bundle"); assertTrue(bundle != null, "Bundle should be detected"); - assertEquals(bundle.get("it"), stringMap("home.empty", "attività"), + assertEquals(bundle.get("it"), stringMap("home.empty", "attivit\u00e0"), "\\uXXXX escapes should still be decoded"); } finally { deleteRecursively(tempDir); diff --git a/maven/css-compiler/pom.xml b/maven/css-compiler/pom.xml index 7f8334296e3..8d1c58e3a9f 100644 --- a/maven/css-compiler/pom.xml +++ b/maven/css-compiler/pom.xml @@ -1,16 +1,14 @@ com.codenameone @@ -35,6 +33,17 @@ com.codenameone codenameone-core + + + com.codenameone + codenameone-svg-transcoder + + + com.codenameone + codenameone-lottie-transcoder + com.vaadin.external.flute flute @@ -56,49 +65,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..291f0084c91 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,17 @@ */ package com.codename1.designer.css; +import com.codename1.designer.css.raster.BackgroundImage; +import com.codename1.designer.css.raster.BackgroundImagePainter; +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.GradientPainter; +import com.codename1.designer.css.raster.GradientSpec; +import com.codename1.designer.css.raster.Shadow; +import com.codename1.designer.css.raster.VectorImage; import com.codename1.io.JSONParser; import com.codename1.io.Util; import com.codename1.processing.Result; @@ -47,6 +58,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 +83,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 +131,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; @@ -165,6 +181,7 @@ static boolean isGradient(LexicalUnit background) { if ("linear-gradient".equals(fn) || "radial-gradient".equals(fn) || "conic-gradient".equals(fn) + || "repeating-conic-gradient".equals(fn) || "repeating-linear-gradient".equals(fn) || "repeating-radial-gradient".equals(fn)) { return true; @@ -288,7 +305,9 @@ void parse(ScaledUnit background) { parseRadialGradientExtended(background, false); } } else if ("conic-gradient".equals(fn)) { - parseConicGradient(background); + parseConicGradient(background, false); + } else if ("repeating-conic-gradient".equals(fn)) { + parseConicGradient(background, true); } else if ("repeating-linear-gradient".equals(fn)) { parseLinearGradientExtended(background, true); } else if ("repeating-radial-gradient".equals(fn)) { @@ -308,6 +327,24 @@ void parse(ScaledUnit background) { /// of emitting SAC_IDENT. The keyword token itself sits in the /// ATTR's stringValue. Treat the two interchangeably so the position /// / shape / extent keywords parse the same in both layouts. + /// The angle `u` in degrees, or null when `u` is not an angle. CSS + /// has four angle units: `deg`, `rad`, `grad` (400 to a turn) and + /// `turn`, which the parser reports as a dimension it has no type for. + private static Double angleDegrees(ScaledUnit u) { + switch (u.getLexicalUnitType()) { + case LexicalUnit.SAC_DEGREE: + return u.getNumericValue(); + case LexicalUnit.SAC_RADIAN: + return u.getNumericValue() * 180.0 / Math.PI; + case LexicalUnit.SAC_GRADIAN: + return u.getNumericValue() * 0.9; + case LexicalUnit.SAC_DIMENSION: + return "turn".equalsIgnoreCase(u.getDimensionUnitText()) ? u.getNumericValue() * 360.0 : null; + default: + return null; + } + } + private static boolean isIdentLike(ScaledUnit u) { int t = u.getLexicalUnitType(); return t == LexicalUnit.SAC_IDENT || t == LexicalUnit.SAC_ATTR; @@ -327,6 +364,131 @@ 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, int currentColor) { + RASTER_CURRENT_COLOR.set(Integer.valueOf(currentColor)); + try { + return describeForRaster(background, width, height); + } finally { + RASTER_CURRENT_COLOR.remove(); + } + } + + /// The `color` of the rule a gradient is being described for, which + /// is what a `currentColor` stop means. Unset while a gradient is + /// parsed for a theme entry, which has no such colour to hold. + private static final ThreadLocal RASTER_CURRENT_COLOR = new ThreadLocal(); + + private 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, false); + } else if ("repeating-conic-gradient".equals(fn)) { + g.parseConicGradient(background, true); + } + 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()); + // `to top right` is not 45 degrees: it is the angle that puts + // the 50% line through the other two corners, which depends + // on the shape of the box. The descriptor holds a fixed + // angle, so the corner is read from the declaration again. + ScaledUnit to = (ScaledUnit) background.getParameters(); + if (to != null && isIdentLike(to) && "to".equals(identValue(to))) { + int cornerX = 0; + int cornerY = 0; + int words = 0; + for (ScaledUnit side = (ScaledUnit) to.getNextLexicalUnit(); + side != null && isIdentLike(side) && words < 2; + side = (ScaledUnit) side.getNextLexicalUnit(), words++) { + String name = identValue(side); + if ("left".equals(name)) { + cornerX = -1; + } else if ("right".equals(name)) { + cornerX = 1; + } else if ("top".equals(name)) { + cornerY = -1; + } else if ("bottom".equals(name)) { + cornerY = 1; + } + } + if (cornerX != 0 && cornerY != 0) { + spec.toCorner(true).cornerX(cornerX).cornerY(cornerY); + } + } + } 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); + switch (rg.getExtent()) { + // An extent keyword is handed on as the keyword. The + // descriptor's own arithmetic gives an ellipse sized to a + // corner equal radii, a circle, where CSS keeps the + // proportions of the box: on the wide, short page these + // images are painted on that is a very different shape. + case RadialGradient.EXTENT_CLOSEST_SIDE: + spec.extent(GradientSpec.Extent.CLOSEST_SIDE); + break; + case RadialGradient.EXTENT_FARTHEST_SIDE: + spec.extent(GradientSpec.Extent.FARTHEST_SIDE); + break; + case RadialGradient.EXTENT_CLOSEST_CORNER: + spec.extent(GradientSpec.Extent.CLOSEST_CORNER); + break; + case RadialGradient.EXTENT_FARTHEST_CORNER: + spec.extent(GradientSpec.Extent.FARTHEST_CORNER); + break; + default: { + 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. @@ -336,14 +498,10 @@ private void parseLinearGradientExtended(ScaledUnit background, boolean repeatin return; } float angle = 180f; // CSS default for linear-gradient is "to bottom" (180deg) + boolean toCorner = false; // Optionally consume an angle or "to " prefix terminated by comma. - if (p.getLexicalUnitType() == LexicalUnit.SAC_DEGREE - || p.getLexicalUnitType() == LexicalUnit.SAC_RADIAN) { - double v = p.getNumericValue(); - if (p.getLexicalUnitType() == LexicalUnit.SAC_RADIAN) { - v = v * 180.0 / Math.PI; - } - angle = (float) v; + if (angleDegrees(p) != null) { + angle = angleDegrees(p).floatValue(); p = (ScaledUnit) p.getNextLexicalUnit(); if (p != null && p.getLexicalUnitType() == LexicalUnit.SAC_OPERATOR_COMMA) { p = (ScaledUnit) p.getNextLexicalUnit(); @@ -355,12 +513,26 @@ private void parseLinearGradientExtended(ScaledUnit background, boolean repeatin ScaledUnit nx2 = (ScaledUnit) nx.getNextLexicalUnit(); String side2 = (nx2 != null && isIdentLike(nx2)) ? identValue(nx2) : null; angle = cssSideToAngle(side1, side2); + toCorner = side2 != null && ("left".equals(side1) || "right".equals(side1)) != ("left".equals(side2) + || "right".equals(side2)); p = (side2 != null ? (ScaledUnit) nx2.getNextLexicalUnit() : (ScaledUnit) nx.getNextLexicalUnit()); if (p != null && p.getLexicalUnitType() == LexicalUnit.SAC_OPERATOR_COMMA) { 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)); + if (toCorner && rasterWidth > 0 && rasterHeight > 0) { + // To a corner the line is not at the 45 degrees the angle + // above stands for, unless the box is square: it is turned + // so that its half way line runs through the other two + // corners, and is then this long. The painter turns it the + // same way, so lengths along it have to be measured on it. + lineLength = 2.0 * rasterWidth * rasterHeight / Math.hypot(rasterWidth, rasterHeight); + } + ParsedStops st = parseStops(p, lineLength); if (st == null || st.colors.length < 2) { reason = "Could not parse stops for linear-gradient"; return; @@ -382,9 +554,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)) { @@ -403,10 +577,19 @@ private void parseRadialGradientExtended(ScaledUnit background, boolean repeatin extent = RadialGradient.EXTENT_FARTHEST_CORNER; sawShapeOrExtent = true; } else if ("at".equals(s)) { sawAt = true; + if (!parsePosition((ScaledUnit) p.getNextLexicalUnit(), centre)) { + return; + } + if (positionEnd != null) { + p = positionEnd; + sawShapeOrExtent = 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 +599,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 { @@ -430,19 +613,81 @@ private void parseRadialGradientExtended(ScaledUnit background, boolean repeatin if (n != null && n.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { ry = (float) (n.getNumericValue() / 100f); p = n; + if (rasterWidth > 0 && rasterHeight > 0) { + // In CSS the first percentage is of the + // box's width and the second of its height. + // RadialGradient measures both against the + // longer side, so with a box to hand they are + // restated in that unit. + float ref = Math.max(rasterWidth, rasterHeight); + rx = rx * rasterWidth / ref; + ry = ry * rasterHeight / ref; + } } else { ry = rx; } extent = RadialGradient.EXTENT_EXPLICIT; sawShapeOrExtent = true; } + } else if (isLength(p)) { + 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); + 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; @@ -458,7 +703,9 @@ private void parseRadialGradientExtended(ScaledUnit background, boolean repeatin this.valid = true; } - private void parseConicGradient(ScaledUnit background) { + /// `repeating` is `repeating-conic-gradient`: the stops are gone + /// round again past the last of them, up to the full turn. + private void parseConicGradient(ScaledUnit background, boolean repeating) { ScaledUnit p = (ScaledUnit) background.getParameters(); if (p == null) { reason = "Empty conic-gradient parameters"; return; } float fromAngle = 0f; @@ -470,31 +717,20 @@ private void parseConicGradient(ScaledUnit background) { String s = identValue(p); if ("from".equals(s)) { ScaledUnit nx = (ScaledUnit) p.getNextLexicalUnit(); - if (nx != null && (nx.getLexicalUnitType() == LexicalUnit.SAC_DEGREE - || nx.getLexicalUnitType() == LexicalUnit.SAC_RADIAN)) { - double v = nx.getNumericValue(); - if (nx.getLexicalUnitType() == LexicalUnit.SAC_RADIAN) { - v = v * 180.0 / Math.PI; - } - fromAngle = (float) v; + if (nx != null && angleDegrees(nx) != null) { + fromAngle = angleDegrees(nx).floatValue(); p = nx; } consumedHeader = true; } 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]; - p = nx; - } else if (nx != null && nx.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { - cx = (float) (nx.getNumericValue() / 100f); - ScaledUnit ny = (ScaledUnit) nx.getNextLexicalUnit(); - if (ny != null && ny.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { - cy = (float) (ny.getNumericValue() / 100f); - p = ny; - } else { - p = nx; - } + float[] centre = {cx, cy}; + if (!parsePosition((ScaledUnit) p.getNextLexicalUnit(), centre)) { + return; + } + cx = centre[0]; + cy = centre[1]; + if (positionEnd != null) { + p = positionEnd; } consumedHeader = true; } else { @@ -508,13 +744,14 @@ 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; } ConicGradient cg = new ConicGradient(st.colors, st.positions); cg.setRelativeCenterX(cx).setRelativeCenterY(cy).setFromAngleDegrees(fromAngle); + cg.setCycleMethod(repeating ? Gradient.CYCLE_REPEAT : Gradient.CYCLE_NONE); extendedDescriptor = cg; this.type = Style.BACKGROUND_GRADIENT_CONIC; this.bgTransparency = (byte) 0xff; @@ -538,15 +775,143 @@ 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. + /// The last token [#parsePosition] read, or null when it read none. + private ScaledUnit positionEnd; + + /// Reads the position that follows `at` in a radial or conic + /// gradient into `centre`, as fractions of the box. + /// + /// One or two values are an x and a y (`left`, `20px 30px`, + /// `center top`), a missing one staying where `centre` had it. Three + /// or four are offsets from named edges: `right 20px bottom 10px` is + /// 20px in from the right and 10px up from the bottom. + /// + /// #### Returns + /// + /// false, with [#reason] set, when the position holds a length and + /// there is no box to measure it in. + private boolean parsePosition(ScaledUnit first, float[] centre) { + positionEnd = null; + List tokens = new ArrayList(); + for (ScaledUnit u = first; u != null; u = (ScaledUnit) u.getNextLexicalUnit()) { + int t = u.getLexicalUnitType(); + boolean keyword = isIdentLike(u) && isPositionKeyword(identValue(u)); + if (!keyword && t != LexicalUnit.SAC_PERCENTAGE && !isLength(u)) { + break; + } + tokens.add(u); + } + if (tokens.isEmpty()) { + return true; + } + boolean edgeOffsets = tokens.size() > 2; + boolean xSet = false; + for (int i = 0; i < tokens.size(); i++) { + ScaledUnit u = tokens.get(i); + if (!isIdentLike(u)) { + Float f = positionFraction(u, !xSet); + if (f == null) { + return false; + } + centre[xSet ? 1 : 0] = f.floatValue(); + xSet = true; + continue; + } + String s = identValue(u); + if ("center".equals(s)) { + centre[xSet ? 1 : 0] = 0.5f; + xSet = true; + continue; + } + boolean horizontal = "left".equals(s) || "right".equals(s); + boolean fromStart = "left".equals(s) || "top".equals(s); + float v = fromStart ? 0f : 1f; + if (edgeOffsets && i + 1 < tokens.size() && !isIdentLike(tokens.get(i + 1))) { + Float f = positionFraction(tokens.get(i + 1), horizontal); + if (f == null) { + return false; + } + v = fromStart ? f.floatValue() : 1f - f.floatValue(); + i++; + } + centre[horizontal ? 0 : 1] = v; + if (horizontal) { + xSet = true; + } + } + positionEnd = tokens.get(tokens.size() - 1); + return true; + } + + private static boolean isPositionKeyword(String s) { + return "left".equals(s) || "right".equals(s) || "top".equals(s) || "bottom".equals(s) + || "center".equals(s); } + /// One value of a position as a fraction of the box's width or + /// height, or null, with [#reason] set, for a length when there is + /// no box. + private Float positionFraction(ScaledUnit u, boolean horizontal) { + if (u.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { + return (float) (u.getNumericValue() / 100f); + } + int basis = horizontal ? rasterWidth : rasterHeight; + if (basis <= 0) { + // Declining sends the rule to a generated image, where the + // length has a box to be measured in. + reason = "A length in the position of a gradient cannot be expressed as a native gradient"; + return null; + } + return (float) (rasterLength(u, basis) / basis); + } + + 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(ScaledUnit u) { + switch (u.getLexicalUnitType()) { + 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; + case LexicalUnit.SAC_DIMENSION: { + // The units the parser has no type of its own for, and + // rasterLength() measures against the page. + String unit = u.getDimensionUnitText(); + return "rem".equals(unit) || "vw".equals(unit) || "vh".equals(unit) + || "vmin".equals(unit) || "vmax".equals(unit); + } + 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,35 +920,114 @@ 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. + /// The colour half way between two, blended with alpha taken into + /// account so a transparent neighbour does not darken it. + private static int midColor(int a, int b) { + int aa = a >>> 24; + int ba = b >>> 24; + int alpha = (aa + ba) / 2; + if (aa + ba == 0) { + return 0; + } + int out = alpha << 24; + for (int shift = 16; shift >= 0; shift -= 8) { + int ca = (a >> shift) & 0xff; + int cb = (b >> shift) & 0xff; + out |= ((ca * aa + cb * ba) / (aa + ba)) << shift; + } + return out; + } + + 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<>(); ScaledUnit p = start; + Float hint = null; while (p != null) { int t = p.getLexicalUnitType(); if (t == LexicalUnit.SAC_OPERATOR_COMMA) { p = (ScaledUnit) p.getNextLexicalUnit(); continue; } + if (!colors.isEmpty() && (t == LexicalUnit.SAC_PERCENTAGE || isLength(p) || angleDegrees(p) != null)) { + // A position with no colour, between two stops, is a + // colour hint: where the blend of its neighbours is half + // way. It is remembered until the colour after it is read. + if (t == LexicalUnit.SAC_PERCENTAGE) { + hint = (float) (p.getNumericValue() / 100f); + } else if (angleDegrees(p) != null) { + hint = (float) (angleDegrees(p).doubleValue() / 360.0); + } else if (lengthBasis <= 0) { + return null; + } else { + hint = (float) (rasterLength(p, lengthBasis) / lengthBasis); + } + p = (ScaledUnit) p.getNextLexicalUnit(); + continue; + } int rgb; int alpha; - try { - rgb = getColorInt(p); - Integer a = getColorAlphaInt(p); - alpha = a == null ? 0xff : a.intValue(); - } catch (RuntimeException ex) { - return null; + Integer current = RASTER_CURRENT_COLOR.get(); + if (current != null && isIdentLike(p) && "currentColor".equalsIgnoreCase(identValue(p))) { + rgb = current.intValue() & 0xffffff; + alpha = current.intValue() >>> 24; + } else { + try { + rgb = getColorInt(p); + Integer a = getColorAlphaInt(p); + alpha = a == null ? 0xff : a.intValue(); + } catch (RuntimeException ex) { + return null; + } } int argb = (alpha << 24) | (rgb & 0xffffff); + if (hint != null) { + // The hint becomes a stop of the half-way colour. CSS + // bends the blend through that point along a curve; two + // straight runs meeting there are what the stop list of a + // theme can hold. + colors.add(midColor(colors.get(colors.size() - 1).intValue(), argb)); + positions.add(hint); + hint = null; + } ScaledUnit nx = (ScaledUnit) p.getNextLexicalUnit(); - Float pos = null; - if (nx != null && nx.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { - pos = (float) (nx.getNumericValue() / 100f); + // A stop is a colour and up to two positions. `red 20% 40%` + // is the colour held from 20% to 40%, which is the same as + // the two stops `red 20%, red 40%`. + int placed = 0; + while (nx != null && placed < 2) { + int nt = nx.getLexicalUnitType(); + Float pos; + if (nt == LexicalUnit.SAC_PERCENTAGE) { + pos = (float) (nx.getNumericValue() / 100f); + } else if (angleDegrees(nx) != null) { + // A conic stop is placed by an angle, in any of the + // four units the gradient's own angle accepts. + pos = (float) (angleDegrees(nx).doubleValue() / 360.0); + } else if (isLength(nx)) { + if (lengthBasis <= 0) { + // No box to measure the length in; see rasterWidth. + return null; + } + pos = (float) (rasterLength(nx, lengthBasis) / lengthBasis); + } else { + break; + } + colors.add(argb); + positions.add(pos); + placed++; nx = (ScaledUnit) nx.getNextLexicalUnit(); } - colors.add(argb); - positions.add(pos); + if (placed == 0) { + colors.add(argb); + positions.add(null); + } p = nx; } if (colors.isEmpty()) return null; @@ -780,7 +1224,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 +1239,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) { @@ -1491,6 +1950,10 @@ String renderAsCSSValue(double targetDpi, int targetScreenWidth, int targetScree } else if ("vmax".equals(unitText)) { return lu.getFloatValue()+"vmax"; + } else if ("turn".equalsIgnoreCase(unitText)) { + // An angle, as in linear-gradient(0.25turn, ...). + return lu.getFloatValue()+"turn"; + } } @@ -1746,28 +2209,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 +2246,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()) { @@ -1995,13 +2396,30 @@ private static String str(LexicalUnit lu, String defaultVal) { return num+unitText; } + /// Puts the localization bundles in the theme, and takes out the ones + /// that are in it and no longer among `bundles`. + /// + /// The second half is for an output that was loaded to be built on: a + /// bundle whose files were deleted is still in it, and would otherwise + /// be saved back for as long as nothing forces a build from nothing. + /// The compiler puts every bundle of the theme there itself, so one it + /// was not given is one that is gone. public void applyLocalizationBundles(Map>> bundles) { - if (bundles == null || bundles.isEmpty()) { - return; + if (bundles == null) { + bundles = new java.util.HashMap>>(); } if (res == null) { + if (bundles.isEmpty()) { + return; + } res = new EditableResourcesForCSS(resourceFile); } + for (String existing : res.getL10NResourceNames()) { + Map> kept = bundles.get(existing); + if (kept == null || kept.isEmpty()) { + res.remove(existing); + } + } for (Map.Entry>> entry : bundles.entrySet()) { String bundleName = entry.getKey(); Map> locales = entry.getValue(); @@ -2361,7 +2779,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); } } @@ -3188,6 +3611,10 @@ public Image getResourceImage(String imageName, File baseDir) { //File largestVersion = null; //long largestSize = 0; for (File f : imageFolder.listFiles()) { + if (!f.isFile() || !endsWithIgnoreCase(f.getName(), ".png")) { + // A .DS_Store or a Thumbs.db is not a density. + continue; + } int density = Display.DENSITY_MEDIUM; switch (f.getName()) { case "2hd.png": @@ -3314,8 +3741,8 @@ 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") + || endsWithIgnoreCase(fileName, ".lottie")) { Image placeholder = registerSVGPlaceholder(fileName); if (placeholder != null) { loadedImages.put(url, placeholder); @@ -3391,13 +3818,13 @@ public Image getBackgroundImage(Map styles, ScaledUnit bgIma imgURL = new URL(baseURL, url); } - if (false && isFileURL(imgURL)) { - // This section is switched off because loading multi-images via url() - // will cause unexpected results in cases where image borders are generated. - // In order for this approach to work, we need take into account multi-images when - // producing snapshots in the webview so that the correct size of image is used. - // You can still load multi-images as theme constants. - // See https://github.com/codenameone/CodenameOne/issues/2569#issuecomment-426730539 + if (isFileURL(imgURL)) { + // A url() naming a directory is a multi-image: one file per + // density, named for it. Opening such a URL as a stream does + // not fail -- it answers with the directory listing as text, + // which was then stored in the theme as the bytes of a PNG. + // A rule that needs a generated image never gets here; it + // reads the directory through readRasterImage. File imgDir = new File(imgURL.toURI()); if (imgDir.isDirectory()) { try { @@ -3498,318 +3925,434 @@ 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; } + } 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; + } + } - 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); + /// See [Element#getChecksum()]. + private static final int RASTER_GENERATION = 1; - 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); - }); + /// 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; - } - } 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)); - }); - } - } + /// Height that goes with [#RASTER_PAGE_WIDTH]. + static final int RASTER_PAGE_HEIGHT = 960; - // 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(); + /// Content height of a box whose rule sets no `height`. + static final int RASTER_DEFAULT_CONTENT_HEIGHT = 100; - 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()); + /// CSS pixels per CSS millimetre (96 per inch). + private static final double CSS_PX_PER_MM = 96.0 / 25.4; - 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); + /// 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: + // No 96/72 here, on purpose. A `pt` in a Codename One + // stylesheet is a density independent pixel, not a CSS + // point: the page the browser used to draw was handed this + // same number as `px`, so a border of 72pt has always come + // out narrower than one of 1in. + 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: + // The page default. An `em` never arrives here from a rule: + // a rule that needs a generated image and holds one is + // refused before it is painted ("Unsupported lex unit type", + // from renderAsCSSValue), as it was when a browser painted + // these, so there is no font size of the rule to measure in. + 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"); + } - 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); - }); + private static double numericValue(LexicalUnit value) { + return value.getLexicalUnitType() == LexicalUnit.SAC_INTEGER ? value.getIntegerValue() : value.getFloatValue(); + } - } - } 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)); - }); - } - } - } + /// 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); + } - Element disabled = e.getDisabled(); - Map disabledStyles = (Map) disabled.getFlattenedStyle(); + /// Whether `url` names an SVG or Lottie asset, which the build-time + /// transcoders turn into classes. The compiler stores a placeholder for + /// one, and paints it itself only into a generated image. + private static boolean isVectorAsset(String url) { + int end = url.length(); + int query = url.indexOf('?'); + if (query >= 0) { + end = query; + } + int fragment = url.indexOf('#'); + if (fragment >= 0 && fragment < end) { + end = fragment; + } + String path = url.substring(0, end); + return endsWithIgnoreCase(path, ".svg") || endsWithIgnoreCase(path, ".json") + || endsWithIgnoreCase(path, ".lottie"); + } - b = disabled.createBorder(disabledStyles); - Border disabledBorder = b; - if (e.requiresImageBorder(disabledStyles)) { - if (!borders.contains(b)) { + /// The directory `url` names when it is a multi-image -- one PNG per + /// density -- and null for anything else. + private File multiImageDirectory(String url) { + if (url.startsWith("http://") || url.startsWith("https://")) { + return null; + } + try { + URL imgURL = new URL(baseURL, url); + if (!isFileURL(imgURL)) { + return null; + } + File dir = new File(imgURL.toURI()); + return dir.isDirectory() ? dir : null; + } catch (java.net.MalformedURLException ex) { + return null; + } catch (java.net.URISyntaxException ex) { + return null; + } catch (IllegalArgumentException ex) { + return null; + } + } - borders.add(b); - resm.addImageProcessor(id + ".dis", (img) -> { - Insets disabledInsets = disabled.getImageBorderInsets(disabledStyles, img.getWidth(), img.getHeight()); + /// Why the image `url` names cannot be painted into a generated image, + /// or null when it can. + private String unpaintableReason(String url) { + File multi = multiImageDirectory(url); + if (multi != null && !new File(multi, "medium.png").isFile()) { + return "a multi-image with no medium.png"; + } + return null; + } - 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 most pixels on a side an SVG or Lottie file is painted at. One + /// drawn larger is painted at this and stretched. + private static final int MAX_VECTOR_SIDE = 4096; - } - } 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); - }); - } - } - } catch (Exception ex) { - throw new RuntimeException("An exception occurred while processing the image border for element "+id, ex); + /// The SVG or Lottie file `url` names, or null when it names neither. + private VectorImage readVectorImage(String url) { + if (com.codename1.designer.css.raster.DataUrl.is(url)) { + // An SVG written into the stylesheet itself. It is beside + // nothing, so it can name no file of its own. + try { + com.codename1.designer.css.raster.DataUrl held = com.codename1.designer.css.raster.DataUrl.parse(url); + return held == null || !held.isSvg() ? null + : VectorImage.readSvg(new java.io.ByteArrayInputStream(held.getBytes())); + } catch (IOException ex) { + throw new IllegalArgumentException("Could not read the SVG in a data: url: " + ex.getMessage(), ex); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Could not read the SVG in a data: url: " + ex.getMessage(), ex); } } - - if (requiresCaptureHtml()) { - resm.createScreenshots(webviewProvider.getWebView(), generateCaptureHtml(), this.baseURL.toExternalForm()); + if (!isVectorAsset(url)) { + return null; } - for (Runnable r : onComplete) { - r.run(); + try { + URL imgURL = url.startsWith("http://") || url.startsWith("https://") ? new URL(url) : new URL(baseURL, url); + InputStream in = imgURL.openStream(); + try { + String path = imgURL.getPath(); + return endsWithIgnoreCase(path, ".svg") ? VectorImage.readSvg(in, imgURL) : VectorImage.readLottie(in); + } finally { + in.close(); + } + } catch (IOException ex) { + throw new IllegalArgumentException("Could not read " + url + ": " + ex.getMessage(), ex); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("Could not read " + url + ": " + ex.getMessage(), ex); } - } - + + private BufferedImage readRasterImage(String url) { + VectorImage vector = readVectorImage(url); + if (vector != null) { + // At the size the file asks for, as a browser sizes one, short + // of a size no image can be allocated at. + return vector.paint(vector.getWidth(), vector.getHeight(), MAX_VECTOR_SIDE); + } + // A picture written into the stylesheet itself, as a `data:` url, + // is read from there: Java has no such protocol to open. + if (com.codename1.designer.css.raster.DataUrl.is(url)) { + try { + com.codename1.designer.css.raster.DataUrl held = com.codename1.designer.css.raster.DataUrl.parse(url); + BufferedImage img = held == null ? null + : javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(held.getBytes())); + if (img == null) { + throw new IllegalArgumentException("The data: url of a background is not an image the compiler" + + " can decode (PNG, JPEG, GIF, BMP or SVG)."); + } + return img; + } catch (IOException ex) { + throw new IllegalArgumentException("Could not read the image in a data: url: " + ex.getMessage(), ex); + } + } + // Otherwise a url is a file or an http address. + try { + URL imgURL = url.startsWith("http://") || url.startsWith("https://") ? new URL(url) : new URL(baseURL, url); + File multi = multiImageDirectory(url); + if (multi != null) { + // The page a generated image is painted on is at medium + // density, one CSS pixel to one image pixel. + imgURL = new File(multi, "medium.png").toURI().toURL(); + } + 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, BMP, SVG or Lottie)."); + } + return img; + } finally { + in.close(); + } + } catch (IOException ex) { + throw new IllegalArgumentException("Could not read " + url + ": " + ex.getMessage(), ex); + } + } + public void save(File outputFile) throws IOException { DataOutputStream resFile = new DataOutputStream(new FileOutputStream(outputFile)); res.save(resFile); @@ -4280,7 +4823,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 +4869,467 @@ 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"); + } + + 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)); + } + + BoxStyle.Builder box = BoxStyle.builder() + .borderBoxWidth(boxWidth).borderBoxHeight(boxHeight) + .padTop(padTop).padRight(padRight).padBottom(padBottom).padLeft(padLeft) + // No fallback to the rule's `color` for `currentColor` + // here: the theme's own bgColor is a number, and a rule + // whose background-color is `currentColor` is refused + // when it is stored ("Invalid color specification"), + // generated image or not, so none reaches a painted box. + .backgroundColor(rasterColor(styles.get("background-color"), 0)) + .top(top).right(right).bottom(bottom).left(left) + .radii(radii); + + // Backgrounds are sized and positioned in the padding box, the + // initial background-origin: that is what a percentage size or a + // gradient's lengths are measured in. A box that is all border + // has none and falls back to the border box. + double[] borderWidths = com.codename1.designer.css.raster.BorderPainter.effectiveWidths( + new BorderSide[] {top, right, bottom, left}, boxWidth, boxHeight); + double paintWidth = boxWidth - borderWidths[1] - borderWidths[3]; + double paintHeight = boxHeight - borderWidths[0] - borderWidths[2]; + if (paintWidth < 1 || paintHeight < 1) { + paintWidth = boxWidth; + paintHeight = boxHeight; + } + + // One gradient, the first: a second one after a comma was never + // painted under it. The page these images used to be captured + // from was written `background:` and the head of the value, one + // function, with nothing of what follows it, so the browser had + // a single layer to draw. Stacking the rest now would change + // images that already ship. + LexicalUnit gradient = styles.get("background"); + while (gradient != null && !isGradient(gradient)) { + gradient = gradient.getNextLexicalUnit(); + } + + // Only the first url() of background-image is a layer here. A + // comma separated list was never painted as a stack: the page + // these images used to be captured from was given the head of the + // value alone (one url()), and the theme's own bgImage is likewise + // the first image. Painting the rest would change images that + // already ship. + LexicalUnit bgImage = styles.get("background-image"); + // The style map keeps the order the properties were declared in, + // by where each was first written: declaring one again changes + // its value and not its place. `background: g1; + // background-image: url(a); background: g2` is therefore + // `background: g2` and then the image, and the image wins. That + // is the order the properties were written to the page these + // images used to be captured from, out of the same map, so it + // is what the browser saw as well and what shipped images hold. + boolean imageDeclaredLater = false; + for (String key : styles.keySet()) { + if ("background".equals(key)) { + imageDeclaredLater = true; + break; } - - if (styles.get("height") == null) { - styles.put("height", new ScaledUnit(new PixelUnit(100), 320, 640, 960)); + if ("background-image".equals(key)) { + break; + } + } + if (bgImage != null && isGradient(bgImage)) { + // `background-image: linear-gradient(...)` is the same + // gradient as one written in `background`, and replaces that + // one when it is declared later. + if (gradient == null || imageDeclaredLater) { + gradient = bgImage; } - //styles.put("margin", new ScaledUnit(new PixelUnit(1), 144, 640, 960)); + bgImage = null; + } + if (bgImage != null && bgImage.getLexicalUnitType() != LexicalUnit.SAC_URI) { + bgImage = null; + } + if (gradient != null && bgImage != null && !sameShorthand(styles.get("background"), bgImage)) { + // A gradient is a background image, so `background` and + // `background-image` set the same thing and the one declared + // later replaces the other. + if (imageDeclaredLater) { + gradient = null; + } else { + bgImage = null; + } + } + if (gradient != null && bgImage != null) { + // Both came from one shorthand, and CSS puts the layer + // written first on top. + box.gradientOverImage(urlFollows(gradient, bgImage)); + } + if (gradient != null) { + double[] tile = bgImage == null ? explicitBackgroundSize(styles, paintWidth, paintHeight) : null; + if (tile == null) { + box.gradient(CN1Gradient.describeForRaster((ScaledUnit) gradient, + (int) paintWidth, (int) paintHeight, currentColor)); + } else { + // A gradient given a `background-size` is an image of + // that size, laid out by `background-repeat` like any + // other. An `auto` axis is the whole box, a gradient + // having no size of its own. + double fullW = Math.min(1e9, Math.max(1, Math.round(tile[0] < 0 ? paintWidth : tile[0]))); + double fullH = Math.min(1e9, Math.max(1, Math.round(tile[1] < 0 ? paintHeight : tile[1]))); + // A tile can be asked for at any size, and only the part + // of it inside the box is ever seen. Past the largest + // image the compiler paints, the gradient is still laid + // out over the whole tile and its top left corner alone + // is painted, which is all a tile that large shows; it + // does not come round again on that axis. + int tileW = (int) Math.min(MAX_VECTOR_SIDE, fullW); + int tileH = (int) Math.min(MAX_VECTOR_SIDE, fullH); + GradientSpec spec = CN1Gradient.describeForRaster((ScaledUnit) gradient, (int) fullW, (int) fullH, + currentColor); + BufferedImage image = new BufferedImage(tileW, tileH, BufferedImage.TYPE_INT_ARGB); + image.setRGB(0, 0, tileW, tileH, + new GradientPainter(spec, 0, 0, fullW, fullH).paint(tileW, tileH), 0, tileW); + BackgroundImage.Repeat repeat = rasterRepeat(styles); + boolean across = tileW == fullW && (repeat == BackgroundImage.Repeat.REPEAT + || repeat == BackgroundImage.Repeat.REPEAT_X); + boolean down = tileH == fullH && (repeat == BackgroundImage.Repeat.REPEAT + || repeat == BackgroundImage.Repeat.REPEAT_Y); + repeat = across && down ? BackgroundImage.Repeat.REPEAT + : across ? BackgroundImage.Repeat.REPEAT_X + : down ? BackgroundImage.Repeat.REPEAT_Y : BackgroundImage.Repeat.NO_REPEAT; + BackgroundImage.Fit[] fit = rasterFit(styles); + box.backgroundImage(new BackgroundImage(image).withRepeat(repeat).withFit(fit[0], fit[1]) + .withPosition(0, true, 0, true).withSize(tileW, tileH)); + } + } + if (bgImage != null) { + String url = bgImage.getStringValue(); + String unpaintable = unpaintableReason(url); + if (unpaintable != null) { + // There are no pixels to paint, so the layer is left out + // and the rest of the box still gets its image. + System.out.println("CSS Warning: " + url + " is " + unpaintable + " and is left out of the image" + + " generated for this rule."); + } else { + box.backgroundImage(rasterBackgroundImage(styles, bgImage, paintWidth, paintHeight)); + } + } - 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(";"); - } + 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(); + } - 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;"); + private LexicalUnit nullIfNone(LexicalUnit value) { + return isNone(value) ? null : value; + } + + /// Whether the url() `image` was written inside the `background` + /// shorthand `shorthand`, as opposed to in a declaration of its own. + /// `shorthand` is the gradient the declaration was recorded under, so + /// the url() may be on either side of it. + private boolean sameShorthand(LexicalUnit shorthand, LexicalUnit image) { + if (shorthand == null) { + return false; + } + if (urlFollows(shorthand, image)) { + return true; + } + String url = image.getStringValue(); + for (LexicalUnit u = shorthand.getPreviousLexicalUnit(); u != null; u = u.getPreviousLexicalUnit()) { + if (u.getLexicalUnitType() == LexicalUnit.SAC_URI && url != null && url.equals(u.getStringValue())) { + return true; } - if (shadowInset.right > 0) { - sb.append("margin-right: ").append(shadowInset.right).append("px !important;"); + } + return false; + } + + /// Whether the url() `image` is written after `from` in the value + /// `from` belongs to. + private boolean urlFollows(LexicalUnit from, LexicalUnit image) { + String url = image.getStringValue(); + for (LexicalUnit u = from; u != null; u = u.getNextLexicalUnit()) { + if (u.getLexicalUnitType() == LexicalUnit.SAC_URI && url != null && url.equals(u.getStringValue())) { + return true; } - if (shadowInset.bottom > 0) { - sb.append("margin-bottom: ").append(shadowInset.bottom).append("px !important;"); + } + return false; + } + + /// `boxWidth` and `boxHeight` are those of the padding box, which a + /// percentage `background-size` is a fraction of. + private BackgroundImage rasterBackgroundImage(Map styles, LexicalUnit bgImage, + double boxWidth, double boxHeight) { + VectorImage vector = readVectorImage(bgImage.getStringValue()); + // A vector image is not painted until the size of its tile is + // known. The pixel that stands in for it is never drawn. + BufferedImage image = vector == null ? readRasterImage(bgImage.getStringValue()) + : new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + BackgroundImage.Repeat repeat = rasterRepeat(styles); + BackgroundImage.Size size = BackgroundImage.Size.AUTO; + double[] explicit = explicitBackgroundSize(styles, boxWidth, boxHeight); + LexicalUnit sizeUnit = styles.get("background-size"); + if (explicit != null) { + size = BackgroundImage.Size.EXPLICIT; + } else if (sizeUnit != null && "cover".equals(sizeUnit.getStringValue())) { + size = BackgroundImage.Size.COVER; + } else if (sizeUnit != null && "contain".equals(sizeUnit.getStringValue())) { + size = BackgroundImage.Size.CONTAIN; + } + // The image sits at the CSS initial position, the top left + // corner, because no position ever reaches this point: the + // background-position longhand is not a property the compiler + // accepts (it is rejected as unsupported before it is stored), + // and the shorthand's position keywords are skipped. The page + // these images used to be captured from was built from the same + // stored properties, so it painted at the top left as well. + BackgroundImage.Fit[] fit = rasterFit(styles); + BackgroundImage layer = new BackgroundImage(image).withRepeat(repeat).withFit(fit[0], fit[1]) + .withPosition(0, true, 0, true); + layer = explicit != null ? layer.withSize(explicit[0], explicit[1]) : layer.withSize(size); + if (vector != null) { + // A vector image has no pixels of its own to stretch. It is + // painted at the size it is drawn at, so a small icon made + // to cover a box is as sharp as the box, and one that asks + // for an enormous size costs no more than its tile. + double[] tile = BackgroundImagePainter.tileSize(vector.getWidth(), vector.getHeight(), layer, + boxWidth, boxHeight); + if (vector.hasShape() && !vector.hasWidth() && !vector.hasHeight()) { + // A view box and no size: a shape to keep, and nothing + // to say how large. Left to itself it is as large as + // fits in the box, as a browser draws one; made to + // cover or fit, or given a side, the shape has already + // settled the tile above. + if (explicit == null && size == BackgroundImage.Size.AUTO) { + double s = Math.min(boxWidth / vector.getWidth(), boxHeight / vector.getHeight()); + tile = new double[] {vector.getWidth() * s, vector.getHeight() * s}; + } + } else if (!vector.hasWidth() || !vector.hasHeight()) { + // A file that does not say how large it is has no shape + // either: a side it leaves open is the side of the box + // it is put in, and made to cover or to fit, it is the + // box. Only a side the rule gives stays as given. + tile = new double[] {vector.hasWidth() ? vector.getWidth() : boxWidth, + vector.hasHeight() ? vector.getHeight() : boxHeight}; + if (explicit != null) { + if (explicit[0] >= 0) { + tile[0] = explicit[0]; + } + if (explicit[1] >= 0) { + tile[1] = explicit[1]; + } + } else if (size != BackgroundImage.Size.AUTO) { + tile = new double[] {boxWidth, boxHeight}; + } } + if (!(tile[0] > 0) || !(tile[1] > 0) || Double.isInfinite(tile[0]) || Double.isInfinite(tile[1])) { + tile = new double[] {vector.getWidth(), vector.getHeight()}; + } + layer = new BackgroundImage(vector.paint(tile[0], tile[1], MAX_VECTOR_SIDE)).withRepeat(repeat) + .withFit(fit[0], fit[1]).withPosition(0, true, 0, true).withSize(tile[0], tile[1]); + } + return layer; + } + + /// How the tiles of a rule's background are fitted across and down: + /// the `space` and `round` of its `background-repeat`. + private BackgroundImage.Fit[] rasterFit(Map styles) { + BackgroundImage.Fit[] fit = {BackgroundImage.Fit.NONE, BackgroundImage.Fit.NONE}; + LexicalUnit repeatUnit = styles.get("background-repeat"); + if (repeatUnit != null && repeatUnit.getStringValue() != null) { + LexicalUnit vertical = repeatUnit.getNextLexicalUnit(); + fit[0] = fitOf(repeatUnit.getStringValue()); + fit[1] = vertical == null || vertical.getStringValue() == null ? fit[0] + : fitOf(vertical.getStringValue()); + } + return fit; + } + + private BackgroundImage.Fit fitOf(String keyword) { + return "space".equals(keyword) ? BackgroundImage.Fit.SPACE + : "round".equals(keyword) ? BackgroundImage.Fit.ROUND : BackgroundImage.Fit.NONE; + } + + /// The `background-repeat` of a rule, as the painter names it. + private BackgroundImage.Repeat rasterRepeat(Map styles) { + BackgroundImage.Repeat repeat = BackgroundImage.Repeat.REPEAT; + LexicalUnit repeatUnit = styles.get("background-repeat"); + if (repeatUnit != null && repeatUnit.getStringValue() != null) { + String keyword = repeatUnit.getStringValue(); + LexicalUnit vertical = repeatUnit.getNextLexicalUnit(); + String second = vertical == null ? null : vertical.getStringValue(); + // `space` and `round` repeat; how their tiles are fitted + // is rasterFit()'s to say. + if (second != null) { + // Two keywords: horizontal, then vertical. + boolean x = !"no-repeat".equals(keyword); + boolean y = !"no-repeat".equals(second); + repeat = x && y ? BackgroundImage.Repeat.REPEAT + : x ? BackgroundImage.Repeat.REPEAT_X + : y ? BackgroundImage.Repeat.REPEAT_Y : BackgroundImage.Repeat.NO_REPEAT; + } else 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; + } + } + return repeat; + } + + /// The width and height a `background-size` written as lengths or + /// percentages gives, a negative value standing for an `auto` axis. + /// Null when the rule sets none, or sets `auto`, `cover` or `contain`. + private double[] explicitBackgroundSize(Map styles, double boxWidth, double boxHeight) { + LexicalUnit sizeUnit = styles.get("background-size"); + if (sizeUnit == null) { + return null; + } + String keyword = sizeUnit.getLexicalUnitType() == LexicalUnit.SAC_IDENT ? sizeUnit.getStringValue() : null; + if ("cover".equals(keyword) || "contain".equals(keyword) + || ("auto".equals(keyword) && sizeUnit.getNextLexicalUnit() == null)) { + return null; + } + double sizeW = "auto".equals(keyword) ? -1 : rasterLength(sizeUnit, boxWidth); + double sizeH = -1; + LexicalUnit second = sizeUnit.getNextLexicalUnit(); + if (second != null) { + boolean auto = second.getLexicalUnitType() == LexicalUnit.SAC_IDENT + && "auto".equals(second.getStringValue()); + sizeH = auto ? -1 : rasterLength(second, boxHeight); + } + return new double[] {sizeW, sizeH}; + } - //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()); + // Only the url() of the border-image shorthand is read: the page + // these images used to be measured on was given that alone, so a + // slice or a repeat keyword written after it never had an effect. + // The slices come from border-image-slice and the edges stretch. + // 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 +5342,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 +5978,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); @@ -5280,8 +6216,11 @@ private float filterAngleDegrees(LexicalUnit p) { if (type == LexicalUnit.SAC_DEGREE) return v; if (type == LexicalUnit.SAC_RADIAN) return (float)(v * 180.0 / Math.PI); if (type == LexicalUnit.SAC_GRADIAN) return v * 0.9f; - // SAC has no `turn` constant in older flute; fall through to - // raw float (assume degrees). + // SAC has no constant for `turn`: it comes as a dimension that + // carries its unit as text. Anything else is taken as degrees. + if (type == LexicalUnit.SAC_DIMENSION && "turn".equalsIgnoreCase(p.getDimensionUnitText())) { + return v * 360f; + } return v; } @@ -6090,6 +7029,10 @@ public int in2px(float inches) { } + /// The opacity a style is stored with. A rule that gets a generated + /// image stores none, and none is painted into the image either: + /// that is how such a rule has always come out, the page its image + /// was captured from having been given every property but this one. public String getThemeOpacity(Map styles) { if (styles.get("opacity") != null && !requiresImageBorder(styles) && !requiresBackgroundImageGeneration(styles)) { double opacity = ((ScaledUnit)styles.get("opacity")).getNumericValue(); @@ -7403,6 +8346,34 @@ 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(); + // `no-repeat repeat` is one value, an axis each. + // What is stored is the first keyword, with the + // second still following it. + if (value != null && value.getLexicalUnitType() == LexicalUnit.SAC_IDENT + && ("repeat".equals(value.getStringValue()) + || "no-repeat".equals(value.getStringValue()))) { + 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: @@ -7451,6 +8422,7 @@ public void apply(Element style, String property, LexicalUnit value) { case "linear-gradient" : case "radial-gradient" : case "conic-gradient" : + case "repeating-conic-gradient" : case "repeating-linear-gradient" : case "repeating-radial-gradient" : style.put("background", value); @@ -8757,6 +9729,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 +9760,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 +9820,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..d5c36645c4b --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CssImports.java @@ -0,0 +1,649 @@ +/* + * 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; + } + + /// [#collect(File)] for a caller that still wants an answer when an + /// import cannot be followed: the files found before the broken one, and + /// the file that is missing. A watcher needs that one most of all, since + /// creating it is what makes the stylesheet compile again. + public static Set collectReachable(File css) { + Set imported = new java.util.LinkedHashSet(); + try { + inline(css, readFully(css), imported); + } catch (MissingImportException ex) { + imported.add(ex.getFile()); + } catch (IOException ex) { + // Unreadable or malformed: what was found so far is the answer. + } + return imported; + } + + /// An `@import` that names a file which is not there. + public static final class MissingImportException extends IOException { + private static final long serialVersionUID = 1L; + private final File file; + + MissingImportException(String message, File file) { + super(message); + this.file = file; + } + + /// The file the import names. + public File getFile() { + return file; + } + } + + /// Every local file a `url()` of `css`, or of anything it imports, + /// names: its images and fonts. One that is not there yet is included, + /// so that a caller watching these notices it arrive. + /// + /// These are compiled into the theme, so a caller deciding whether a + /// theme is stale has to look at them as well as at the stylesheets. + public static Set assets(File css) throws IOException { + final File dir = css.getCanonicalFile().getParentFile(); + final Set found = new java.util.LinkedHashSet(); + String text = inline(css, readFully(css), new java.util.LinkedHashSet()); + rewriteUrls(text, new UrlRewriter() { + @Override + public String rewrite(String url) { + // The name is taken as written. One with a query or a + // fragment after it (`icon.png?v=2`) is not looked for under + // the shorter name: the compiler turns the address of an + // image or a font into a file and stops on one with a query + // ("URI has a query component"), as it always has, so no + // theme is ever built from one and none can go stale. An + // import is different, see where one is resolved. + if (isRelativeUrl(url)) { + File file = new File(dir, url); + if (file.isFile() || !file.exists()) { + found.add(file); + } else if (file.isDirectory()) { + // A multi-image: a directory of one image per + // density. It is listed, for an image added to it + // or taken away, which moves its own time, and so + // is each image, for one that is edited, which + // moves only that image's. + found.add(file); + File[] images = file.listFiles(); + if (images != null) { + java.util.Arrays.sort(images); + for (File image : images) { + if (image.isFile()) { + found.add(image); + } + } + } + } + } + return url; + } + }); + return found; + } + + /// Where the query or the fragment of an import's name starts, or -1. + private static int suffixStart(String target) { + int query = target.indexOf('?'); + int fragment = target.indexOf('#'); + return query < 0 ? fragment : fragment < 0 ? query : Math.min(query, fragment); + } + + /// 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; + // Whether a rule, or a block of any kind, has been met in this file. + boolean ruled = false; + 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++; + ruled = true; + 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))) { + // An import comes before every rule of its file, which is + // what CSS asks. A browser drops one that comes later and + // says nothing; here that would leave a file out of a theme + // without a word, so the compile stops and names it. + if (ruled) { + int stop = endOfStatement(text, i); + throw new IOException(current + ": " + text.substring(i, Math.min(len, stop)).trim() + + " comes after a rule. Move every @import to the top of the file."); + } + 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)) { + // A quoted name may hold a parenthesis of its own, so the one + // that closes url() is looked for after the string. + int nameStart = 4; + while (nameStart < statement.length() && Character.isWhitespace(statement.charAt(nameStart))) { + nameStart++; + } + boolean quoted = nameStart < statement.length() + && (statement.charAt(nameStart) == '"' || statement.charAt(nameStart) == '\''); + int close = statement.indexOf(')', quoted ? skipString(statement, nameStart) : nameStart); + 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); + } + // What follows the name is its media, once any comment is out of it. + rest = rest.replaceAll("(?s)/\\*.*?\\*/", " ").trim(); + 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()) { + // `base.css?v=1`: what a browser sends the server after the + // name, and no part of the name of the file. A file really + // called that is found above, before this is tried. + int suffix = suffixStart(target); + if (suffix > 0) { + File plain = new File(target.substring(0, suffix)); + if (!plain.isAbsolute()) { + plain = new File(current.getParentFile(), target.substring(0, suffix)); + } + if (plain.isFile()) { + file = plain; + } + } + } + if (!file.isFile()) { + throw new MissingImportException( + "@import in " + current + " names a file that does not exist: " + file, 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); + } + // An SVG or Lottie file beside a stylesheet imported from outside + // the project is compiled to its placeholder like any other, but the + // build's transcoder looks for these files in the project's own + // source directories and does not follow imports. Such a file has to + // be in one of those directories to be drawn at runtime. + // 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, exactly as if they had been written in an `@media` block + // here. The queries a theme evaluates are the Codename One ones + // (`platform-*`, `density-*`, `device-*`); a browser media type + // such as `print` or `screen` selects nothing in a theme and its + // rules apply, in an import as in a block. Dropping the rules of + // one here and not the other would make the two forms disagree. + out.append(underMedia(rest, body)); + } else { + out.append(body).append('\n'); + } + return out.toString(); + } + + /// Puts `body` under the media `outer`. + /// + /// The parser reads one level of `@media` and drops a block written + /// inside another, rules and all. So the blocks `body` already has are + /// not wrapped: each is written out beside the others, under its own + /// media narrowed by `outer`, and only the rules between them are wrapped + /// in a block of their own. + private static String underMedia(String outer, String body) { + StringBuilder out = new StringBuilder(body.length() + 64); + StringBuilder plain = new StringBuilder(); + int len = body.length(); + int i = 0; + int depth = 0; + while (i < len) { + char c = body.charAt(i); + if (c == '/' && i + 1 < len && body.charAt(i + 1) == '*') { + int end = body.indexOf("*/", i + 2); + end = end < 0 ? len : end + 2; + plain.append(body, i, end); + i = end; + } else if (c == '"' || c == '\'') { + int end = skipString(body, i); + plain.append(body, i, end); + i = end; + } else if (c == '@' && depth == 0 && body.regionMatches(true, i, "@media", 0, 6) + && i + 6 < len && !isIdentChar(body.charAt(i + 6))) { + int open = body.indexOf('{', i); + int close = open < 0 ? -1 : endOfBlock(body, open); + if (close < 0) { + plain.append(body, i, len); + break; + } + flushPlain(out, outer, plain); + String narrowed = narrow(outer, body.substring(i + 6, open).trim()); + if (narrowed != null) { + out.append("@media ").append(narrowed).append(" {") + .append(body, open + 1, close).append("}\n"); + } + i = close + 1; + } else { + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + } + plain.append(c); + i++; + } + } + flushPlain(out, outer, plain); + return out.toString(); + } + + private static void flushPlain(StringBuilder out, String outer, StringBuilder plain) { + if (plain.toString().trim().length() > 0) { + out.append("@media ").append(outer).append(" {\n").append(plain).append("\n}\n"); + } + plain.setLength(0); + } + + /// The index of the `}` that closes the block opened at `open`, or -1. + private static int endOfBlock(String text, int open) { + int depth = 0; + int len = text.length(); + int i = open; + while (i < len) { + char c = text.charAt(i); + if (c == '/' && i + 1 < len && text.charAt(i + 1) == '*') { + int end = text.indexOf("*/", i + 2); + i = end < 0 ? len : end + 2; + } else if (c == '"' || c == '\'') { + i = skipString(text, i); + } else { + if (c == '{') { + depth++; + } else if (c == '}' && --depth == 0) { + return i; + } + i++; + } + } + return -1; + } + + /// The media of a block written as `inner` inside one written as + /// `outer`, or null when no device is in both. + /// + /// A theme's media are lists of keys of three kinds -- a platform, a + /// density, a device type -- and a rule applies where one key of each + /// kind named matches. Inside another block a kind named by both keeps + /// the keys they share, and a kind named by one keeps that one's. Keys + /// of no kind select nothing in a theme and are carried along as they + /// are. + private static String narrow(String outer, String inner) { + List a = mediaKeys(outer); + List b = mediaKeys(inner); + List result = new ArrayList(); + for (String kind : new String[] {"platform-", "density-", "device-"}) { + List fromA = ofKind(a, kind); + List fromB = ofKind(b, kind); + if (!fromA.isEmpty() && !fromB.isEmpty()) { + fromA.retainAll(fromB); + if (fromA.isEmpty()) { + return null; + } + result.addAll(fromA); + } else { + result.addAll(fromA); + result.addAll(fromB); + } + } + for (String key : a) { + if (!isThemeKey(key) && !result.contains(key)) { + result.add(key); + } + } + for (String key : b) { + if (!isThemeKey(key) && !result.contains(key)) { + result.add(key); + } + } + StringBuilder out = new StringBuilder(); + for (String key : result) { + if (out.length() > 0) { + out.append(", "); + } + out.append(key); + } + return out.toString(); + } + + private static boolean isThemeKey(String key) { + return key.startsWith("platform-") || key.startsWith("density-") || key.startsWith("device-"); + } + + private static List ofKind(List keys, String kind) { + List out = new ArrayList(); + for (String key : keys) { + if (key.startsWith(kind)) { + out.add(key); + } + } + return out; + } + + private static List mediaKeys(String media) { + List out = new ArrayList(); + for (String key : media.split(",")) { + String trimmed = key.trim(); + if (trimmed.length() > 0) { + out.add(trimmed); + } + } + return out; + } + + /// Decides what one `url()` of a stylesheet becomes, for [#rewriteUrls]. + public interface UrlRewriter { + /// Returns the replacement for `url`, the unquoted content of one + /// `url()`; returning `url` itself leaves it as written. + String rewrite(String url) throws IOException; + } + + /// 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, final File fromDir, final File toDir) throws IOException { + if (fromDir.getCanonicalFile().equals(toDir.getCanonicalFile())) { + return text; + } + return rewriteUrls(text, new UrlRewriter() { + @Override + public String rewrite(String url) throws IOException { + return rebase(url, fromDir, toDir); + } + }); + } + + /// Whether `url` is resolved against the stylesheet's own directory, as + /// opposed to an absolute path, a fragment, or anything with a scheme + /// (`http:`, `data:`). + public static boolean isRelativeUrl(String url) { + return url.length() > 0 && url.charAt(0) != '/' && url.charAt(0) != '#' && url.indexOf(':') <= 0; + } + + /// Passes the content of every `url()` in `text` through `rewriter`, + /// skipping comments and string literals. Each `url()` is written back + /// double quoted. + public static String rewriteUrls(String text, UrlRewriter rewriter) throws IOException { + 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)))) { + // A quoted url may hold a `)` of its own, as in + // url("icon(1).png"); the one that ends it comes after the + // closing quote. + int start = i + 4; + while (start < len && Character.isWhitespace(text.charAt(start))) { + start++; + } + int from = start < len && (text.charAt(start) == '"' || text.charAt(start) == '\'') + ? skipString(text, start) : start; + int close = text.indexOf(')', from); + if (close < 0) { + out.append(text, i, len); + break; + } + String raw = unquote(text.substring(i + 4, close).trim()); + String rewritten = rewriter.rewrite(raw); + // In double quotes, as every url is written out, unless it + // holds one: an SVG written into a url is full of them, and + // would end at the first. It then goes in single quotes, + // which is how such a url is written to begin with, and + // where it holds both the double ones are escaped. + if (rewritten.indexOf('"') < 0) { + out.append("url(\"").append(rewritten).append("\")"); + } else if (rewritten.indexOf('\'') < 0) { + out.append("url('").append(rewritten).append("')"); + } else { + out.append("url(\"").append(rewritten.replace("\\", "\\\\").replace("\"", "\\\"")) + .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 (!isRelativeUrl(url)) { + 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 == '/' && i + 1 < len && text.charAt(i + 1) == '*') { + // A comment may hold a semicolon of its own. + int end = text.indexOf("*/", i + 2); + i = end < 0 ? len : end + 2; + } 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..c0bbaa99b86 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 @@ -1,5 +1,24 @@ /* * 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; @@ -7,10 +26,14 @@ import com.codename1.l10n.L10NManager; import com.codename1.ui.Component; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; 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 +50,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 +68,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 +164,94 @@ 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 { + // A path here is the name of a resource in the jar, never a file: the + // compiler reads every image a stylesheet names itself, through a + // stream, so nothing reaches this with a location on disk and it + // must not open one. + InputStream in = HeadlessCssCompilerImplementation.class.getResourceAsStream( + path.startsWith("/") ? path : "/" + path); + if (in == null) { + throw new IOException("No such resource: " + 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 +268,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; } @@ -130,6 +302,11 @@ private static final class Triple { @Override public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { return 0; } @Override public int stringWidth(Object nativeFont, String str) { return 0; } @Override public int charWidth(Object nativeFont, char ch) { return 0; } + // No font is measured here, and none needs to be. A theme font is + // written as a face, a style and a size setting, or as a file name and a + // size setting; the height of the system font is read only to size the + // preview glyphs of a file-backed font, which are never stored. Zero + // gives a zero-point preview and the same resource file. @Override public int getHeight(Object nativeFont) { return 0; } @Override public Object getDefaultFont() { return new Triple(0, 0, 0); } @Override public Object connect(String url, boolean read, boolean write) throws IOException { return null; } 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/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/PollingFileWatcher.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/PollingFileWatcher.java index 1e795fabcee..0b80e2b14e7 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/PollingFileWatcher.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/PollingFileWatcher.java @@ -28,7 +28,7 @@ public class PollingFileWatcher { private File[] files; private long interval; - private long lastMtime; + private long[] lastMtime; private boolean stop; public PollingFileWatcher(File[] files, long interval) { @@ -37,19 +37,37 @@ public PollingFileWatcher(File[] files, long interval) { this.lastMtime = lastModified(); } - private long lastModified() { - long out = 0; - for (File f : files) { - out = Math.max(out, f.lastModified()); + // One time per file, 0 for a file that is not there. The newest of them + // alone would not move when an older file is edited back, or deleted. + private long[] lastModified() { + long[] out = new long[files.length]; + for (int i = 0; i < files.length; i++) { + out[i] = files[i].lastModified(); } return out; } public synchronized void poll() throws InterruptedException { - while (!stop && lastModified() == lastMtime) { + while (!stop && java.util.Arrays.equals(lastModified(), lastMtime)) { wait(interval); } - lastMtime = lastModified(); + long[] now = lastModified(); + java.util.List moved = new java.util.ArrayList(); + for (int i = 0; i < now.length; i++) { + if (now[i] != lastMtime[i]) { + moved.add(files[i]); + } + } + changed = moved.toArray(new File[moved.size()]); + lastMtime = now; + } + + private File[] changed = new File[0]; + + /// The files whose time moved, either way, at the poll that last + /// returned; none before the first. + public synchronized File[] changed() { + return changed.clone(); } public synchronized void stop() { 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/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..c098d8f9c79 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImage.java @@ -0,0 +1,155 @@ +/* + * 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 } + + /// How the tiles of a repeating axis are fitted to the positioning + /// area: as they come, spread out so that a whole number fits with gaps + /// between them (`space`), or resized so that a whole number fits + /// exactly (`round`). + public enum Fit { NONE, SPACE, ROUND } + + /// 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; + /// How the tiles are fitted across, when the image repeats across. + public final Fit fitX; + /// How the tiles are fitted down, when the image repeats down. + public final Fit fitY; + + /// 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, repeat, posX, posXPercent, posY, posYPercent, size, sizeW, sizeH, Fit.NONE, Fit.NONE); + } + + private BackgroundImage(BufferedImage image, Repeat repeat, double posX, boolean posXPercent, + double posY, boolean posYPercent, Size size, double sizeW, double sizeH, Fit fitX, Fit fitY) { + this.fitX = fitX == null ? Fit.NONE : fitX; + this.fitY = fitY == null ? Fit.NONE : fitY; + 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, fitX, fitY); + } + + /// Returns a copy whose tiles are fitted differently. + public BackgroundImage withFit(Fit x, Fit y) { + return new BackgroundImage(image, repeat, posX, posXPercent, posY, posYPercent, size, sizeW, sizeH, x, y); + } + + /// 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, fitX, fitY); + } + + /// 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, fitX, fitY); + } + + /// 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, fitX, fitY); + } + + 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..3fb352ce733 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BackgroundImagePainter.java @@ -0,0 +1,262 @@ +/* + * 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) { + return tileSize(bg.image.getWidth(), bg.image.getHeight(), bg, areaW, areaH); + } + + /// The size of one tile of an image whose own size is `iw` by `ih`, + /// which need not be the size of the pixels `bg` holds. + public static double[] tileSize(double iw, double ih, BackgroundImage bg, double areaW, double areaH) { + 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; + } + 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; + boolean roundX = repeatX && bg.fitX == BackgroundImage.Fit.ROUND; + boolean roundY = repeatY && bg.fitY == BackgroundImage.Fit.ROUND; + // `round` resizes the tile so that a whole number of them fills the + // area. Rounded on one axis only, an image whose other side is + // `auto` keeps its shape. + if (roundX) { + double fitted = area.getWidth() / Math.max(1, Math.round(area.getWidth() / tw)); + if (!roundY && autoSide(bg, false)) { + th = th * fitted / tw; + } + tw = fitted; + } + if (roundY) { + double fitted = area.getHeight() / Math.max(1, Math.round(area.getHeight() / th)); + if (!roundX && autoSide(bg, true)) { + tw = tw * fitted / th; + } + th = fitted; + } + if (!(tw > 0) || !(th > 0)) { + return null; + } + double px = roundX ? area.getX() + : area.getX() + (bg.posXPercent ? (area.getWidth() - tw) * bg.posX / 100 : bg.posX); + double py = roundY ? area.getY() + : area.getY() + (bg.posYPercent ? (area.getHeight() - th) * bg.posY / 100 : bg.posY); + + // `space` keeps the tiles whole: as many as fit in the area, the + // first and last against its edges and the room left over shared out + // between them. With room for one only, it is placed like an image + // that does not repeat. + double stepX = tw; + double stepY = th; + double x0 = px; + long nx = 1; + if (repeatX && bg.fitX == BackgroundImage.Fit.SPACE) { + long fit = (long) Math.floor(area.getWidth() / tw); + if (fit >= 2) { + nx = fit; + x0 = area.getX(); + stepX = tw + (area.getWidth() - fit * tw) / (fit - 1); + } + } else 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 && bg.fitY == BackgroundImage.Fit.SPACE) { + long fit = (long) Math.floor(area.getHeight() / th); + if (fit >= 2) { + ny = fit; + y0 = area.getY(); + stepY = th + (area.getHeight() - fit * th) / (fit - 1); + } + } else 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); + // Divided, not multiplied: two counts large enough wrap around + // to a small product, and the loops below would then never end. + if (nx > MAX_DRAWN_TILES / ny) { + // Too many to draw one by one. What repeats is one step of + // the layout, so with `space` it is the tile and the gap + // after it: a tile wide enough to leave a gap one can see + // gets here too, when there are many rows of it. + BufferedImage cell = src; + if (stepX > tw || stepY > th) { + int cw = (int) Math.max(src.getWidth(), Math.round(stepX * src.getWidth() / tw)); + int ch = (int) Math.max(src.getHeight(), Math.round(stepY * src.getHeight() / th)); + cell = new BufferedImage(cw, ch, BufferedImage.TYPE_INT_ARGB); + Graphics2D cg = cell.createGraphics(); + try { + cg.drawImage(src, 0, 0, null); + } finally { + cg.dispose(); + } + } + g.setPaint(new TexturePaint(cell, new Rectangle2D.Double(x0, y0, stepX, stepY))); + g.fill(new Rectangle2D.Double(x0, y0, nx * stepX, ny * stepY)); + } 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 * stepX, y0 + j * stepY); + at.scale(sx, sy); + g.drawImage(src, at, null); + } + } + } + } finally { + g.dispose(); + } + return layer.getRGB(0, 0, w, h, null, 0, w); + } + + /// Whether the image's width (or height) is left to follow its shape. + private static boolean autoSide(BackgroundImage bg, boolean width) { + if (bg.size == BackgroundImage.Size.AUTO) { + return true; + } + return bg.size == BackgroundImage.Size.EXPLICIT && (width ? bg.sizeW < 0 : bg.sizeH < 0); + } + + /// Halves an image, each way by itself, until that way it is less than + /// twice the target size. A wide strip drawn narrow and no lower is + /// halved across only, which is where it would otherwise be sampled + /// too thinly. + private static BufferedImage reduce(BufferedImage img, double tw, double th) { + BufferedImage cur = img; + while (true) { + boolean across = cur.getWidth() >= tw * 2 && cur.getWidth() >= 2; + boolean down = cur.getHeight() >= th * 2 && cur.getHeight() >= 2; + if (!across && !down) { + break; + } + int nw = across ? cur.getWidth() / 2 : cur.getWidth(); + int nh = down ? cur.getHeight() / 2 : cur.getHeight(); + 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..21002425f32 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderImagePainter.java @@ -0,0 +1,228 @@ +/* + * 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]; + // Opposite widths that are more than the box between them are all + // made smaller by the one factor that makes them fit, as CSS has + // it: two corners of eight in a box of ten are each five, and do + // not lie over one another. + double fit = 1; + if (l + r > box.getWidth() && l + r > 0) { + fit = Math.min(fit, box.getWidth() / (l + r)); + } + if (t + b > box.getHeight() && t + b > 0) { + fit = Math.min(fit, box.getHeight() / (t + b)); + } + if (fit < 1) { + t *= fit; + r *= fit; + b *= fit; + l *= fit; + } + 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..9027a93fda6 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BorderPainter.java @@ -0,0 +1,533 @@ +/* + * 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); + } + + /// The most pixels the borders are worked out for at a time. + static final int MAX_BAND_PIXELS = 1 << 20; + + /// 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) { + paint(dst, w, h, outer, sides, widths, MAX_BAND_PIXELS); + } + + /// Paints the borders, working out at most `bandPixels` pixels at a time. + static void paint(int[] dst, int w, int h, RoundedBox outer, BorderSide[] sides, double[] widths, + int bandPixels) { + 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]); + // A band at a time: mixed borders keep some ten planes of floats + // per pixel while they are worked out, which for an image near the + // size the rasterizer accepts is several gigabytes. The shapes are + // the same for every band, only moved up, so the result is too. + int bandRows = Math.max(1, bandPixels / Math.max(1, w)); + for (int y0 = 0; y0 < h; y0 += bandRows) { + paintBand(dst, w, h, y0, Math.min(bandRows, h - y0), outer, inner, sides, widths, uniform, color); + } + } + + /// Paints rows `y0` to `y0 + rows` of the borders. + private static void paintBand(int[] dst, int w, int h, int y0, int rows, RoundedBox outer, RoundedBox inner, + BorderSide[] sides, double[] widths, boolean uniform, int color) { + int n = w * rows; + int first = y0 * w; + float[] ring = Pixels.coverage(RoundedBox.ring(outer, inner), w, rows, 0, -y0); + if (uniform) { + if ((color >>> 24) != 0) { + for (int i = 0; i < n; i++) { + if (ring[i] > 0f) { + Pixels.blend(dst, first + i, color, ring[i]); + } + } + } + return; + } + + 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, rows, 0, -y0); + 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, rows, 0, -y0); + } + 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, rows, 0, -y0); + 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, rows, 0, -y0); + } + 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, first + 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 < MAX_DOTS)) { + // As for dots: dashes too short to be marks are the line they + // add up to. + return new BasicStroke((float) reach, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL) + .createStrokedShape(path); + } + 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); + } + + /// The most dots drawn along one side. + private static final int MAX_DOTS = 65536; + + /// 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); + if (!(length / (2 * width) < MAX_DOTS)) { + // Dots this small are not separate marks in any image the + // rasterizer paints. They are drawn as the line they add up to, + // instead of one by one without end. + return new BasicStroke((float) width, BasicStroke.CAP_BUTT, BasicStroke.JOIN_BEVEL) + .createStrokedShape(path); + } + 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..ac2f6a864fa --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxShadowPainter.java @@ -0,0 +1,183 @@ +/* + * 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; + } + BlurredMask mask = BlurredMask.of(shape.toPath(), w, h, Math.max(0, shadow.blur) / 2); + for (int y = 0; y < h; y++) { + int row = y * w; + for (int x = 0; x < w; x++) { + float a = mask.at(x, y) * (1f - borderCoverage[row + x]); + if (a > 0f) { + Pixels.blend(dst, row + x, shadow.color, a); + } + } + } + } + + /// A shape's coverage of a `w` by `h` image, blurred. + /// + /// The blur needs a margin as wide as it reaches, which for a large blur + /// is far more than the image. Past a budget the mask is blurred at a + /// fraction of the size instead: a blur that wide has no detail a + /// smaller plane would lose. + private static final class BlurredMask { + private final float[] cov; + private final int stride; + private final int margin; + private final int scale; + + private BlurredMask(float[] cov, int stride, int margin, int scale) { + this.cov = cov; + this.stride = stride; + this.margin = margin; + this.scale = scale; + } + + /// `outline` may be null for a shape that covers nothing. + static BlurredMask of(java.awt.Shape outline, int w, int h, double sigma) { + double reach = 3 * sigma + 2; + double fullW = w + 2 * reach; + double fullH = h + 2 * reach; + int scale = 1; + if (fullW * fullH > MAX_BLUR_PLANE) { + scale = (int) Math.min(MAX_BLUR_SCALE, Math.ceil(Math.sqrt(fullW * fullH / MAX_BLUR_PLANE))); + // Past the largest reduction the blur is wider than anything + // left to blur, and a wider one still looks the same. + sigma = Math.min(sigma / scale, MAX_REDUCED_SIGMA); + } + int m = GaussianBlur.radius(sigma) + 1; + int bw = (w + scale - 1) / scale + 2 * m; + int bh = (h + scale - 1) / scale + 2 * m; + float[] cov; + if (outline == null) { + cov = new float[bw * bh]; + } else { + if (scale > 1) { + outline = java.awt.geom.AffineTransform.getScaleInstance(1.0 / scale, 1.0 / scale) + .createTransformedShape(outline); + } + cov = Pixels.coverage(outline, bw, bh, m, m); + GaussianBlur.blur(cov, bw, bh, sigma); + } + return new BlurredMask(cov, bw, m, scale); + } + + float at(int x, int y) { + return cov[(y / scale + margin) * stride + margin + x / scale]; + } + } + + /// The most values in the plane a shadow is blurred on. + private static final double MAX_BLUR_PLANE = 16.0 * 1024 * 1024; + /// The most a shadow's plane is reduced by. + private static final int MAX_BLUR_SCALE = 4096; + /// The widest blur applied to a reduced plane, whose margins stay within + /// [#MAX_BLUR_PLANE] at this width. + private static final double MAX_REDUCED_SIGMA = 512; + + /// 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); + BlurredMask mask = BlurredMask.of(hole.isEmpty() ? null : hole.toPath(), w, h, + Math.max(0, shadow.blur) / 2); + for (int y = 0; y < h; y++) { + int row = y * w; + for (int x = 0; x < w; x++) { + float c = clip[row + x]; + if (c > 0f) { + float a = (1f - mask.at(x, y)) * 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..99f2ecb5897 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/BoxStyle.java @@ -0,0 +1,308 @@ +/* + * 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 boolean gradientOverImage; + 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; + gradientOverImage = b.gradientOverImage; + 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, or `null`. It is above + /// the gradient unless [#isGradientOverImage()] says otherwise. + public BackgroundImage getBackgroundImage() { + return backgroundImage; + } + + /// Whether the gradient is the upper of the two layers. In CSS the layer + /// written first is the one on top, so this is `true` for + /// `background: linear-gradient(...), url(...)`. + public boolean isGradientOverImage() { + return gradientOverImage; + } + + 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 boolean gradientOverImage; + 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 gradientOverImage(boolean v) { + gradientOverImage = 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..22d807acc1d --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/CssBoxRasterizer.java @@ -0,0 +1,273 @@ +/* + * 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 and 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 unless [BoxStyle#isGradientOverImage()]; +/// 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(); + // The limit is on the image that is allocated, not on the box: a + // side shorter than a pixel still takes a whole row or column. + if (Math.max(1, Math.floor(fullW)) * Math.max(1, Math.floor(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); + + // The two background layers go down bottom first. The image is the + // upper one unless the style says the gradient was written first. + if (style.isGradientOverImage()) { + paintBackgroundImage(px, w, h, style, paddingBox, borderCoverage); + paintGradient(px, w, h, style, paddingBox, borderCoverage); + } else { + paintGradient(px, w, h, style, paddingBox, borderCoverage); + paintBackgroundImage(px, w, h, style, paddingBox, 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 paintGradient(int[] px, int w, int h, BoxStyle style, RoundedBox paddingBox, + float[] borderCoverage) { + GradientSpec gradient = style.getGradient(); + if (gradient == null) { + return; + } + // A gradient is sized and positioned in the padding box, the + // initial background-origin, and shows through to the border + // edge, the initial background-clip. A box that is all border has + // no padding box to measure in and falls back to the border box. + GradientPainter painter = paddingBox.isEmpty() + ? new GradientPainter(gradient, style.getPadLeft(), style.getPadTop(), + style.getBorderBoxWidth(), style.getBorderBoxHeight()) + : new GradientPainter(gradient, paddingBox.getX(), paddingBox.getY(), + paddingBox.getWidth(), paddingBox.getHeight()); + Pixels.layer(px, painter.paint(w, h), borderCoverage); + } + + private static void paintBackgroundImage(int[] px, int w, int h, BoxStyle style, RoundedBox paddingBox, + float[] borderCoverage) { + BackgroundImage bg = style.getBackgroundImage(); + if (bg == null || paddingBox.isEmpty()) { + return; + } + int[] layer = BackgroundImagePainter.paint(bg, w, h, + new Rectangle2D.Double(paddingBox.getX(), paddingBox.getY(), + paddingBox.getWidth(), paddingBox.getHeight()), + new Rectangle2D.Double(style.getPadLeft(), style.getPadTop(), + style.getBorderBoxWidth(), style.getBorderBoxHeight())); + if (layer != null) { + Pixels.layer(px, layer, borderCoverage); + } + } + + 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/DataUrl.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/DataUrl.java new file mode 100644 index 00000000000..366d441f456 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/DataUrl.java @@ -0,0 +1,100 @@ +/* + * 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.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +/// A `data:` address taken apart: what it says it holds, and the bytes. +/// +/// The bytes are base64 when the address says so. Otherwise they are +/// written out, with any of them as `%` and two hex digits: an escape is +/// the byte it names, `%89` the byte 0x89 of a picture, and not a +/// character to be read and written back as some other bytes. Anything not +/// escaped is text, and stands for the text's own bytes. +public final class DataUrl { + private final String type; + private final byte[] bytes; + + private DataUrl(String type, byte[] bytes) { + this.type = type; + this.bytes = bytes; + } + + /// Whether `url` is a `data:` address at all. + public static boolean is(String url) { + return url != null && url.regionMatches(true, 0, "data:", 0, 5); + } + + /// Takes a `data:` address apart, or answers null for one that is not + /// one or has no comma between what it is and what it holds. + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when it says base64 and is not + public static DataUrl parse(String url) { + if (!is(url)) { + return null; + } + int comma = url.indexOf(','); + if (comma < 0) { + return null; + } + String kind = url.substring(5, comma); + String payload = url.substring(comma + 1); + byte[] octets = unescaped(payload); + if (kind.length() >= 7 && kind.regionMatches(true, kind.length() - 7, ";base64", 0, 7)) { + // The escapes come off first: `%3D` is the `=` that pads it. + return new DataUrl(kind, java.util.Base64.getMimeDecoder() + .decode(new String(octets, StandardCharsets.ISO_8859_1))); + } + return new DataUrl(kind, octets); + } + + private static byte[] unescaped(String payload) { + ByteArrayOutputStream octets = new ByteArrayOutputStream(); + for (int i = 0; i < payload.length(); i++) { + char c = payload.charAt(i); + int hi = c == '%' && i + 2 < payload.length() ? Character.digit(payload.charAt(i + 1), 16) : -1; + int lo = hi >= 0 ? Character.digit(payload.charAt(i + 2), 16) : -1; + if (hi >= 0 && lo >= 0) { + octets.write((hi << 4) | lo); + i += 2; + } else { + byte[] text = String.valueOf(c).getBytes(StandardCharsets.UTF_8); + octets.write(text, 0, text.length); + } + } + return octets.toByteArray(); + } + + /// Whether it says it holds an SVG. + public boolean isSvg() { + return type.regionMatches(true, 0, "image/svg", 0, 9); + } + + /// What it holds. + public byte[] getBytes() { + return bytes.clone(); + } +} 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..a361f30cada --- /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 padding box, the initial +/// `background-origin`. Sizing a gradient 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/main/java/com/codename1/designer/css/raster/SvgFilter.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/SvgFilter.java new file mode 100644 index 00000000000..0efc572e666 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/SvgFilter.java @@ -0,0 +1,1420 @@ +/* + * 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 com.codename1.svg.transcoder.model.SVGFilter; +import com.codename1.svg.transcoder.model.SVGFilter.Primitive; +import com.codename1.svg.transcoder.parser.ColorParser; +import java.awt.Rectangle; +import java.awt.geom.AffineTransform; +import java.awt.geom.NoninvertibleTransformException; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.util.HashMap; +import java.util.Map; + +/// The effects of an SVG ``, worked out on the pixels of the element +/// it is on. +/// +/// The element is painted first, on a layer of its own. The filter's region +/// of that layer is then taken through the effects in the order they are +/// written, each reading the result of the one before it or one it names, +/// and the last result replaces the element. Nothing of it is left outside +/// the region. +/// +/// Colours are worked on as linear light unless +/// `color-interpolation-filters` says `sRGB`, and with their opacity +/// multiplied in. Lengths are those of the filtered element: a blur of 2 on +/// an element drawn at three times its size is a blur of 6 pixels. +/// +/// The page behind the element is empty as an input, which no browser gives +/// a filter either. The picture an `feImage` names is painted by whoever +/// runs the filter, which is who knows where the file is. +final class SvgFilter { + /// Paints what an `feImage` names. + interface Pictures { + /// The picture of `p`, as an image `w` by `h` of the region's + /// pixels, or null when it names none that can be read. + /// + /// #### Parameters + /// + /// - `area`: where the effect puts it, in the element's coordinates + /// + /// - `toPixels`: from those coordinates to the pixels of the image + BufferedImage picture(Primitive p, Rectangle2D area, AffineTransform toPixels, int w, int h); + } + + private Pictures pictures; + + /// One image in the chain: four planes, colour multiplied by opacity. + private static final class Plane { + final float[] r; + final float[] g; + final float[] b; + final float[] a; + boolean linear; + /// The part of the region this one covers. + int x0; + int y0; + int x1; + int y1; + + Plane(int w, int h, boolean linear) { + r = new float[w * h]; + g = new float[w * h]; + b = new float[w * h]; + a = new float[w * h]; + this.linear = linear; + x1 = w; + y1 = h; + } + + float[] channel(int c) { + return c == 0 ? r : c == 1 ? g : c == 2 ? b : a; + } + } + + private static final float[] TO_LINEAR = new float[256]; + + static { + for (int i = 0; i < 256; i++) { + TO_LINEAR[i] = (float) toLinear(i / 255.0); + } + } + + private final int w; + private final int h; + /// From the coordinates of the filtered element to the pixels of the region. + private final AffineTransform at; + /// Pixels to one of the lengths an effect is written in, each way. + private final double ux; + private final double uy; + private final Rectangle2D box; + private final boolean boxUnits; + private final Map named = new HashMap(); + /// The region covered with the element's fill and with its stroke, or null. + private Plane fillPaint; + private Plane strokePaint; + + private SvgFilter(int w, int h, AffineTransform at, Rectangle2D box, boolean boxUnits) { + this.w = w; + this.h = h; + this.at = at; + this.box = box; + this.boxUnits = boxUnits && box != null; + double sx = Math.hypot(at.getScaleX(), at.getShearY()); + double sy = Math.hypot(at.getShearX(), at.getScaleY()); + this.ux = this.boxUnits ? sx * box.getWidth() : sx; + this.uy = this.boxUnits ? sy * box.getHeight() : sy; + } + + /// Takes `layer`, which holds one element and nothing else, through + /// `filter`. + /// + /// #### Parameters + /// + /// - `box`: the bounding box of the element in its own coordinates, or + /// null when it has none to measure + /// + /// - `device`: from the element's coordinates to the pixels of `layer` + /// + /// - `fillPaint`: the size of `layer` and covered with the element's + /// fill, for a filter that reads `FillPaint`; null for an empty one + /// + /// - `strokePaint`: the same of its stroke + static void apply(BufferedImage layer, SVGFilter filter, Rectangle2D box, AffineTransform device, + BufferedImage fillPaint, BufferedImage strokePaint) { + apply(layer, filter, box, device, fillPaint, strokePaint, null); + } + + /// As above, with `pictures` to paint what an `feImage` names; without + /// it such an effect is empty. + static void apply(BufferedImage layer, SVGFilter filter, Rectangle2D box, AffineTransform device, + BufferedImage fillPaint, BufferedImage strokePaint, Pictures pictures) { + int lw = layer.getWidth(); + int lh = layer.getHeight(); + boolean boxed = box != null && box.getWidth() > 0 && box.getHeight() > 0; + Rectangle2D region = null; + if (filter.isRegionUserSpace()) { + region = new Rectangle2D.Double(filter.getRegionX(), filter.getRegionY(), + filter.getRegionWidth(), filter.getRegionHeight()); + } else if (boxed) { + region = new Rectangle2D.Double(box.getX() + filter.getRegionX() * box.getWidth(), + box.getY() + filter.getRegionY() * box.getHeight(), + filter.getRegionWidth() * box.getWidth(), filter.getRegionHeight() * box.getHeight()); + } + if (region == null && box != null) { + // A region in shares of a box that has no width or no height, + // around a level line say, is no region. The specification + // has the effect ignored then (SVG 1.1, 7.11): the element is + // drawn as it is, without the filter. Browsers draw nothing + // of it instead, which is not what is written. + return; + } + // With no box to take a share of, the region is all there is. + Rectangle whole = new Rectangle(0, 0, lw, lh); + Rectangle px = region == null ? whole : device.createTransformedShape(region).getBounds().intersection(whole); + int[] all = layer.getRGB(0, 0, lw, lh, null, 0, lw); + int[] out = new int[all.length]; + if (!px.isEmpty()) { + AffineTransform at = AffineTransform.getTranslateInstance(-px.x, -px.y); + at.concatenate(device); + SvgFilter f = new SvgFilter(px.width, px.height, at, boxed ? box : null, + filter.isPrimitiveObjectBoundingBox()); + f.pictures = pictures; + Plane source = plane(all, lw, px); + f.fillPaint = fillPaint == null ? null : plane(fillPaint.getRGB(0, 0, lw, lh, null, 0, lw), lw, px); + f.strokePaint = strokePaint == null ? null + : plane(strokePaint.getRGB(0, 0, lw, lh, null, 0, lw), lw, px); + Plane result = f.in(f.run(filter, source), false); + for (int y = 0; y < px.height; y++) { + for (int x = 0; x < px.width; x++) { + int i = y * px.width + x; + float a = clamp(result.a[i]); + if (a <= 0f) { + continue; + } + out[(y + px.y) * lw + x + px.x] = (Math.round(a * 255f) << 24) + | (Math.round(clamp(result.r[i] / a) * 255f) << 16) + | (Math.round(clamp(result.g[i] / a) * 255f) << 8) + | Math.round(clamp(result.b[i] / a) * 255f); + } + } + } + layer.setRGB(0, 0, lw, lh, out, 0, lw); + } + + /// The part `px` of an image `lw` wide, as a plane. + private static Plane plane(int[] all, int lw, Rectangle px) { + Plane out = new Plane(px.width, px.height, false); + for (int y = 0; y < px.height; y++) { + for (int x = 0; x < px.width; x++) { + int argb = all[(y + px.y) * lw + x + px.x]; + float a = (argb >>> 24) / 255f; + int i = y * px.width + x; + out.a[i] = a; + out.r[i] = ((argb >> 16) & 0xff) / 255f * a; + out.g[i] = ((argb >> 8) & 0xff) / 255f * a; + out.b[i] = (argb & 0xff) / 255f * a; + } + } + return out; + } + + /// Whether an effect of `filter` reads the input of that name. + static boolean reads(SVGFilter filter, String input) { + return reads(filter.getPrimitives(), input); + } + + private static boolean reads(java.util.List primitives, String input) { + for (Primitive p : primitives) { + if ((p.get("in") != null && input.equals(p.get("in").trim())) + || (p.get("in2") != null && input.equals(p.get("in2").trim())) + || reads(p.getChildren(), input)) { + return true; + } + } + return false; + } + + private Plane run(SVGFilter filter, Plane source) { + // A filter with no effects in it leaves nothing of the element. + Plane last = new Plane(w, h, false); + boolean first = true; + for (Primitive p : filter.getPrimitives()) { + if (!p.getName().startsWith("fe")) { + continue; + } + String space = p.get("color-interpolation-filters"); + boolean linear = space == null ? !filter.isSrgb() : !"sRGB".equalsIgnoreCase(space.trim()); + Plane out = effect(p, first ? source : last, source, linear); + first = false; + bound(p, out); + String name = p.get("result"); + if (name != null && name.trim().length() > 0) { + named.put(name.trim(), out); + } + last = out; + } + return last; + } + + private Plane effect(Primitive p, Plane last, Plane source, boolean linear) { + String name = p.getName(); + if ("feGaussianBlur".equals(name)) { + double[] sd = numbers(p.get("stdDeviation")); + double sdx = sd.length > 0 ? sd[0] : 0; + double sdy = sd.length > 1 ? sd[1] : sdx; + return blur(input(p, "in", last, source, linear), boxUnits ? sdx * box.getWidth() : sdx, + boxUnits ? sdy * box.getHeight() : sdy); + } + if ("feOffset".equals(name)) { + // Handed over as written; offset() scales them by the box when + // the effects are measured in shares of it, as it does for + // the shadow below. + return offset(input(p, "in", last, source, linear), number(p, "dx", 0), number(p, "dy", 0)); + } + if ("feFlood".equals(name)) { + return flood(p.get("flood-color"), alpha(p, "flood-opacity"), linear); + } + if ("feColorMatrix".equals(name)) { + return colorMatrix(p, input(p, "in", last, source, linear)); + } + if ("feComponentTransfer".equals(name)) { + return componentTransfer(p, input(p, "in", last, source, linear)); + } + if ("feComposite".equals(name)) { + return composite(p, input(p, "in", last, source, linear), input(p, "in2", last, source, linear)); + } + if ("feBlend".equals(name)) { + return blend(p.get("mode"), input(p, "in", last, source, linear), input(p, "in2", last, source, linear)); + } + if ("feMerge".equals(name)) { + Plane out = new Plane(w, h, linear); + for (Primitive node : p.getChildren()) { + if ("feMergeNode".equals(node.getName())) { + out = over(input(node, "in", last, source, linear), out); + } + } + return out; + } + if ("feDropShadow".equals(name)) { + Plane in = input(p, "in", last, source, linear); + double[] sd = numbers(p.get("stdDeviation")); + double sdx = sd.length > 0 ? sd[0] : 2; + double sdy = sd.length > 1 ? sd[1] : sdx; + Plane shadow = offset(blur(alphaOf(in), boxUnits ? sdx * box.getWidth() : sdx, + boxUnits ? sdy * box.getHeight() : sdy), number(p, "dx", 2), number(p, "dy", 2)); + Plane colour = flood(p.get("flood-color"), alpha(p, "flood-opacity"), linear); + for (int i = 0; i < w * h; i++) { + float a = shadow.a[i]; + shadow.r[i] = colour.r[i] * a; + shadow.g[i] = colour.g[i] * a; + shadow.b[i] = colour.b[i] * a; + shadow.a[i] = colour.a[i] * a; + } + return over(in, shadow); + } + if ("feMorphology".equals(name)) { + return morphology(p, input(p, "in", last, source, linear)); + } + if ("feTile".equals(name)) { + return tile(input(p, "in", last, source, linear)); + } + if ("feConvolveMatrix".equals(name)) { + return convolve(p, input(p, "in", last, source, linear)); + } + if ("feDisplacementMap".equals(name)) { + return displace(p, input(p, "in", last, source, linear), input(p, "in2", last, source, linear)); + } + if ("feTurbulence".equals(name)) { + return turbulence(p, linear); + } + if ("feDiffuseLighting".equals(name) || "feSpecularLighting".equals(name)) { + return lighting(p, input(p, "in", last, source, linear), linear, "feSpecularLighting".equals(name)); + } + if ("feImage".equals(name) && pictures != null) { + Rectangle2D area = area(p); + BufferedImage picture = area == null ? null : pictures.picture(p, area, at, w, h); + if (picture != null) { + return in(plane(picture.getRGB(0, 0, w, h, null, 0, w), w, new Rectangle(0, 0, w, h)), linear); + } + } + return new Plane(w, h, linear); + } + + /// Where an effect paints, in the element's coordinates: the sides it + /// gives, and those of the filter's region for the ones it leaves out. + /// Null when the region cannot be taken back to those coordinates. + private Rectangle2D area(Primitive p) { + AffineTransform back; + try { + back = at.createInverse(); + } catch (NoninvertibleTransformException ex) { + return null; + } + Rectangle2D all = back.createTransformedShape(new Rectangle2D.Double(0, 0, w, h)).getBounds2D(); + return new Rectangle2D.Double(side(p, "x", all.getX(), true, false), side(p, "y", all.getY(), false, false), + side(p, "width", all.getWidth(), true, true), side(p, "height", all.getHeight(), false, true)); + } + + /// The image an effect reads: the result before it, one that was given + /// a name, or the element itself. + private Plane input(Primitive p, String attribute, Plane last, Plane source, boolean linear) { + String name = p.get(attribute); + name = name == null ? "" : name.trim(); + Plane found; + if (name.length() == 0) { + found = last; + } else if ("SourceGraphic".equals(name)) { + found = source; + } else if ("SourceAlpha".equals(name)) { + found = alphaOf(source); + } else if ("FillPaint".equals(name)) { + found = fillPaint == null ? new Plane(w, h, linear) : fillPaint; + } else if ("StrokePaint".equals(name)) { + found = strokePaint == null ? new Plane(w, h, linear) : strokePaint; + } else if ("BackgroundImage".equals(name) || "BackgroundAlpha".equals(name)) { + found = new Plane(w, h, linear); + } else { + found = named.containsKey(name) ? named.get(name) : last; + } + return in(found, linear); + } + + /// `plane` in the colours an effect works in. A copy when they differ. + private Plane in(Plane plane, boolean linear) { + if (plane.linear == linear) { + return plane; + } + Plane out = new Plane(w, h, linear); + out.x0 = plane.x0; + out.y0 = plane.y0; + out.x1 = plane.x1; + out.y1 = plane.y1; + for (int i = 0; i < w * h; i++) { + float a = plane.a[i]; + out.a[i] = a; + if (a <= 0f) { + continue; + } + out.r[i] = convert(plane.r[i] / a, linear) * a; + out.g[i] = convert(plane.g[i] / a, linear) * a; + out.b[i] = convert(plane.b[i] / a, linear) * a; + } + return out; + } + + private static float convert(float v, boolean toLinear) { + v = clamp(v); + return (float) (toLinear ? toLinear(v) : toSrgb(v)); + } + + private static double toLinear(double v) { + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + } + + private static double toSrgb(double v) { + return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; + } + + /// Cuts a result down to the part of the region its effect is given + /// with `x`, `y`, `width` and `height`. + private void bound(Primitive p, Plane out) { + if (p.get("x") == null && p.get("y") == null && p.get("width") == null && p.get("height") == null) { + return; + } + AffineTransform back; + try { + back = at.createInverse(); + } catch (NoninvertibleTransformException ex) { + return; + } + // Sides left out are those of the region. + Rectangle2D all = back.createTransformedShape(new Rectangle2D.Double(0, 0, w, h)).getBounds2D(); + double x = side(p, "x", all.getX(), true, false); + double y = side(p, "y", all.getY(), false, false); + double width = side(p, "width", all.getWidth(), true, true); + double height = side(p, "height", all.getHeight(), false, true); + Rectangle part = at.createTransformedShape(new Rectangle2D.Double(x, y, width, height)).getBounds() + .intersection(new Rectangle(0, 0, w, h)); + out.x0 = part.x; + out.y0 = part.y; + out.x1 = part.x + Math.max(0, part.width); + out.y1 = part.y + Math.max(0, part.height); + for (int py = 0; py < h; py++) { + for (int px = 0; px < w; px++) { + if (px < out.x0 || px >= out.x1 || py < out.y0 || py >= out.y1) { + int i = py * w + px; + out.r[i] = 0; + out.g[i] = 0; + out.b[i] = 0; + out.a[i] = 0; + } + } + } + } + + private double side(Primitive p, String attribute, double fallback, boolean horizontal, boolean size) { + String value = p.get(attribute); + if (value == null) { + return fallback; + } + double[] v = numbers(value); + if (v.length == 0) { + return fallback; + } + if (!boxUnits) { + return v[0]; + } + double scaled = v[0] * (horizontal ? box.getWidth() : box.getHeight()); + return size ? scaled : scaled + (horizontal ? box.getX() : box.getY()); + } + + private Plane alphaOf(Plane in) { + Plane out = new Plane(w, h, in.linear); + System.arraycopy(in.a, 0, out.a, 0, w * h); + return out; + } + + private Plane copy(Plane in) { + Plane out = new Plane(w, h, in.linear); + for (int c = 0; c < 4; c++) { + System.arraycopy(in.channel(c), 0, out.channel(c), 0, w * h); + } + return out; + } + + /// Blurs by `dx` across and `dy` down in the element's coordinates. + /// + /// Those are not the rows and columns of the image when the element is + /// turned or sheared, and a blur wider one way than the other turns + /// with it. What the blur spreads by on the image is worked out whole, + /// as how far it goes along the rows, down the columns, and how much + /// the two lean together. Where they do not lean it is a blur along + /// each; where they do, it is one along the rows and one along a + /// slanted line, which together are the same spread. + private Plane blur(Plane in, double dx, double dy) { + if (dx < 0 || dy < 0 || (dx == 0 && dy == 0)) { + return copy(in); + } + double vx = dx * dx; + double vy = dy * dy; + double xx = at.getScaleX() * at.getScaleX() * vx + at.getShearX() * at.getShearX() * vy; + double yy = at.getShearY() * at.getShearY() * vx + at.getScaleY() * at.getScaleY() * vy; + double xy = at.getScaleX() * at.getShearY() * vx + at.getShearX() * at.getScaleY() * vy; + if (Math.abs(xy) < 1e-9 * Math.max(1e-12, Math.max(xx, yy)) || !(yy > 0)) { + return blurAlong(in, Math.sqrt(xx), Math.sqrt(yy)); + } + Plane out = blurAlong(in, Math.sqrt(Math.max(0, xx - xy * xy / yy)), 0); + float[] k = GaussianBlur.kernel(Math.sqrt(yy)); + int r = k.length / 2; + double lean = xy / yy; + for (int c = 0; c < 4; c++) { + float[] data = out.channel(c); + float[] src = data.clone(); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + float sum = 0; + for (int j = Math.max(-r, -y); j <= Math.min(r, h - 1 - y); j++) { + double fx = x + lean * j; + int x0 = (int) Math.floor(fx); + float part = (float) (fx - x0); + int row = (y + j) * w; + float v = 0; + if (x0 >= 0 && x0 < w) { + v += src[row + x0] * (1 - part); + } + if (x0 + 1 >= 0 && x0 + 1 < w) { + v += src[row + x0 + 1] * part; + } + sum += v * k[j + r]; + } + data[y * w + x] = sum; + } + } + } + return out; + } + + /// Blurs by `sx` pixels along the rows and `sy` down the columns. + private Plane blurAlong(Plane in, double sx, double sy) { + Plane out = copy(in); + if (sx < 0 || sy < 0 || (sx == 0 && sy == 0)) { + return out; + } + for (int c = 0; c < 4; c++) { + float[] data = out.channel(c); + if (Math.abs(sx - sy) < 1e-6) { + GaussianBlur.blur(data, w, h, sx); + } else { + // A different amount each way: one row of the blur at a time. + if (sx > 0) { + float[] k = GaussianBlur.kernel(sx); + for (int y = 0; y < h; y++) { + line(data, y * w, 1, w, k); + } + } + if (sy > 0) { + float[] k = GaussianBlur.kernel(sy); + for (int x = 0; x < w; x++) { + line(data, x, w, h, k); + } + } + } + } + return out; + } + + private static void line(float[] data, int off, int stride, int len, float[] k) { + float[] src = new float[len]; + for (int i = 0; i < len; i++) { + src[i] = data[off + i * stride]; + } + int r = k.length / 2; + for (int i = 0; i < len; i++) { + float sum = 0; + for (int j = Math.max(0, i - r); j <= Math.min(len - 1, i + r); j++) { + sum += src[j] * k[j - i + r]; + } + data[off + i * stride] = sum; + } + } + + private Plane offset(Plane in, double dx, double dy) { + // The offset is a direction in the element's coordinates. + Point2D moved = at.deltaTransform(new Point2D.Double(boxUnits ? dx * box.getWidth() : dx, + boxUnits ? dy * box.getHeight() : dy), null); + int ox = (int) Math.round(moved.getX()); + int oy = (int) Math.round(moved.getY()); + Plane out = new Plane(w, h, in.linear); + for (int y = 0; y < h; y++) { + int fromY = y - oy; + if (fromY < 0 || fromY >= h) { + continue; + } + for (int x = 0; x < w; x++) { + int fromX = x - ox; + if (fromX < 0 || fromX >= w) { + continue; + } + int i = y * w + x; + int j = fromY * w + fromX; + out.r[i] = in.r[j]; + out.g[i] = in.g[j]; + out.b[i] = in.b[j]; + out.a[i] = in.a[j]; + } + } + return out; + } + + /// A colour as three values in the colours being worked in. + private static float[] colour(String value, boolean linear, int fallback) { + int argb = fallback; + if (value != null && !ColorParser.isCurrentColor(value)) { + argb = ColorParser.parseOrDefault(value, fallback); + } + float[] out = {((argb >> 16) & 0xff) / 255f, ((argb >> 8) & 0xff) / 255f, (argb & 0xff) / 255f, + (argb >>> 24) / 255f}; + if (linear) { + out[0] = TO_LINEAR[(argb >> 16) & 0xff]; + out[1] = TO_LINEAR[(argb >> 8) & 0xff]; + out[2] = TO_LINEAR[argb & 0xff]; + } + return out; + } + + private Plane flood(String value, float opacity, boolean linear) { + float[] c = colour(value, linear, 0xff000000); + float a = clamp(opacity) * c[3]; + Plane out = new Plane(w, h, linear); + java.util.Arrays.fill(out.r, c[0] * a); + java.util.Arrays.fill(out.g, c[1] * a); + java.util.Arrays.fill(out.b, c[2] * a); + java.util.Arrays.fill(out.a, a); + return out; + } + + private Plane colorMatrix(Primitive p, Plane in) { + String type = p.get("type") == null ? "matrix" : p.get("type").trim(); + double[] v = numbers(p.get("values")); + double[] m = {1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0}; + if ("matrix".equals(type)) { + if (v.length == 20) { + m = v; + } + } else if ("saturate".equals(type)) { + double s = v.length > 0 ? v[0] : 1; + m = new double[] {0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s, 0, 0, + 0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s, 0, 0, + 0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s, 0, 0, + 0, 0, 0, 1, 0}; + } else if ("hueRotate".equals(type)) { + double angle = Math.toRadians(v.length > 0 ? v[0] : 0); + double c = Math.cos(angle); + double s = Math.sin(angle); + m = new double[] {0.213 + c * 0.787 - s * 0.213, 0.715 - c * 0.715 - s * 0.715, + 0.072 - c * 0.072 + s * 0.928, 0, 0, + 0.213 - c * 0.213 + s * 0.143, 0.715 + c * 0.285 + s * 0.140, + 0.072 - c * 0.072 - s * 0.283, 0, 0, + 0.213 - c * 0.213 - s * 0.787, 0.715 - c * 0.715 + s * 0.715, + 0.072 + c * 0.928 + s * 0.072, 0, 0, + 0, 0, 0, 1, 0}; + } else if ("luminanceToAlpha".equals(type)) { + m = new double[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.2125, 0.7154, 0.0721, 0, 0}; + } + Plane out = new Plane(w, h, in.linear); + for (int i = 0; i < w * h; i++) { + float a = in.a[i]; + float r = a > 0 ? in.r[i] / a : 0; + float g = a > 0 ? in.g[i] / a : 0; + float b = a > 0 ? in.b[i] / a : 0; + float na = clamp((float) (m[15] * r + m[16] * g + m[17] * b + m[18] * a + m[19])); + out.a[i] = na; + out.r[i] = clamp((float) (m[0] * r + m[1] * g + m[2] * b + m[3] * a + m[4])) * na; + out.g[i] = clamp((float) (m[5] * r + m[6] * g + m[7] * b + m[8] * a + m[9])) * na; + out.b[i] = clamp((float) (m[10] * r + m[11] * g + m[12] * b + m[13] * a + m[14])) * na; + } + return out; + } + + private Plane componentTransfer(Primitive p, Plane in) { + Primitive[] functions = new Primitive[4]; + for (Primitive f : p.getChildren()) { + String n = f.getName(); + int c = "feFuncR".equals(n) ? 0 : "feFuncG".equals(n) ? 1 : "feFuncB".equals(n) ? 2 + : "feFuncA".equals(n) ? 3 : -1; + if (c >= 0) { + functions[c] = f; + } + } + Plane out = new Plane(w, h, in.linear); + for (int i = 0; i < w * h; i++) { + float a = in.a[i]; + float na = transfer(functions[3], a); + out.a[i] = na; + out.r[i] = transfer(functions[0], a > 0 ? in.r[i] / a : 0) * na; + out.g[i] = transfer(functions[1], a > 0 ? in.g[i] / a : 0) * na; + out.b[i] = transfer(functions[2], a > 0 ? in.b[i] / a : 0) * na; + } + return out; + } + + private static float transfer(Primitive f, float c) { + if (f == null) { + return c; + } + String type = f.get("type") == null ? "identity" : f.get("type").trim(); + if ("linear".equals(type)) { + return clamp((float) (number(f, "slope", 1) * c + number(f, "intercept", 0))); + } + if ("gamma".equals(type)) { + return clamp((float) (number(f, "amplitude", 1) * Math.pow(c, number(f, "exponent", 1)) + + number(f, "offset", 0))); + } + boolean table = "table".equals(type); + if (table || "discrete".equals(type)) { + double[] v = numbers(f.get("tableValues")); + if (v.length == 0) { + return c; + } + if (table) { + int n = v.length - 1; + if (n == 0) { + return clamp((float) v[0]); + } + int k = Math.min(n - 1, (int) (c * n)); + return clamp((float) (v[k] + (c * n - k) * (v[k + 1] - v[k]))); + } + return clamp((float) v[Math.min(v.length - 1, (int) (c * v.length))]); + } + return c; + } + + private Plane over(Plane top, Plane bottom) { + Plane out = new Plane(w, h, top.linear); + for (int i = 0; i < w * h; i++) { + float keep = 1 - top.a[i]; + out.r[i] = top.r[i] + bottom.r[i] * keep; + out.g[i] = top.g[i] + bottom.g[i] * keep; + out.b[i] = top.b[i] + bottom.b[i] * keep; + out.a[i] = top.a[i] + bottom.a[i] * keep; + } + return out; + } + + private Plane composite(Primitive p, Plane a, Plane b) { + String op = p.get("operator") == null ? "over" : p.get("operator").trim(); + if ("over".equals(op)) { + return over(a, b); + } + float k1 = (float) number(p, "k1", 0); + float k2 = (float) number(p, "k2", 0); + float k3 = (float) number(p, "k3", 0); + float k4 = (float) number(p, "k4", 0); + Plane out = new Plane(w, h, a.linear); + for (int i = 0; i < w * h; i++) { + float fa; + float fb; + if ("in".equals(op)) { + fa = b.a[i]; + fb = 0; + } else if ("out".equals(op)) { + fa = 1 - b.a[i]; + fb = 0; + } else if ("atop".equals(op)) { + fa = b.a[i]; + fb = 1 - a.a[i]; + } else if ("xor".equals(op)) { + fa = 1 - b.a[i]; + fb = 1 - a.a[i]; + } else if ("lighter".equals(op)) { + fa = 1; + fb = 1; + } else if ("arithmetic".equals(op)) { + float na = clamp(k1 * a.a[i] * b.a[i] + k2 * a.a[i] + k3 * b.a[i] + k4); + out.a[i] = na; + out.r[i] = Math.min(na, clamp(k1 * a.r[i] * b.r[i] + k2 * a.r[i] + k3 * b.r[i] + k4)); + out.g[i] = Math.min(na, clamp(k1 * a.g[i] * b.g[i] + k2 * a.g[i] + k3 * b.g[i] + k4)); + out.b[i] = Math.min(na, clamp(k1 * a.b[i] * b.b[i] + k2 * a.b[i] + k3 * b.b[i] + k4)); + continue; + } else { + fa = 1; + fb = 1 - a.a[i]; + } + out.a[i] = clamp(a.a[i] * fa + b.a[i] * fb); + out.r[i] = Math.min(out.a[i], a.r[i] * fa + b.r[i] * fb); + out.g[i] = Math.min(out.a[i], a.g[i] * fa + b.g[i] * fb); + out.b[i] = Math.min(out.a[i], a.b[i] * fa + b.b[i] * fb); + } + return out; + } + + /// `top` blended onto `bottom` the way `mode` says. + private Plane blend(String mode, Plane top, Plane bottom) { + String m = mode == null ? "normal" : mode.trim(); + if ("normal".equals(m)) { + return over(top, bottom); + } + Plane out = new Plane(w, h, top.linear); + for (int i = 0; i < w * h; i++) { + float as = top.a[i]; + float ab = bottom.a[i]; + out.a[i] = as + ab - as * ab; + for (int c = 0; c < 3; c++) { + float ps = top.channel(c)[i]; + float pb = bottom.channel(c)[i]; + float cs = as > 0 ? ps / as : 0; + float cb = ab > 0 ? pb / ab : 0; + out.channel(c)[i] = ps * (1 - ab) + pb * (1 - as) + as * ab * clamp(mix(m, cb, cs)); + } + } + return out; + } + + private static float mix(String mode, float cb, float cs) { + if ("multiply".equals(mode)) { + return cb * cs; + } + if ("screen".equals(mode)) { + return cb + cs - cb * cs; + } + if ("darken".equals(mode)) { + return Math.min(cb, cs); + } + if ("lighten".equals(mode)) { + return Math.max(cb, cs); + } + if ("overlay".equals(mode)) { + return mix("hard-light", cs, cb); + } + if ("hard-light".equals(mode)) { + return cs <= 0.5f ? cb * 2 * cs : mix("screen", cb, 2 * cs - 1); + } + if ("color-dodge".equals(mode)) { + return cb == 0 ? 0 : cs >= 1 ? 1 : Math.min(1, cb / (1 - cs)); + } + if ("color-burn".equals(mode)) { + return cb >= 1 ? 1 : cs <= 0 ? 0 : 1 - Math.min(1, (1 - cb) / cs); + } + if ("soft-light".equals(mode)) { + if (cs <= 0.5f) { + return cb - (1 - 2 * cs) * cb * (1 - cb); + } + float d = cb <= 0.25f ? ((16 * cb - 12) * cb + 4) * cb : (float) Math.sqrt(cb); + return cb + (2 * cs - 1) * (d - cb); + } + if ("difference".equals(mode)) { + return Math.abs(cb - cs); + } + if ("exclusion".equals(mode)) { + return cb + cs - 2 * cb * cs; + } + return cs; + } + + private Plane morphology(Primitive p, Plane in) { + double[] radius = numbers(p.get("radius")); + double rx = radius.length > 0 ? radius[0] : 0; + double ry = radius.length > 1 ? radius[1] : rx; + int px = (int) Math.round(rx * ux); + int py = (int) Math.round(ry * uy); + if (rx <= 0 || ry <= 0) { + return copy(in); + } + boolean dilate = p.get("operator") != null && "dilate".equals(p.get("operator").trim()); + Plane out = new Plane(w, h, in.linear); + if (Math.abs(at.getShearX()) > 1e-9 || Math.abs(at.getShearY()) > 1e-9) { + // The kernel is a rectangle in the element's coordinates. On + // an element that is turned or sheared it is not one on the + // image, and cannot be done a row and then a column at a time: + // every pixel of it is looked at, where the element puts it. + double across = boxUnits ? rx * box.getWidth() : rx; + double down = boxUnits ? ry * box.getHeight() : ry; + double det = at.getScaleX() * at.getScaleY() - at.getShearX() * at.getShearY(); + if (Math.abs(det) < 1e-12) { + return copy(in); + } + int reachX = (int) Math.ceil(Math.abs(at.getScaleX()) * across + Math.abs(at.getShearX()) * down); + int reachY = (int) Math.ceil(Math.abs(at.getShearY()) * across + Math.abs(at.getScaleY()) * down); + java.util.List cells = new java.util.ArrayList(); + for (int dy = -reachY; dy <= reachY; dy++) { + for (int dx = -reachX; dx <= reachX; dx++) { + double ex = (at.getScaleY() * dx - at.getShearX() * dy) / det; + double ey = (-at.getShearY() * dx + at.getScaleX() * dy) / det; + if (Math.abs(ex) <= across + 1e-6 && Math.abs(ey) <= down + 1e-6) { + cells.add(new int[] {dx, dy}); + } + } + } + for (int c = 0; c < 4; c++) { + float[] src = in.channel(c); + float[] dst = out.channel(c); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + float v = dilate ? 0f : 1f; + for (int[] cell : cells) { + int sx = x + cell[0]; + int sy = y + cell[1]; + float s = sx < 0 || sx >= w || sy < 0 || sy >= h ? 0f : src[sy * w + sx]; + v = dilate ? Math.max(v, s) : Math.min(v, s); + } + dst[y * w + x] = v; + } + } + } + return out; + } + float[] row = new float[w * h]; + for (int c = 0; c < 4; c++) { + float[] src = in.channel(c); + float[] dst = out.channel(c); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + float v = dilate ? 0f : 1f; + for (int k = x - px; k <= x + px; k++) { + float s = k < 0 || k >= w ? 0f : src[y * w + k]; + v = dilate ? Math.max(v, s) : Math.min(v, s); + } + row[y * w + x] = v; + } + } + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + float v = dilate ? 0f : 1f; + for (int k = y - py; k <= y + py; k++) { + float s = k < 0 || k >= h ? 0f : row[k * w + x]; + v = dilate ? Math.max(v, s) : Math.min(v, s); + } + dst[y * w + x] = v; + } + } + } + return out; + } + + /// Repeats the part of the region `in` covers over all of it. + private Plane tile(Plane in) { + int tw = in.x1 - in.x0; + int th = in.y1 - in.y0; + if (tw <= 0 || th <= 0) { + return new Plane(w, h, in.linear); + } + Plane out = new Plane(w, h, in.linear); + for (int y = 0; y < h; y++) { + int fromY = in.y0 + Math.floorMod(y - in.y0, th); + for (int x = 0; x < w; x++) { + int j = fromY * w + in.x0 + Math.floorMod(x - in.x0, tw); + int i = y * w + x; + out.r[i] = in.r[j]; + out.g[i] = in.g[j]; + out.b[i] = in.b[j]; + out.a[i] = in.a[j]; + } + } + return out; + } + + private Plane convolve(Primitive p, Plane in) { + double[] order = numbers(p.get("order")); + int ox = order.length > 0 ? (int) order[0] : 3; + int oy = order.length > 1 ? (int) order[1] : ox; + double[] k = numbers(p.get("kernelMatrix")); + if (ox <= 0 || oy <= 0 || k.length != ox * oy) { + return new Plane(w, h, in.linear); + } + double sum = 0; + for (double v : k) { + sum += v; + } + double divisor = number(p, "divisor", 0); + if (divisor == 0) { + divisor = sum == 0 ? 1 : sum; + } + double bias = number(p, "bias", 0); + int tx = (int) number(p, "targetX", ox / 2); + int ty = (int) number(p, "targetY", oy / 2); + String edge = p.get("edgeMode") == null ? "duplicate" : p.get("edgeMode").trim(); + boolean keepAlpha = p.get("preserveAlpha") != null && "true".equals(p.get("preserveAlpha").trim()); + // How far apart the kernel's cells are: one pixel of the image, or + // with `kernelUnitLength` that many of the units the effects are + // measured in, so that the result does not change with the size + // the image is drawn at. + // A cell across and a cell down are directions in the element's + // coordinates, so on one that is turned they are taken through + // its transform and do not lie along the image's rows and columns. + double[] unit = numbers(p.get("kernelUnitLength")); + double ax = 1; + double ay = 0; + double bx = 0; + double by = 1; + if (unit.length > 0 && unit[0] > 0) { + double across = unit[0] * (boxUnits ? box.getWidth() : 1); + double down = (unit.length > 1 && unit[1] > 0 ? unit[1] : unit[0]) * (boxUnits ? box.getHeight() : 1); + ax = at.getScaleX() * across; + ay = at.getShearY() * across; + bx = at.getShearX() * down; + by = at.getScaleY() * down; + // Never less than a pixel apart, or the cells would all read + // the same one. + double al = Math.hypot(ax, ay); + double bl = Math.hypot(bx, by); + if (al > 0 && al < 1) { + ax /= al; + ay /= al; + } + if (bl > 0 && bl < 1) { + bx /= bl; + by /= bl; + } + } + Plane out = new Plane(w, h, in.linear); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + double[] acc = new double[4]; + for (int j = 0; j < oy; j++) { + for (int i = 0; i < ox; i++) { + int sx = (int) Math.round(x + (i - tx) * ax + (j - ty) * bx); + int sy = (int) Math.round(y + (i - tx) * ay + (j - ty) * by); + if ("wrap".equals(edge)) { + sx = Math.floorMod(sx, w); + sy = Math.floorMod(sy, h); + } else if ("none".equals(edge)) { + if (sx < 0 || sx >= w || sy < 0 || sy >= h) { + continue; + } + } else { + sx = Math.max(0, Math.min(w - 1, sx)); + sy = Math.max(0, Math.min(h - 1, sy)); + } + double weight = k[(oy - 1 - j) * ox + (ox - 1 - i)]; + int s = sy * w + sx; + float a = in.a[s]; + // With the opacity kept, the colours are worked on + // without it multiplied in. + float un = keepAlpha && a > 0 ? 1 / a : 1; + acc[0] += weight * in.r[s] * un; + acc[1] += weight * in.g[s] * un; + acc[2] += weight * in.b[s] * un; + acc[3] += weight * a; + } + } + int d = y * w + x; + float a = keepAlpha ? in.a[d] : clamp((float) (acc[3] / divisor + bias)); + float cap = keepAlpha ? 1f : a; + float scale = keepAlpha ? a : 1f; + out.a[d] = a; + out.r[d] = Math.min(cap, clamp((float) (acc[0] / divisor + bias * cap))) * scale; + out.g[d] = Math.min(cap, clamp((float) (acc[1] / divisor + bias * cap))) * scale; + out.b[d] = Math.min(cap, clamp((float) (acc[2] / divisor + bias * cap))) * scale; + } + } + return out; + } + + private Plane displace(Primitive p, Plane in, Plane map) { + double scale = number(p, "scale", 0); + int xc = selector(p.get("xChannelSelector")); + int yc = selector(p.get("yChannelSelector")); + // What the map moves a pixel by is a direction in the element's + // coordinates, across by one channel and down by the other. On an + // element that is turned that is not across and down the image, + // so the whole of it is taken through the element's transform. + double across = boxUnits ? scale * box.getWidth() : scale; + double down = boxUnits ? scale * box.getHeight() : scale; + double m00 = at.getScaleX() * across; + double m10 = at.getShearY() * across; + double m01 = at.getShearX() * down; + double m11 = at.getScaleY() * down; + Plane out = new Plane(w, h, in.linear); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int i = y * w + x; + float a = map.a[i]; + float vx = xc == 3 ? a : a > 0 ? map.channel(xc)[i] / a : 0; + float vy = yc == 3 ? a : a > 0 ? map.channel(yc)[i] / a : 0; + int fromX = (int) Math.round(x + m00 * (vx - 0.5) + m01 * (vy - 0.5)); + int fromY = (int) Math.round(y + m10 * (vx - 0.5) + m11 * (vy - 0.5)); + if (fromX < 0 || fromX >= w || fromY < 0 || fromY >= h) { + continue; + } + int j = fromY * w + fromX; + out.r[i] = in.r[j]; + out.g[i] = in.g[j]; + out.b[i] = in.b[j]; + out.a[i] = in.a[j]; + } + } + return out; + } + + private static int selector(String value) { + String v = value == null ? "A" : value.trim(); + return "R".equals(v) ? 0 : "G".equals(v) ? 1 : "B".equals(v) ? 2 : 3; + } + + // ---- feTurbulence, by the reference code of the specification. + + private static final int NOISE_SIZE = 0x100; + private static final int NOISE_MASK = 0xff; + private static final int PERLIN_N = 0x1000; + + private static final class Noise { + final int[] lattice = new int[NOISE_SIZE + NOISE_SIZE + 2]; + final double[][][] gradient = new double[4][NOISE_SIZE + NOISE_SIZE + 2][2]; + + Noise(long seed) { + if (seed <= 0) { + seed = -(seed % 2147483646L) + 1; + } + if (seed > 2147483646L) { + seed = 2147483646L; + } + for (int k = 0; k < 4; k++) { + for (int i = 0; i < NOISE_SIZE; i++) { + lattice[i] = i; + for (int j = 0; j < 2; j++) { + seed = random(seed); + gradient[k][i][j] = (double) ((seed % (NOISE_SIZE + NOISE_SIZE)) - NOISE_SIZE) / NOISE_SIZE; + } + double s = Math.sqrt(gradient[k][i][0] * gradient[k][i][0] + + gradient[k][i][1] * gradient[k][i][1]); + if (s > 0) { + gradient[k][i][0] /= s; + gradient[k][i][1] /= s; + } + } + } + for (int i = NOISE_SIZE - 1; i > 0; i--) { + int k = lattice[i]; + seed = random(seed); + int j = (int) (seed % NOISE_SIZE); + lattice[i] = lattice[j]; + lattice[j] = k; + } + for (int i = 0; i < NOISE_SIZE + 2; i++) { + lattice[NOISE_SIZE + i] = lattice[i]; + for (int k = 0; k < 4; k++) { + gradient[k][NOISE_SIZE + i][0] = gradient[k][i][0]; + gradient[k][NOISE_SIZE + i][1] = gradient[k][i][1]; + } + } + } + + private static long random(long seed) { + long result = 16807L * (seed % 127773L) - 2836L * (seed / 127773L); + return result <= 0 ? result + 2147483647L : result; + } + + /// `stitch` is null, or for noise that meets itself at the edges + /// of a tile `{wrapX, wrapY, width, height}`: the lattice cell the + /// tile ends at each way, and how many cells wide and high it is. + double noise(int channel, double x, double y, int[] stitch) { + double t = x + PERLIN_N; + int bx0 = (int) t; + int bx1 = bx0 + 1; + double rx0 = t - (long) t; + double rx1 = rx0 - 1.0; + t = y + PERLIN_N; + int by0 = (int) t; + int by1 = by0 + 1; + if (stitch != null) { + // Past the end of the tile the lattice starts over, so the + // far edge is made of the same cells as the near one. + if (bx0 >= stitch[0]) { + bx0 -= stitch[2]; + } + if (bx1 >= stitch[0]) { + bx1 -= stitch[2]; + } + if (by0 >= stitch[1]) { + by0 -= stitch[3]; + } + if (by1 >= stitch[1]) { + by1 -= stitch[3]; + } + } + bx0 &= NOISE_MASK; + bx1 &= NOISE_MASK; + by0 &= NOISE_MASK; + by1 &= NOISE_MASK; + double ry0 = t - (long) t; + double ry1 = ry0 - 1.0; + int i = lattice[bx0]; + int j = lattice[bx1]; + int b00 = lattice[i + by0]; + int b10 = lattice[j + by0]; + int b01 = lattice[i + by1]; + int b11 = lattice[j + by1]; + double sx = rx0 * rx0 * (3.0 - 2.0 * rx0); + double sy = ry0 * ry0 * (3.0 - 2.0 * ry0); + double[][] g = gradient[channel]; + double u = rx0 * g[b00][0] + ry0 * g[b00][1]; + double v = rx1 * g[b10][0] + ry0 * g[b10][1]; + double a = u + sx * (v - u); + u = rx0 * g[b01][0] + ry1 * g[b01][1]; + v = rx1 * g[b11][0] + ry1 * g[b11][1]; + double b = u + sx * (v - u); + return a + sy * (b - a); + } + } + + private Plane turbulence(Primitive p, boolean linear) { + double[] f = numbers(p.get("baseFrequency")); + double fx = f.length > 0 ? f[0] : 0; + double fy = f.length > 1 ? f[1] : fx; + int octaves = (int) number(p, "numOctaves", 1); + boolean fractal = p.get("type") != null && "fractalNoise".equals(p.get("type").trim()); + // The seed is cut off at the point, not rounded: 1.9 is 1. + Noise noise = new Noise((long) number(p, "seed", 0)); + // Noise that tiles: the frequencies are moved to the nearest that + // fit a whole number of cells into the effect's area, and the + // lattice is wrapped at its far edges, as the specification's + // reference code has it. + boolean stitched = p.get("stitchTiles") != null && "stitch".equals(p.get("stitchTiles").trim()); + Rectangle2D tile = stitched ? area(p) : null; + int[] stitch = null; + if (tile != null && tile.getWidth() > 0 && tile.getHeight() > 0) { + if (fx != 0) { + double low = Math.floor(tile.getWidth() * fx) / tile.getWidth(); + double high = Math.ceil(tile.getWidth() * fx) / tile.getWidth(); + fx = low > 0 && fx / low < high / fx ? low : high; + } + if (fy != 0) { + double low = Math.floor(tile.getHeight() * fy) / tile.getHeight(); + double high = Math.ceil(tile.getHeight() * fy) / tile.getHeight(); + fy = low > 0 && fy / low < high / fy ? low : high; + } + int cellsX = (int) (tile.getWidth() * fx + 0.5); + int cellsY = (int) (tile.getHeight() * fy + 0.5); + stitch = new int[] {(int) (tile.getX() * fx + PERLIN_N + cellsX), + (int) (tile.getY() * fy + PERLIN_N + cellsY), cellsX, cellsY}; + } + Plane out = new Plane(w, h, linear); + AffineTransform back; + try { + back = at.createInverse(); + } catch (NoninvertibleTransformException ex) { + return out; + } + Point2D.Double point = new Point2D.Double(); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + point.setLocation(x, y); + back.transform(point, point); + float[] v = new float[4]; + for (int c = 0; c < 4; c++) { + double sum = 0; + double vx = point.x * fx; + double vy = point.y * fy; + double ratio = 1; + int[] wrap = stitch == null ? null : stitch.clone(); + for (int o = 0; o < octaves; o++) { + double n = noise.noise(c, vx, vy, wrap); + sum += (fractal ? n : Math.abs(n)) / ratio; + vx *= 2; + vy *= 2; + ratio *= 2; + if (wrap != null) { + // Twice as many cells in an octave up, and the + // tile ends twice as far along the lattice. + wrap[2] *= 2; + wrap[0] = 2 * wrap[0] - PERLIN_N; + wrap[3] *= 2; + wrap[1] = 2 * wrap[1] - PERLIN_N; + } + } + v[c] = clamp((float) (fractal ? (sum + 1) / 2 : sum)); + } + int i = y * w + x; + out.a[i] = v[3]; + out.r[i] = v[0] * v[3]; + out.g[i] = v[1] * v[3]; + out.b[i] = v[2] * v[3]; + } + } + return out; + } + + // ---- feDiffuseLighting and feSpecularLighting. + + private Plane lighting(Primitive p, Plane in, boolean linear, boolean specular) { + Plane out = new Plane(w, h, linear); + Primitive light = null; + for (Primitive child : p.getChildren()) { + String n = child.getName(); + if ("feDistantLight".equals(n) || "fePointLight".equals(n) || "feSpotLight".equals(n)) { + light = child; + break; + } + } + if (light == null) { + return out; + } + float[] colour = colour(p.get("lighting-color"), linear, 0xffffffff); + double surfaceScale = number(p, "surfaceScale", 1); + double constant = number(p, specular ? "specularConstant" : "diffuseConstant", 1); + double exponent = number(p, "specularExponent", 1); + boolean distant = "feDistantLight".equals(light.getName()); + boolean spot = "feSpotLight".equals(light.getName()); + double azimuth = Math.toRadians(number(light, "azimuth", 0)); + double elevation = Math.toRadians(number(light, "elevation", 0)); + // The light is placed in the element's coordinates; its height is + // taken to scale as its position across does. + Point2D at0 = at.transform(new Point2D.Double(number(light, "x", 0), number(light, "y", 0)), null); + double lz = number(light, "z", 0) * ux; + Point2D to = at.transform(new Point2D.Double(number(light, "pointsAtX", 0), + number(light, "pointsAtY", 0)), null); + double toZ = number(light, "pointsAtZ", 0) * ux; + double spotExponent = number(light, "specularExponent", 1); + String cone = light.get("limitingConeAngle"); + double coneCos = cone == null ? -2 : Math.cos(Math.toRadians(Math.abs(number(light, "limitingConeAngle", 0)))); + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + // The slope of the surface the opacity describes. + double nx = -surfaceScale / 4.0 * ((height(in, x + 1, y - 1) + 2 * height(in, x + 1, y) + + height(in, x + 1, y + 1)) - (height(in, x - 1, y - 1) + 2 * height(in, x - 1, y) + + height(in, x - 1, y + 1))); + double ny = -surfaceScale / 4.0 * ((height(in, x - 1, y + 1) + 2 * height(in, x, y + 1) + + height(in, x + 1, y + 1)) - (height(in, x - 1, y - 1) + 2 * height(in, x, y - 1) + + height(in, x + 1, y - 1))); + double nz = 1; + double nl = Math.sqrt(nx * nx + ny * ny + nz * nz); + nx /= nl; + ny /= nl; + nz /= nl; + double lx; + double ly; + double lzz; + if (distant) { + lx = Math.cos(azimuth) * Math.cos(elevation); + ly = Math.sin(azimuth) * Math.cos(elevation); + lzz = Math.sin(elevation); + } else { + lx = at0.getX() - x; + ly = at0.getY() - y; + lzz = lz - surfaceScale * height(in, x, y); + double ll = Math.sqrt(lx * lx + ly * ly + lzz * lzz); + if (ll > 0) { + lx /= ll; + ly /= ll; + lzz /= ll; + } + } + double strength = 1; + if (spot) { + double sx = to.getX() - at0.getX(); + double sy = to.getY() - at0.getY(); + double sz = toZ - lz; + double sl = Math.sqrt(sx * sx + sy * sy + sz * sz); + double towards = sl > 0 ? -(lx * sx + ly * sy + lzz * sz) / sl : 0; + strength = towards <= 0 || towards < coneCos ? 0 : Math.pow(towards, spotExponent); + } + double amount; + if (specular) { + double hx = lx; + double hy = ly; + double hz = lzz + 1; + double hl = Math.sqrt(hx * hx + hy * hy + hz * hz); + double nh = hl > 0 ? (nx * hx + ny * hy + nz * hz) / hl : 0; + amount = constant * Math.pow(Math.max(0, nh), exponent); + } else { + amount = constant * Math.max(0, nx * lx + ny * ly + nz * lzz); + } + amount *= strength; + int i = y * w + x; + out.r[i] = clamp((float) (amount * colour[0])); + out.g[i] = clamp((float) (amount * colour[1])); + out.b[i] = clamp((float) (amount * colour[2])); + out.a[i] = specular ? Math.max(out.r[i], Math.max(out.g[i], out.b[i])) : 1f; + } + } + return out; + } + + private float height(Plane in, int x, int y) { + return in.a[Math.max(0, Math.min(h - 1, y)) * w + Math.max(0, Math.min(w - 1, x))]; + } + + /// An opacity attribute, 1 when it is not given. `50%` is a half. + private static float alpha(Primitive p, String attribute) { + String value = p.get(attribute); + if (value != null && value.trim().endsWith("%")) { + // The sign is taken off before the number is read, which with + // it on is no number at all. + double[] v = numbers(value.trim().substring(0, value.trim().length() - 1)); + return v.length == 0 ? 1f : (float) (v[0] / 100); + } + return (float) number(p, attribute, 1); + } + + private static double number(Primitive p, String attribute, double fallback) { + double[] v = numbers(p.get(attribute)); + return v.length == 0 ? fallback : v[0]; + } + + /// The numbers of a list, as many as can be read from its start. + private static double[] numbers(String value) { + if (value == null || value.trim().length() == 0) { + return new double[0]; + } + String[] parts = value.trim().split("[\\s,]+"); + double[] out = new double[parts.length]; + int n = 0; + for (String part : parts) { + try { + out[n] = Double.parseDouble(part); + } catch (NumberFormatException ex) { + break; + } + n++; + } + return n == out.length ? out : java.util.Arrays.copyOf(out, n); + } + + private static float clamp(float v) { + return v < 0f ? 0f : (v > 1f ? 1f : v); + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/VectorImage.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/VectorImage.java new file mode 100644 index 00000000000..e8d79a20f6c --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/raster/VectorImage.java @@ -0,0 +1,2760 @@ +/* + * 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 com.codename1.lottie.transcoder.parser.LottieParser; +import com.codename1.svg.transcoder.model.SVGCircle; +import com.codename1.svg.transcoder.model.SVGClipPath; +import com.codename1.svg.transcoder.model.SVGDocument; +import com.codename1.svg.transcoder.model.SVGEllipse; +import com.codename1.svg.transcoder.model.SVGFilter; +import com.codename1.svg.transcoder.model.SVGGradientStop; +import com.codename1.svg.transcoder.model.SVGGroup; +import com.codename1.svg.transcoder.model.SVGImage; +import com.codename1.svg.transcoder.model.SVGLine; +import com.codename1.svg.transcoder.model.SVGLinearGradient; +import com.codename1.svg.transcoder.model.SVGMarker; +import com.codename1.svg.transcoder.model.SVGNode; +import com.codename1.svg.transcoder.model.SVGPath; +import com.codename1.svg.transcoder.model.SVGPattern; +import com.codename1.svg.transcoder.model.SVGPolyline; +import com.codename1.svg.transcoder.model.SVGRadialGradient; +import com.codename1.svg.transcoder.model.SVGRect; +import com.codename1.svg.transcoder.model.SVGSymbol; +import com.codename1.svg.transcoder.model.SVGText; +import com.codename1.svg.transcoder.model.SVGUse; +import com.codename1.svg.transcoder.model.SVGViewport; +import com.codename1.svg.transcoder.parser.PathCommand; +import com.codename1.svg.transcoder.parser.SVGPaint; +import com.codename1.svg.transcoder.parser.SVGParser; +import com.codename1.svg.transcoder.parser.SVGStyle; +import com.codename1.svg.transcoder.parser.SVGTransform; +import java.awt.AlphaComposite; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.LinearGradientPaint; +import java.awt.MultipleGradientPaint; +import java.awt.Paint; +import java.awt.RadialGradientPaint; +import java.awt.Shape; +import java.awt.geom.AffineTransform; +import java.awt.geom.Arc2D; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.geom.RoundRectangle2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/// An SVG or Lottie file, painted with Java2D. +/// +/// The file is read by the parsers the build already transcodes these files +/// with (`codenameone-svg-transcoder` and `codenameone-lottie-transcoder`), so +/// what is understood here is what is understood there. An application draws +/// such a file at runtime from the class those transcoders generate; a rule +/// that needs a generated image has its background baked into that image, and +/// this is what bakes it. +/// +/// Nothing here is shared with the class the transcoders generate, which +/// draws through the framework at runtime, so the two can differ: this +/// follows `` references and `fill-rule`, tiles a ``, sees +/// through a ``, draws markers and applies filters, none of which +/// that class does. +/// +/// An animated file is painted at rest: the values its elements are written +/// with, before any animation has moved them. A Lottie file is painted the +/// same way, from the first frame the parser resolves. +public final class VectorImage { + private final SVGDocument doc; + /// Whether the drawing is fitted to the viewport it is painted into. + private final boolean scales; + /// The symbol a `` is drawing a copy of at the moment, or null. + private SVGNode instanced; + private static final int MAX_USE_DEPTH = 16; + /// The size of text whose font size nobody sets: CSS's `medium`, which + /// is 16 pixels. + private static final float INITIAL_FONT_SIZE = 16f; + /// How many `` elements deep the painting is. One image is painted + /// by one thread at a time, which `paint` sees to. + private int useDepth; + + /// Where the file is, for the pictures it names beside itself; null + /// when it was not read from anywhere that has a beside. + private java.net.URL base; + /// How many files deep this one is: a picture inside an SVG can be an + /// SVG with pictures of its own, and one that names itself would never + /// end. + private int fileDepth; + /// Whether this was read from an SVG, which may leave its size open. + private boolean svg; + /// The `preserveAspectRatio` of the `` this file is drawn by, + /// which takes the place of its own; null when it is drawn by none. + private String fitAs; + private static final int MAX_FILE_DEPTH = 4; + + private VectorImage(SVGDocument doc, boolean scales) { + this.doc = doc; + this.scales = scales; + } + + /// Reads an SVG document. + public static VectorImage readSvg(InputStream in) throws IOException { + return readSvg(in, null); + } + + /// Reads an SVG document that is at `base`, which is what the pictures + /// it names with a relative address are found against. + public static VectorImage readSvg(InputStream in, java.net.URL base) throws IOException { + SVGDocument doc = new SVGParser().parse(in); + VectorImage image = new VectorImage(doc, doc.isViewBoxDeclared()); + image.base = base; + image.svg = true; + return image; + } + + /// Reads a Lottie animation. + /// + /// The stream is either the animation's JSON or a dotLottie archive, a + /// zip of animations of which the one its manifest picks is read. + public static VectorImage readLottie(InputStream in) throws IOException { + java.io.BufferedInputStream buffered = new java.io.BufferedInputStream(in); + buffered.mark(4); + int first = buffered.read(); + int second = buffered.read(); + buffered.reset(); + if (first != 'P' || second != 'K') { + return new VectorImage(LottieParser.parse(buffered), true); + } + String json = com.codename1.lottie.transcoder.parser.DotLottie.animation(buffered); + if (json == null) { + throw new IOException("the dotLottie archive holds no animation"); + } + return new VectorImage(LottieParser.parse(new java.io.ByteArrayInputStream( + json.getBytes(java.nio.charset.StandardCharsets.UTF_8))), true); + } + + // A file that gives only a view box is the size of that box: the + // parser fills in a side the root element leaves open, in the shape of + // the view box. A file with neither has no size of its own on that + // side, and is as large as whatever it is put in: a background's + // positioning area, which is for the caller to give. With nothing to + // be put in it is the 300 by 150 a browser falls back to. + private static final double DEFAULT_WIDTH = 300; + private static final double DEFAULT_HEIGHT = 150; + + /// Whether the file says how wide it is: an SVG that gives a width, or + /// a height and a view box to work the width out from. A view box + /// alone is a shape and not a size; see [#hasShape]. A Lottie + /// animation always says. + public boolean hasWidth() { + return doc.getWidth() > 0 && (!svg || doc.isGiven("width") + || (doc.isGiven("height") && doc.isViewBoxDeclared())); + } + + /// Whether the file says how high it is; see [#hasWidth]. + public boolean hasHeight() { + return doc.getHeight() > 0 && (!svg || doc.isGiven("height") + || (doc.isGiven("width") && doc.isViewBoxDeclared())); + } + + /// Whether the file has a shape, a width to its height, even where it + /// has no size: [#getWidth] and [#getHeight] are then in that shape. + public boolean hasShape() { + return doc.getWidth() > 0 && doc.getHeight() > 0; + } + + /// The width the file asks for, in CSS pixels; 300 when [#hasWidth] is false. + public double getWidth() { + return doc.getWidth() > 0 ? doc.getWidth() : DEFAULT_WIDTH; + } + + /// The height the file asks for, in CSS pixels; 150 when [#hasHeight] is false. + public double getHeight() { + return doc.getHeight() > 0 ? doc.getHeight() : DEFAULT_HEIGHT; + } + + /// Paints the file into a viewport of `width` by `height` CSS pixels. + /// + /// The view box is fitted to the viewport the way the file's + /// `preserveAspectRatio` says, which by default keeps its shape and + /// centres it. A file with no view box is not scaled at all, and a Lottie + /// animation is fitted like an SVG with one. + /// + /// #### Parameters + /// + /// - `width`: viewport width + /// + /// - `height`: viewport height + /// + /// - `maxSide`: the most pixels the image may have on a side. A larger + /// viewport is painted smaller, in proportion, for the caller to + /// stretch back. + public synchronized BufferedImage paint(double width, double height, int maxSide) { + double k = Math.min(1.0, maxSide / Math.max(1.0, Math.max(width, height))); + if (!(k > 0)) { + k = 1; + } + int w = (int) Math.max(1, Math.min(maxSide, Math.ceil(width * k))); + int h = (int) Math.max(1, Math.min(maxSide, Math.ceil(height * k))); + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + SVGStyle root = doc.getStyle(); + if ((root != null && root.isDisplayNone()) || doc.isGiven("empty")) { + // Not displayed, or given a width or a height of nothing. + return out; + } + Graphics2D g = out.createGraphics(); + try { + Pixels.hints(g); + g.scale(k, k); + AffineTransform view = new AffineTransform(); + if (scales) { + double vw = doc.getViewBoxWidth() > 0 ? doc.getViewBoxWidth() : getWidth(); + double vh = doc.getViewBoxHeight() > 0 ? doc.getViewBoxHeight() : getHeight(); + String par = fitAs != null ? fitAs.trim() + : doc.getPreserveAspectRatio() == null ? "" : doc.getPreserveAspectRatio().trim(); + double sx = width / vw; + double sy = height / vh; + double tx = 0; + double ty = 0; + if (!par.startsWith("none")) { + double s = par.endsWith("slice") ? Math.max(sx, sy) : Math.min(sx, sy); + double freeX = width - vw * s; + double freeY = height - vh * s; + tx = par.startsWith("xMin") ? 0 : (par.startsWith("xMax") ? freeX : freeX / 2); + ty = par.indexOf("YMin") >= 0 ? 0 : (par.indexOf("YMax") >= 0 ? freeY : freeY / 2); + sx = s; + sy = s; + } + view.translate(tx, ty); + view.scale(sx, sy); + view.translate(-doc.getViewBoxX(), -doc.getViewBoxY()); + } + SVGGroup framed = rootEffects(view, width, height); + if (framed != null) { + paintNode(g, framed, doc.getStyle(), w, h); + } else { + g.transform(view); + for (SVGNode child : doc.getChildren()) { + paintNode(g, child, doc.getStyle(), w, h); + } + } + } finally { + g.dispose(); + } + // The opacity of the root element is not something its children + // inherit: it fades the finished drawing, once and as a whole. + float opacity = root == null || root.getOpacity() == null ? 1f : clamp(root.getOpacity().floatValue()); + if (opacity < 1f) { + int[] px = out.getRGB(0, 0, w, h, null, 0, w); + for (int i = 0; i < px.length; i++) { + px[i] = (Math.round((px[i] >>> 24) * opacity) << 24) | (px[i] & 0xffffff); + } + out.setRGB(0, 0, w, h, px, 0, w); + } + return out; + } + + /// The drawing as one group carrying what the root element does to it + /// as a whole: its `transform`, `clip-path`, `mask` and `filter`. None + /// of them is inherited, so handing the root's style down to its + /// children applies none of them. Null when the root asks for none. + /// + /// They belong to the root element and not to what is inside it, so + /// they are worked out in the viewport's coordinates, before the view + /// box is fitted. The root is the one element of the file that is a + /// box on the page, which is why its transform turns about the middle + /// of the viewport, where a transform on anything inside turns about + /// the origin. + private SVGGroup rootEffects(AffineTransform view, double width, double height) { + SVGStyle root = doc.getStyle(); + SVGTransform turned = doc.getTransform(); + boolean styled = root != null + && (root.getClipPathRef() != null || root.getMaskRef() != null || root.getFilterRef() != null); + if (!styled && (turned == null || turned.isIdentity())) { + return null; + } + SVGGroup inner = new SVGGroup(); + double[] m = new double[6]; + view.getMatrix(m); + inner.setTransform(new SVGTransform((float) m[0], (float) m[1], (float) m[2], (float) m[3], + (float) m[4], (float) m[5])); + for (SVGNode child : doc.getChildren()) { + inner.addChild(child); + } + SVGGroup outer = new SVGGroup(); + outer.addChild(inner); + if (styled) { + SVGStyle effects = new SVGStyle(); + effects.setClipPathRef(root.getClipPathRef()); + effects.setMaskRef(root.getMaskRef()); + effects.setFilterRef(root.getFilterRef()); + outer.setStyle(effects); + } + if (turned != null && !turned.isIdentity()) { + AffineTransform about = AffineTransform.getTranslateInstance(width / 2, height / 2); + about.concatenate(new AffineTransform(turned.a, turned.b, turned.c, turned.d, turned.e, turned.f)); + about.translate(-width / 2, -height / 2); + about.getMatrix(m); + outer.setTransform(new SVGTransform((float) m[0], (float) m[1], (float) m[2], (float) m[3], + (float) m[4], (float) m[5])); + } + return outer; + } + + /// Paints the file into a viewport of `width` by `height` pixels. + public BufferedImage paint(int width, int height) { + return paint(width, height, Math.max(1, Math.max(width, height))); + } + + private void paintNode(Graphics2D g, SVGNode node, SVGStyle parentStyle, int w, int h) { + // Worked out on a style of its own. Filling in the element's would + // fix what it inherits the first time it is drawn, and an element a + // `` draws twice inherits from each of them in turn. + SVGStyle style = new SVGStyle(); + SVGStyle own = node.getStyle(); + if (own != null) { + // Not a symbol a `` is drawing: its `display` is that of + // the definition, which is never shown, and says nothing of + // the copy the `` makes of it. + if (own.isDisplayNone() && node != instanced) { + return; + } + style.inherit(own); + style.setOpacity(own.isOpacityInherited() && parentStyle != null ? parentStyle.getOpacity() + : own.getOpacity()); + style.setClipPathRef(own.getClipPathRef()); + style.setNonScalingStroke(own.isNonScalingStroke()); + } + style.inherit(parentStyle); + // `currentColor` is the `color` of the element it is used on, which + // is its own or the nearest one above it, and black when nobody + // gives one. It is put in here, each time, because an element that + // inherits a `currentColor` fill takes the word and not the colour: + // it stands for that element's own `color`. + int current = style.getColor() == null ? 0xff000000 : style.getColor().intValue(); + if (style.isFillCurrent()) { + style.setFill(SVGPaint.ofColor(current)); + } + if (style.isStrokeCurrent()) { + style.setStroke(SVGPaint.ofColor(current)); + } + // The same word after a reference: what to paint with when nothing + // has that id. + if (style.getFill() != null && style.getFill().isFallbackCurrent()) { + style.setFill(style.getFill().withCurrent(current)); + } + if (style.getStroke() != null && style.getStroke().isFallbackCurrent()) { + style.setStroke(style.getStroke().withCurrent(current)); + } + // `context-fill` and `context-stroke` are the fill and the stroke + // of the element a marker is drawn on, so that one arrowhead takes + // the colour of every line it ends. Anywhere else they are nothing. + if (style.getFillContext() != null) { + style.setFill("fill".equals(style.getFillContext()) ? contextFill : contextStroke); + } + if (style.getStrokeContext() != null) { + style.setStroke("fill".equals(style.getStrokeContext()) ? contextFill : contextStroke); + } + float opacity = style.getOpacity() == null ? 1f : clamp(style.getOpacity().floatValue()); + if (opacity <= 0f) { + return; + } + SVGFilter filter = null; + if (own != null && own.getFilterRef() != null) { + SVGNode def = doc.getDefinitions().get(own.getFilterRef()); + if (def instanceof SVGFilter) { + filter = inherited((SVGFilter) def); + } + } + SVGClipPath mask = null; + if (own != null && own.getMaskRef() != null && useDepth < MAX_USE_DEPTH) { + SVGNode def = doc.getDefinitions().get(own.getMaskRef()); + if (def instanceof SVGClipPath && ((SVGClipPath) def).isMask()) { + mask = (SVGClipPath) def; + } + } + if (opacity < 1f || mask != null || filter != null) { + // An element that is not opaque is painted whole on a layer of + // its own, and the layer is laid down once at that opacity. + // Fading its parts one by one would let them show through each + // other where they overlap. + // The layer is the size of the image being painted, which is + // handed down: what a Graphics2D reports as its device is not + // the image behind it on every JDK. + BufferedImage layer = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + // A filter comes before the clip: what it spreads, a blur for + // one, is cut off at the clip's edge and not blurred past it. + String clipRef = style.getClipPathRef(); + if (filter != null) { + style.setClipPathRef(null); + } + Graphics2D lg = layer.createGraphics(); + try { + Pixels.hints(lg); + lg.setTransform(g.getTransform()); + lg.setClip(g.getClip()); + paintOpaque(lg, node, style, w, h); + } finally { + lg.dispose(); + } + if (filter != null) { + AffineTransform device = new AffineTransform(g.getTransform()); + SVGTransform t = node.getTransform(); + if (t != null) { + device.concatenate(new AffineTransform(t.a, t.b, t.c, t.d, t.e, t.f)); + } + BufferedImage fillPaint = SvgFilter.reads(filter, "FillPaint") + ? flooded(style.getFill() == null ? SVGPaint.BLACK : style.getFill(), + style.getFillOpacity(), node, device, w, h) : null; + BufferedImage strokePaint = SvgFilter.reads(filter, "StrokePaint") + ? flooded(style.getStroke(), style.getStrokeOpacity(), node, device, w, h) : null; + SvgFilter.apply(layer, filter, boundsOf(node), device, fillPaint, strokePaint, + new SvgFilter.Pictures() { + @Override + public BufferedImage picture(SVGFilter.Primitive p, Rectangle2D area, + AffineTransform toPixels, int pw, int ph) { + return filterPicture(p, area, toPixels, pw, ph); + } + }); + Shape outline = clipRef == null ? null : clipShape(node, clipRef); + if (outline != null) { + BufferedImage cut = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D cg = cut.createGraphics(); + try { + Pixels.hints(cg); + cg.setTransform(device); + cg.clip(outline); + cg.setTransform(new AffineTransform()); + cg.drawImage(layer, 0, 0, null); + } finally { + cg.dispose(); + } + layer = cut; + } + } + if (mask != null) { + applyMask(layer, mask, node, g, w, h); + } + AffineTransform at = g.getTransform(); + java.awt.Composite composite = g.getComposite(); + try { + g.setTransform(new AffineTransform()); + g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); + g.drawImage(layer, 0, 0, null); + } finally { + g.setComposite(composite); + g.setTransform(at); + } + return; + } + paintOpaque(g, node, style, w, h); + } + + /// The whole image covered with a paint of `node`, its fill or its + /// stroke, which is what a filter reads as `FillPaint` or `StrokePaint`. + /// Empty when the paint is `none`. + private BufferedImage flooded(SVGPaint paint, Float opacity, SVGNode node, AffineTransform device, int w, int h) { + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + if (paint == null || paint.isNone()) { + return out; + } + Graphics2D fg = out.createGraphics(); + try { + Pixels.hints(fg); + fg.setTransform(device); + Shape all = device.createInverse().createTransformedShape(new Rectangle2D.Double(0, 0, w, h)); + Rectangle2D box = boundsOf(node); + paintWith(fg, paint, opacity == null ? 1f : clamp(opacity.floatValue()), all, + box == null ? all.getBounds2D() : box); + } catch (java.awt.geom.NoninvertibleTransformException ex) { + // Squashed flat: there is nowhere to paint. + return out; + } finally { + fg.dispose(); + } + return out; + } + + /// Lets through as much of `layer`, which holds `node`, as `mask` says. + /// + /// The mask's content is painted on a layer of its own, in the + /// coordinates of the element it masks. Each pixel of the element then + /// keeps the share of its opacity that the mask's pixel there is light + /// and opaque, or for an alpha mask just opaque. Where the mask paints + /// nothing, and everywhere outside its region, nothing of the element + /// is left. + private void applyMask(BufferedImage layer, SVGClipPath mask, SVGNode node, Graphics2D g, int w, int h) { + BufferedImage seen = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D mg = seen.createGraphics(); + useDepth++; + try { + Pixels.hints(mg); + mg.setTransform(g.getTransform()); + SVGTransform t = node.getTransform(); + if (t != null) { + mg.transform(new AffineTransform(t.a, t.b, t.c, t.d, t.e, t.f)); + } + // The mask covers a region and no more: outside it nothing of + // the element shows, whatever the mask's content would paint + // there. The region is in fractions of the element's bounding + // box unless it says otherwise, and for an element with no box + // to measure that leaves no region to keep to. + Rectangle2D box = boundsOf(node); + if (!mask.isRegionUserSpace() && box != null && box.isEmpty()) { + // A region in shares of a box with no width or no height, + // around a level line say, is no region, and the mask is + // then ignored (SVG 1.1, 7.11): the element is left as it + // is drawn. + return; + } + if (mask.isRegionUserSpace()) { + mg.clip(new Rectangle2D.Double(mask.getRegionX(), mask.getRegionY(), + mask.getRegionWidth(), mask.getRegionHeight())); + } else if (box != null && !box.isEmpty()) { + mg.clip(new Rectangle2D.Double(box.getX() + mask.getRegionX() * box.getWidth(), + box.getY() + mask.getRegionY() * box.getHeight(), + mask.getRegionWidth() * box.getWidth(), mask.getRegionHeight() * box.getHeight())); + } + boolean measurable = true; + if (mask.isObjectBoundingBox()) { + measurable = box != null && !box.isEmpty(); + if (measurable) { + mg.transform(new AffineTransform(box.getWidth(), 0, 0, box.getHeight(), box.getX(), box.getY())); + } + } + if (measurable) { + for (SVGNode child : mask.getChildren()) { + paintNode(mg, child, mask.getStyle(), w, h); + } + } + } finally { + useDepth--; + mg.dispose(); + } + int[] kept = layer.getRGB(0, 0, w, h, null, 0, w); + int[] through = seen.getRGB(0, 0, w, h, null, 0, w); + for (int i = 0; i < kept.length; i++) { + int a = kept[i] >>> 24; + if (a == 0) { + continue; + } + int m = through[i]; + float share = (m >>> 24) / 255f; + if (!mask.isAlphaMask() && mask.isLinearLuminance()) { + // The brightness of the light, not of the number written + // for it: a mid grey lets a fifth through, not a half. + share *= (float) (0.2126 * linearLight((m >> 16) & 0xff) + 0.7152 * linearLight((m >> 8) & 0xff) + + 0.0722 * linearLight(m & 0xff)); + } else if (!mask.isAlphaMask()) { + share *= (0.2126f * ((m >> 16) & 0xff) + 0.7152f * ((m >> 8) & 0xff) + 0.0722f * (m & 0xff)) / 255f; + } + kept[i] = (Math.round(a * share) << 24) | (kept[i] & 0xffffff); + } + layer.setRGB(0, 0, w, h, kept, 0, w); + } + + /// An sRGB channel, 0 to 255, as linear light, 0 to 1. + private static double linearLight(int channel) { + double c = channel / 255.0; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + } + + /// Draws the picture an `` names into the box it gives, fitted + /// the way its `preserveAspectRatio` says. One that cannot be found or + /// read leaves its box empty, as it does in a browser. + private void paintImage(Graphics2D g, SVGImage image) { + // A width or a height of nothing, written out, is not one left to + // the picture: it shows nothing. + if ((image.isGiven("width") && !(image.getWidth() > 0)) + || (image.isGiven("height") && !(image.getHeight() > 0))) { + return; + } + BufferedImage picture = loadPicture(image); + if (picture == null) { + return; + } + double bw = image.getWidth() > 0 ? image.getWidth() : picture.getWidth(); + double bh = image.getHeight() > 0 ? image.getHeight() : picture.getHeight(); + String par = undeferred(image.getPreserveAspectRatio()); + double sx = bw / picture.getWidth(); + double sy = bh / picture.getHeight(); + double tx = 0; + double ty = 0; + if (!par.startsWith("none")) { + double s = par.endsWith("slice") ? Math.max(sx, sy) : Math.min(sx, sy); + double freeX = bw - picture.getWidth() * s; + double freeY = bh - picture.getHeight() * s; + tx = par.startsWith("xMin") ? 0 : (par.startsWith("xMax") ? freeX : freeX / 2); + ty = par.indexOf("YMin") >= 0 ? 0 : (par.indexOf("YMax") >= 0 ? freeY : freeY / 2); + sx = s; + sy = s; + } + Shape savedClip = g.getClip(); + Object savedHint = g.getRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION); + try { + g.clip(new Rectangle2D.Double(image.getX(), image.getY(), bw, bh)); + g.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION, + java.awt.RenderingHints.VALUE_INTERPOLATION_BICUBIC); + AffineTransform at = new AffineTransform(); + at.translate(image.getX() + tx, image.getY() + ty); + at.scale(sx, sy); + g.drawImage(picture, at, null); + } finally { + g.setClip(savedClip); + if (savedHint != null) { + g.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION, savedHint); + } + } + } + + /// What an `feImage` shows: a picture, fitted into the effect's area + /// like an ``, or an element of the file, drawn like a `` + /// of it. Null when it names neither. + private BufferedImage filterPicture(SVGFilter.Primitive p, Rectangle2D area, AffineTransform toPixels, + int w, int h) { + String href = p.get("href"); + if (href == null || href.trim().length() == 0 || useDepth >= MAX_USE_DEPTH) { + return null; + } + href = href.trim(); + BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = out.createGraphics(); + useDepth++; + try { + Pixels.hints(g); + g.setTransform(toPixels); + if (href.charAt(0) == '#') { + SVGNode target = doc.getDefinitions().get(href.substring(1)); + if (target == null || target instanceof SVGFilter) { + return null; + } + // Where the effect says, when it says: left out, the + // element stays where it was drawn. + if (p.get("x") != null || p.get("y") != null) { + g.translate(p.get("x") != null ? area.getX() : 0, p.get("y") != null ? area.getY() : 0); + } + paintNode(g, target, new SVGStyle(), w, h); + return out; + } + SVGImage image = new SVGImage(); + image.setHref(href); + image.setX((float) area.getX()); + image.setY((float) area.getY()); + image.setWidth((float) area.getWidth()); + image.setHeight((float) area.getHeight()); + image.setPreserveAspectRatio(p.get("preserveAspectRatio")); + if (loadPicture(image) == null) { + return null; + } + paintImage(g, image); + return out; + } finally { + useDepth--; + g.dispose(); + } + } + + /// A `preserveAspectRatio` without the `defer` an `` may put + /// before it, which only matters to a picture that has one of its own. + private static String undeferred(String par) { + String p = par == null ? "" : par.trim(); + return p.startsWith("defer") ? p.substring(5).trim() : p; + } + + /// The pixels of the picture an `` names, or null. A `data:` + /// address is decoded; any other is a file beside this one. An SVG is + /// painted at the size of the box it goes in. + private BufferedImage loadPicture(SVGImage image) { + String href = image.getHref(); + if (href == null || href.length() == 0 || fileDepth >= MAX_FILE_DEPTH) { + return null; + } + try { + byte[] data; + boolean svg; + java.net.URL at = null; + if (DataUrl.is(href)) { + DataUrl held = DataUrl.parse(href); + if (held == null) { + return null; + } + svg = held.isSvg(); + data = held.getBytes(); + } else { + // Only a file: an image baked into a theme is not something + // to fetch from a server named inside a picture. + if (base == null || href.indexOf(':') >= 0) { + return null; + } + at = new java.net.URL(base, href); + if (!"file".equals(at.getProtocol())) { + return null; + } + String path = at.getPath(); + svg = path.length() >= 4 && path.regionMatches(true, path.length() - 4, ".svg", 0, 4); + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + try (InputStream in = at.openStream()) { + byte[] chunk = new byte[8192]; + for (int n = in.read(chunk); n >= 0; n = in.read(chunk)) { + bytes.write(chunk, 0, n); + } + } + data = bytes.toByteArray(); + } + if (!svg) { + return javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(data)); + } + VectorImage inner = readSvg(new java.io.ByteArrayInputStream(data), at); + inner.fileDepth = fileDepth + 1; + double pw = image.getWidth() > 0 ? image.getWidth() : inner.getWidth(); + double ph = image.getHeight() > 0 ? image.getHeight() : inner.getHeight(); + if (!inner.scales) { + // No view box to fit: it is the size it says, and is laid + // in the box like any other picture. + return inner.paint(inner.getWidth(), inner.getHeight(), 4096); + } + // How it is fitted to the box is for the `` to say. What + // the file's own root says is for when it is shown by itself. + // Unless it defers: then the file's own, where it has one. + String asked = image.getPreserveAspectRatio() == null ? "" : image.getPreserveAspectRatio().trim(); + inner.fitAs = asked.startsWith("defer") && inner.doc.getPreserveAspectRatio() != null ? null + : undeferred(asked); + return inner.paint(pw, ph, 4096); + } catch (IOException ex) { + return null; + } catch (RuntimeException ex) { + return null; + } + } + + /// A filter with what it takes from the one it names through `href`: + /// the effects, when it has none of its own, and each of the region, + /// the region's units and the effects' units that it does not give. + /// The filter itself when it names none. + private SVGFilter inherited(SVGFilter filter) { + SVGFilter from = filter; + SVGFilter out = filter; + for (int depth = 0; depth < MAX_USE_DEPTH && from.getHref() != null; depth++) { + SVGNode def = doc.getDefinitions().get(from.getHref()); + if (!(def instanceof SVGFilter) || def == filter) { + break; + } + from = (SVGFilter) def; + if (out == filter) { + out = new SVGFilter(); + out.setId(filter.getId()); + out.getPrimitives().addAll(filter.getPrimitives()); + out.setRegion(filter.getRegionX(), filter.getRegionY(), filter.getRegionWidth(), + filter.getRegionHeight()); + out.setRegionUserSpace(filter.isRegionUserSpace()); + out.setPrimitiveObjectBoundingBox(filter.isPrimitiveObjectBoundingBox()); + out.setSrgb(filter.isSrgb()); + for (String given : new String[] {"x", "y", "width", "height", "filterUnits", "primitiveUnits"}) { + if (filter.isGiven(given)) { + out.markGiven(given); + } + } + } + if (out.getPrimitives().isEmpty()) { + out.getPrimitives().addAll(from.getPrimitives()); + out.setSrgb(from.isSrgb()); + } + // Each side it does not give, and the units when it gives + // none. A side is kept as the number written, which reads the + // same in either units, so one taken from here goes with the + // units taken from there. + float[] region = {out.getRegionX(), out.getRegionY(), out.getRegionWidth(), out.getRegionHeight()}; + float[] theirs = {from.getRegionX(), from.getRegionY(), from.getRegionWidth(), from.getRegionHeight()}; + String[] sides = {"x", "y", "width", "height"}; + for (int k = 0; k < sides.length; k++) { + if (!out.isGiven(sides[k]) && from.isGiven(sides[k])) { + region[k] = theirs[k]; + out.markGiven(sides[k]); + } + } + if (!out.isGiven("filterUnits") && from.isGiven("filterUnits")) { + out.setRegionUserSpace(from.isRegionUserSpace()); + out.markGiven("filterUnits"); + // The sides nobody gives are a tenth out all round, in + // those units: as the filter they come from has them. + for (int k = 0; k < sides.length; k++) { + if (!out.isGiven(sides[k])) { + region[k] = theirs[k]; + } + } + } + out.setRegion(region[0], region[1], region[2], region[3]); + if (!out.isGiven("primitiveUnits") && from.isGiven("primitiveUnits")) { + out.setPrimitiveObjectBoundingBox(from.isPrimitiveObjectBoundingBox()); + out.markGiven("primitiveUnits"); + } + } + return out; + } + + /// Paints `node` with everything but its own opacity. + private void paintOpaque(Graphics2D g, SVGNode node, SVGStyle style, int w, int h) { + AffineTransform savedTransform = g.getTransform(); + Shape savedClip = g.getClip(); + try { + SVGTransform t = node.getTransform(); + if (t != null) { + g.transform(new AffineTransform(t.a, t.b, t.c, t.d, t.e, t.f)); + } + if (style.getClipPathRef() != null) { + Shape outline = clipShape(node, style.getClipPathRef()); + if (outline != null) { + g.clip(outline); + } + } + if (node instanceof SVGClipPath) { + // A definition. It is used through clip-path, never drawn. + return; + } + if (node.isGiven("transform-box")) { + throw new IllegalArgumentException("the SVG transforms an element about its own box" + + " (transform-box), which is not supported here. Give transform-origin as a point of the" + + " drawing, or write the transform out"); + } + if (node instanceof com.codename1.svg.transcoder.model.SVGForeignObject) { + // What is in one is a page, HTML as a rule, and drawing it + // takes a browser. Leaving it out would give an image with + // a hole where a label was and no word of it, so the image + // is refused, with what to do about it. Only here, where + // it would be drawn: one nothing shows is no fault. + throw new IllegalArgumentException("the SVG holds a , whose content cannot be drawn" + + " here. Put it in a with something to draw in its place, or take it out"); + } + if (node instanceof SVGUse) { + paintUse(g, (SVGUse) node, style, w, h); + return; + } + if (node instanceof SVGViewport) { + // An `` inside the drawing: its content is laid out in + // its box and cut off at the box's edges. A box with no + // size shows nothing. + SVGViewport port = (SVGViewport) node; + if (port.getWidth() > 0 && port.getHeight() > 0) { + if (!port.isOverflowVisible()) { + g.clip(new Rectangle2D.Double(port.getX(), port.getY(), port.getWidth(), port.getHeight())); + } + g.transform(layout(port)); + for (SVGNode child : port.getContent().getChildren()) { + paintNode(g, child, style, w, h); + } + } + } else if (node instanceof SVGGroup) { + for (SVGNode child : ((SVGGroup) node).getChildren()) { + paintNode(g, child, style, w, h); + } + } else if (Boolean.FALSE.equals(style.getVisible())) { + // Hidden, which unlike `display: none` a child may undo. + return; + } else if (node instanceof SVGImage) { + paintImage(g, (SVGImage) node); + } else if (node instanceof SVGText) { + paintText(g, (SVGText) node, style, w, h); + } else { + Shape shape = outline(node); + if (shape != null) { + for (String layer : layers(style)) { + if ("fill".equals(layer)) { + fill(g, shape, style, true); + } else if ("stroke".equals(layer)) { + stroke(g, shape, style, node.getPathLength()); + } else if (node instanceof SVGPath || node instanceof SVGLine + || node instanceof SVGPolyline) { + paintMarkers(g, shape, style, w, h); + } + } + } + } + } finally { + g.setTransform(savedTransform); + g.setClip(savedClip); + } + } + + /// Draws the markers of a line on its points: `marker-start` on the + /// first, `marker-end` on the last and `marker-mid` on each between. + /// + /// First and last are of the whole element. A path of several subpaths + /// has one first point and one last, and where one subpath ends and the + /// next begins are points between like any other: that is how the + /// browser these images used to be captured in places them, and what + /// the specification says ("the first vertex of the given shape"). + /// + /// A point is where a segment of the line ends. A marker that turns + /// with the line faces the way the line runs there, and where the line + /// bends, half way between the two directions. An arc is drawn as a + /// few curves, and the points where those meet count as points of the + /// line. + /// What `context-fill` and `context-stroke` stand for: the paints of + /// the element whose markers are being drawn, and `none` outside one. + private SVGPaint contextFill = SVGPaint.NONE; + private SVGPaint contextStroke = SVGPaint.NONE; + + private void paintMarkers(Graphics2D g, Shape shape, SVGStyle style, int w, int h) { + SVGMarker start = markerOf(style.getMarkerStart()); + SVGMarker mid = markerOf(style.getMarkerMid()); + SVGMarker end = markerOf(style.getMarkerEnd()); + if (start == null && mid == null && end == null) { + return; + } + SVGPaint outerFill = contextFill; + SVGPaint outerStroke = contextStroke; + contextFill = style.getFill() == null ? SVGPaint.BLACK : style.getFill(); + contextStroke = style.getStroke() == null ? SVGPaint.NONE : style.getStroke(); + try { + paintMarkers(g, shape, style, start, mid, end, w, h); + } finally { + contextFill = outerFill; + contextStroke = outerStroke; + } + } + + // The first vertex and the last are those of the whole element, not of + // each piece of a path made of several. The specification has + // `marker-start` "drawn at the first vertex of the given path element + // or basic shape", `marker-end` at its last, and `marker-mid` at "every + // vertex other than the first and last" (SVG 1.1, 11.6.3; SVG 2 says + // the same of "the given shape"). Where one piece ends and the next + // begins are therefore vertices in the middle. + private void paintMarkers(Graphics2D g, Shape shape, SVGStyle style, SVGMarker start, SVGMarker mid, + SVGMarker end, int w, int h) { + // Each point: where it is, the way the line comes in and the way + // it leaves, either of which a point at an end does not have. + List points = new ArrayList(); + double[] seg = new double[6]; + double[] first = null; + double[] last = null; + for (java.awt.geom.PathIterator it = shape.getPathIterator(null); !it.isDone(); it.next()) { + int type = it.currentSegment(seg); + if (type == java.awt.geom.PathIterator.SEG_MOVETO) { + last = new double[] {seg[0], seg[1], Double.NaN, 0, Double.NaN, 0}; + first = last; + points.add(last); + continue; + } + if (last == null) { + continue; + } + double ex; + double ey; + double outX; + double outY; + double inX; + double inY; + if (type == java.awt.geom.PathIterator.SEG_CLOSE) { + ex = first[0]; + ey = first[1]; + outX = ex - last[0]; + outY = ey - last[1]; + inX = outX; + inY = outY; + } else if (type == java.awt.geom.PathIterator.SEG_LINETO) { + ex = seg[0]; + ey = seg[1]; + outX = ex - last[0]; + outY = ey - last[1]; + inX = outX; + inY = outY; + } else if (type == java.awt.geom.PathIterator.SEG_QUADTO) { + ex = seg[2]; + ey = seg[3]; + outX = seg[0] - last[0]; + outY = seg[1] - last[1]; + inX = ex - seg[0]; + inY = ey - seg[1]; + } else { + ex = seg[4]; + ey = seg[5]; + outX = seg[0] - last[0]; + outY = seg[1] - last[1]; + if (outX == 0 && outY == 0) { + outX = seg[2] - last[0]; + outY = seg[3] - last[1]; + } + inX = ex - seg[2]; + inY = ey - seg[3]; + if (inX == 0 && inY == 0) { + inX = ex - seg[0]; + inY = ey - seg[1]; + } + } + // A control point on top of an end gives no direction; the + // straight line between the ends does. + if (outX == 0 && outY == 0) { + outX = ex - last[0]; + outY = ey - last[1]; + } + if (inX == 0 && inY == 0) { + inX = ex - last[0]; + inY = ey - last[1]; + } + if (outX != 0 || outY != 0) { + last[4] = outX; + last[5] = outY; + } + double[] next = new double[] {ex, ey, Double.NaN, 0, Double.NaN, 0}; + if (inX != 0 || inY != 0) { + next[2] = inX; + next[3] = inY; + } + if (type == java.awt.geom.PathIterator.SEG_CLOSE) { + // Back where the line began: it bends there as it does + // anywhere else, between its last stretch and its first. + next[4] = first[4]; + next[5] = first[5]; + first[2] = next[2]; + first[3] = next[3]; + } + points.add(next); + last = next; + } + float strokeWidth = style.getStrokeWidth() == null ? 1f : style.getStrokeWidth().floatValue(); + for (int i = 0; i < points.size(); i++) { + SVGMarker marker = i == 0 ? start : i == points.size() - 1 ? end : mid; + if (marker == null) { + continue; + } + double[] p = points.get(i); + double angle = 0; + boolean in = !Double.isNaN(p[2]); + boolean out = !Double.isNaN(p[4]); + if (in && out) { + double li = Math.hypot(p[2], p[3]); + double lo = Math.hypot(p[4], p[5]); + double bx = p[2] / li + p[4] / lo; + double by = p[3] / li + p[5] / lo; + // Doubling straight back, there is no half way: the way in. + angle = bx == 0 && by == 0 ? Math.atan2(p[3], p[2]) : Math.atan2(by, bx); + } else if (in) { + angle = Math.atan2(p[3], p[2]); + } else if (out) { + angle = Math.atan2(p[5], p[4]); + } + paintMarker(g, marker, p[0], p[1], angle, i == 0, strokeWidth, w, h); + } + } + + private SVGMarker markerOf(String id) { + SVGNode def = id == null || id.length() == 0 ? null : doc.getDefinitions().get(id); + return def instanceof SVGMarker ? (SVGMarker) def : null; + } + + /// Draws one marker with its reference point on a point of a line. + private void paintMarker(Graphics2D g, SVGMarker marker, double x, double y, double angle, boolean first, + float strokeWidth, int w, int h) { + double mw = marker.getMarkerWidth(); + double mh = marker.getMarkerHeight(); + if (useDepth >= MAX_USE_DEPTH || !(mw > 0) || !(mh > 0)) { + return; + } + String orient = marker.getOrient() == null ? "" : marker.getOrient(); + double turn = 0; + if ("auto".equals(orient)) { + turn = angle; + } else if ("auto-start-reverse".equals(orient)) { + turn = first ? angle + Math.PI : angle; + } else if (orient.length() > 0) { + // An angle in any of its units; a bare number is degrees. + String o = orient.trim(); + try { + if (o.endsWith("grad")) { + turn = Math.toRadians(Double.parseDouble(o.substring(0, o.length() - 4)) * 0.9); + } else if (o.endsWith("rad")) { + turn = Double.parseDouble(o.substring(0, o.length() - 3)); + } else if (o.endsWith("turn")) { + turn = Double.parseDouble(o.substring(0, o.length() - 4)) * 2 * Math.PI; + } else { + turn = Math.toRadians(Double.parseDouble(o.endsWith("deg") ? o.substring(0, o.length() - 3) : o)); + } + } catch (NumberFormatException ex) { + turn = 0; + } + } + AffineTransform savedTransform = g.getTransform(); + Shape savedClip = g.getClip(); + useDepth++; + try { + g.translate(x, y); + g.rotate(turn); + if (!marker.isUserSpace()) { + // Sized in widths of the stroke it is on. + g.scale(strokeWidth, strokeWidth); + } + AffineTransform fit = marker.getViewBoxWidth() > 0 && marker.getViewBoxHeight() > 0 + ? fit(marker.getPreserveAspectRatio(), mw, mh, marker.getViewBoxX(), marker.getViewBoxY(), + marker.getViewBoxWidth(), marker.getViewBoxHeight()) + : new AffineTransform(); + Point2D ref = fit.transform(new Point2D.Double(marker.getRefX(), marker.getRefY()), null); + g.translate(-ref.getX(), -ref.getY()); + if (!marker.isOverflowVisible()) { + g.clip(new Rectangle2D.Double(0, 0, mw, mh)); + } + g.transform(fit); + for (SVGNode child : marker.getChildren()) { + paintNode(g, child, marker.getStyle(), w, h); + } + } finally { + useDepth--; + g.setTransform(savedTransform); + g.setClip(savedClip); + } + } + + /// Fits a view box into a box of `width` by `height` at the origin the + /// way `par`, a `preserveAspectRatio`, says: stretched for `none`, and + /// otherwise keeping its shape, as large as fits or for `slice` as + /// small as covers, in the middle unless it names a side. + private static AffineTransform fit(String par, double width, double height, double vx, double vy, + double vw, double vh) { + AffineTransform at = new AffineTransform(); + String p = par == null ? "" : par.trim(); + double sx = width / vw; + double sy = height / vh; + if (!p.startsWith("none")) { + double s = p.endsWith("slice") ? Math.max(sx, sy) : Math.min(sx, sy); + double freeX = width - vw * s; + double freeY = height - vh * s; + at.translate(p.startsWith("xMin") ? 0 : (p.startsWith("xMax") ? freeX : freeX / 2), + p.indexOf("YMin") >= 0 ? 0 : (p.indexOf("YMax") >= 0 ? freeY : freeY / 2)); + sx = s; + sy = s; + } + at.scale(sx, sy); + at.translate(-vx, -vy); + return at; + } + + /// The outline `node` is clipped to by the clip path of that id, in the + /// coordinates of `node`, or null when there is no such clip path. + private Shape clipShape(SVGNode node, String ref) { + SVGNode clip = doc.getDefinitions().get(ref); + if (!(clip instanceof SVGClipPath)) { + return null; + } + // A clip path that is clipped itself, or that holds a shape that + // is, asks for another one from in here. One that comes back to + // itself that way would never end, and clips nothing. + if (clipDepth >= MAX_USE_DEPTH) { + return null; + } + clipDepth++; + try { + return clipShape(node, (SVGClipPath) clip); + } finally { + clipDepth--; + } + } + + private Shape clipShape(SVGNode node, SVGClipPath clip) { + Shape outline = clipOutline(clip); + if (clip.isObjectBoundingBox()) { + // In fractions of the box around what is clipped. With no box + // to measure, nothing is inside it. + Rectangle2D box = boundsOf(node); + outline = box == null || box.isEmpty() ? new Rectangle2D.Double() + : new AffineTransform(box.getWidth(), 0, 0, box.getHeight(), box.getX(), box.getY()) + .createTransformedShape(outline); + } + // A `clip-path` on the clip path itself: only what is inside both + // is kept. + String again = clip.getStyle() == null ? null : clip.getStyle().getClipPathRef(); + if (again != null) { + Shape outer = clipShape(node, again); + if (outer != null) { + java.awt.geom.Area both = new java.awt.geom.Area(outline); + both.intersect(new java.awt.geom.Area(outer)); + outline = both; + } + } + return outline; + } + + /// How many clip paths deep the one being worked out is. + private int clipDepth; + + /// Draws the element a `` names, at the offset it gives. What the + /// element does not set for itself it takes from the ``, as if it + /// were written there. + private void paintUse(Graphics2D g, SVGUse use, SVGStyle style, int w, int h) { + String href = use.getHref(); + SVGNode target = href == null ? null : doc.getDefinitions().get(href); + // The file the element is in, which is the one that knows the + // gradients and clip paths it refers to. + VectorImage owner = this; + if (target == null && href != null && href.indexOf('#') > 0) { + // `sprite.svg#icon`: an element of another file beside this one. + owner = beside(href.substring(0, href.indexOf('#'))); + target = owner == null ? null : owner.doc.getDefinitions().get(href.substring(href.indexOf('#') + 1)); + } + if (target == null || target instanceof SVGClipPath || target instanceof SVGPattern + || target instanceof SVGMarker || target instanceof SVGFilter + || target instanceof SVGLinearGradient + || target instanceof SVGRadialGradient) { + return; + } + // An element that draws itself, directly or through others, would + // never finish. + if (useDepth >= MAX_USE_DEPTH) { + return; + } + useDepth++; + int ownerDepth = owner.useDepth; + try { + owner.useDepth = useDepth; + Shape outer = g.getClip(); + if (target instanceof SVGSymbol && !((SVGSymbol) target).isOverflowVisible() + && use.getWidth() > 0 && use.getHeight() > 0) { + // A symbol is a viewport, and what reaches past the size + // the `` gives it is cut off unless it says not. + g.clip(new Rectangle2D.Double(use.getX(), use.getY(), use.getWidth(), use.getHeight())); + } + g.transform(placing(use, target)); + target = sized(use, target); + if (target == null) { + g.setClip(outer); + return; + } + SVGNode outerInstanced = owner.instanced; + owner.instanced = target instanceof SVGSymbol ? target : outerInstanced; + try { + owner.paintNode(g, target, style, w, h); + } finally { + owner.instanced = outerInstanced; + g.setClip(outer); + } + } finally { + owner.useDepth = ownerDepth; + useDepth--; + } + } + + /// The SVG files this one has drawn elements of, by the name it calls + /// them; a name that could not be read is kept with null. + private java.util.Map besides; + + /// The SVG file of that name beside this one, or null: when this one + /// has no beside, the name is not that of a file there, it cannot be + /// read, or the files are nested too deep. Like a picture, it is only + /// ever a file; nothing is fetched. + private VectorImage beside(String name) { + if (besides == null) { + besides = new java.util.HashMap(); + } + if (besides.containsKey(name)) { + return besides.get(name); + } + VectorImage found = null; + if (base != null && name.indexOf(':') < 0 && fileDepth < MAX_FILE_DEPTH) { + try { + java.net.URL at = new java.net.URL(base, name); + if ("file".equals(at.getProtocol())) { + try (InputStream in = at.openStream()) { + found = readSvg(in, at); + found.fileDepth = fileDepth + 1; + } + } + } catch (IOException ex) { + found = null; + } catch (RuntimeException ex) { + found = null; + } + } + besides.put(name, found); + return found; + } + + /// How the content of a nested `` gets into its box: moved to the + /// box's corner, and with a view box fitted to the box the way + /// `preserveAspectRatio` says, which by default keeps its shape and + /// centres it. + private static AffineTransform layout(SVGViewport port) { + AffineTransform at = AffineTransform.getTranslateInstance(port.getX(), port.getY()); + double vw = port.getViewBoxWidth(); + double vh = port.getViewBoxHeight(); + if (!(vw > 0) || !(vh > 0)) { + return at; + } + at.concatenate(fit(port.getPreserveAspectRatio(), port.getWidth(), port.getHeight(), + port.getViewBoxX(), port.getViewBoxY(), vw, vh)); + return at; + } + + /// Where a `` puts the element it draws: at its offset, and a + /// symbol with a view box fitted into the size the `` gives, the + /// way the symbol's `preserveAspectRatio` says. + /// What a `` draws of `target`: the element itself, or for an + /// `` the same at the size the `` gives, which takes the place + /// of the one that element gives itself. Null when the `` gives an + /// `` or a symbol a width or a height of zero, which is a size, and + /// one at which nothing is drawn. + private static SVGNode sized(SVGUse use, SVGNode target) { + boolean wide = use.isGiven("width"); + boolean high = use.isGiven("height"); + if ((target instanceof SVGViewport || target instanceof SVGSymbol) + && ((wide && !(use.getWidth() > 0)) || (high && !(use.getHeight() > 0)))) { + return null; + } + if (!(target instanceof SVGViewport) || !(use.getWidth() > 0 || use.getHeight() > 0)) { + return target; + } + SVGViewport as = (SVGViewport) target; + SVGViewport sized = new SVGViewport(); + sized.setStyle(as.getStyle()); + sized.setTransform(as.getTransform()); + sized.setX(as.getX()); + sized.setY(as.getY()); + sized.setWidth(use.getWidth() > 0 ? use.getWidth() : as.getWidth()); + sized.setHeight(use.getHeight() > 0 ? use.getHeight() : as.getHeight()); + sized.setViewBox(as.getViewBoxX(), as.getViewBoxY(), as.getViewBoxWidth(), as.getViewBoxHeight()); + sized.setPreserveAspectRatio(as.getPreserveAspectRatio()); + sized.setOverflowVisible(as.isOverflowVisible()); + for (SVGNode child : as.getContent().getChildren()) { + sized.getContent().addChild(child); + } + return sized; + } + + private static AffineTransform placing(SVGUse use, SVGNode target) { + AffineTransform at = AffineTransform.getTranslateInstance(use.getX(), use.getY()); + if (target instanceof SVGSymbol) { + SVGSymbol symbol = (SVGSymbol) target; + if (symbol.getViewBoxWidth() > 0 && symbol.getViewBoxHeight() > 0 + && use.getWidth() > 0 && use.getHeight() > 0) { + at.concatenate(fit(symbol.getPreserveAspectRatio(), use.getWidth(), use.getHeight(), + symbol.getViewBoxX(), symbol.getViewBoxY(), + symbol.getViewBoxWidth(), symbol.getViewBoxHeight())); + } + } + return at; + } + + /// The box around the shapes of `node`, in its own coordinates, or null + /// when it has none that can be measured. + private Rectangle2D boundsOf(SVGNode node) { + return boundsOf(node, 0); + } + + private Rectangle2D boundsOf(SVGNode node, int depth) { + if (node.getStyle() != null && node.getStyle().isDisplayNone()) { + return null; + } + if (node instanceof SVGImage) { + SVGImage image = (SVGImage) node; + return image.getWidth() > 0 && image.getHeight() > 0 + ? new Rectangle2D.Double(image.getX(), image.getY(), image.getWidth(), image.getHeight()) : null; + } + if (node instanceof SVGText) { + Shape glyphs = textOutline((SVGText) node, new java.awt.font.FontRenderContext(null, true, true)); + return glyphs == null ? null : glyphs.getBounds2D(); + } + if (node instanceof SVGUse) { + SVGUse use = (SVGUse) node; + String href = use.getHref(); + SVGNode target = href == null ? null : doc.getDefinitions().get(href); + // An element of another file is measured by that file, as it + // is drawn by it. + VectorImage owner = this; + if (target == null && href != null && href.indexOf('#') > 0) { + owner = beside(href.substring(0, href.indexOf('#'))); + target = owner == null ? null : owner.doc.getDefinitions().get(href.substring(href.indexOf('#') + 1)); + } + if (target == null || depth >= MAX_USE_DEPTH) { + return null; + } + // Measured as it is drawn: at the size the `` gives. + AffineTransform put = placing(use, target); + target = sized(use, target); + if (target == null) { + return null; + } + Rectangle2D box = owner.boundsOf(target, depth + 1); + if (box == null) { + return null; + } + AffineTransform at = put; + SVGTransform t = target.getTransform(); + if (t != null) { + at.concatenate(new AffineTransform(t.a, t.b, t.c, t.d, t.e, t.f)); + } + return at.createTransformedShape(box).getBounds2D(); + } + if (node instanceof SVGViewport) { + // No more than its box, which is all of it that shows. + SVGViewport port = (SVGViewport) node; + Rectangle2D inside = boundsOf(port.getContent(), depth); + if (inside == null || !(port.getWidth() > 0) || !(port.getHeight() > 0)) { + return null; + } + Rectangle2D shown = layout(port).createTransformedShape(inside).getBounds2D().createIntersection( + new Rectangle2D.Double(port.getX(), port.getY(), port.getWidth(), port.getHeight())); + return shown.isEmpty() ? null : shown; + } + if (node instanceof SVGClipPath || node instanceof SVGPattern || node instanceof SVGMarker) { + return null; + } + if (!(node instanceof SVGGroup)) { + Shape shape = outline(node); + return shape == null ? null : shape.getBounds2D(); + } + Rectangle2D all = null; + for (SVGNode child : ((SVGGroup) node).getChildren()) { + if (child instanceof SVGClipPath) { + continue; + } + Rectangle2D box = boundsOf(child, depth); + if (box == null) { + continue; + } + SVGTransform t = child.getTransform(); + if (t != null) { + box = new AffineTransform(t.a, t.b, t.c, t.d, t.e, t.f).createTransformedShape(box).getBounds2D(); + } + if (all == null) { + all = box; + } else { + all = all.createUnion(box); + } + } + return all; + } + + /// The union of the shapes of a clip path, each filled by the + /// `clip-rule` it has or inherits: with `evenodd` a subpath inside + /// another is a hole in the clip. + private Shape clipOutline(SVGClipPath clip) { + java.awt.geom.Area all = new java.awt.geom.Area(); + // A transform on the clip path itself moves all of its children. + SVGTransform own = clip.getTransform(); + addOutlines(all, clip, own == null ? new AffineTransform() + : new AffineTransform(own.a, own.b, own.c, own.d, own.e, own.f), + clip.getStyle() == null ? null : clip.getStyle().getClipEvenOdd(), 0); + return all; + } + + private void addOutlines(java.awt.geom.Area all, SVGGroup group, AffineTransform at, Boolean evenOdd, + int depth) { + addOutlines(all, group, at, evenOdd, depth, + group.getStyle() == null ? null : group.getStyle().getVisible()); + } + + /// `visible` is the `visibility` the children have from what they are + /// in, which each may set again for itself. + private void addOutlines(java.awt.geom.Area all, SVGGroup group, AffineTransform at, Boolean evenOdd, + int depth, Boolean visible) { + for (SVGNode child : group.getChildren()) { + // What is not displayed cuts nothing, nor does anything in it. + if (child.getStyle() != null && child.getStyle().isDisplayNone()) { + continue; + } + Boolean seen = child.getStyle() != null && child.getStyle().getVisible() != null + ? child.getStyle().getVisible() : visible; + AffineTransform childAt = new AffineTransform(at); + SVGTransform t = child.getTransform(); + if (t != null) { + childAt.concatenate(new AffineTransform(t.a, t.b, t.c, t.d, t.e, t.f)); + } + Boolean rule = child.getStyle() != null && child.getStyle().getClipEvenOdd() != null + ? child.getStyle().getClipEvenOdd() : evenOdd; + if (child instanceof SVGUse) { + // A clip path made of a shape defined elsewhere: the shape + // is cut out where the `` puts it. + SVGUse use = (SVGUse) child; + SVGNode target = use.getHref() == null ? null : doc.getDefinitions().get(use.getHref()); + if (target == null || target instanceof SVGClipPath || depth >= MAX_USE_DEPTH) { + continue; + } + childAt.concatenate(placing(use, target)); + SVGGroup one = new SVGGroup(); + one.addChild(target); + addOutlines(all, one, childAt, rule, depth + 1, seen); + } else if (child instanceof SVGGroup) { + addOutlines(all, (SVGGroup) child, childAt, rule, depth + 1, seen); + } else { + Shape shape = outline(child); + if (shape != null && !Boolean.FALSE.equals(seen)) { + Path2D.Double ruled = new Path2D.Double(shape); + ruled.setWindingRule(Boolean.TRUE.equals(rule) ? Path2D.WIND_EVEN_ODD : Path2D.WIND_NON_ZERO); + java.awt.geom.Area part = new java.awt.geom.Area(ruled); + // A shape of a clip path can be clipped in its turn, in + // its own coordinates: only that much of it is added. + String own = child.getStyle() == null ? null : child.getStyle().getClipPathRef(); + Shape cut = own == null ? null : clipShape(child, own); + if (cut != null) { + part.intersect(new java.awt.geom.Area(cut)); + } + part.transform(childAt); + all.add(part); + } + } + } + } + + private static Shape outline(SVGNode node) { + if (node instanceof SVGRect) { + SVGRect r = (SVGRect) node; + double rx = r.getRx(); + double ry = r.getRy(); + if (rx == 0 && ry > 0) { + rx = ry; + } + if (ry == 0 && rx > 0) { + ry = rx; + } + if (rx <= 0 && ry <= 0) { + return new Rectangle2D.Double(r.getX(), r.getY(), r.getWidth(), r.getHeight()); + } + rx = Math.min(rx, r.getWidth() / 2.0); + ry = Math.min(ry, r.getHeight() / 2.0); + return new RoundRectangle2D.Double(r.getX(), r.getY(), r.getWidth(), r.getHeight(), rx * 2, ry * 2); + } + if (node instanceof SVGCircle) { + SVGCircle c = (SVGCircle) node; + return new Ellipse2D.Double(c.getCx() - c.getR(), c.getCy() - c.getR(), 2.0 * c.getR(), 2.0 * c.getR()); + } + if (node instanceof SVGEllipse) { + SVGEllipse e = (SVGEllipse) node; + return new Ellipse2D.Double(e.getCx() - e.getRx(), e.getCy() - e.getRy(), + 2.0 * e.getRx(), 2.0 * e.getRy()); + } + if (node instanceof SVGLine) { + SVGLine l = (SVGLine) node; + Path2D.Double p = new Path2D.Double(); + p.moveTo(l.getX1(), l.getY1()); + p.lineTo(l.getX2(), l.getY2()); + return p; + } + if (node instanceof SVGPolyline) { + SVGPolyline pl = (SVGPolyline) node; + float[] pts = pl.getPoints(); + if (pts.length < 4) { + return null; + } + Path2D.Double p = new Path2D.Double(Path2D.WIND_NON_ZERO); + p.moveTo(pts[0], pts[1]); + for (int i = 2; i + 1 < pts.length; i += 2) { + p.lineTo(pts[i], pts[i + 1]); + } + if (pl.isClosed()) { + p.closePath(); + } + return p; + } + if (node instanceof SVGPath) { + return pathOutline((SVGPath) node); + } + return null; + } + + private static Shape pathOutline(SVGPath path) { + List commands = path.getCommands(); + if (commands == null || commands.isEmpty()) { + return null; + } + Path2D.Double p = new Path2D.Double(Path2D.WIND_NON_ZERO); + boolean started = false; + for (PathCommand pc : commands) { + float[] a = pc.getArgs(); + switch (pc.getType()) { + case MOVE: + p.moveTo(a[0], a[1]); + started = true; + break; + case LINE: + if (started) { + p.lineTo(a[0], a[1]); + } + break; + case CUBIC: + if (started) { + p.curveTo(a[0], a[1], a[2], a[3], a[4], a[5]); + } + break; + case QUAD: + if (started) { + p.quadTo(a[0], a[1], a[2], a[3]); + } + break; + case ARC: + // The arguments are the point the arc starts at, the two + // radii, the rotation, the two flags and the end point. + if (started) { + arcTo(p, a[0], a[1], a[2], a[3], a[4], a[5] != 0f, a[6] != 0f, a[7], a[8]); + } + break; + case CLOSE: + if (started) { + p.closePath(); + } + break; + default: + break; + } + } + return started ? p : null; + } + + /// Appends an SVG elliptical arc, by the centre parameterization of the + /// SVG specification's implementation notes. + private static void arcTo(Path2D.Double p, double x0, double y0, double rx, double ry, double rotationDeg, + boolean largeArc, boolean sweep, double x, double y) { + if (rx == 0 || ry == 0) { + p.lineTo(x, y); + return; + } + if (x0 == x && y0 == y) { + return; + } + rx = Math.abs(rx); + ry = Math.abs(ry); + double phi = Math.toRadians(rotationDeg % 360.0); + double cosPhi = Math.cos(phi); + double sinPhi = Math.sin(phi); + double dx2 = (x0 - x) / 2.0; + double dy2 = (y0 - y) / 2.0; + double x1 = cosPhi * dx2 + sinPhi * dy2; + double y1 = -sinPhi * dx2 + cosPhi * dy2; + double check = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry); + if (check > 1) { + double s = Math.sqrt(check); + rx *= s; + ry *= s; + } + double rx2 = rx * rx; + double ry2 = ry * ry; + double sq = (rx2 * ry2 - rx2 * y1 * y1 - ry2 * x1 * x1) / (rx2 * y1 * y1 + ry2 * x1 * x1); + double coef = (largeArc == sweep ? -1 : 1) * Math.sqrt(Math.max(0, sq)); + double cx1 = coef * (rx * y1 / ry); + double cy1 = coef * -(ry * x1 / rx); + double cx = (x0 + x) / 2.0 + (cosPhi * cx1 - sinPhi * cy1); + double cy = (y0 + y) / 2.0 + (sinPhi * cx1 + cosPhi * cy1); + double ux = (x1 - cx1) / rx; + double uy = (y1 - cy1) / ry; + double vx = (-x1 - cx1) / rx; + double vy = (-y1 - cy1) / ry; + double start = Math.toDegrees(Math.atan2(uy, ux)); + double n = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)); + double extent = n == 0 ? 0 : Math.toDegrees(Math.acos(Math.max(-1, Math.min(1, (ux * vx + uy * vy) / n)))); + if (ux * vy - uy * vx < 0) { + extent = -extent; + } + if (!sweep && extent > 0) { + extent -= 360; + } else if (sweep && extent < 0) { + extent += 360; + } + // Arc2D measures its angles counter-clockwise with y pointing up. + Arc2D.Double arc = new Arc2D.Double(cx - rx, cy - ry, rx * 2, ry * 2, -start, -extent, Arc2D.OPEN); + p.append(AffineTransform.getRotateInstance(phi, cx, cy).createTransformedShape(arc), true); + } + + /// The layers of a shape, `fill`, `stroke` and `markers`, in the order + /// its `paint-order` has them painted: the ones it names first, in + /// the order it names them, and then the rest in their usual order. + private static String[] layers(SVGStyle style) { + List order = new ArrayList(); + String given = style.getPaintOrder(); + if (given != null) { + for (String word : given.trim().split("\\s+")) { + if (("fill".equals(word) || "stroke".equals(word) || "markers".equals(word)) + && !order.contains(word)) { + order.add(word); + } + } + } + for (String word : new String[] {"fill", "stroke", "markers"}) { + if (!order.contains(word)) { + order.add(word); + } + } + return order.toArray(new String[0]); + } + + /// Fills `shape` and strokes it, in the order `paint-order` gives the + /// two. `ruled` is whether `fill-rule` applies, which it does not to + /// the outlines of text. + private void fillAndStroke(Graphics2D g, Shape shape, SVGStyle style, boolean ruled) { + for (String layer : layers(style)) { + if ("fill".equals(layer)) { + fill(g, shape, style, ruled); + } else if ("stroke".equals(layer)) { + stroke(g, shape, style); + } + } + } + + private void fill(Graphics2D g, Shape shape, SVGStyle style, boolean ruled) { + SVGPaint fill = style.getFill() == null ? SVGPaint.BLACK : style.getFill(); + if (!fill.isNone()) { + float a = style.getFillOpacity() == null ? 1f : clamp(style.getFillOpacity().floatValue()); + Shape filled = shape; + if (ruled && shape instanceof Path2D && Boolean.TRUE.equals(style.getFillEvenOdd())) { + // `fill-rule: evenodd`: a subpath inside another is a hole. + Path2D holes = new Path2D.Double(shape); + holes.setWindingRule(Path2D.WIND_EVEN_ODD); + filled = holes; + } + paintWith(g, fill, a, filled, shape); + } + } + + private void stroke(Graphics2D g, Shape shape, SVGStyle style) { + stroke(g, shape, style, 0f); + } + + /// How long the outline of `shape` is, every piece of it. + private static double lengthOf(Shape shape) { + double total = 0; + double[] seg = new double[6]; + double px = 0; + double py = 0; + double sx = 0; + double sy = 0; + for (java.awt.geom.PathIterator it = new java.awt.geom.FlatteningPathIterator(shape.getPathIterator(null), 0.05); + !it.isDone(); it.next()) { + int kind = it.currentSegment(seg); + double nx = kind == java.awt.geom.PathIterator.SEG_CLOSE ? sx : seg[0]; + double ny = kind == java.awt.geom.PathIterator.SEG_CLOSE ? sy : seg[1]; + if (kind == java.awt.geom.PathIterator.SEG_MOVETO) { + sx = nx; + sy = ny; + } else { + total += Math.hypot(nx - px, ny - py); + } + px = nx; + py = ny; + } + return total; + } + + /// `pathLength` is how long the author takes the outline to be, or zero: + /// the dashes are then written in that measure, and are made longer or + /// shorter by as much as the outline really is. + private void stroke(Graphics2D g, Shape shape, SVGStyle style, float pathLength) { + SVGPaint stroke = style.getStroke(); + if (stroke != null && !stroke.isNone()) { + float width = style.getStrokeWidth() == null ? 1f : style.getStrokeWidth().floatValue(); + if (width <= 0) { + return; + } + int cap = style.getStrokeLineCap() == null ? SVGStyle.LINECAP_BUTT : style.getStrokeLineCap().intValue(); + int join = style.getStrokeLineJoin() == null ? SVGStyle.LINEJOIN_MITER + : style.getStrokeLineJoin().intValue(); + float miter = style.getStrokeMiterLimit() == null ? 4f : style.getStrokeMiterLimit().floatValue(); + float[] dashes = style.getStrokeDashArray(); + if (dashes != null && dashes.length == 0) { + dashes = null; + } + float measure = 1f; + if (dashes != null && pathLength > 0) { + measure = (float) (lengthOf(shape) / pathLength); + dashes = dashes.clone(); + float sum = 0; + for (int i = 0; i < dashes.length; i++) { + dashes[i] *= measure; + sum += dashes[i]; + } + if (!(sum > 0)) { + dashes = null; + } + } + float phase = 0f; + if (dashes != null && style.getStrokeDashOffset() != null) { + // A negative offset moves the pattern the other way, which + // is the same as moving it on by what is left of one round. + float round = 0f; + for (float d : dashes) { + round += d; + } + phase = (style.getStrokeDashOffset().floatValue() * measure) % round; + if (phase < 0f) { + phase += round; + } + } + BasicStroke pen = new BasicStroke(width, + cap == SVGStyle.LINECAP_ROUND ? BasicStroke.CAP_ROUND + : cap == SVGStyle.LINECAP_SQUARE ? BasicStroke.CAP_SQUARE : BasicStroke.CAP_BUTT, + join == SVGStyle.LINEJOIN_ROUND ? BasicStroke.JOIN_ROUND + : join == SVGStyle.LINEJOIN_BEVEL ? BasicStroke.JOIN_BEVEL : BasicStroke.JOIN_MITER, + Math.max(1f, miter), dashes, phase); + float a = style.getStrokeOpacity() == null ? 1f : clamp(style.getStrokeOpacity().floatValue()); + Shape stroked = null; + if (style.isNonScalingStroke()) { + // The pen is as wide as it says on the image, whatever the + // drawing is scaled by: the line is drawn where it lands, + // and brought back to be filled like any other. + AffineTransform at = g.getTransform(); + try { + stroked = at.createInverse().createTransformedShape( + pen.createStrokedShape(at.createTransformedShape(shape))); + } catch (java.awt.geom.NoninvertibleTransformException ex) { + stroked = null; + } + } + paintWith(g, stroke, a, stroked == null ? pen.createStrokedShape(shape) : stroked, shape); + } + } + + /// Fills `area` with `paint`. A gradient given in fractions of a + /// bounding box is measured on that of `boundsOf`, the shape itself, also + /// when what is filled is its stroke. + private void paintWith(Graphics2D g, SVGPaint paint, float alpha, Shape area, Shape boundsOf) { + if (alpha <= 0) { + return; + } + Paint p; + if (paint.isReference()) { + String ref = paint.getReference(); + SVGNode def = doc.getDefinitions().get(ref); + // A gradient or a pattern of another file beside this one, + // `paints.svg#g`, is worked out by that file, which is the one + // that knows what it refers to in its turn. + VectorImage owner = this; + if (def == null && ref.indexOf('#') > 0) { + VectorImage other = beside(ref.substring(0, ref.indexOf('#'))); + if (other != null) { + owner = other; + def = other.doc.getDefinitions().get(ref.substring(ref.indexOf('#') + 1)); + } + } + if (def instanceof SVGPattern) { + owner.paintPattern(g, (SVGPattern) def, alpha, area, boundsOf.getBounds2D()); + return; + } + if (def instanceof SVGLinearGradient || def instanceof SVGRadialGradient) { + p = owner.gradient(def, boundsOf.getBounds2D()); + if (p == null) { + return; + } + } else { + // Nothing to paint with has that id. The colour written + // after the reference stands in; with none, nothing is + // painted. + SVGPaint fallback = paint.getFallback(); + if (fallback == null || fallback.isNone()) { + return; + } + p = new Color(fallback.getColor(), true); + } + } else { + p = new Color(paint.getColor(), true); + } + java.awt.Composite saved = g.getComposite(); + try { + g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.min(1f, alpha))); + g.setPaint(p); + g.fill(area); + } finally { + g.setComposite(saved); + } + } + + private Paint gradient(SVGNode def, Rectangle2D bounds) { + // In linear light when the gradient asks for it, itself or through + // the one it takes after: black to white is then a light grey + // half way and not a mid one. + MultipleGradientPaint.ColorSpaceType space = MultipleGradientPaint.ColorSpaceType.SRGB; + SVGNode asks = def; + for (int depth = 0; depth < MAX_USE_DEPTH && asks != null; depth++) { + if (asks.isGiven("color-interpolation")) { + if (asks.isGiven("linearRGB")) { + space = MultipleGradientPaint.ColorSpaceType.LINEAR_RGB; + } + break; + } + String href = asks instanceof SVGLinearGradient ? ((SVGLinearGradient) asks).getHref() + : asks instanceof SVGRadialGradient ? ((SVGRadialGradient) asks).getHref() : null; + asks = href == null ? null : doc.getDefinitions().get(href); + } + def = resolved(def); + List stops = def instanceof SVGLinearGradient ? ((SVGLinearGradient) def).getStops() + : def instanceof SVGRadialGradient ? ((SVGRadialGradient) def).getStops() : null; + if (stops == null || stops.isEmpty()) { + // Not a gradient, or one with nothing in it: SVG paints nothing. + return null; + } + // Java2D wants strictly increasing fractions. Stops that share an + // offset are a hard edge, kept as the smallest step a float holds: + // each is put just after the one before it, and where that runs + // past the end -- stops sharing the offset 1 -- the last is the end + // and the ones before it are moved down instead. It is the last of + // them the gradient goes on in past its end, so it cannot be the + // one that is dropped. + // + // The colours between two stops are mixed channel by channel with + // the alpha as one more channel, not multiplied by it first. That + // is how an SVG gradient is defined and how a browser paints one -- + // only a CSS gradient is mixed premultiplied -- so red fading to + // `transparent`, which is transparent black, darkens on the way + // here as it does there. + float[] placed = new float[stops.size()]; + float before = -1f; + for (int i = 0; i < placed.length; i++) { + float offset = clamp(stops.get(i).getOffset()); + placed[i] = offset <= before ? Math.nextUp(before) : offset; + before = placed[i]; + } + float after = Math.nextUp(1f); + for (int i = placed.length - 1; i >= 0 && placed[i] >= after; i--) { + placed[i] = Math.max(0f, Math.nextDown(after)); + after = placed[i]; + } + List fractions = new ArrayList(); + List colors = new ArrayList(); + float last = -1f; + for (int i = 0; i < placed.length; i++) { + SVGGradientStop stop = stops.get(i); + float offset = placed[i]; + if (offset <= last) { + // More stops on one offset than there are floats to tell + // them apart; never in a file anyone wrote. + continue; + } + int alpha = Math.round(255f * clamp(stop.getOpacity())); + int rgb = stop.getColor(); + alpha = alpha * (rgb >>> 24) / 255; + fractions.add(Float.valueOf(offset)); + colors.add(new Color((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, alpha)); + last = offset; + } + if (colors.size() == 1) { + return colors.get(0); + } + float[] f = new float[fractions.size()]; + for (int i = 0; i < f.length; i++) { + f[i] = fractions.get(i).floatValue(); + } + Color[] c = colors.toArray(new Color[0]); + // Past its ends a gradient goes on in its last colour, unless it + // says to start over or to run back. + String spread = def instanceof SVGLinearGradient ? ((SVGLinearGradient) def).getSpreadMethod() + : ((SVGRadialGradient) def).getSpreadMethod(); + MultipleGradientPaint.CycleMethod cycle = "repeat".equals(spread) ? MultipleGradientPaint.CycleMethod.REPEAT + : "reflect".equals(spread) ? MultipleGradientPaint.CycleMethod.REFLECT + : MultipleGradientPaint.CycleMethod.NO_CYCLE; + SVGTransform gt = def instanceof SVGLinearGradient ? ((SVGLinearGradient) def).getGradientTransform() + : ((SVGRadialGradient) def).getGradientTransform(); + AffineTransform turned = gt == null ? null : new AffineTransform(gt.a, gt.b, gt.c, gt.d, gt.e, gt.f); + if (turned != null && Math.abs(turned.getDeterminant()) < 1e-12) { + // Squashed flat: there is no gradient left to see. + return c[c.length - 1]; + } + if (def instanceof SVGLinearGradient && turned != null) { + SVGLinearGradient lg = (SVGLinearGradient) def; + Point2D start = new Point2D.Double(lg.getX1(), lg.getY1()); + Point2D end = new Point2D.Double(lg.getX2(), lg.getY2()); + if (start.equals(end)) { + return c[c.length - 1]; + } + // The gradient is laid out in its own coordinates and those are + // then moved: by its transform, and for one given in fractions + // of the bounding box by that box after it. + AffineTransform at = new AffineTransform(); + if (!lg.isUserSpace()) { + if (bounds.getWidth() <= 0 || bounds.getHeight() <= 0) { + return c[c.length - 1]; + } + at.setTransform(bounds.getWidth(), 0, 0, bounds.getHeight(), bounds.getX(), bounds.getY()); + } + at.concatenate(turned); + return new LinearGradientPaint(start, end, f, c, cycle, space, at); + } + if (def instanceof SVGLinearGradient) { + SVGLinearGradient lg = (SVGLinearGradient) def; + Point2D start = point(lg.getX1(), lg.getY1(), lg.isUserSpace(), bounds); + Point2D end = point(lg.getX2(), lg.getY2(), lg.isUserSpace(), bounds); + if (start.equals(end)) { + return c[c.length - 1]; + } + return new LinearGradientPaint(start, end, f, c, cycle, space, new AffineTransform()); + } + SVGRadialGradient rg = (SVGRadialGradient) def; + if (rg.getFr() > 0 && rg.getFr() < rg.getR()) { + // The first stop fills a circle about the focal point, and the + // gradient runs from the edge of that one. With the focal + // point at the centre that is the same stops squeezed into + // what is left of the radius, which is how it is done; with + // the focal point elsewhere the inner circle is only nearly + // where it should be. + float inner = rg.getFr() / rg.getR(); + for (int i = 0; i < f.length; i++) { + f[i] = inner + f[i] * (1 - inner); + } + for (int i = 1; i < f.length; i++) { + if (f[i] <= f[i - 1]) { + f[i] = Math.nextUp(f[i - 1]); + } + } + if (f[f.length - 1] > 1f) { + f[f.length - 1] = 1f; + for (int i = f.length - 2; i >= 0 && f[i] >= f[i + 1]; i--) { + f[i] = Math.nextDown(f[i + 1]); + } + } + } + if (rg.isUserSpace()) { + if (rg.getR() <= 0) { + return c[c.length - 1]; + } + return new RadialGradientPaint(new Point2D.Double(rg.getCx(), rg.getCy()), rg.getR(), focusOf(rg), f, c, + cycle, space, turned == null ? new AffineTransform() : turned); + } + if (rg.getR() <= 0 || bounds.getWidth() <= 0 || bounds.getHeight() <= 0) { + return c[c.length - 1]; + } + // In fractions of the bounding box, which makes the circle an + // ellipse when the box is not square. + AffineTransform toBox = new AffineTransform(bounds.getWidth(), 0, 0, bounds.getHeight(), + bounds.getX(), bounds.getY()); + if (turned != null) { + toBox.concatenate(turned); + } + Point2D centre = new Point2D.Double(rg.getCx(), rg.getCy()); + return new RadialGradientPaint(centre, rg.getR(), focusOf(rg), f, c, + cycle, space, toBox); + } + + /// Where a radial gradient's first colour starts: its focal point, + /// which is its centre unless `fx` or `fy` says otherwise. + private static Point2D focusOf(SVGRadialGradient rg) { + return new Point2D.Double(rg.getFx() == null ? rg.getCx() : rg.getFx().floatValue(), + rg.getFy() == null ? rg.getCy() : rg.getFy().floatValue()); + } + + /// The elements `def` takes what it leaves out from, itself first: the + /// one its `href` names, the one that one names, and so on. They are + /// patterns when `pattern` is set and gradients otherwise; the chain + /// ends at anything else, and at an element it has already passed. + private List chainOf(SVGNode def, boolean pattern) { + List chain = new ArrayList(); + SVGNode at = def; + while (at != null && chain.size() < 8 && !chain.contains(at)) { + String href; + if (pattern && at instanceof SVGPattern) { + href = ((SVGPattern) at).getHref(); + } else if (!pattern && at instanceof SVGLinearGradient) { + href = ((SVGLinearGradient) at).getHref(); + } else if (!pattern && at instanceof SVGRadialGradient) { + href = ((SVGRadialGradient) at).getHref(); + } else { + break; + } + chain.add(at); + at = href == null ? null : doc.getDefinitions().get(href.startsWith("#") ? href.substring(1) : href); + } + return chain; + } + + /// The first element of `chain` that is a `type` and wrote `attribute` + /// itself, or null. + private static T giver(List chain, String attribute, Class type) { + for (SVGNode node : chain) { + if (type.isInstance(node) && node.isGiven(attribute)) { + return type.cast(node); + } + } + return null; + } + + /// A gradient with everything it leaves to the one its `href` names + /// filled in: not only the stops but where it runs, what that is + /// measured in and its transform. Where it runs is taken only from a + /// gradient of its own kind, since a line has no radius to give a + /// circle. Anything that refers to nothing is answered as it is. + private SVGNode resolved(SVGNode def) { + List chain = chainOf(def, false); + if (chain.size() < 2) { + return def; + } + boolean userSpace = false; + SVGTransform transform = null; + List stops = null; + boolean unitsFound = false; + String spread = null; + for (SVGNode node : chain) { + boolean linear = node instanceof SVGLinearGradient; + if (spread == null && node.isGiven("spreadMethod")) { + spread = linear ? ((SVGLinearGradient) node).getSpreadMethod() + : ((SVGRadialGradient) node).getSpreadMethod(); + } + if (!unitsFound && node.isGiven("gradientUnits")) { + unitsFound = true; + userSpace = linear ? ((SVGLinearGradient) node).isUserSpace() + : ((SVGRadialGradient) node).isUserSpace(); + } + if (transform == null) { + transform = linear ? ((SVGLinearGradient) node).getGradientTransform() + : ((SVGRadialGradient) node).getGradientTransform(); + } + List own = linear ? ((SVGLinearGradient) node).getStops() + : ((SVGRadialGradient) node).getStops(); + if (stops == null && !own.isEmpty()) { + stops = own; + } + } + if (def instanceof SVGLinearGradient) { + SVGLinearGradient out = new SVGLinearGradient(); + SVGLinearGradient from = giver(chain, "x1", SVGLinearGradient.class); + if (from != null) { + out.setX1(from.getX1()); + } + from = giver(chain, "y1", SVGLinearGradient.class); + if (from != null) { + out.setY1(from.getY1()); + } + from = giver(chain, "x2", SVGLinearGradient.class); + if (from != null) { + out.setX2(from.getX2()); + } + from = giver(chain, "y2", SVGLinearGradient.class); + if (from != null) { + out.setY2(from.getY2()); + } + out.setUserSpace(userSpace); + out.setSpreadMethod(spread); + out.setGradientTransform(transform); + if (stops != null) { + out.getStops().addAll(stops); + } + return out; + } + SVGRadialGradient out = new SVGRadialGradient(); + SVGRadialGradient from = giver(chain, "cx", SVGRadialGradient.class); + if (from != null) { + out.setCx(from.getCx()); + } + from = giver(chain, "cy", SVGRadialGradient.class); + if (from != null) { + out.setCy(from.getCy()); + } + from = giver(chain, "r", SVGRadialGradient.class); + if (from != null) { + out.setR(from.getR()); + } + from = giver(chain, "fx", SVGRadialGradient.class); + if (from != null) { + out.setFx(from.getFx()); + } + from = giver(chain, "fr", SVGRadialGradient.class); + if (from != null) { + out.setFr(from.getFr()); + } + from = giver(chain, "fy", SVGRadialGradient.class); + if (from != null) { + out.setFy(from.getFy()); + } + out.setUserSpace(userSpace); + out.setSpreadMethod(spread); + out.setGradientTransform(transform); + if (stops != null) { + out.getStops().addAll(stops); + } + return out; + } + + private static final int MAX_TILE_SIDE = 2048; + + /// Fills `area` with the tile of a pattern, laid side by side from the + /// tile's own corner. + /// + /// The tile is painted once, at the size it has on the image, and then + /// repeated. Its content is cut off at its edges. A pattern with no + /// size, or measured against a box that has none, paints nothing. + private void paintPattern(Graphics2D g, SVGPattern def, float alpha, Shape area, Rectangle2D bounds) { + List chain = chainOf(def, true); + if (chain.isEmpty() || useDepth >= MAX_USE_DEPTH) { + return; + } + SVGPattern units = giver(chain, "patternUnits", SVGPattern.class); + boolean userSpace = units != null && units.isUserSpace(); + SVGPattern px = giver(chain, "x", SVGPattern.class); + SVGPattern py = giver(chain, "y", SVGPattern.class); + SVGPattern pw = giver(chain, "width", SVGPattern.class); + SVGPattern ph = giver(chain, "height", SVGPattern.class); + double x = px == null ? 0 : px.getX(); + double y = py == null ? 0 : py.getY(); + double tw = pw == null ? 0 : pw.getWidth(); + double th = ph == null ? 0 : ph.getHeight(); + boolean boxed = bounds.getWidth() > 0 && bounds.getHeight() > 0; + if (!userSpace) { + if (!boxed) { + return; + } + x = bounds.getX() + x * bounds.getWidth(); + y = bounds.getY() + y * bounds.getHeight(); + tw *= bounds.getWidth(); + th *= bounds.getHeight(); + } + if (!(tw > 0) || !(th > 0)) { + return; + } + SVGPattern turned = giver(chain, "patternTransform", SVGPattern.class); + SVGTransform t = turned == null ? null : turned.getPatternTransform(); + AffineTransform pt = t == null ? new AffineTransform() : new AffineTransform(t.a, t.b, t.c, t.d, t.e, t.f); + AffineTransform back; + try { + back = pt.createInverse(); + } catch (java.awt.geom.NoninvertibleTransformException ex) { + return; + } + SVGPattern content = def; + for (SVGNode node : chain) { + if (!((SVGPattern) node).getChildren().isEmpty()) { + content = (SVGPattern) node; + break; + } + } + // As many pixels as the tile covers on the image, so that it is + // not drawn small and stretched. + AffineTransform device = new AffineTransform(g.getTransform()); + device.concatenate(pt); + double sx = Math.hypot(device.getScaleX(), device.getShearY()); + double sy = Math.hypot(device.getShearX(), device.getScaleY()); + int iw = (int) Math.max(1, Math.min(MAX_TILE_SIDE, Math.ceil(tw * sx))); + int ih = (int) Math.max(1, Math.min(MAX_TILE_SIDE, Math.ceil(th * sy))); + BufferedImage tile = new BufferedImage(iw, ih, BufferedImage.TYPE_INT_ARGB); + Graphics2D tg = tile.createGraphics(); + useDepth++; + try { + Pixels.hints(tg); + // What a pattern holds is drawn from the corner of its tile: + // the tile's `x` and `y` say where the tiles are laid, and what + // is in them moves along. A rectangle at 20 in a tile at 20 is + // 20 inside the tile, in a browser as here, so the tile is not + // shifted back by its own position. This is the specification: + // "If there is no viewBox attribute, then the new coordinate + // system has its origin at (x, y)" of the pattern (SVG 1.1, + // 13.3). That the content is in the user space of the element + // the pattern is used on says which units and directions it is + // drawn in, not where its origin is. + tg.scale(iw / tw, ih / th); + SVGPattern boxGiver = giver(chain, "viewBox", SVGPattern.class); + SVGPattern contentUnits = giver(chain, "patternContentUnits", SVGPattern.class); + boolean drawn = true; + if (boxGiver != null && boxGiver.getViewBoxWidth() > 0 && boxGiver.getViewBoxHeight() > 0) { + SVGPattern fit = giver(chain, "preserveAspectRatio", SVGPattern.class); + String par = fit == null || fit.getPreserveAspectRatio() == null ? "" + : fit.getPreserveAspectRatio().trim(); + double vw = boxGiver.getViewBoxWidth(); + double vh = boxGiver.getViewBoxHeight(); + double fx = tw / vw; + double fy = th / vh; + double ox = 0; + double oy = 0; + if (!par.startsWith("none")) { + double s = par.endsWith("slice") ? Math.max(fx, fy) : Math.min(fx, fy); + double freeX = tw - vw * s; + double freeY = th - vh * s; + ox = par.startsWith("xMin") ? 0 : (par.startsWith("xMax") ? freeX : freeX / 2); + oy = par.indexOf("YMin") >= 0 ? 0 : (par.indexOf("YMax") >= 0 ? freeY : freeY / 2); + fx = s; + fy = s; + } + tg.translate(ox, oy); + tg.scale(fx, fy); + tg.translate(-boxGiver.getViewBoxX(), -boxGiver.getViewBoxY()); + } else if (contentUnits != null && contentUnits.isContentObjectBoundingBox()) { + drawn = boxed; + if (drawn) { + tg.scale(bounds.getWidth(), bounds.getHeight()); + } + } + if (drawn) { + for (SVGNode child : content.getChildren()) { + // What a pattern has from another through `href` is + // drawn as if it were written in this one, so it has + // from this one what it does not set itself: a + // `color`, say, for its `currentColor`. + paintNode(tg, child, def.getStyle(), iw, ih); + } + } + } finally { + useDepth--; + tg.dispose(); + } + AffineTransform savedTransform = g.getTransform(); + java.awt.Composite saved = g.getComposite(); + Paint savedPaint = g.getPaint(); + try { + g.transform(pt); + g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, Math.min(1f, alpha))); + g.setPaint(new java.awt.TexturePaint(tile, new Rectangle2D.Double(x, y, tw, th))); + g.fill(back.createTransformedShape(area)); + } finally { + g.setPaint(savedPaint); + g.setComposite(saved); + g.setTransform(savedTransform); + } + } + + private static Point2D point(double x, double y, boolean userSpace, Rectangle2D bounds) { + return userSpace ? new Point2D.Double(x, y) + : new Point2D.Double(bounds.getX() + x * bounds.getWidth(), bounds.getY() + y * bounds.getHeight()); + } + + private void paintText(Graphics2D g, SVGText text, SVGStyle style, int w, int h) { + List runs = runsOf(text); + List outlines = textOutlines(text, runs, g.getFontRenderContext()); + for (int i = 0; i < runs.size(); i++) { + Shape outline = outlines.get(i); + if (outline == null) { + continue; + } + // A span is styled by what it sets and, for the rest, by the + // text it is in. + SVGStyle own = runs.get(i).getStyle(); + SVGStyle runStyle = style; + if (own != null) { + if (own.isDisplayNone()) { + continue; + } + runStyle = new SVGStyle(); + runStyle.inherit(own); + runStyle.inherit(style); + int current = runStyle.getColor() == null ? 0xff000000 : runStyle.getColor().intValue(); + if (runStyle.isFillCurrent()) { + runStyle.setFill(SVGPaint.ofColor(current)); + } + if (runStyle.isStrokeCurrent()) { + runStyle.setStroke(SVGPaint.ofColor(current)); + } + } + if (Boolean.FALSE.equals(runStyle.getVisible())) { + continue; + } + // A span that is not opaque is painted whole and then laid down + // at its opacity, like any element: its stroke does not show + // through its fill where they overlap. + // `inherit` is the opacity of the text it is in, applied to + // the span as well. + Float given = own == null ? null : own.isOpacityInherited() ? style.getOpacity() : own.getOpacity(); + float opacity = given == null ? 1f : clamp(given.floatValue()); + if (opacity <= 0f) { + continue; + } + if (opacity < 1f) { + BufferedImage layer = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D lg = layer.createGraphics(); + try { + Pixels.hints(lg); + lg.setTransform(g.getTransform()); + lg.setClip(g.getClip()); + fillAndStroke(lg, outline, runStyle, false); + } finally { + lg.dispose(); + } + AffineTransform at = g.getTransform(); + java.awt.Composite composite = g.getComposite(); + try { + g.setTransform(new AffineTransform()); + g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity)); + g.drawImage(layer, 0, 0, null); + } finally { + g.setComposite(composite); + g.setTransform(at); + } + continue; + } + // Filled and then stroked like any other shape: text with no + // fill and a stroke is an outline, not nothing. + fillAndStroke(g, outline, runStyle, false); + } + } + + /// The stretches of a ``. One that was not read from a file has + /// its content as a single stretch. + private static List runsOf(SVGText text) { + List runs = text.getRuns(); + if (!runs.isEmpty() || text.getContent() == null || text.getContent().length() == 0) { + return runs; + } + SVGText.Run whole = new SVGText.Run(); + whole.setText(text.getContent()); + List one = new ArrayList(); + one.add(whole); + return one; + } + + /// The outlines of the glyphs of each stretch of a ``, where it + /// puts them; null for a stretch with nothing in it. + /// + /// The text runs on from one stretch to the next unless a stretch says + /// where it starts. `text-anchor` is about each piece that starts + /// somewhere of its own: that piece, with the stretches that run on + /// from it, begins, is centred or ends at the point given. + private List textOutlines(SVGText text, List written, + java.awt.font.FontRenderContext context) { + // A stretch that is not displayed takes no part: it has no width, + // moves nothing and starts nothing. It stays in the list, empty, + // so that the outlines still answer to the stretches one for one. + List runs = new ArrayList(written.size()); + for (SVGText.Run run : written) { + if (run.getStyle() != null && run.getStyle().isDisplayNone()) { + SVGText.Run gone = new SVGText.Run(); + gone.setPathRef(run.getPathRef()); + gone.setStartOffset(run.getStartOffset(), run.isStartOffsetShare()); + runs.add(gone); + } else { + runs.add(run); + } + } + List out = new ArrayList(); + double cx = text.getX(); + double cy = text.getY(); + int i = 0; + while (i < runs.size()) { + int end = i + 1; + String along = runs.get(i).getPathRef(); + while (end < runs.size() && runs.get(end).getX() == null && runs.get(end).getY() == null + && (along == null ? runs.get(end).getPathRef() == null : along.equals(runs.get(end).getPathRef()))) { + end++; + } + SVGText.Run head = runs.get(i); + if (along != null) { + // Text on a path: each glyph is set down on the path, turned + // the way the path runs there. What follows it goes on from + // where the path ends. + double[] ended = onPath(text, runs, i, end, context, out); + if (ended != null) { + cx = ended[0]; + cy = ended[1]; + } + i = end; + continue; + } + if (head.getX() != null) { + cx = head.getX().floatValue(); + } + if (head.getY() != null) { + cy = head.getY().floatValue(); + } + double startX = cx; + List glyphs = new ArrayList(); + double[] xs = new double[end - i]; + double[] ys = new double[end - i]; + for (int k = i; k < end; k++) { + SVGText.Run run = runs.get(k); + cx += run.getDx(); + cy += run.getDy(); + float size = run.getFontSize() != null ? run.getFontSize().floatValue() : text.getFontSize(); + if (!(size > 0)) { + size = INITIAL_FONT_SIZE; + } + boolean bold = run.getBold() != null ? run.getBold().booleanValue() : text.isBold(); + boolean italic = run.getItalic() != null ? run.getItalic().booleanValue() : text.isItalic(); + String family = run.getFontFamily() != null ? run.getFontFamily() : text.getFontFamily(); + Font font = new Font(familyOf(family), (bold ? Font.BOLD : 0) | (italic ? Font.ITALIC : 0), 1) + .deriveFont(size); + java.awt.font.GlyphVector gv = font.createGlyphVector(context, run.getText()); + // `letter-spacing`: that much more after every glyph, and + // `word-spacing` that much more again after a space. + float apart = spacingOf(text, run); + float words = run.getWordSpacing() != null ? run.getWordSpacing().floatValue() + : text.getWordSpacing(); + if (apart != 0f || words != 0f) { + double more = 0; + for (int j = 1; j <= gv.getNumGlyphs(); j++) { + more += apart + (isSpace(run.getText(), gv.getGlyphCharIndex(j - 1)) ? words : 0); + Point2D at = gv.getGlyphPosition(j); + gv.setGlyphPosition(j, new Point2D.Double(at.getX() + more, at.getY())); + } + } + glyphs.add(gv); + xs[k - i] = cx; + ys[k - i] = cy + baselineShift(text, run, font, context); + cx += gv.getLogicalBounds().getWidth(); + if (apart != 0f || words != 0f) { + // The bounds stop at the last glyph; the space after it + // is part of how far the text runs all the same. + cx = xs[k - i] + gv.getGlyphPosition(gv.getNumGlyphs()).getX(); + } + } + double advance = cx - startX; + // The anchor of the piece is that of the stretch it starts + // with, which is the text's own unless a span sets another. + SVGText.Anchor anchor = head.getAnchor() != null ? head.getAnchor() : text.getAnchor(); + double shift = anchor == SVGText.Anchor.MIDDLE ? -advance / 2.0 + : anchor == SVGText.Anchor.END ? -advance : 0; + for (int k = 0; k < glyphs.size(); k++) { + SVGText.Run drawn = runs.get(i + k); + java.awt.font.GlyphVector gv = glyphs.get(k); + if (drawn.getText().length() == 0) { + out.add(null); + } else if (drawn.getRotate() == 0f) { + out.add(gv.getOutline((float) (xs[k] + shift), (float) ys[k])); + } else { + // `rotate`: each glyph turned about where it starts. + Path2D.Double turned = new Path2D.Double(); + for (int j = 0; j < gv.getNumGlyphs(); j++) { + Point2D at = gv.getGlyphPosition(j); + double gx = xs[k] + shift + at.getX(); + double gy = ys[k] + at.getY(); + Shape glyph = gv.getGlyphOutline(j, (float) (xs[k] + shift), (float) ys[k]); + turned.append(AffineTransform.getRotateInstance(Math.toRadians(drawn.getRotate()), gx, gy) + .createTransformedShape(glyph), false); + } + out.add(turned); + } + } + cx += shift; + i = end; + } + return out; + } + + /// Lays the stretches `from` to `to`, which are on one ``, + /// along its path, adding an outline for each to `out`: null for one + /// with no text or none of it on the path. A glyph is where its middle + /// falls on the path, and one whose middle is off either end is not + /// drawn. Returns where the path ends, or null when there is no such + /// path, in which case the text is not drawn at all. + private double[] onPath(SVGText text, List runs, int from, int to, + java.awt.font.FontRenderContext context, List out) { + SVGNode target = doc.getDefinitions().get(runs.get(from).getPathRef()); + Shape line = target == null ? null : outline(target); + if (line == null) { + for (int k = from; k < to; k++) { + out.add(null); + } + return null; + } + SVGTransform moved = target.getTransform(); + AffineTransform at = moved == null ? null + : new AffineTransform(moved.a, moved.b, moved.c, moved.d, moved.e, moved.f); + // The path as short straight pieces: where each starts, and how + // far along the path that is. + List pieces = new ArrayList(); + double[] seg = new double[6]; + double total = 0; + double px = 0; + double py = 0; + double sx = 0; + double sy = 0; + for (java.awt.geom.PathIterator it = new java.awt.geom.FlatteningPathIterator(line.getPathIterator(at), 0.05); + !it.isDone(); it.next()) { + int kind = it.currentSegment(seg); + double nx = kind == java.awt.geom.PathIterator.SEG_CLOSE ? sx : seg[0]; + double ny = kind == java.awt.geom.PathIterator.SEG_CLOSE ? sy : seg[1]; + if (kind == java.awt.geom.PathIterator.SEG_MOVETO) { + sx = nx; + sy = ny; + } else { + double len = Math.hypot(nx - px, ny - py); + if (len > 0) { + pieces.add(new double[] {px, py, nx, ny, total, len}); + total += len; + } + } + px = nx; + py = ny; + } + if (pieces.isEmpty()) { + for (int k = from; k < to; k++) { + out.add(null); + } + return null; + } + List glyphs = new ArrayList(); + double advance = 0; + for (int k = from; k < to; k++) { + SVGText.Run run = runs.get(k); + float size = run.getFontSize() != null ? run.getFontSize().floatValue() : text.getFontSize(); + if (!(size > 0)) { + size = INITIAL_FONT_SIZE; + } + boolean bold = run.getBold() != null ? run.getBold().booleanValue() : text.isBold(); + boolean italic = run.getItalic() != null ? run.getItalic().booleanValue() : text.isItalic(); + String family = run.getFontFamily() != null ? run.getFontFamily() : text.getFontFamily(); + Font font = new Font(familyOf(family), (bold ? Font.BOLD : 0) | (italic ? Font.ITALIC : 0), 1) + .deriveFont(size); + java.awt.font.GlyphVector gv = font.createGlyphVector(context, run.getText()); + glyphs.add(gv); + advance += run.getDx() + gv.getLogicalBounds().getWidth() + gv.getNumGlyphs() * spacingOf(text, run); + float wide = run.getWordSpacing() != null ? run.getWordSpacing().floatValue() : text.getWordSpacing(); + for (int j = 0; wide != 0f && j < gv.getNumGlyphs(); j++) { + advance += isSpace(run.getText(), gv.getGlyphCharIndex(j)) ? wide : 0; + } + } + SVGText.Run head = runs.get(from); + double along = head.isStartOffsetShare() ? head.getStartOffset() * total + : target.getPathLength() > 0 ? head.getStartOffset() * total / target.getPathLength() + : head.getStartOffset(); + SVGText.Anchor anchor = head.getAnchor() != null ? head.getAnchor() : text.getAnchor(); + along += anchor == SVGText.Anchor.MIDDLE ? -advance / 2.0 + : anchor == SVGText.Anchor.END ? -advance : 0; + int piece = 0; + for (int k = from; k < to; k++) { + SVGText.Run run = runs.get(k); + java.awt.font.GlyphVector gv = glyphs.get(k - from); + along += run.getDx(); + Path2D.Double drawn = null; + for (int j = 0; j < gv.getNumGlyphs(); j++) { + double wide = gv.getGlyphMetrics(j).getAdvanceX(); + double middle = along + wide / 2; + along += wide + spacingOf(text, run); + if (isSpace(run.getText(), gv.getGlyphCharIndex(j))) { + along += run.getWordSpacing() != null ? run.getWordSpacing().floatValue() : text.getWordSpacing(); + } + if (middle < 0 || middle > total) { + continue; + } + while (piece > 0 && pieces.get(piece)[4] > middle) { + piece--; + } + while (piece < pieces.size() - 1 && pieces.get(piece)[4] + pieces.get(piece)[5] < middle) { + piece++; + } + double[] on = pieces.get(piece); + double part = (middle - on[4]) / on[5]; + AffineTransform place = AffineTransform.getTranslateInstance(on[0] + (on[2] - on[0]) * part, + on[1] + (on[3] - on[1]) * part); + place.rotate(Math.atan2(on[3] - on[1], on[2] - on[0]) + Math.toRadians(run.getRotate())); + // `dy` lifts the glyph off the path, across the way it runs. + place.translate(-wide / 2, run.getDy() + baselineShift(text, run, gv.getFont(), context)); + Point2D origin = gv.getGlyphPosition(j); + Shape glyph = gv.getGlyphOutline(j, (float) -origin.getX(), (float) -origin.getY()); + if (drawn == null) { + drawn = new Path2D.Double(); + } + drawn.append(place.createTransformedShape(glyph), false); + } + out.add(drawn); + } + double[] last = pieces.get(pieces.size() - 1); + return new double[] {last[2], last[3]}; + } + + /// How far below the `y` it is given the letters of a stretch stand: + /// nothing when `y` is the line they stand on, which it is unless + /// `dominant-baseline` makes it another line of the font -- its middle, + /// its top, its bottom. + private static double baselineShift(SVGText text, SVGText.Run run, Font font, + java.awt.font.FontRenderContext context) { + String line = run.getBaseline() != null ? run.getBaseline() : text.getBaseline(); + if (line == null || "auto".equals(line) || "alphabetic".equals(line) || "baseline".equals(line)) { + return 0; + } + java.awt.font.LineMetrics metrics = font.getLineMetrics("x", context); + double ascent = metrics.getAscent(); + double descent = metrics.getDescent(); + if ("middle".equals(line)) { + // Half the height of a small letter above the line. + return font.createGlyphVector(context, "x").getVisualBounds().getHeight() / 2; + } + if ("central".equals(line)) { + return (ascent - descent) / 2; + } + if ("hanging".equals(line)) { + return ascent * 0.8; + } + if ("mathematical".equals(line)) { + return ascent / 2; + } + if ("text-before-edge".equals(line) || "text-top".equals(line)) { + return ascent; + } + if ("text-after-edge".equals(line) || "text-bottom".equals(line) || "ideographic".equals(line)) { + return -descent; + } + return 0; + } + + /// Whether the character at `index` of `text` is a space between words. + private static boolean isSpace(String text, int index) { + return index >= 0 && index < text.length() && (text.charAt(index) == ' ' || text.charAt(index) == 0xa0); + } + + /// How much further apart than the font has them the glyphs of a + /// stretch are set: its own `letter-spacing`, or the text's. + private static float spacingOf(SVGText text, SVGText.Run run) { + return run.getLetterSpacing() != null ? run.getLetterSpacing().floatValue() : text.getLetterSpacing(); + } + + /// The outlines of all the glyphs of a ``, or null when it has no + /// text. + private Shape textOutline(SVGText text, java.awt.font.FontRenderContext context) { + Path2D.Double all = null; + for (Shape outline : textOutlines(text, runsOf(text), context)) { + if (outline != null) { + if (all == null) { + all = new Path2D.Double(); + } + all.append(outline, false); + } + } + return all; + } + + /// The font family a `font-family` list comes to: the first name in it + /// that is a generic family or a font this machine has, and sans-serif + /// when there is none. + private static String familyOf(String families) { + if (families == null) { + return Font.SANS_SERIF; + } + for (String part : families.split(",")) { + String name = part.trim(); + if (name.length() >= 2 && (name.charAt(0) == '"' || name.charAt(0) == '\'')) { + name = name.substring(1, name.length() - 1).trim(); + } + if (name.length() == 0) { + continue; + } + if ("serif".equalsIgnoreCase(name)) { + return Font.SERIF; + } + if ("sans-serif".equalsIgnoreCase(name) || "system-ui".equalsIgnoreCase(name)) { + return Font.SANS_SERIF; + } + if ("monospace".equalsIgnoreCase(name)) { + return Font.MONOSPACED; + } + for (String have : InstalledFonts.NAMES) { + if (have.equalsIgnoreCase(name)) { + return have; + } + } + } + return Font.SANS_SERIF; + } + + /// The font families of this machine, asked for once and only when an + /// SVG names one. + private static final class InstalledFonts { + static final String[] NAMES = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment() + .getAvailableFontFamilyNames(); + } + + private static float clamp(float v) { + return v < 0f ? 0f : (v > 1f ? 1f : v); + } +} 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("