diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 14bbc191..962e6581 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,9 @@ on: - master schedule: [cron: "40 1 * * *"] +env: + JAVA_VERSION: "17" + jobs: commitsar: name: Verify commit messages @@ -438,6 +441,35 @@ jobs: set -e yarn test + test-java: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-13, windows-2022] + + name: Java 17 on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Build native library + # Build with `--release` as Gradle config expects `target/release` + run: cargo build --release + working-directory: bindings/java + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Run tests + working-directory: bindings/java + run: gradle clean test + test-python: strategy: fail-fast: false diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml new file mode 100644 index 00000000..58de762e --- /dev/null +++ b/.github/workflows/java-release.yml @@ -0,0 +1,216 @@ +name: "[Java] Release" + +on: + push: + tags: + - java-v* + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + JAVA_VERSION: "17" + +jobs: + build-native-libraries: + strategy: + matrix: + include: + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + lib: libcss_inline.so + platform: linux-x86_64 + + - os: macos-13 + target: x86_64-apple-darwin + lib: libcss_inline.dylib + platform: darwin-x86_64 + - os: macos-14 + target: aarch64-apple-darwin + lib: libcss_inline.dylib + platform: darwin-aarch64 + + - os: windows-2022 + target: x86_64-pc-windows-msvc + lib: css_inline.dll + platform: win32-x86_64 + + name: Build native library for ${{ matrix.target }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + css-inline + bindings/java + + - name: Build native library + working-directory: bindings/java + run: cargo build --release --target ${{ matrix.target }} + + - name: Upload native library + uses: actions/upload-artifact@v4 + with: + name: native-${{ matrix.platform }} + if-no-files-found: error + path: bindings/java/target/${{ matrix.target }}/release/${{ matrix.lib }} + + build-jar: + name: Build JAR file + runs-on: ubuntu-22.04 + needs: build-native-libraries + steps: + - uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Download all native libraries + uses: actions/download-artifact@v4 + with: + path: native-libs + + - name: Assemble JAR with native libraries + working-directory: bindings/java + run: | + mkdir -p src/main/resources/org/cssinline/native/{linux-x86_64,darwin-x86_64,darwin-aarch64,win32-x86_64} + + # Copy native libraries to their expected location + cp ../../native-libs/native-linux-x86_64/libcss_inline.so src/main/resources/org/cssinline/native/linux-x86_64/ + cp ../../native-libs/native-darwin-x86_64/libcss_inline.dylib src/main/resources/org/cssinline/native/darwin-x86_64/ + cp ../../native-libs/native-darwin-aarch64/libcss_inline.dylib src/main/resources/org/cssinline/native/darwin-aarch64/ + cp ../../native-libs/native-win32-x86_64/css_inline.dll src/main/resources/org/cssinline/native/win32-x86_64/ + + gradle build --info + + - name: Upload JAR + uses: actions/upload-artifact@v4 + with: + name: java-jar + if-no-files-found: error + path: bindings/java/build/libs/*.jar + + test-jar: + needs: build-jar + strategy: + matrix: + include: + - os: ubuntu-22.04 + platform: linux-x86_64 + - os: macos-13 + platform: darwin-x86_64 + - os: macos-14 + platform: darwin-aarch64 + - os: windows-2022 + platform: win32-x86_64 + + name: Test JAR on ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + steps: + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Download JAR + uses: actions/download-artifact@v4 + with: + name: java-jar + + - name: Integration test on ${{ matrix.platform }} + run: | + cat > Test.java << 'EOF' + import org.cssinline.CssInline; + public class Test { + public static void main(String[] args) { + String html = "

Test

