Skip to content

Commit bfe0717

Browse files
committed
feat: Java bindings
Signed-off-by: Dmitry Dygalo <[email protected]>
1 parent a487b68 commit bfe0717

File tree

10 files changed

+357
-1
lines changed

10 files changed

+357
-1
lines changed

.github/workflows/build.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,30 @@ jobs:
438438
set -e
439439
yarn test
440440
441+
test-java:
442+
name: Java JNI tests
443+
runs-on: ubuntu-22.04
444+
steps:
445+
- uses: actions/checkout@v4
446+
447+
- run: cargo build --release
448+
working-directory: bindings/java
449+
450+
- name: Set up Java
451+
uses: actions/setup-java@v4
452+
with:
453+
distribution: temurin
454+
java-version: 21
455+
456+
- name: Setup Gradle
457+
uses: gradle/actions/setup-gradle@v4
458+
459+
- name: Run Java tests
460+
working-directory: bindings/java
461+
run: |
462+
echo "nativeLibraryPath=../target/release" >> gradle.properties
463+
gradle clean test --no-daemon
464+
441465
test-python:
442466
strategy:
443467
fail-fast: false

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ into:
4141
- Optionally caches external stylesheets
4242
- Works on Linux, Windows, and macOS
4343
- Supports HTML5 & CSS3
44-
- 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.
44+
- 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.
4545
- Command Line Interface
4646

4747
## Playground

bindings/java/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "css_inline"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[lib]
7+
crate-type = ["cdylib"]
8+
path = "src/main/rust/lib.rs"
9+
10+
[dependencies]
11+
jni = "0.21.1"
12+
13+
[dependencies.css-inline]
14+
path = "../../css-inline"
15+
version = "*"
16+
default-features = false
17+
features = ["http", "file", "stylesheet-cache"]

bindings/java/build.gradle

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
plugins {
2+
id 'java'
3+
}
4+
5+
group = 'org.cssinline'
6+
version = '0.1.0'
7+
8+
repositories {
9+
mavenCentral()
10+
}
11+
12+
dependencies {
13+
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
14+
}
15+
16+
test {
17+
useJUnitPlatform()
18+
19+
def nativeDir = file('target/release')
20+
if (nativeDir.exists()) {
21+
jvmArgs += "-Djava.library.path=${nativeDir.absolutePath}"
22+
} else {
23+
logger.warn("Native dir ${nativeDir} not found")
24+
}
25+
}

bindings/java/rustfmt.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
imports_granularity = "Crate"
2+
edition = "2021"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package org.cssinline;
2+
3+
public class CssInline {
4+
static { System.loadLibrary("css_inline"); }
5+
6+
private static native String nativeInline(String html, CssInlineConfig cfg);
7+
8+
public static String inline(String html, CssInlineConfig cfg) {
9+
return nativeInline(html, cfg);
10+
}
11+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package org.cssinline;
2+
3+
public class CssInlineConfig {
4+
public final boolean inlineStyleTags;
5+
public final boolean keepStyleTags;
6+
public final boolean keepLinkTags;
7+
public final boolean loadRemoteStylesheets;
8+
public final String baseUrl;
9+
public final String extraCss;
10+
public final int cacheSize;
11+
public final int preallocateNodeCapacity;
12+
13+
private CssInlineConfig(
14+
boolean inlineStyleTags,
15+
boolean keepStyleTags,
16+
boolean keepLinkTags,
17+
boolean loadRemoteStylesheets,
18+
String baseUrl,
19+
String extraCss,
20+
int cacheSize,
21+
int preallocateNodeCapacity
22+
) {
23+
this.inlineStyleTags = inlineStyleTags;
24+
this.keepStyleTags = keepStyleTags;
25+
this.keepLinkTags = keepLinkTags;
26+
this.loadRemoteStylesheets = loadRemoteStylesheets;
27+
this.baseUrl = baseUrl;
28+
this.extraCss = extraCss;
29+
this.cacheSize = cacheSize;
30+
this.preallocateNodeCapacity = preallocateNodeCapacity;
31+
}
32+
33+
public static class Builder {
34+
private boolean inlineStyleTags = true;
35+
private boolean keepStyleTags = false;
36+
private boolean keepLinkTags = false;
37+
private boolean loadRemoteStylesheets = true;
38+
private String baseUrl = null;
39+
private String extraCss = null;
40+
private int cacheSize = 0;
41+
private int preallocateNodeCapacity = 32;
42+
43+
public Builder setInlineStyleTags(boolean b) { this.inlineStyleTags = b; return this; }
44+
public Builder setKeepStyleTags(boolean b) { this.keepStyleTags = b; return this; }
45+
public Builder setKeepLinkTags(boolean b) { this.keepLinkTags = b; return this; }
46+
public Builder setLoadRemoteStylesheets(boolean b) { this.loadRemoteStylesheets = b; return this; }
47+
public Builder setBaseUrl(String url) { this.baseUrl = url; return this; }
48+
public Builder setExtraCss(String css) { this.extraCss = css; return this; }
49+
public Builder setCacheSize(int size) { this.cacheSize = size; return this; }
50+
public Builder setPreallocateNodeCapacity(int cap) { this.preallocateNodeCapacity = cap; return this; }
51+
52+
public CssInlineConfig build() {
53+
return new CssInlineConfig(
54+
inlineStyleTags,
55+
keepStyleTags,
56+
keepLinkTags,
57+
loadRemoteStylesheets,
58+
baseUrl,
59+
extraCss,
60+
cacheSize,
61+
preallocateNodeCapacity
62+
);
63+
}
64+
}
65+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.cssinline;
2+
3+
public class CssInlineException extends RuntimeException {
4+
public CssInlineException(String message) {
5+
super(message);
6+
}
7+
public CssInlineException(String message, Throwable cause) {
8+
super(message, cause);
9+
}
10+
}

bindings/java/src/main/rust/lib.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
use core::fmt;
2+
use css_inline::{CSSInliner, StylesheetCache};
3+
use jni::{
4+
JNIEnv,
5+
errors::Result as JNIResult,
6+
objects::{JClass, JObject, JString},
7+
sys::jstring,
8+
};
9+
use std::{borrow::Cow, num::NonZeroUsize};
10+
11+
trait JNIExt {
12+
fn get_bool_field(&mut self, obj: &JObject, name: &str) -> JNIResult<bool>;
13+
fn get_int_field(&mut self, obj: &JObject, name: &str) -> JNIResult<i32>;
14+
fn get_string_field_opt(&mut self, obj: &JObject, name: &str) -> JNIResult<Option<String>>;
15+
}
16+
17+
impl<'a> JNIExt for JNIEnv<'a> {
18+
fn get_bool_field(&mut self, obj: &JObject, name: &str) -> JNIResult<bool> {
19+
self.get_field(obj, name, "Z")?.z()
20+
}
21+
22+
fn get_int_field(&mut self, obj: &JObject, name: &str) -> JNIResult<i32> {
23+
self.get_field(obj, name, "I")?.i()
24+
}
25+
26+
fn get_string_field_opt(&mut self, cfg: &JObject, name: &str) -> JNIResult<Option<String>> {
27+
let value = self.get_field(cfg, name, "Ljava/lang/String;")?.l()?;
28+
if value.is_null() {
29+
Ok(None)
30+
} else {
31+
Ok(Some(self.get_string(&JString::from(value))?.into()))
32+
}
33+
}
34+
}
35+
36+
enum Error<E> {
37+
Jni(jni::errors::Error),
38+
Other(E),
39+
}
40+
41+
impl<E> From<jni::errors::Error> for Error<E> {
42+
fn from(value: jni::errors::Error) -> Self {
43+
Error::Jni(value)
44+
}
45+
}
46+
47+
impl<E: fmt::Display> fmt::Display for Error<E> {
48+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49+
match self {
50+
Error::Jni(error) => error.fmt(f),
51+
Error::Other(error) => error.fmt(f),
52+
}
53+
}
54+
}
55+
56+
fn build_inliner(
57+
env: &mut JNIEnv,
58+
cfg: JObject,
59+
) -> Result<CSSInliner<'static>, Error<css_inline::ParseError>> {
60+
let inline_style_tags = env.get_bool_field(&cfg, "inlineStyleTags")?;
61+
let keep_style_tags = env.get_bool_field(&cfg, "keepStyleTags")?;
62+
let keep_link_tags = env.get_bool_field(&cfg, "keepLinkTags")?;
63+
let load_remote_stylesheets = env.get_bool_field(&cfg, "loadRemoteStylesheets")?;
64+
let cache_size = env.get_int_field(&cfg, "cacheSize")?;
65+
let preallocate_node_capacity = env.get_int_field(&cfg, "preallocateNodeCapacity")?;
66+
67+
let extra_css = env.get_string_field_opt(&cfg, "extraCss")?;
68+
let base_url = env.get_string_field_opt(&cfg, "baseUrl")?;
69+
let mut builder = CSSInliner::options()
70+
.inline_style_tags(inline_style_tags)
71+
.keep_style_tags(keep_style_tags)
72+
.keep_link_tags(keep_link_tags)
73+
.load_remote_stylesheets(load_remote_stylesheets)
74+
.extra_css(extra_css.map(Cow::Owned))
75+
.preallocate_node_capacity(preallocate_node_capacity as usize);
76+
77+
if let Some(url) = base_url {
78+
match css_inline::Url::parse(&url) {
79+
Ok(url) => {
80+
builder = builder.base_url(Some(url));
81+
}
82+
Err(error) => return Err(Error::Other(error)),
83+
}
84+
}
85+
86+
if cache_size > 0 {
87+
builder = builder.cache(StylesheetCache::new(
88+
NonZeroUsize::new(cache_size as usize).expect("Cache size is not null"),
89+
));
90+
}
91+
92+
Ok(builder.build())
93+
}
94+
95+
fn throw(mut env: JNIEnv, message: String) -> jstring {
96+
let exception = env
97+
.find_class("org/cssinline/CssInlineException")
98+
.expect("CssInlineException class not found");
99+
env.throw_new(exception, message)
100+
.expect("Failed to throw CssInlineException");
101+
std::ptr::null_mut()
102+
}
103+
104+
#[unsafe(no_mangle)]
105+
pub extern "system" fn Java_org_cssinline_CssInline_nativeInline(
106+
mut env: JNIEnv,
107+
_class: JClass,
108+
input: JString,
109+
cfg: JObject,
110+
) -> jstring {
111+
let html: String = env
112+
.get_string(&input)
113+
.expect("Failed to get Java String")
114+
.into();
115+
let inliner = match build_inliner(&mut env, cfg) {
116+
Ok(inliner) => inliner,
117+
Err(error) => return throw(env, error.to_string()),
118+
};
119+
match inliner.inline(&html) {
120+
Ok(out) => env
121+
.new_string(out)
122+
.expect("Failed to get Java String")
123+
.into_raw(),
124+
Err(error) => throw(env, error.to_string()),
125+
}
126+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package org.cssinline;
2+
3+
import org.junit.jupiter.api.Test;
4+
import static org.junit.jupiter.api.Assertions.*;
5+
6+
class CssInlineTest {
7+
8+
@Test
9+
void inlinesSimpleStyleTag() {
10+
String html = "<html><head><style>h1 { color: blue; }</style></head><body><h1>Hello</h1></body></html>";
11+
12+
CssInlineConfig cfg = new CssInlineConfig.Builder().build();
13+
String out = CssInline.inline(html, cfg);
14+
15+
assertTrue(out.contains("style=\"color: blue;\""),
16+
"Output should inline styles for h1, got: " + out);
17+
}
18+
19+
@Test
20+
void extraCssAddsBackground() {
21+
String html = "<html><head></head><body><h1>Hello</h1></body></html>";
22+
23+
CssInlineConfig cfg = new CssInlineConfig.Builder()
24+
.setExtraCss("h1 { color: blue; }")
25+
.build();
26+
27+
String out = CssInline.inline(html, cfg);
28+
29+
assertTrue(out.contains("style=\"color: blue;\""),
30+
"Output should inline styles for h1, got: " + out);
31+
}
32+
33+
@Test
34+
void validBaseUrlParses() {
35+
CssInlineConfig cfg = new CssInlineConfig.Builder()
36+
.setBaseUrl("https://example.com/styles/")
37+
.build();
38+
39+
String in = "<p>No styles</p>";
40+
String out = CssInline.inline(in, cfg);
41+
assertNotNull(out);
42+
assertTrue(out.contains("<p>No styles</p>"));
43+
}
44+
45+
@Test
46+
void invalidBaseUrlThrows() {
47+
CssInlineConfig cfg = new CssInlineConfig.Builder()
48+
.setBaseUrl("not a url")
49+
.build();
50+
51+
CssInlineException ex = assertThrows(
52+
CssInlineException.class,
53+
() -> CssInline.inline("<p>Hi</p>", cfg)
54+
);
55+
assertEquals(
56+
ex.getMessage(),
57+
"relative URL without a base",
58+
"Expected URL parse error, got: " + ex.getMessage()
59+
);
60+
}
61+
62+
@Test
63+
void keepStyleTagsPreserved() {
64+
String html = "<html><head><style>h1{font-weight:bold}</style></head>"
65+
+ "<body><h1>Bold</h1></body></html>";
66+
67+
CssInlineConfig cfg = new CssInlineConfig.Builder()
68+
.setKeepStyleTags(true)
69+
.build();
70+
71+
String out = CssInline.inline(html, cfg);
72+
assertTrue(out.contains("<style>h1{font-weight:bold}</style>"),
73+
"Expected to keep original style tags");
74+
assertTrue(out.contains("style=\"font-weight: bold;\""));
75+
}
76+
}

0 commit comments

Comments
 (0)