"; + String result = CssInline.inline(html); + if (!result.contains("style=\"color: red;\"")) { + throw new RuntimeException("Expected inlined style not found in: " + result); + } + System.out.println("✓ Integration test passed on ${{ matrix.platform }}"); + } + } + EOF + + JAR_FILE=$(ls *.jar) + + if [[ "$RUNNER_OS" == "Windows" ]]; then + CLASSPATH_SEP=";" + else + CLASSPATH_SEP=":" + fi + + javac -cp "$JAR_FILE" Test.java + java -cp "$JAR_FILE${CLASSPATH_SEP}." Test + + publish-github-packages: + name: Publish to GitHub Packages + runs-on: ubuntu-22.04 + needs: [build-jar, test-jar] + if: startsWith(github.ref, 'refs/tags/java-v') + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: ${{ env.JAVA_VERSION }} + + - name: Download JAR artifact + uses: actions/download-artifact@v4 + with: + name: java-jar + path: bindings/java/build/libs + + - name: Extract version + run: echo "version=${GITHUB_REF#refs/tags/java-v}" >> $GITHUB_ENV + + - name: Publish to GitHub Packages + working-directory: bindings/java + run: gradle publish -Pversion=${{ env.version }} + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + release: + name: Create GitHub Release + runs-on: ubuntu-22.04 + needs: [build-jar, test-jar, publish-github-packages] + if: startsWith(github.ref, 'refs/tags/java-v') + steps: + - name: Download JAR + uses: actions/download-artifact@v4 + with: + name: java-jar + path: dist + + - name: Extract version + run: echo "version=${GITHUB_REF#refs/tags/java-v}" >> $GITHUB_ENV + + - name: GitHub Release + uses: softprops/action-gh-release@v2 + with: + make_latest: false + draft: true + name: "[Java] Release ${{ env.version }}" + files: dist/*.jar diff --git a/README.md b/README.md index 1aa469ff..5dd99360 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ into: - Optionally caches external stylesheets - Works on Linux, Windows, and macOS - Supports HTML5 & CSS3 -- Bindings for [Python](https://github.com/Stranger6667/css-inline/tree/master/bindings/python), [Ruby](https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby), [JavaScript](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript), [C](https://github.com/Stranger6667/css-inline/tree/master/bindings/c), and a [WebAssembly](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript/wasm) module to run in browsers. +- Bindings for [Python](https://github.com/Stranger6667/css-inline/tree/master/bindings/python), [Ruby](https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby), [JavaScript](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript), [Java](https://github.com/Stranger6667/css-inline/tree/master/bindings/java), [C](https://github.com/Stranger6667/css-inline/tree/master/bindings/c), and a [WebAssembly](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript/wasm) module to run in browsers. - Command Line Interface ## Playground diff --git a/bindings/java/.gitignore b/bindings/java/.gitignore new file mode 100644 index 00000000..67bcc2f7 --- /dev/null +++ b/bindings/java/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ diff --git a/bindings/java/CHANGELOG.md b/bindings/java/CHANGELOG.md new file mode 100644 index 00000000..a17d7f83 --- /dev/null +++ b/bindings/java/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- Initial public release diff --git a/bindings/java/Cargo.toml b/bindings/java/Cargo.toml new file mode 100644 index 00000000..ceb1b86b --- /dev/null +++ b/bindings/java/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "css_inline" +version = "0.15.0" +edition = "2024" +authors = ["Dmitry Dygalo "] + +[lib] +crate-type = ["cdylib"] +path = "src/main/rust/lib.rs" + +[dependencies] +jni = "0.21.1" + +[dependencies.css-inline] +path = "../../css-inline" +version = "*" +default-features = false +features = ["http", "file", "stylesheet-cache"] diff --git a/bindings/java/README.md b/bindings/java/README.md new file mode 100644 index 00000000..8232ec33 --- /dev/null +++ b/bindings/java/README.md @@ -0,0 +1,249 @@ +# css-inline + +[build status](https://github.com/Stranger6667/css-inline/actions/workflows/build.yml) +[github packages](https://github.com/Stranger6667/css-inline/packages) + +Java bindings for the high-performance `css-inline` library that inlines CSS into HTML 'style' attributes. + +This library is designed for scenarios such as preparing HTML emails or embedding HTML into third-party web pages. + +Transforms HTML like this: + +```html + + + + + +

Big Text

+ + +``` + +into: + +```html + + + +

Big Text

+ + +``` + +## Features + +- Uses reliable components from Mozilla's Servo project +- Inlines CSS from `style` and `link` tags +- Removes `style` and `link` tags +- Resolves external stylesheets (including local files) +- Optionally caches external stylesheets +- Works on Linux, Windows, and macOS +- Supports HTML5 & CSS3 + +## Installation + +This package is available on [GitHub Packages](https://github.com/Stranger6667/css-inline/packages). + +> Maven Central publishing is in the works + +### Setup + +GitHub Packages requires authentication even for public packages. See the [GitHub documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry#authenticating-to-github-packages) for authentication setup. + +**Gradle:** +```gradle +repositories { + maven { + url = uri("https://maven.pkg.github.com/Stranger6667/css-inline") + credentials { + username = project.findProperty("gpr.user") ?: System.getenv("USERNAME") + password = project.findProperty("gpr.key") ?: System.getenv("TOKEN") + } + } +} + +dependencies { + implementation 'org.css-inline:css-inline:0.15.0' +} +``` + +**Maven:** +```xml + + + github + https://maven.pkg.github.com/Stranger6667/css-inline + + + + + + org.css-inline + css-inline + 0.15.0 + + +``` + +See [GitHub's Maven documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry) for Maven authentication setup. + +### Platform Support + +This JAR includes native libraries for the following platforms: + +- **Linux** x86_64 +- **macOS** x86_64 +- **macOS** aarch64 (Apple Silicon) +- **Windows** x86_64 + +Requires Java 17+ with 64-bit JVM. + +## Usage + +```java +import org.cssinline.CssInline; + +public class Example { + public static void main(String[] args) { + String html = """ + + + + + +

Big Text

+ + """; + + String inlined = CssInline.inline(html); + System.out.println(inlined); + } +} +``` + +You can also configure the inlining process: + +```java +import org.cssinline.CssInline; +import org.cssinline.CssInlineConfig; + +public class ConfigExample { + public static void main(String[] args) { + String html = "..."; + + CssInlineConfig config = new CssInlineConfig.Builder() + .setLoadRemoteStylesheets(false) + .setKeepStyleTags(true) + .setBaseUrl("https://example.com/") + .build(); + + String inlined = CssInline.inline(html, config); + } +} +``` + +- **`setInlineStyleTags(boolean)`** - Inline CSS from ` + + + +

Big Text

+ +``` + +The `data-css-inline="ignore"` attribute also allows you to skip `link` and `style` tags: + +```html + + + + + +

Big Text

+ +``` + +Alternatively, you may keep `style` from being removed by using the `data-css-inline="keep"` attribute. +This is useful if you want to keep `@media` queries for responsive emails in separate `style` tags: + +```html + + + + + +

Big Text

+ +``` + +Such tags will be kept in the resulting HTML even if the `keepStyleTags` option is set to `false`. + +## Performance + +`css-inline` is powered by efficient tooling from Mozilla's Servo project to provide high-performance CSS inlining for Java applications. + +Here is the performance comparison: + +| | Size | `css-inline 0.15.0` | `CSSBox 5.0.0` | +|-------------|---------|---------------------|------------------------| +| Basic | 230 B | 7.67 µs | 209.93 µs (**27.37x**) | +| Realistic-1 | 8.58 KB | 123.18 µs | 1.92 ms (**15.58x**) | +| Realistic-2 | 4.3 KB | 77.74 µs | 608.65 µs (**7.82x**) | +| GitHub page | 1.81 MB | 168.43 ms | 316.21 ms (**1.87x**) | + +The benchmarking code is available in the `src/jmh/java/org/cssinline/CSSInlineBench.java` file. The benchmarks were conducted using the stable `rustc 1.87`, `OpenJDK 24.0.1` on Ryzen 9 9950X. + +## License + +This project is licensed under the terms of the [MIT license](https://opensource.org/licenses/MIT). diff --git a/bindings/java/build.gradle b/bindings/java/build.gradle new file mode 100644 index 00000000..a7882a38 --- /dev/null +++ b/bindings/java/build.gradle @@ -0,0 +1,108 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'me.champeau.jmh' version '0.7.3' +} + +group = 'org.css-inline' +version = System.getenv('VERSION') ?: '0.15.0-SNAPSHOT' + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' + implementation 'com.google.code.gson:gson:2.10.1' + + jmh 'org.openjdk.jmh:jmh-core:1.37' + jmh 'org.openjdk.jmh:jmh-generator-annprocess:1.37' + + jmh 'net.sf.cssbox:cssbox:5.0.0' + jmh 'net.sf.cssbox:jstyleparser:3.5.0' + jmh 'com.fasterxml.jackson.core:jackson-core:2.15.2' + jmh 'com.fasterxml.jackson.core:jackson-databind:2.15.2' +} + +publishing { + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/Stranger6667/css-inline") + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } + + publications { + maven(MavenPublication) { + from components.java + + pom { + name = 'CSS Inline Java' + description = 'Java bindings for css-inline - a high-performance CSS inlining library' + url = 'https://css-inline.org' + + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/licenses/MIT' + } + } + + developers { + developer { + id = 'stranger6667' + name = 'Dmitry Dygalo' + email = 'dmitry@dygalo.dev' + } + } + + scm { + connection = 'scm:git:git://github.com/Stranger6667/css-inline.git' + developerConnection = 'scm:git:ssh://github.com:Stranger6667/css-inline.git' + url = 'https://github.com/Stranger6667/css-inline/tree/master' + } + } + } + } +} + +jmh { + jmhVersion = '1.37' + + def baseArgs = [ + '-server', + '--add-opens=java.base/sun.misc=ALL-UNNAMED', + '--add-exports=java.base/sun.misc=ALL-UNNAMED', + '--enable-native-access=ALL-UNNAMED' + ] + + def nativeDir = file('target/release') + if (nativeDir.exists()) { + jvmArgs = baseArgs + ["-Djava.library.path=${nativeDir.absolutePath}"] + println "Using native library path: ${nativeDir.absolutePath}" + } else { + jvmArgs = baseArgs + println "Native dir not found, using bundled libraries" + } +} + +test { + useJUnitPlatform() + + def nativeDir = file('target/release') + if (nativeDir.exists()) { + jvmArgs += "-Djava.library.path=${nativeDir.absolutePath}" + println "Test using native library path: ${nativeDir.absolutePath}" + } else { + println "Test using bundled libraries" + } +} diff --git a/bindings/java/rustfmt.toml b/bindings/java/rustfmt.toml new file mode 100644 index 00000000..bf772232 --- /dev/null +++ b/bindings/java/rustfmt.toml @@ -0,0 +1,2 @@ +imports_granularity = "Crate" +edition = "2021" diff --git a/bindings/java/settings.gradle b/bindings/java/settings.gradle new file mode 100644 index 00000000..f2a23080 --- /dev/null +++ b/bindings/java/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'css-inline' diff --git a/bindings/java/src/jmh/java/org/cssinline/CSSBoxInliner.java b/bindings/java/src/jmh/java/org/cssinline/CSSBoxInliner.java new file mode 100644 index 00000000..c5cdfd73 --- /dev/null +++ b/bindings/java/src/jmh/java/org/cssinline/CSSBoxInliner.java @@ -0,0 +1,45 @@ +package org.cssinline; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.fit.cssbox.css.CSSNorm; +import org.fit.cssbox.css.DOMAnalyzer; +import org.fit.cssbox.css.NormalOutput; +import org.fit.cssbox.css.Output; +import org.fit.cssbox.io.DOMSource; +import org.fit.cssbox.io.DefaultDOMSource; +import org.fit.cssbox.io.DefaultDocumentSource; +import org.fit.cssbox.io.DocumentSource; +import org.w3c.dom.Document; + +public class CSSBoxInliner { + + public String inline(String html) throws Exception { + String dataUrl = "data:text/html;charset=utf-8," + java.net.URLEncoder.encode(html, "UTF-8"); + DocumentSource docSource = new DefaultDocumentSource(dataUrl); + + try { + DOMSource parser = new DefaultDOMSource(docSource); + Document doc = parser.parse(); + + DOMAnalyzer da = new DOMAnalyzer(doc, docSource.getURL()); + da.attributesToStyles(); + da.addStyleSheet(null, CSSNorm.stdStyleSheet(), DOMAnalyzer.Origin.AGENT); + da.addStyleSheet(null, CSSNorm.userStyleSheet(), DOMAnalyzer.Origin.AGENT); + da.getStyleSheets(); + + da.stylesToDomInherited(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(baos, true, "UTF-8"); + Output out = new NormalOutput(doc); + out.dumpTo(ps); + ps.close(); + + return baos.toString("UTF-8"); + + } finally { + docSource.close(); + } + } +} diff --git a/bindings/java/src/jmh/java/org/cssinline/CSSInlineBench.java b/bindings/java/src/jmh/java/org/cssinline/CSSInlineBench.java new file mode 100644 index 00000000..12efd0b7 --- /dev/null +++ b/bindings/java/src/jmh/java/org/cssinline/CSSInlineBench.java @@ -0,0 +1,72 @@ +package org.cssinline; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.RunnerException; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Fork(value = 1, jvmArgs = {"-server"}) +@Warmup(iterations = 1, time = 10, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 3, time = 10, timeUnit = TimeUnit.SECONDS) +public class CSSInlineBench { + + @Param({"simple", "merging", "double_quotes", "big_email_1", "big_email_2", "big_page"}) + public String name; + + private static Map cases; + + private CssInlineConfig cfg; + + private CSSBoxInliner cssBoxInliner; + + @Setup(Level.Trial) + public void setup() throws IOException { + cfg = new CssInlineConfig.Builder().build(); + cssBoxInliner = new CSSBoxInliner(); + + if (cases == null) { + Path path = Paths.get("").resolve("../../benchmarks/benchmarks.json"); + + List> list = new ObjectMapper().readValue(path.toFile(), + new TypeReference>>() { + }); + + cases = new LinkedHashMap<>(); + for (Map entry : list) { + cases.put(entry.get("name"), entry.get("html")); + } + } + } + + @Benchmark + public String benchCSSInline() { + return CssInline.inline(cases.get(name), cfg); + } + + @Benchmark + public String benchCSSBox() { + try { + return cssBoxInliner.inline(cases.get(name)); + } catch (Exception e) { + throw new RuntimeException("CSSBox benchmark failed", e); + } + } + + public static void main(String[] args) throws RunnerException { + Options opt = new OptionsBuilder().include(CSSInlineBench.class.getSimpleName()).build(); + new Runner(opt).run(); + } +} diff --git a/bindings/java/src/main/java/org/cssinline/CssInline.java b/bindings/java/src/main/java/org/cssinline/CssInline.java new file mode 100644 index 00000000..325e7b3d --- /dev/null +++ b/bindings/java/src/main/java/org/cssinline/CssInline.java @@ -0,0 +1,83 @@ +package org.cssinline; + +/** + * Main entry point for CSS inlining. + * + * Inlines CSS styles from <style> and <link> tags directly into + * HTML element style attributes. Useful for preparing HTML emails or embedding + * HTML content where external stylesheets are not supported. + */ +public class CssInline { + static { + NativeLibraryLoader.loadLibrary(); + } + + /** + * Private constructor to prevent instantiation of utility class. + */ + private CssInline() {} + + private static native String nativeInline(String html, CssInlineConfig cfg); + private static native String nativeInlineFragment(String html, String css, CssInlineConfig cfg); + + /** + * Inlines CSS styles into HTML elements using the specified configuration. + * + * @param html + * the HTML document to process + * @param cfg + * the configuration object specifying inlining behavior + * @return the HTML document with CSS styles inlined + * @throws CssInlineException + * if an error occurs during processing + */ + public static String inline(String html, CssInlineConfig cfg) { + return nativeInline(html, cfg); + } + + /** + * Inlines CSS styles into HTML elements using default configuration. + * + * @param html + * the HTML document to process + * @return the HTML document with CSS styles inlined + * @throws CssInlineException + * if an error occurs during processing + */ + public static String inline(String html) { + return inline(html, new CssInlineConfig.Builder().build()); + } + + /** + * Inlines the provided CSS into an HTML fragment using the specified configuration. + * + *

Unlike {@link #inline(String, CssInlineConfig)}, this method works with HTML fragments + * (elements without <html>, <head>, or <body> tags) and applies the + * provided CSS directly without parsing <style> or <link> tags. + * + * @param fragment the HTML fragment to process + * @param css the CSS rules to inline + * @param cfg the configuration object specifying inlining behavior + * @return the HTML fragment with CSS styles inlined + * @throws CssInlineException if an error occurs during processing + */ + public static String inlineFragment(String fragment, String css, CssInlineConfig cfg) { + return nativeInlineFragment(fragment, css, cfg); + } + + /** + * Inlines the provided CSS into an HTML fragment using default configuration. + * + *

Unlike {@link #inline(String)}, this method works with HTML fragments + * (elements without <html>, <head>, or <body> tags) and applies the + * provided CSS directly without parsing <style> or <link> tags. + * + * @param fragment the HTML fragment to process + * @param css the CSS rules to inline + * @return the HTML fragment with CSS styles inlined + * @throws CssInlineException if an error occurs during processing + */ + public static String inlineFragment(String fragment, String css) { + return inlineFragment(fragment, css, new CssInlineConfig.Builder().build()); + } +} diff --git a/bindings/java/src/main/java/org/cssinline/CssInlineConfig.java b/bindings/java/src/main/java/org/cssinline/CssInlineConfig.java new file mode 100644 index 00000000..557e2b15 --- /dev/null +++ b/bindings/java/src/main/java/org/cssinline/CssInlineConfig.java @@ -0,0 +1,175 @@ +package org.cssinline; + +/** Configuration options for CSS inlining. */ +public class CssInlineConfig { + /** Whether to inline CSS from "style" tags. */ + public final boolean inlineStyleTags; + + /** Keep "style" tags after inlining. */ + public final boolean keepStyleTags; + + /** Keep "link" tags after inlining. */ + public final boolean keepLinkTags; + + /** Whether remote stylesheets should be loaded or not. */ + public final boolean loadRemoteStylesheets; + + /** Used for loading external stylesheets via relative URLs. */ + public final String baseUrl; + + /** Additional CSS to inline. */ + public final String extraCss; + + /** External stylesheet cache size. */ + public final int cacheSize; + + /** Pre-allocate capacity for HTML nodes during parsing. */ + public final int preallocateNodeCapacity; + + private CssInlineConfig(boolean inlineStyleTags, boolean keepStyleTags, boolean keepLinkTags, + boolean loadRemoteStylesheets, String baseUrl, String extraCss, int cacheSize, + int preallocateNodeCapacity) { + this.inlineStyleTags = inlineStyleTags; + this.keepStyleTags = keepStyleTags; + this.keepLinkTags = keepLinkTags; + this.loadRemoteStylesheets = loadRemoteStylesheets; + this.baseUrl = baseUrl; + this.extraCss = extraCss; + this.cacheSize = cacheSize; + this.preallocateNodeCapacity = preallocateNodeCapacity; + } + + /** + * Builder for creating {@link CssInlineConfig} instances. + */ + public static class Builder { + private boolean inlineStyleTags = true; + private boolean keepStyleTags = false; + private boolean keepLinkTags = false; + private boolean loadRemoteStylesheets = true; + private String baseUrl = null; + private String extraCss = null; + private int cacheSize = 0; + private int preallocateNodeCapacity = 32; + + /** + * Creates a new builder with default configuration values. + */ + public Builder() { + // Default constructor for builder + } + + /** + * Whether to inline CSS from "style" tags. + * Sometimes HTML may include boilerplate styles that are not applicable + * in every scenario and it is useful to ignore them and use extraCss instead. + * + * @param b true to inline CSS from style tags, false to ignore them + * @return this builder instance for method chaining + */ + public Builder setInlineStyleTags(boolean b) { + this.inlineStyleTags = b; + return this; + } + + /** + * Keep "style" tags after inlining. + * + * @param b true to preserve style tags, false to remove them + * @return this builder instance for method chaining + */ + public Builder setKeepStyleTags(boolean b) { + this.keepStyleTags = b; + return this; + } + + /** + * Keep "link" tags after inlining. + * + * @param b true to preserve link tags, false to remove them + * @return this builder instance for method chaining + */ + public Builder setKeepLinkTags(boolean b) { + this.keepLinkTags = b; + return this; + } + + /** + * Whether remote stylesheets should be loaded or not. + * + * @param b true to load external stylesheets, false to ignore them + * @return this builder instance for method chaining + */ + public Builder setLoadRemoteStylesheets(boolean b) { + this.loadRemoteStylesheets = b; + return this; + } + + /** + * Used for loading external stylesheets via relative URLs. + * + * @param url the base URL as a string, or null to use no base URL + * @return this builder instance for method chaining + */ + public Builder setBaseUrl(String url) { + this.baseUrl = url; + return this; + } + + /** + * Additional CSS to inline. + * + * @param css additional CSS rules as a string, or null for no extra CSS + * @return this builder instance for method chaining + */ + public Builder setExtraCss(String css) { + this.extraCss = css; + return this; + } + + /** + * External stylesheet cache size. + * + * @param size + * cache size, must be non-negative + * @return this builder instance for method chaining + * @throws IllegalArgumentException + * if size is negative + */ + public Builder setCacheSize(int size) { + if (size < 0) { + throw new IllegalArgumentException("Cache size must be non-negative, got: " + size); + } + this.cacheSize = size; + return this; + } + + /** + * Pre-allocate capacity for HTML nodes during parsing. Can improve performance + * when you have an estimate of the number of nodes in your HTML document. + * + * @param cap + * initial node capacity, must be positive + * @return this builder instance for method chaining + * @throws IllegalArgumentException + * if cap is zero or negative + */ + public Builder setPreallocateNodeCapacity(int cap) { + if (cap <= 0) { + throw new IllegalArgumentException("Preallocate node capacity must be positive, got: " + cap); + } + this.preallocateNodeCapacity = cap; + return this; + } + + /** + * Creates a new {@link CssInlineConfig} instance with the current builder settings. + * + * @return a new immutable configuration instance + */ + public CssInlineConfig build() { + return new CssInlineConfig(inlineStyleTags, keepStyleTags, keepLinkTags, loadRemoteStylesheets, baseUrl, + extraCss, cacheSize, preallocateNodeCapacity); + } + } +} diff --git a/bindings/java/src/main/java/org/cssinline/CssInlineException.java b/bindings/java/src/main/java/org/cssinline/CssInlineException.java new file mode 100644 index 00000000..dc3db31a --- /dev/null +++ b/bindings/java/src/main/java/org/cssinline/CssInlineException.java @@ -0,0 +1,33 @@ +package org.cssinline; + +/** + * Exception thrown when CSS inlining operations fail. + * + * This runtime exception is thrown when errors occur during CSS processing, + * HTML parsing, or stylesheet loading. + */ +public class CssInlineException extends RuntimeException { + + /** + * Creates a new exception with the specified error message. + * + * @param message + * the error message describing what went wrong + */ + public CssInlineException(String message) { + super(message); + } + + /** + * Creates a new exception with the specified error message and underlying + * cause. + * + * @param message + * the error message describing what went wrong + * @param cause + * the underlying exception that caused this error + */ + public CssInlineException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bindings/java/src/main/java/org/cssinline/NativeLibraryLoader.java b/bindings/java/src/main/java/org/cssinline/NativeLibraryLoader.java new file mode 100644 index 00000000..6785fad7 --- /dev/null +++ b/bindings/java/src/main/java/org/cssinline/NativeLibraryLoader.java @@ -0,0 +1,91 @@ +package org.cssinline; + +import java.io.*; +import java.nio.file.*; + +class NativeLibraryLoader { + private static final String LIBRARY_NAME = "css_inline"; + private static boolean loaded = false; + + static synchronized void loadLibrary() { + if (loaded) + return; + + try { + // Try system library first (for development) + System.loadLibrary(LIBRARY_NAME); + loaded = true; + return; + } catch (UnsatisfiedLinkError e) { + // Fall back to bundled library + } + + String platform = detectPlatform(); + String libraryPath = "/org/cssinline/native/" + platform + "/" + getLibraryFileName(); + + try (InputStream is = NativeLibraryLoader.class.getResourceAsStream(libraryPath)) { + if (is == null) { + throw new UnsatisfiedLinkError("Native library not found: " + libraryPath + + ". Available platforms: linux-x86_64, darwin-x86_64, darwin-aarch64, win32-x86_64"); + } + + // Extract to temporary file + Path tempDir = Files.createTempDirectory("css-inline-native"); + Path tempFile = tempDir.resolve(getLibraryFileName()); + Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); + + // Load library + System.load(tempFile.toAbsolutePath().toString()); + loaded = true; + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { + Files.deleteIfExists(tempFile); + Files.deleteIfExists(tempDir); + } catch (IOException ignored) { + } + })); + + } catch (IOException e) { + throw new UnsatisfiedLinkError("Failed to load native library: " + e.getMessage()); + } + } + + private static String detectPlatform() { + String os = System.getProperty("os.name").toLowerCase(); + String arch = System.getProperty("os.arch").toLowerCase(); + + String osName; + if (os.contains("windows")) { + osName = "win32"; + } else if (os.contains("mac") || os.contains("darwin")) { + osName = "darwin"; + } else if (os.contains("linux")) { + osName = "linux"; + } else { + throw new UnsatisfiedLinkError("Unsupported OS: " + os); + } + + String archName; + if (arch.contains("amd64") || arch.contains("x86_64")) { + archName = "x86_64"; + } else if (arch.contains("aarch64") || arch.contains("arm64")) { + archName = "aarch64"; + } else { + throw new UnsatisfiedLinkError("Unsupported architecture: " + arch); + } + + return osName + "-" + archName; + } + + private static String getLibraryFileName() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("windows")) { + return LIBRARY_NAME + ".dll"; + } else if (os.contains("mac") || os.contains("darwin")) { + return "lib" + LIBRARY_NAME + ".dylib"; + } else { + return "lib" + LIBRARY_NAME + ".so"; + } + } +} diff --git a/bindings/java/src/main/rust/lib.rs b/bindings/java/src/main/rust/lib.rs new file mode 100644 index 00000000..3a1b3615 --- /dev/null +++ b/bindings/java/src/main/rust/lib.rs @@ -0,0 +1,154 @@ +use core::fmt; +use css_inline::{CSSInliner, StylesheetCache}; +use jni::{ + JNIEnv, + errors::Result as JNIResult, + objects::{JClass, JObject, JString}, + sys::jstring, +}; +use std::{borrow::Cow, num::NonZeroUsize}; + +trait JNIExt { + fn get_rust_string(&mut self, obj: &JString) -> String; + fn to_jstring(&mut self, obj: String) -> jstring; + fn get_bool_field(&mut self, obj: &JObject, name: &str) -> JNIResult; + fn get_int_field(&mut self, obj: &JObject, name: &str) -> JNIResult; + fn get_string_field_opt(&mut self, obj: &JObject, name: &str) -> JNIResult>; +} + +impl<'a> JNIExt for JNIEnv<'a> { + fn get_rust_string(&mut self, obj: &JString) -> String { + self.get_string(&obj) + .expect("Failed to get Java String") + .into() + } + + fn to_jstring(&mut self, obj: String) -> jstring { + self.new_string(obj) + .expect("Failed to get Java String") + .into_raw() + } + + fn get_bool_field(&mut self, obj: &JObject, name: &str) -> JNIResult { + self.get_field(obj, name, "Z")?.z() + } + + fn get_int_field(&mut self, obj: &JObject, name: &str) -> JNIResult { + self.get_field(obj, name, "I")?.i() + } + + fn get_string_field_opt(&mut self, cfg: &JObject, name: &str) -> JNIResult> { + let value = self.get_field(cfg, name, "Ljava/lang/String;")?.l()?; + if value.is_null() { + Ok(None) + } else { + Ok(Some(self.get_string(&JString::from(value))?.into())) + } + } +} + +enum Error { + Jni(jni::errors::Error), + Other(E), +} + +impl From for Error { + fn from(value: jni::errors::Error) -> Self { + Error::Jni(value) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Jni(error) => error.fmt(f), + Error::Other(error) => error.fmt(f), + } + } +} + +fn build_inliner( + env: &mut JNIEnv, + cfg: JObject, +) -> Result, Error> { + let inline_style_tags = env.get_bool_field(&cfg, "inlineStyleTags")?; + let keep_style_tags = env.get_bool_field(&cfg, "keepStyleTags")?; + let keep_link_tags = env.get_bool_field(&cfg, "keepLinkTags")?; + let load_remote_stylesheets = env.get_bool_field(&cfg, "loadRemoteStylesheets")?; + let cache_size = env.get_int_field(&cfg, "cacheSize")?; + let preallocate_node_capacity = env.get_int_field(&cfg, "preallocateNodeCapacity")?; + + let extra_css = env.get_string_field_opt(&cfg, "extraCss")?; + let base_url = env.get_string_field_opt(&cfg, "baseUrl")?; + let mut builder = CSSInliner::options() + .inline_style_tags(inline_style_tags) + .keep_style_tags(keep_style_tags) + .keep_link_tags(keep_link_tags) + .load_remote_stylesheets(load_remote_stylesheets) + .extra_css(extra_css.map(Cow::Owned)) + .preallocate_node_capacity(preallocate_node_capacity as usize); + + if let Some(url) = base_url { + match css_inline::Url::parse(&url) { + Ok(url) => { + builder = builder.base_url(Some(url)); + } + Err(error) => return Err(Error::Other(error)), + } + } + + if cache_size > 0 { + builder = builder.cache(StylesheetCache::new( + NonZeroUsize::new(cache_size as usize).expect("Cache size is not null"), + )); + } + + Ok(builder.build()) +} + +fn throw(mut env: JNIEnv, message: String) -> jstring { + let exception = env + .find_class("org/cssinline/CssInlineException") + .expect("CssInlineException class not found"); + env.throw_new(exception, message) + .expect("Failed to throw CssInlineException"); + std::ptr::null_mut() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_cssinline_CssInline_nativeInline( + mut env: JNIEnv, + _class: JClass, + input: JString, + cfg: JObject, +) -> jstring { + let html = env.get_rust_string(&input); + let inliner = match build_inliner(&mut env, cfg) { + Ok(inliner) => inliner, + Err(error) => return throw(env, error.to_string()), + }; + match inliner.inline(&html) { + Ok(out) => env.to_jstring(out), + Err(error) => throw(env, error.to_string()), + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_cssinline_CssInline_nativeInlineFragment( + mut env: JNIEnv, + _class: JClass, + input: JString, + css: JString, + cfg: JObject, +) -> jstring { + let html = env.get_rust_string(&input); + let css = env.get_rust_string(&css); + let inliner = match build_inliner(&mut env, cfg) { + Ok(inliner) => inliner, + Err(error) => return throw(env, error.to_string()), + }; + match inliner.inline_fragment(&html, &css) { + Ok(out) => env.to_jstring(out), + Err(error) => throw(env, error.to_string()), + } +} diff --git a/bindings/java/src/test/java/org/cssinline/CssInlineTest.java b/bindings/java/src/test/java/org/cssinline/CssInlineTest.java new file mode 100644 index 00000000..465a3252 --- /dev/null +++ b/bindings/java/src/test/java/org/cssinline/CssInlineTest.java @@ -0,0 +1,136 @@ +package org.cssinline; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class CssInlineTest { + + @Test + void inlinesSimpleStyleTag() { + String html = "

Hello

"; + + String out = CssInline.inline(html); + + assertTrue(out.contains("style=\"color: blue;\""), "Output should inline styles for h1, got: " + out); + } + + @Test + void extraCssAddsBackground() { + String html = "

Hello

"; + + CssInlineConfig cfg = new CssInlineConfig.Builder().setExtraCss("h1 { color: blue; }").build(); + + String out = CssInline.inline(html, cfg); + + assertTrue(out.contains("style=\"color: blue;\""), "Output should inline styles for h1, got: " + out); + } + + @Test + void validBaseUrlParses() { + CssInlineConfig cfg = new CssInlineConfig.Builder().setBaseUrl("https://example.com/styles/").build(); + + String in = "

No styles

"; + String out = CssInline.inline(in, cfg); + assertNotNull(out); + assertTrue(out.contains("

No styles

")); + } + + @Test + void invalidBaseUrlThrows() { + CssInlineConfig cfg = new CssInlineConfig.Builder().setBaseUrl("not a url").build(); + + CssInlineException ex = assertThrows(CssInlineException.class, () -> CssInline.inline("

Hi

", cfg)); + assertEquals(ex.getMessage(), "relative URL without a base", + "Expected URL parse error, got: " + ex.getMessage()); + } + + @Test + void keepStyleTagsPreserved() { + String html = "" + "

Bold

"; + + CssInlineConfig cfg = new CssInlineConfig.Builder().setKeepStyleTags(true).build(); + + String out = CssInline.inline(html, cfg); + assertTrue(out.contains(""), "Expected to keep original style tags"); + assertTrue(out.contains("style=\"font-weight: bold;\"")); + } + + @Test + void negativeCacheSizeThrows() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> new CssInlineConfig.Builder().setCacheSize(-1)); + assertEquals("Cache size must be non-negative, got: -1", ex.getMessage()); + } + + @Test + void zeroOrNegativePreallocateCapacityThrows() { + IllegalArgumentException ex1 = assertThrows(IllegalArgumentException.class, + () -> new CssInlineConfig.Builder().setPreallocateNodeCapacity(0)); + assertEquals("Preallocate node capacity must be positive, got: 0", ex1.getMessage()); + + IllegalArgumentException ex2 = assertThrows(IllegalArgumentException.class, + () -> new CssInlineConfig.Builder().setPreallocateNodeCapacity(-5)); + assertEquals("Preallocate node capacity must be positive, got: -5", ex2.getMessage()); + } + + @Test + void validConfigurationBuilds() { + assertDoesNotThrow(() -> { + CssInlineConfig cfg = new CssInlineConfig.Builder().setCacheSize(0).setCacheSize(100) + .setPreallocateNodeCapacity(1).build(); + }); + } + + @Test + void inlineFragmentBasic() { + String fragment = """ +
+

Hello

+
+

who am i

+
+
"""; + + String css = """ + p { + color: red; + } + + h1 { + color: blue; + } + """; + + String result = CssInline.inlineFragment(fragment, css); + + assertTrue(result.contains("h1 style=\"color: blue;\""), "Should inline h1 color"); + assertTrue(result.contains("p style=\"color: red;\""), "Should inline p color"); + assertFalse(result.contains(""), "Should not add html wrapper"); + assertFalse(result.contains(""), "Should not add head wrapper"); + } + + @Test + void inlineFragmentWithConfig() { + String fragment = "

Test

"; + String css = "h1 { color: blue; font-size: 16px; }"; + + CssInlineConfig config = new CssInlineConfig.Builder() + .setExtraCss("div { margin: 10px; }") + .build(); + + String result = CssInline.inlineFragment(fragment, css, config); + + assertTrue(result.contains("h1 style=\"color: blue;font-size: 16px;\""), + "Should inline h1 styles"); + assertTrue(result.contains("div style=\"margin: 10px;\""), + "Should apply extra CSS"); + } + + @Test + void inlineFragmentEmpty() { + String result = CssInline.inlineFragment("", ""); + + assertEquals("", result, "Empty fragment and CSS should return empty string"); + } +} diff --git a/bindings/javascript/wasm/index.html b/bindings/javascript/wasm/index.html index 3b7a9bef..4f26b3ba 100644 --- a/bindings/javascript/wasm/index.html +++ b/bindings/javascript/wasm/index.html @@ -5,11 +5,11 @@ CSS Inline | High-performance CSS inlining High-performance library for inlining CSS into HTML 'style' attributes

- css-inline leverages Mozilla's Servo project components and works with - Rust, Python, Ruby, JavaScript, and C. The playground below is powered - by WebAssembly allows direct browser interaction with the library. Edit - the HTML in the text area and click "Inline" to view the results - instantly. + css-inline uses components from Mozilla's Servo project and provides + bindings for Rust, Python, Ruby, JavaScript, Java, and C. The playground + runs the library compiled to WebAssembly in the browser. Paste HTML with + CSS into the text area and click "Inline" to process the output.