diff --git a/.changepacks/config.json b/.changepacks/config.json
index 9229667c..4eeb6d96 100644
--- a/.changepacks/config.json
+++ b/.changepacks/config.json
@@ -1,5 +1,5 @@
{
- "ignore": ["**", "!packages/python/pyproject.toml", "!packages/dotnet/BraillifyNet/BraillifyNet.csproj", "!packages/dotnet/Braillify/Braillify.csproj", "!packages/node/package.json", "!packages/c/Cargo.toml", "!libs/braillify/Cargo.toml"],
+ "ignore": ["**", "!packages/python/pyproject.toml", "!packages/dotnet/BraillifyNet/BraillifyNet.csproj", "!packages/dotnet/Braillify/Braillify.csproj", "!packages/node/package.json", "!packages/c/Cargo.toml", "!packages/jvm/build.gradle.kts", "!libs/braillify/Cargo.toml"],
"baseBranch": "main",
"latestPackage": null,
"publish": {}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 871220c1..05f204fb 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -208,6 +208,50 @@ jobs:
run: dotnet test --no-build -c Release --verbosity normal --framework net9.0 Braillify.Tests/Braillify.Tests.csproj
working-directory: packages/dotnet
+ jvm-test:
+ name: JVM Test - ${{ matrix.native-target }}
+ runs-on: ${{ matrix.runner }}
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ native-target: linux-x86_64
+ - runner: ubuntu-24.04-arm
+ native-target: linux-aarch64
+ - runner: macos-15-intel
+ native-target: macos-x86_64
+ - runner: macos-15
+ native-target: macos-aarch64
+ - runner: windows-2022
+ native-target: windows-x86_64
+ steps:
+ # pull_request_target에서도 PR 코드를 체크아웃해 실제 JVM 바인딩을 테스트한다.
+ - uses: actions/checkout@v7
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ allow-unsafe-pr-checkout: true
+ - name: Validate Gradle Wrapper
+ uses: gradle/actions/wrapper-validation@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "17"
+ - name: Setup Rust
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: stable
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-read-only: ${{ github.event_name == 'pull_request_target' }}
+ - name: Test JVM binding
+ run: ./gradlew test --no-daemon
+ working-directory: packages/jvm
+
deploy-landing:
name: Deploy Landing
runs-on: ubuntu-latest
@@ -215,6 +259,7 @@ jobs:
needs:
- test
- dotnet-test
+ - jvm-test
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
@@ -262,6 +307,7 @@ jobs:
needs:
- test
- dotnet-test
+ - jvm-test
steps:
- uses: actions/checkout@v7
- uses: changepacks/action@main
@@ -962,3 +1008,391 @@ jobs:
with:
upload_url: ${{ fromJson(needs.changepacks.outputs.release_assets_urls)['packages/c/Cargo.toml'] }}
asset_path: "c-artifacts/*.tar.gz"
+
+ # jvm
+ # JVM 릴리스 job은 다른 바인딩 게시 흐름과 섞이지 않도록 파일 맨 아래에 둔다.
+ jvm-native-build:
+ name: JVM Native Build - ${{ matrix.native-target }}
+ runs-on: ${{ matrix.runner }}
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'packages/jvm/build.gradle.kts') }}
+ needs:
+ - changepacks
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ native-target: linux-x86_64
+ rust-target: x86_64-unknown-linux-gnu
+ library-name: libbraillify_jvm.so
+ glibc: "2.17"
+ - runner: ubuntu-24.04
+ native-target: linux-aarch64
+ rust-target: aarch64-unknown-linux-gnu
+ library-name: libbraillify_jvm.so
+ glibc: "2.17"
+ - runner: macos-15-intel
+ native-target: macos-x86_64
+ rust-target: x86_64-apple-darwin
+ library-name: libbraillify_jvm.dylib
+ deployment-target: "10.13"
+ - runner: macos-15
+ native-target: macos-aarch64
+ rust-target: aarch64-apple-darwin
+ library-name: libbraillify_jvm.dylib
+ deployment-target: "11.0"
+ - runner: windows-2022
+ native-target: windows-x86_64
+ rust-target: x86_64-pc-windows-msvc
+ library-name: braillify_jvm.dll
+ steps:
+ - uses: actions/checkout@v7
+ - name: Setup Rust
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: stable
+ target: ${{ matrix.rust-target }}
+ - name: Set up MSVC
+ if: runner.os == 'Windows'
+ uses: ilammy/msvc-dev-cmd@v1
+ - name: Set up Zig
+ if: matrix.glibc
+ uses: mlugg/setup-zig@v2
+ with:
+ version: 0.14.1
+ - name: Install cargo-zigbuild
+ if: matrix.glibc
+ uses: taiki-e/install-action@v2
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ with:
+ tool: cargo-zigbuild
+ - name: Build Linux native library with glibc 2.17 compatibility
+ if: matrix.glibc
+ run: cargo zigbuild --release -p braillify-jvm --target ${{ matrix.rust-target }}.${{ matrix.glibc }}
+ - name: Build macOS or Windows native library
+ if: ${{ !matrix.glibc }}
+ run: cargo build --release -p braillify-jvm --target ${{ matrix.rust-target }}
+ env:
+ MACOSX_DEPLOYMENT_TARGET: ${{ matrix.deployment-target }}
+ - name: Inspect Linux ABI and dependencies
+ if: runner.os == 'Linux'
+ shell: bash
+ env:
+ LIBRARY: target/${{ matrix.rust-target }}/release/${{ matrix.library-name }}
+ run: |
+ readelf -d "$LIBRARY"
+ readelf --version-info "$LIBRARY"
+ if readelf --version-info "$LIBRARY" \
+ | grep -oE 'GLIBC_[0-9]+\.[0-9]+' \
+ | sort -Vu \
+ | awk -F'[_.]' '$2 > 2 || ($2 == 2 && $3 > 17) { found = 1 } END { exit !found }'; then
+ echo "::error::$LIBRARY requires a GLIBC symbol newer than 2.17"
+ exit 1
+ fi
+ if [ "${{ matrix.native-target }}" = "linux-x86_64" ]; then
+ ldd "$LIBRARY"
+ fi
+ - name: Inspect macOS dependencies
+ if: runner.os == 'macOS'
+ run: otool -L target/${{ matrix.rust-target }}/release/${{ matrix.library-name }}
+ - name: Inspect Windows dependencies
+ if: runner.os == 'Windows'
+ run: dumpbin /DEPENDENTS target/${{ matrix.rust-target }}/release/${{ matrix.library-name }}
+ - name: Upload native library
+ uses: actions/upload-artifact@v7
+ with:
+ name: jvm-native-${{ matrix.native-target }}
+ path: target/${{ matrix.rust-target }}/release/${{ matrix.library-name }}
+ if-no-files-found: error
+ retention-days: 1
+
+ jvm-jar:
+ name: JVM Fat JAR
+ runs-on: ubuntu-latest
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'packages/jvm/build.gradle.kts') }}
+ needs:
+ - changepacks
+ - jvm-native-build
+ steps:
+ - uses: actions/checkout@v7
+ - name: Validate Gradle Wrapper
+ uses: gradle/actions/wrapper-validation@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "17"
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+ - name: Download native libraries
+ uses: actions/download-artifact@v8
+ with:
+ pattern: jvm-native-*
+ path: native-artifacts
+ - name: Stage native libraries
+ shell: bash
+ run: |
+ stage_native() {
+ local native_target="$1"
+ local library_name="$2"
+ local library
+ library=$(find "native-artifacts/jvm-native-$native_target" -type f -name "$library_name" -print -quit)
+ if [ -z "$library" ]; then
+ echo "::error::Missing $library_name for $native_target"
+ exit 1
+ fi
+ ./packages/jvm/gradlew -p packages/jvm stageNativeLibrary \
+ -PnativeTarget="$native_target" \
+ -PnativeLibrary="$GITHUB_WORKSPACE/$library" \
+ --no-daemon
+ }
+ stage_native linux-x86_64 libbraillify_jvm.so
+ stage_native linux-aarch64 libbraillify_jvm.so
+ stage_native macos-x86_64 libbraillify_jvm.dylib
+ stage_native macos-aarch64 libbraillify_jvm.dylib
+ stage_native windows-x86_64 braillify_jvm.dll
+ - name: Assemble fat JAR
+ run: ./gradlew assembleJvmJar --no-daemon
+ working-directory: packages/jvm
+ - name: Verify fat JAR
+ shell: bash
+ run: |
+ JAR=$(find packages/jvm/build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit)
+ if [ -z "$JAR" ]; then
+ echo "::error::JVM binary JAR was not created"
+ exit 1
+ fi
+ jar tf "$JAR" | sort > jvm-jar-entries.txt
+ for entry in \
+ META-INF/native/linux-x86_64/libbraillify_jvm.so \
+ META-INF/native/linux-aarch64/libbraillify_jvm.so \
+ META-INF/native/macos-x86_64/libbraillify_jvm.dylib \
+ META-INF/native/macos-aarch64/libbraillify_jvm.dylib \
+ META-INF/native/windows-x86_64/braillify_jvm.dll; do
+ grep -Fx "$entry" jvm-jar-entries.txt >/dev/null || {
+ echo "::error::Missing JAR entry: $entry"
+ exit 1
+ }
+ done
+ if find packages/jvm/build/libs -maxdepth 1 -type f -name '*-debug*' -print -quit | grep -q .; then
+ echo "::error::Debug artifact found in JVM release output"
+ exit 1
+ fi
+ sha256sum "$JAR"
+ - name: Upload fat JAR
+ uses: actions/upload-artifact@v7
+ with:
+ name: jvm-fat-jar
+ path: packages/jvm/build/libs/*.jar
+ if-no-files-found: error
+ retention-days: 1
+
+ jvm-consumer-test:
+ name: JVM JAR Consumer - ${{ matrix.native-target }} / Java ${{ matrix.java-version }}
+ runs-on: ${{ matrix.runner }}
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'packages/jvm/build.gradle.kts') }}
+ needs:
+ - changepacks
+ - jvm-jar
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ native-target: linux-x86_64
+ java-version: "17"
+ path-separator: ":"
+ - runner: ubuntu-24.04-arm
+ native-target: linux-aarch64
+ java-version: "17"
+ path-separator: ":"
+ - runner: macos-15-intel
+ native-target: macos-x86_64
+ java-version: "17"
+ path-separator: ":"
+ - runner: macos-15
+ native-target: macos-aarch64
+ java-version: "17"
+ path-separator: ":"
+ - runner: windows-2022
+ native-target: windows-x86_64
+ java-version: "17"
+ path-separator: ";"
+ - runner: ubuntu-24.04
+ native-target: linux-x86_64
+ java-version: "8"
+ path-separator: ":"
+ - runner: ubuntu-24.04
+ native-target: linux-x86_64
+ java-version: "25"
+ path-separator: ":"
+ java-options: --enable-native-access=ALL-UNNAMED
+ steps:
+ - name: Set up Java ${{ matrix.java-version }}
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: ${{ matrix.java-version }}
+ - name: Download fat JAR
+ uses: actions/download-artifact@v8
+ with:
+ name: jvm-fat-jar
+ path: jvm-dist
+ - name: Compile and run external consumer
+ shell: bash
+ run: |
+ JAR=$(find jvm-dist -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit)
+ if [ -z "$JAR" ]; then
+ echo "::error::JVM binary JAR artifact is missing"
+ exit 1
+ fi
+ if [ "$RUNNER_OS" = "Windows" ]; then
+ JAR=$(cygpath -w "$JAR")
+ fi
+ printf '%s\n' \
+ 'import io.github.kdyann.braillify.Braillify;' \
+ 'public final class Consumer {' \
+ ' public static void main(String[] args) {' \
+ ' byte[] encoded = Braillify.encode("안녕하세요");' \
+ ' String unicode = Braillify.translateToUnicode("안녕하세요");' \
+ ' String font = Braillify.translateToBrailleFont("안녕하세요");' \
+ ' if (encoded.length == 0 || unicode.isEmpty() || font.isEmpty()) {' \
+ ' throw new AssertionError("Braillify returned an empty result");' \
+ ' }' \
+ ' for (int i = 0; i < unicode.length(); i++) {' \
+ ' char cell = unicode.charAt(i);' \
+ ' if (cell < 0x2800 || cell > 0x28ff) {' \
+ ' throw new AssertionError("Non-braille Unicode result");' \
+ ' }' \
+ ' }' \
+ ' }' \
+ '}' > Consumer.java
+ javac -cp "$JAR" Consumer.java
+ java ${{ matrix.java-options }} -cp "$JAR${{ matrix.path-separator }}." Consumer
+ - name: Compile and run Kotlin consumer
+ if: matrix.native-target == 'linux-x86_64' && matrix.java-version == '17'
+ shell: bash
+ run: |
+ JAR=$(find jvm-dist -maxdepth 1 -type f -name '*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' -print -quit)
+ printf '%s\n' \
+ 'import io.github.kdyann.braillify.Braillify' \
+ 'fun main() {' \
+ ' val encoded = Braillify.encode("안녕하세요")' \
+ ' val unicode = Braillify.translateToUnicode("안녕하세요")' \
+ ' val font = Braillify.translateToBrailleFont("안녕하세요")' \
+ ' check(encoded.isNotEmpty() && unicode.isNotEmpty() && font.isNotEmpty())' \
+ '}' > Consumer.kt
+ kotlinc -jvm-target 1.8 -cp "$JAR" Consumer.kt -include-runtime -d consumer-kotlin.jar
+ java -cp "$JAR:consumer-kotlin.jar" ConsumerKt
+
+ jvm-publish:
+ name: JVM Publish to Maven Central
+ runs-on: ubuntu-latest
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(needs.changepacks.outputs.changepacks, 'packages/jvm/build.gradle.kts') }}
+ needs:
+ - changepacks
+ - jvm-native-build
+ - jvm-consumer-test
+ permissions:
+ contents: read
+ id-token: write
+ attestations: write
+ steps:
+ - uses: actions/checkout@v7
+ - name: Validate Gradle Wrapper
+ uses: gradle/actions/wrapper-validation@v4
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "17"
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+ - name: Download native libraries
+ uses: actions/download-artifact@v8
+ with:
+ pattern: jvm-native-*
+ path: native-artifacts
+ - name: Stage native libraries
+ shell: bash
+ run: |
+ stage_native() {
+ local native_target="$1"
+ local library_name="$2"
+ local library
+ library=$(find "native-artifacts/jvm-native-$native_target" -type f -name "$library_name" -print -quit)
+ if [ -z "$library" ]; then
+ echo "::error::Missing $library_name for $native_target"
+ exit 1
+ fi
+ ./packages/jvm/gradlew -p packages/jvm stageNativeLibrary \
+ -PnativeTarget="$native_target" \
+ -PnativeLibrary="$GITHUB_WORKSPACE/$library" \
+ --no-daemon
+ }
+ stage_native linux-x86_64 libbraillify_jvm.so
+ stage_native linux-aarch64 libbraillify_jvm.so
+ stage_native macos-x86_64 libbraillify_jvm.dylib
+ stage_native macos-aarch64 libbraillify_jvm.dylib
+ stage_native windows-x86_64 braillify_jvm.dll
+ - name: Check whether the JVM release already exists
+ id: jvm-release
+ shell: bash
+ run: |
+ VERSION=$(./gradlew properties --no-daemon --console=plain -q | sed -n 's/^version: //p' | head -1)
+ if [ -z "$VERSION" ]; then
+ echo "::error::Could not resolve the JVM publication version"
+ exit 1
+ fi
+ POM_URL="https://repo1.maven.org/maven2/io/github/kdyann/braillify/$VERSION/braillify-$VERSION.pom"
+ if curl -fsIL "$POM_URL" >/dev/null; then
+ echo "::notice::io.github.kdyann:braillify:$VERSION is already on Maven Central. Nothing to publish."
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ fi
+ working-directory: packages/jvm
+ - name: Create signed Maven Central bundle
+ if: steps.jvm-release.outputs.exists != 'true'
+ run: ./gradlew createCentralBundle --no-daemon
+ working-directory: packages/jvm
+ env:
+ MAVEN_SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY }}
+ MAVEN_SIGNING_PASSWORD: ${{ secrets.MAVEN_SIGNING_PASSWORD }}
+ - name: Verify Maven Central bundle
+ if: steps.jvm-release.outputs.exists != 'true'
+ shell: bash
+ run: |
+ test -s packages/jvm/build/central/central-bundle.zip
+ unzip -l packages/jvm/build/central/central-bundle.zip
+ unzip -Z1 packages/jvm/build/central/central-bundle.zip > central-bundle-entries.txt
+ for suffix in .jar .pom -sources.jar -javadoc.jar .asc .md5 .sha1; do
+ grep -F -- "$suffix" central-bundle-entries.txt >/dev/null || {
+ echo "::error::Central bundle is missing an artifact ending in $suffix"
+ exit 1
+ }
+ done
+ sha256sum packages/jvm/build/central/central-bundle.zip
+ - name: Generate artifact attestation
+ if: steps.jvm-release.outputs.exists != 'true'
+ uses: actions/attest-build-provenance@v4
+ with:
+ subject-path: packages/jvm/build/central/central-bundle.zip
+ - name: Publish and validate on Maven Central Portal
+ if: steps.jvm-release.outputs.exists != 'true'
+ run: ./gradlew publishToCentralPortal --no-daemon
+ working-directory: packages/jvm
+ env:
+ MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
+ MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
+ MAVEN_SIGNING_KEY: ${{ secrets.MAVEN_SIGNING_KEY }}
+ MAVEN_SIGNING_PASSWORD: ${{ secrets.MAVEN_SIGNING_PASSWORD }}
+ - name: Upload Central bundle
+ if: steps.jvm-release.outputs.exists != 'true'
+ uses: actions/upload-artifact@v7
+ with:
+ name: jvm-central-bundle
+ path: packages/jvm/build/central/central-bundle.zip
+ if-no-files-found: error
+ retention-days: 7
diff --git a/Cargo.lock b/Cargo.lock
index 7cd6aca8..c98e00f6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -205,6 +205,15 @@ dependencies = [
"braillify",
]
+[[package]]
+name = "braillify-jvm"
+version = "0.1.0"
+dependencies = [
+ "braillify",
+ "jni",
+ "rstest",
+]
+
[[package]]
name = "bstr"
version = "1.12.3"
@@ -222,6 +231,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
[[package]]
name = "cast"
version = "0.3.0"
@@ -332,6 +347,16 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "combine"
+version = "4.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
[[package]]
name = "console"
version = "0.16.4"
@@ -713,6 +738,55 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn",
+]
+
[[package]]
name = "js-sys"
version = "0.3.103"
@@ -1451,6 +1525,22 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
[[package]]
name = "similar"
version = "2.7.0"
@@ -1517,6 +1607,26 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+[[package]]
+name = "thiserror"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "thousands"
version = "0.2.0"
diff --git a/package.json b/package.json
index 7b26dfdb..55b72f13 100644
--- a/package.json
+++ b/package.json
@@ -7,14 +7,16 @@
"scripts": {
"lint": "oxlint . && cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings",
"lint:fix": "oxlint . --fix && cargo fmt --all && cargo clippy --workspace --all-targets --fix --allow-dirty --allow-staged -- -D warnings && cargo clippy --workspace --all-targets -- -D warnings",
- "test": "bun run test:rust && bun run test:c && bun run test:node && bun run test:python",
+ "test": "bun run test:rust && bun run test:c && bun run test:node && bun run test:python && bun run test:jvm",
"test:rust": "bun scripts/test-rust.ts",
"test:node": "bun test",
"test:python": "cd py-test && uv run --locked pytest",
"test:c": "bun scripts/test-c.ts",
+ "test:jvm": "cd packages/jvm && ./gradlew test",
"test:testcase": "cargo test -p braillify test_by_testcase -- --nocapture",
"preinstall": "uv sync && (cargo install wasm-pack || node -e \"process.exit(0)\") && (pip install maturin || \"process.exit(0)\")",
"build": "cargo build --release -p braillify && cargo build --release -p braillify-c && bun -F braillify build && cd packages/python && maturin build --release --out dist",
+ "build:jvm": "cd packages/jvm && ./gradlew assembleJvmJar",
"build:landing": "bun run build && (cargo test test_by_testcase || node -e \"process.exit(0)\") && bun -F landing build",
"changepacks": "bunx @changepacks/cli",
"dev": "bun -F landing dev"
diff --git a/packages/jvm/.gitignore b/packages/jvm/.gitignore
new file mode 100644
index 00000000..242c0cb6
--- /dev/null
+++ b/packages/jvm/.gitignore
@@ -0,0 +1,5 @@
+.gradle/
+build/
+native/
+*.class
+hs_err_pid*
diff --git a/packages/jvm/Cargo.toml b/packages/jvm/Cargo.toml
new file mode 100644
index 00000000..4eb28d61
--- /dev/null
+++ b/packages/jvm/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "braillify-jvm"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+description = "JNI bindings for braillify"
+license = "Apache-2.0"
+repository = "https://github.com/dev-five-git/braillify"
+
+[lib]
+name = "braillify_jvm"
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+braillify = { path = "../../libs/braillify", default-features = false }
+jni = "=0.22.4"
+
+[dev-dependencies]
+rstest = "0.26"
diff --git a/packages/jvm/README.md b/packages/jvm/README.md
new file mode 100644
index 00000000..3d249d0b
--- /dev/null
+++ b/packages/jvm/README.md
@@ -0,0 +1,97 @@
+# braillify for Java and Kotlin
+
+한국어 텍스트를 한국 점자로 변환하는 braillify의 JVM 바인딩입니다. Rust 코어를 JNI로 직접 호출하며 Java와 Kotlin에서 같은 JAR을 사용합니다.
+
+## 설치
+
+Maven Central 배포 후 Gradle에서는 다음과 같이 추가합니다.
+
+```kotlin
+dependencies {
+ implementation("io.github.kdyann:braillify:BRAILLIFY_VERSION")
+}
+```
+
+Maven:
+
+```xml
+
+ io.github.kdyann
+ braillify
+ BRAILLIFY_VERSION
+
+```
+
+## 사용법
+
+Java:
+
+```java
+import io.github.kdyann.braillify.Braillify;
+
+byte[] cells = Braillify.encode("안녕하세요");
+String unicode = Braillify.translateToUnicode("안녕하세요");
+String font = Braillify.translateToBrailleFont("안녕하세요");
+```
+
+Kotlin:
+
+```kotlin
+import io.github.kdyann.braillify.Braillify
+
+val cells: ByteArray = Braillify.encode("안녕하세요")
+val unicode: String = Braillify.translateToUnicode("안녕하세요")
+val font: String = Braillify.translateToBrailleFont("안녕하세요")
+```
+
+`null`은 `NullPointerException`으로 거부합니다. 규정상 변환할 수 없는 문자, NUL 문자, 올바르지 않은 UTF-16 surrogate는 `BraillifyException`을 발생시킵니다. Rust panic이나 JNI/네이티브 로딩 실패는 `BraillifyInternalException`으로 구분합니다.
+
+## 지원 환경
+
+- Java 8 이상 (빌드 실행에는 JDK 17 이상 필요)
+- Linux glibc x86_64 / aarch64
+- macOS x86_64 / Apple Silicon
+- Windows x86_64
+
+Android, Windows arm64, Linux musl, GraalVM Native Image는 현재 지원하지 않습니다. Android는 NDK 바이너리와 AAR 패키징을 별도 작업으로 제공할 예정입니다.
+
+## 로컬 빌드와 테스트
+
+```bash
+./gradlew test
+./gradlew assembleJvmJar
+```
+
+`test`는 현재 호스트용 Rust JNI 라이브러리를 빌드하고 JAR 리소스로 넣은 뒤 실제 JVM 호출을 검사합니다. `assembleJvmJar` 결과는 `build/libs/braillify-.jar`입니다.
+
+크로스 컴파일한 release 라이브러리를 fat JAR에 넣을 때는 각 바이너리를 먼저 스테이징합니다.
+
+```bash
+./gradlew stageNativeLibrary \
+ -PnativeTarget=linux-x86_64 \
+ -PnativeLibrary=/absolute/path/libbraillify_jvm.so
+./gradlew assembleJvmJar
+```
+
+지원하는 `nativeTarget` 값은 `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`입니다. 다섯 target을 같은 build 디렉터리에 순서대로 스테이징한 뒤 조립하면 단일 fat JAR에 모두 포함됩니다.
+
+## 네이티브 로딩 문제 해결
+
+JAR 안의 라이브러리는 class loader별 고유 임시 디렉터리에 콘텐츠 해시가 포함된 이름으로 추출됩니다. 임시 디렉터리가 `noexec`으로 마운트된 환경에서는 미리 설치한 라이브러리의 절대 경로를 지정할 수 있습니다.
+
+```bash
+java -Dbraillify.native.path=/opt/braillify/libbraillify_jvm.so ...
+```
+
+디렉터리를 지정하면 현재 플랫폼에 맞는 표준 파일명을 그 안에서 찾습니다. 최신 JDK가 native access 경고 또는 제한을 적용하는 경우 애플리케이션 실행 옵션에 `--enable-native-access=ALL-UNNAMED`를 추가하세요.
+
+## 게시 산출물
+
+`publishAllPublicationsToLocalStagingRepository`는 Maven 저장소 형태의 산출물을 `build/staging-repository`에 만듭니다. PGP 환경 변수를 설정한 뒤 `createCentralBundle`을 실행하면 `build/central/central-bundle.zip`이 생성됩니다.
+
+- `MAVEN_SIGNING_KEY`
+- `MAVEN_SIGNING_PASSWORD`
+- `MAVEN_CENTRAL_USERNAME`
+- `MAVEN_CENTRAL_PASSWORD`
+
+`publishToCentralPortal`은 bundle을 Central Portal에 올리고 검증 결과가 나올 때까지 기다립니다. 같은 이름의 Gradle property도 환경 변수 대신 사용할 수 있습니다.
diff --git a/packages/jvm/build.gradle.kts b/packages/jvm/build.gradle.kts
new file mode 100644
index 00000000..257f59d7
--- /dev/null
+++ b/packages/jvm/build.gradle.kts
@@ -0,0 +1,342 @@
+import java.net.HttpURLConnection
+import java.net.URL
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.util.Base64
+import java.util.Locale
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+plugins {
+ `java-library`
+ `maven-publish`
+ signing
+}
+
+group = "io.github.kdyann"
+version = "0.1.0"
+description = "Korean text-to-braille conversion for Java and Kotlin"
+
+repositories {
+ mavenCentral()
+}
+
+java {
+ withSourcesJar()
+ withJavadocJar()
+}
+
+tasks.withType().configureEach {
+ options.release.set(8)
+ options.encoding = "UTF-8"
+}
+
+dependencies {
+ testImplementation(platform("org.junit:junit-bom:5.13.4"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+val generatedNativeResources = layout.buildDirectory.dir("generated/native-resources")
+val externalStageMarkers = layout.buildDirectory.dir("staged-native-markers")
+sourceSets.main {
+ resources.srcDir(generatedNativeResources)
+}
+
+fun normalizedHostPlatform(): String {
+ val os = System.getProperty("os.name").lowercase(Locale.ROOT)
+ val arch = System.getProperty("os.arch").lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]"), "")
+ val normalizedOs = when {
+ os.contains("linux") -> "linux"
+ os.contains("mac") || os.contains("darwin") -> "macos"
+ os.contains("windows") -> "windows"
+ else -> throw GradleException("Unsupported JVM native build OS: ${System.getProperty("os.name")}")
+ }
+ val normalizedArch = when (arch) {
+ "x8664", "amd64" -> "x86_64"
+ "aarch64", "arm64" -> "aarch64"
+ else -> throw GradleException("Unsupported JVM native build architecture: ${System.getProperty("os.arch")}")
+ }
+ if (normalizedOs == "windows" && normalizedArch == "aarch64") {
+ throw GradleException("Windows aarch64 is not supported in the first JVM release")
+ }
+ return "$normalizedOs-$normalizedArch"
+}
+
+fun libraryFileName(platform: String): String = when {
+ platform.startsWith("windows-") -> "braillify_jvm.dll"
+ platform.startsWith("macos-") -> "libbraillify_jvm.dylib"
+ else -> "libbraillify_jvm.so"
+}
+
+val hostPlatform = normalizedHostPlatform()
+val rustTargetDirectory = layout.buildDirectory.dir("rust-target")
+val hostNativeLibrary = rustTargetDirectory.map {
+ it.file("debug/${libraryFileName(hostPlatform)}")
+}
+
+val buildRustNative by tasks.registering(Exec::class) {
+ group = "build"
+ description = "Builds the host JNI library with Cargo."
+ workingDir = rootProject.projectDir
+ commandLine("cargo", "build", "-p", "braillify-jvm")
+ environment("CARGO_TARGET_DIR", rustTargetDirectory.get().asFile.absolutePath)
+ inputs.files(
+ fileTree("src") { include("**/*.rs") },
+ file("Cargo.toml"),
+ file("../../Cargo.toml"),
+ file("../../Cargo.lock"),
+ file("../../libs/braillify"),
+ )
+ outputs.file(hostNativeLibrary)
+ onlyIf {
+ !externalStageMarkers.get().file(hostPlatform).asFile.exists()
+ }
+}
+
+val prepareNativeResources by tasks.registering(Copy::class) {
+ group = "build"
+ description = "Copies the host JNI library into its JAR resource path."
+ dependsOn(buildRustNative)
+ from(hostNativeLibrary)
+ into(generatedNativeResources.map { it.dir("META-INF/native/$hostPlatform") })
+ // Cross-target release staging writes this marker. Never replace a staged
+ // release library with the host debug build while assembling the fat JAR.
+ onlyIf {
+ !externalStageMarkers.get()
+ .file(hostPlatform)
+ .asFile.exists()
+ }
+}
+
+val stageNativeLibrary by tasks.registering {
+ group = "build"
+ description = "Stages a cross-compiled JNI library (-PnativeTarget and -PnativeLibrary)."
+ val target = providers.gradleProperty("nativeTarget")
+ val library = providers.gradleProperty("nativeLibrary")
+ inputs.property("nativeTarget", target)
+ inputs.file(library)
+ outputs.dir(generatedNativeResources)
+ doLast {
+ val targetValue = target.orNull
+ ?: throw GradleException("stageNativeLibrary requires -PnativeTarget")
+ val supported = setOf(
+ "linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"
+ )
+ if (targetValue !in supported) {
+ throw GradleException("Unsupported nativeTarget: $targetValue")
+ }
+ val source = library.orNull?.let(::file)
+ ?: throw GradleException("stageNativeLibrary requires -PnativeLibrary")
+ if (!source.isFile) {
+ throw GradleException("Native library does not exist: $source")
+ }
+ copy {
+ from(source)
+ into(generatedNativeResources.get().dir("META-INF/native/$targetValue"))
+ rename { libraryFileName(targetValue) }
+ }
+ externalStageMarkers.get()
+ .file(targetValue)
+ .asFile
+ .apply {
+ parentFile.mkdirs()
+ writeText("release\n")
+ }
+ }
+}
+
+tasks.processResources {
+ dependsOn(prepareNativeResources)
+}
+
+tasks.test {
+ dependsOn(prepareNativeResources, tasks.jar)
+ useJUnitPlatform()
+ systemProperty("java.library.path", "")
+ systemProperty("braillify.test.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
+}
+
+tasks.jar {
+ archiveBaseName.set("braillify")
+ manifest {
+ attributes(
+ "Automatic-Module-Name" to "io.github.kdyann.braillify",
+ "Implementation-Title" to "braillify",
+ "Implementation-Version" to project.version,
+ )
+ }
+ duplicatesStrategy = DuplicatesStrategy.FAIL
+}
+
+tasks.named("sourcesJar") {
+ dependsOn(prepareNativeResources)
+ exclude("META-INF/native/**")
+}
+
+val assembleJvmJar by tasks.registering {
+ group = "build"
+ description = "Assembles the JVM fat JAR with all currently staged native libraries."
+ dependsOn(tasks.jar)
+}
+
+publishing {
+ publications {
+ create("mavenJava") {
+ from(components["java"])
+ artifactId = "braillify"
+ pom {
+ name.set("braillify")
+ description.set(project.description)
+ url.set("https://braillify.kr")
+ licenses {
+ license {
+ name.set("The Apache License, Version 2.0")
+ url.set("https://www.apache.org/licenses/LICENSE-2.0.txt")
+ distribution.set("repo")
+ }
+ }
+ developers {
+ developer {
+ id.set("kdyann")
+ name.set("JeongMin Oh")
+ email.set("owjs39@gmail.com")
+ }
+ }
+ scm {
+ connection.set("scm:git:https://github.com/dev-five-git/braillify.git")
+ developerConnection.set("scm:git:ssh://git@github.com/dev-five-git/braillify.git")
+ url.set("https://github.com/dev-five-git/braillify")
+ }
+ }
+ }
+ }
+ repositories {
+ maven {
+ name = "localStaging"
+ url = uri(layout.buildDirectory.dir("staging-repository"))
+ }
+ }
+}
+
+fun secret(name: String): String? = providers.gradleProperty(name).orNull ?: System.getenv(name)
+val signingKey = secret("MAVEN_SIGNING_KEY")
+val signingPassword = secret("MAVEN_SIGNING_PASSWORD")
+signing {
+ if (!signingKey.isNullOrBlank()) {
+ useInMemoryPgpKeys(signingKey, signingPassword)
+ sign(publishing.publications["mavenJava"])
+ }
+}
+
+fun digest(file: File, algorithm: String): String {
+ val messageDigest = MessageDigest.getInstance(algorithm)
+ file.inputStream().buffered().use { input ->
+ val buffer = ByteArray(8192)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ messageDigest.update(buffer, 0, count)
+ }
+ }
+ return messageDigest.digest().joinToString("") { "%02x".format(it) }
+}
+
+val createCentralBundle by tasks.registering {
+ group = "publishing"
+ description = "Creates the Maven Central Portal deployment bundle."
+ dependsOn("publishAllPublicationsToLocalStagingRepository")
+ val repository = layout.buildDirectory.dir("staging-repository")
+ val output = layout.buildDirectory.file("central/central-bundle.zip")
+ inputs.dir(repository)
+ outputs.file(output)
+ doLast {
+ if (signingKey.isNullOrBlank()) {
+ throw GradleException("MAVEN_SIGNING_KEY is required to create a Central bundle")
+ }
+ val root = repository.get().asFile
+ val versionDirectory = root.resolve("io/github/kdyann/braillify/${project.version}")
+ if (!versionDirectory.isDirectory) {
+ throw GradleException("Maven publication directory is missing: $versionDirectory")
+ }
+ val originals = versionDirectory.walkTopDown()
+ .filter { it.isFile && !it.extension.matches(Regex("asc|md5|sha1|sha256|sha512")) }
+ .toList()
+ originals.forEach { source ->
+ mapOf("md5" to "MD5", "sha1" to "SHA-1", "sha256" to "SHA-256", "sha512" to "SHA-512")
+ .forEach { (extension, algorithm) ->
+ File(source.parentFile, "${source.name}.$extension").writeText(digest(source, algorithm))
+ }
+ }
+ val bundle = output.get().asFile
+ bundle.parentFile.mkdirs()
+ ZipOutputStream(bundle.outputStream().buffered()).use { zip ->
+ versionDirectory.walkTopDown()
+ .filter(File::isFile)
+ .sortedBy { it.relativeTo(root).invariantSeparatorsPath }
+ .forEach { source ->
+ zip.putNextEntry(ZipEntry(source.relativeTo(root).invariantSeparatorsPath))
+ source.inputStream().use { it.copyTo(zip) }
+ zip.closeEntry()
+ }
+ }
+ }
+}
+
+val publishToCentralPortal by tasks.registering {
+ group = "publishing"
+ description = "Uploads the bundle to Central Portal and waits for validation."
+ dependsOn(createCentralBundle)
+ doLast {
+ val username = secret("MAVEN_CENTRAL_USERNAME")
+ ?: throw GradleException("MAVEN_CENTRAL_USERNAME is required")
+ val password = secret("MAVEN_CENTRAL_PASSWORD")
+ ?: throw GradleException("MAVEN_CENTRAL_PASSWORD is required")
+ val token = Base64.getEncoder().encodeToString("$username:$password".toByteArray(StandardCharsets.UTF_8))
+ val bundle = layout.buildDirectory.file("central/central-bundle.zip").get().asFile
+ val boundary = "BraillifyBoundary${System.nanoTime()}"
+ val endpoint = URL("https://central.sonatype.com/api/v1/publisher/upload?publishingType=AUTOMATIC&name=braillify-${project.version}")
+ val connection = endpoint.openConnection() as HttpURLConnection
+ connection.requestMethod = "POST"
+ connection.connectTimeout = 30_000
+ connection.readTimeout = 120_000
+ connection.setRequestProperty("Authorization", "Bearer $token")
+ connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
+ connection.doOutput = true
+ connection.outputStream.use { outputStream ->
+ outputStream.write("--$boundary\r\nContent-Disposition: form-data; name=\"bundle\"; filename=\"central-bundle.zip\"\r\nContent-Type: application/octet-stream\r\n\r\n".toByteArray(StandardCharsets.UTF_8))
+ bundle.inputStream().use { it.copyTo(outputStream) }
+ outputStream.write("\r\n--$boundary--\r\n".toByteArray(StandardCharsets.UTF_8))
+ }
+ val responseCode = connection.responseCode
+ val response = (if (responseCode < 400) connection.inputStream else connection.errorStream)
+ ?.bufferedReader()?.use { it.readText() }.orEmpty()
+ if (responseCode !in 200..299) {
+ throw GradleException("Central Portal upload failed ($responseCode): $response")
+ }
+ val deploymentId = response.trim().trim('"')
+ logger.lifecycle("Central Portal accepted deployment $deploymentId")
+
+ repeat(120) {
+ Thread.sleep(5_000)
+ val statusConnection = URL("https://central.sonatype.com/api/v1/publisher/status?id=$deploymentId")
+ .openConnection() as HttpURLConnection
+ statusConnection.requestMethod = "POST"
+ statusConnection.connectTimeout = 30_000
+ statusConnection.readTimeout = 30_000
+ statusConnection.setRequestProperty("Authorization", "Bearer $token")
+ val statusCode = statusConnection.responseCode
+ val statusBody = (if (statusCode < 400) statusConnection.inputStream else statusConnection.errorStream)
+ ?.bufferedReader()?.use { it.readText() }.orEmpty()
+ if (statusCode !in 200..299) {
+ throw GradleException("Central Portal status failed ($statusCode): $statusBody")
+ }
+ logger.lifecycle("Central Portal status: $statusBody")
+ when {
+ statusBody.contains("PUBLISHED") -> return@doLast
+ statusBody.contains("FAILED") -> throw GradleException("Central Portal validation failed: $statusBody")
+ }
+ }
+ throw GradleException("Timed out waiting for Central Portal validation")
+ }
+}
diff --git a/packages/jvm/gradle/wrapper/gradle-wrapper.jar b/packages/jvm/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..1b33c55b
Binary files /dev/null and b/packages/jvm/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/packages/jvm/gradle/wrapper/gradle-wrapper.properties b/packages/jvm/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..78cb6e16
--- /dev/null
+++ b/packages/jvm/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,8 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/packages/jvm/gradlew b/packages/jvm/gradlew
new file mode 100755
index 00000000..23d15a93
--- /dev/null
+++ b/packages/jvm/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# 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
+#
+# https://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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/packages/jvm/gradlew.bat b/packages/jvm/gradlew.bat
new file mode 100644
index 00000000..5eed7ee8
--- /dev/null
+++ b/packages/jvm/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/packages/jvm/settings.gradle.kts b/packages/jvm/settings.gradle.kts
new file mode 100644
index 00000000..6b4abb9b
--- /dev/null
+++ b/packages/jvm/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "braillify-jvm"
diff --git a/packages/jvm/src/lib.rs b/packages/jvm/src/lib.rs
new file mode 100644
index 00000000..475b221b
--- /dev/null
+++ b/packages/jvm/src/lib.rs
@@ -0,0 +1,248 @@
+//! JNI bridge for the desktop/server JVM binding.
+//!
+//! The bridge intentionally calls the Rust core directly. It owns no encoder
+//! state and never lets a Rust panic unwind across the JNI ABI boundary.
+
+use std::ffi::c_void;
+use std::ptr;
+use std::slice;
+
+use jni::errors::ErrorPolicy;
+use jni::objects::{JByteArray, JClass, JString};
+use jni::strings::JNIString;
+use jni::sys::{JNI_VERSION_1_8, jbyteArray, jint, jstring};
+use jni::{Env, EnvUnowned};
+
+const PUBLIC_EXCEPTION: &str = "io/github/kdyann/braillify/BraillifyException";
+const INTERNAL_EXCEPTION: &str = "io/github/kdyann/braillify/BraillifyInternalException";
+
+#[derive(Debug)]
+enum BridgeError {
+ Core(String),
+ InvalidUtf16,
+ Jni(jni::errors::Error),
+}
+
+impl From for BridgeError {
+ fn from(error: jni::errors::Error) -> Self {
+ Self::Jni(error)
+ }
+}
+
+fn decode_utf16_units(units: &[u16]) -> Result {
+ String::from_utf16(units).map_err(|_| BridgeError::InvalidUtf16)
+}
+
+/// Reads Java UTF-16 code units through `GetStringChars` and decodes them
+/// strictly. `JNIEnv::get_string` uses modified UTF-8 semantics; reading code
+/// units directly is what lets this binding reject isolated surrogates instead
+/// of silently replacing them.
+fn get_string_strict(env: &mut Env<'_>, input: &JString<'_>) -> Result {
+ if input.is_null() {
+ return Err(BridgeError::Jni(jni::errors::Error::NullPtr(
+ "Braillify input",
+ )));
+ }
+
+ let raw_env = env.get_raw();
+ let raw_string = input.as_raw();
+ // SAFETY: `raw_env` and `raw_string` were supplied by the JVM for this
+ // native call. JNI guarantees the function table for the duration of it.
+ let length = unsafe { ((**raw_env).v1_1.GetStringLength)(raw_env, raw_string) };
+ // SAFETY: same JNI-owned values as above. A null result means a pending JVM
+ // exception (normally OutOfMemoryError), which the caller preserves.
+ let chars = unsafe { ((**raw_env).v1_1.GetStringChars)(raw_env, raw_string, ptr::null_mut()) };
+ if chars.is_null() {
+ return Err(BridgeError::Jni(jni::errors::Error::NullPtr(
+ "GetStringChars result",
+ )));
+ }
+
+ // Copy before releasing the JVM buffer, then strictly decode the copy.
+ // SAFETY: JNI returned `chars` with exactly `length` UTF-16 code units.
+ let units = unsafe { slice::from_raw_parts(chars.cast::(), length as usize) }.to_vec();
+ // SAFETY: `chars` was obtained above from `GetStringChars` for `raw_string`
+ // and has not previously been released.
+ unsafe { ((**raw_env).v1_1.ReleaseStringChars)(raw_env, raw_string, chars) };
+ decode_utf16_units(&units)
+}
+
+fn throw_if_possible(env: &mut Env<'_>, class: &str, message: &str) {
+ // Pending VM exceptions (notably OutOfMemoryError) must be preserved.
+ if env.exception_check() {
+ return;
+ }
+ let _ = env.throw_new(JNIString::new(class), JNIString::new(message));
+}
+
+fn report_error(env: &mut Env<'_>, error: BridgeError) {
+ match error {
+ BridgeError::Core(message) => throw_if_possible(env, PUBLIC_EXCEPTION, &message),
+ BridgeError::InvalidUtf16 => throw_if_possible(
+ env,
+ PUBLIC_EXCEPTION,
+ "Input contains malformed UTF-16 (an unpaired surrogate)",
+ ),
+ BridgeError::Jni(jni::errors::Error::NullPtr("Braillify input")) => throw_if_possible(
+ env,
+ "java/lang/NullPointerException",
+ "text must not be null",
+ ),
+ BridgeError::Jni(_) => throw_if_possible(
+ env,
+ INTERNAL_EXCEPTION,
+ "The JVM native bridge could not complete the conversion",
+ ),
+ }
+}
+
+fn report_panic(env: &mut Env<'_>) {
+ throw_if_possible(
+ env,
+ INTERNAL_EXCEPTION,
+ "The native braillify engine failed unexpectedly",
+ );
+}
+
+fn encode_impl(env: &mut Env<'_>, input: &JString<'_>) -> Result {
+ let text = get_string_strict(env, input)?;
+ let encoded = braillify::encode(&text).map_err(BridgeError::Core)?;
+ let array: JByteArray<'_> = env.byte_array_from_slice(&encoded)?;
+ Ok(array.into_raw())
+}
+
+fn translate_impl(
+ env: &mut Env<'_>,
+ input: &JString<'_>,
+ translate: fn(&str) -> Result,
+) -> Result {
+ let text = get_string_strict(env, input)?;
+ let translated = translate(&text).map_err(BridgeError::Core)?;
+ Ok(env.new_string(translated)?.into_raw())
+}
+
+struct BridgePolicy;
+
+impl ErrorPolicy for BridgePolicy {
+ type Captures<'unowned_env_local: 'native_method, 'native_method> = ();
+
+ fn on_error<'unowned_env_local: 'native_method, 'native_method>(
+ env: &mut Env<'unowned_env_local>,
+ _captures: &mut Self::Captures<'unowned_env_local, 'native_method>,
+ error: BridgeError,
+ ) -> jni::errors::Result {
+ report_error(env, error);
+ Ok(T::default())
+ }
+
+ fn on_panic<'unowned_env_local: 'native_method, 'native_method>(
+ env: &mut Env<'unowned_env_local>,
+ _captures: &mut Self::Captures<'unowned_env_local, 'native_method>,
+ _payload: Box,
+ ) -> jni::errors::Result {
+ report_panic(env);
+ Ok(T::default())
+ }
+}
+
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_io_github_kdyann_braillify_Braillify_encodeNative<'local>(
+ mut unowned_env: EnvUnowned<'local>,
+ _class: JClass<'local>,
+ input: JString<'local>,
+) -> jbyteArray {
+ unowned_env
+ .with_env(|env| encode_impl(env, &input))
+ .resolve::()
+}
+
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_io_github_kdyann_braillify_Braillify_translateToUnicodeNative<
+ 'local,
+>(
+ mut unowned_env: EnvUnowned<'local>,
+ _class: JClass<'local>,
+ input: JString<'local>,
+) -> jstring {
+ unowned_env
+ .with_env(|env| translate_impl(env, &input, braillify::encode_to_unicode))
+ .resolve::()
+}
+
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_io_github_kdyann_braillify_Braillify_translateToBrailleFontNative<
+ 'local,
+>(
+ mut unowned_env: EnvUnowned<'local>,
+ _class: JClass<'local>,
+ input: JString<'local>,
+) -> jstring {
+ unowned_env
+ .with_env(|env| translate_impl(env, &input, braillify::encode_to_braille_font))
+ .resolve::()
+}
+
+/// Test-only JNI entry point used to prove that a panic is mapped to the
+/// internal exception class. It is excluded from release artifacts.
+#[cfg(debug_assertions)]
+#[unsafe(no_mangle)]
+pub extern "system" fn Java_io_github_kdyann_braillify_Braillify_panicForTestingNative<'local>(
+ mut unowned_env: EnvUnowned<'local>,
+ _class: JClass<'local>,
+) {
+ unowned_env
+ .with_env(|_env| -> Result<(), BridgeError> { panic!("intentional JNI test panic") })
+ .resolve::();
+}
+
+#[unsafe(no_mangle)]
+pub extern "system" fn JNI_OnLoad(_vm: *mut jni::sys::JavaVM, _reserved: *mut c_void) -> jint {
+ JNI_VERSION_1_8
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[rstest::rstest]
+ #[case::isolated_high(&[0xD800])]
+ #[case::isolated_low(&[0xDC00])]
+ #[case::reversed_pair(&[0xDC00, 0xD800])]
+ fn malformed_utf16_is_rejected(#[case] units: &[u16]) {
+ assert!(matches!(
+ decode_utf16_units(units),
+ Err(BridgeError::InvalidUtf16)
+ ));
+ }
+
+ #[test]
+ fn supplementary_pair_is_preserved() {
+ assert_eq!(decode_utf16_units(&[0xD83D, 0xDE00]).unwrap(), "😀");
+ }
+
+ #[test]
+ fn embedded_nul_is_preserved_by_utf16_decoder() {
+ assert_eq!(
+ decode_utf16_units(&[b'a' as u16, 0, b'b' as u16]).unwrap(),
+ "a\0b"
+ );
+ }
+
+ #[rstest::rstest]
+ #[case::bytes("안녕")]
+ #[case::unicode("hello")]
+ #[case::braille_font("점자")]
+ fn core_delegations_accept_supported_text(#[case] input: &str) {
+ assert!(braillify::encode(input).is_ok());
+ assert!(braillify::encode_to_unicode(input).is_ok());
+ assert!(braillify::encode_to_braille_font(input).is_ok());
+ }
+
+ #[test]
+ fn core_error_remains_an_error_at_bridge_boundary() {
+ assert!(matches!(
+ braillify::encode("😀").map_err(BridgeError::Core),
+ Err(BridgeError::Core(_))
+ ));
+ }
+}
diff --git a/packages/jvm/src/main/java/io/github/kdyann/braillify/Braillify.java b/packages/jvm/src/main/java/io/github/kdyann/braillify/Braillify.java
new file mode 100644
index 00000000..4fad4b79
--- /dev/null
+++ b/packages/jvm/src/main/java/io/github/kdyann/braillify/Braillify.java
@@ -0,0 +1,60 @@
+package io.github.kdyann.braillify;
+
+import java.util.Objects;
+
+/** Korean text-to-braille conversion backed by the braillify Rust engine. */
+public final class Braillify {
+ private Braillify() {
+ }
+
+ /**
+ * Converts text to braille cell values (0 through 255).
+ *
+ * @param text text to convert
+ * @return one byte per braille cell
+ * @throws NullPointerException if {@code text} is {@code null}
+ * @throws BraillifyException if the core cannot convert the input
+ */
+ public static byte[] encode(String text) {
+ Objects.requireNonNull(text, "text must not be null");
+ NativeLibraryLoader.ensureLoaded();
+ return encodeNative(text);
+ }
+
+ /**
+ * Converts text to Unicode braille characters (U+2800 through U+28FF).
+ *
+ * @param text text to convert
+ * @return Unicode braille text
+ * @throws NullPointerException if {@code text} is {@code null}
+ * @throws BraillifyException if the core cannot convert the input
+ */
+ public static String translateToUnicode(String text) {
+ Objects.requireNonNull(text, "text must not be null");
+ NativeLibraryLoader.ensureLoaded();
+ return translateToUnicodeNative(text);
+ }
+
+ /**
+ * Converts text to the braille-font representation produced by the core engine.
+ *
+ * @param text text to convert
+ * @return the core engine's braille-font representation
+ * @throws NullPointerException if {@code text} is {@code null}
+ * @throws BraillifyException if the core cannot convert the input
+ */
+ public static String translateToBrailleFont(String text) {
+ Objects.requireNonNull(text, "text must not be null");
+ NativeLibraryLoader.ensureLoaded();
+ return translateToBrailleFontNative(text);
+ }
+
+ private static native byte[] encodeNative(String text);
+
+ private static native String translateToUnicodeNative(String text);
+
+ private static native String translateToBrailleFontNative(String text);
+
+ // Only exported by debug native builds. Package-private for JUnit.
+ static native void panicForTestingNative();
+}
diff --git a/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyException.java b/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyException.java
new file mode 100644
index 00000000..f331ee25
--- /dev/null
+++ b/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyException.java
@@ -0,0 +1,21 @@
+package io.github.kdyann.braillify;
+
+/** Thrown when input cannot be converted according to the braillify rules. */
+public class BraillifyException extends RuntimeException {
+ /**
+ * Creates an input/conversion error with a message.
+ * @param message error detail
+ */
+ public BraillifyException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates an input/conversion error with a message and cause.
+ * @param message error detail
+ * @param cause underlying failure
+ */
+ public BraillifyException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyInternalException.java b/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyInternalException.java
new file mode 100644
index 00000000..4ce372af
--- /dev/null
+++ b/packages/jvm/src/main/java/io/github/kdyann/braillify/BraillifyInternalException.java
@@ -0,0 +1,21 @@
+package io.github.kdyann.braillify;
+
+/** Thrown when the native bridge fails independently of the caller's input. */
+public final class BraillifyInternalException extends RuntimeException {
+ /**
+ * Creates an internal native-bridge error with a message.
+ * @param message error detail
+ */
+ public BraillifyInternalException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates an internal native-bridge error with a message and cause.
+ * @param message error detail
+ * @param cause underlying failure
+ */
+ public BraillifyInternalException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/packages/jvm/src/main/java/io/github/kdyann/braillify/NativeLibraryLoader.java b/packages/jvm/src/main/java/io/github/kdyann/braillify/NativeLibraryLoader.java
new file mode 100644
index 00000000..e3f0f607
--- /dev/null
+++ b/packages/jvm/src/main/java/io/github/kdyann/braillify/NativeLibraryLoader.java
@@ -0,0 +1,193 @@
+package io.github.kdyann.braillify;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Locale;
+
+final class NativeLibraryLoader {
+ private static final Object LOAD_LOCK = new Object();
+ private static volatile boolean loaded;
+
+ private NativeLibraryLoader() {
+ }
+
+ static void ensureLoaded() {
+ if (loaded) {
+ return;
+ }
+
+ synchronized (LOAD_LOCK) {
+ if (loaded) {
+ return;
+ }
+ loadNativeLibrary();
+ loaded = true;
+ }
+ }
+
+ private static void loadNativeLibrary() {
+ String platform = currentPlatform();
+ String fileName = libraryFileName(platform);
+ String override = System.getProperty("braillify.native.path");
+ if (override != null && !override.trim().isEmpty()) {
+ Path path = Paths.get(override).toAbsolutePath();
+ if (Files.isDirectory(path)) {
+ path = path.resolve(fileName);
+ }
+ loadAbsolute(path, "system property braillify.native.path");
+ return;
+ }
+
+ String resource = "/META-INF/native/" + platform + "/" + fileName;
+ try (InputStream stream = NativeLibraryLoader.class.getResourceAsStream(resource)) {
+ if (stream == null) {
+ throw unavailable("Native resource is missing: " + resource, platform, null);
+ }
+ byte[] bytes = readAllBytes(stream);
+ String digest = sha256(bytes).substring(0, 16);
+ Path directory = Files.createTempDirectory("braillify-" + digest + "-");
+ directory.toFile().deleteOnExit();
+ Path destination = directory.resolve(withDigest(fileName, digest));
+ Path temporary = Files.createTempFile(directory, "extract-", ".tmp");
+ temporary.toFile().deleteOnExit();
+ Files.write(temporary, bytes);
+ moveAtomically(temporary, destination);
+ destination.toFile().deleteOnExit();
+ loadAbsolute(destination, "JAR resource " + resource);
+ } catch (IOException error) {
+ throw unavailable("Could not extract native resource " + resource, platform, error);
+ }
+ }
+
+ static String currentPlatform() {
+ return platform(System.getProperty("os.name"), System.getProperty("os.arch"),
+ System.getProperty("java.runtime.name"), System.getProperty("java.vm.name"));
+ }
+
+ static String platform(String osName, String osArch, String runtimeName, String vmName) {
+ String os = normalize(osName);
+ String arch = normalize(osArch);
+ String runtime = normalize(runtimeName) + " " + normalize(vmName);
+
+ if (runtime.contains("android") || runtime.contains("dalvik")) {
+ throw new UnsupportedOperationException(
+ "Android is not supported by the JVM JAR; use the future Android AAR binding");
+ }
+
+ String normalizedOs;
+ if (os.contains("linux")) {
+ normalizedOs = "linux";
+ } else if (os.contains("mac") || os.contains("darwin")) {
+ normalizedOs = "macos";
+ } else if (os.contains("windows")) {
+ normalizedOs = "windows";
+ } else {
+ throw unsupported(osName, osArch);
+ }
+
+ String normalizedArch;
+ if (arch.equals("x8664") || arch.equals("amd64")) {
+ normalizedArch = "x86_64";
+ } else if (arch.equals("aarch64") || arch.equals("arm64")) {
+ normalizedArch = "aarch64";
+ } else {
+ throw unsupported(osName, osArch);
+ }
+
+ if (normalizedOs.equals("windows") && normalizedArch.equals("aarch64")) {
+ throw unsupported(osName, osArch);
+ }
+ return normalizedOs + "-" + normalizedArch;
+ }
+
+ static String libraryFileName(String platform) {
+ if (platform.startsWith("windows-")) {
+ return "braillify_jvm.dll";
+ }
+ if (platform.startsWith("macos-")) {
+ return "libbraillify_jvm.dylib";
+ }
+ return "libbraillify_jvm.so";
+ }
+
+ private static String normalize(String value) {
+ return value == null ? "" : value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
+ }
+
+ private static UnsupportedOperationException unsupported(String os, String arch) {
+ return new UnsupportedOperationException(
+ "Unsupported braillify JVM platform: os.name=" + os + ", os.arch=" + arch);
+ }
+
+ private static void loadAbsolute(Path path, String source) {
+ if (!Files.isRegularFile(path)) {
+ throw unavailable("Native library does not exist at " + path + " (from " + source + ")",
+ safeCurrentPlatform(), null);
+ }
+ try {
+ System.load(path.toString());
+ } catch (UnsatisfiedLinkError error) {
+ throw unavailable("Could not load native library " + path + " (from " + source + ")",
+ safeCurrentPlatform(), error);
+ }
+ }
+
+ private static BraillifyInternalException unavailable(String message, String platform, Throwable cause) {
+ String detail = message + ". Detected platform: " + platform
+ + ". If temporary directories are mounted noexec, set -Dbraillify.native.path=/absolute/path/to/library";
+ return cause == null ? new BraillifyInternalException(detail)
+ : new BraillifyInternalException(detail, cause);
+ }
+
+ private static String safeCurrentPlatform() {
+ try {
+ return currentPlatform();
+ } catch (RuntimeException ignored) {
+ return System.getProperty("os.name") + "-" + System.getProperty("os.arch");
+ }
+ }
+
+ private static byte[] readAllBytes(InputStream input) throws IOException {
+ java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = input.read(buffer)) != -1) {
+ output.write(buffer, 0, read);
+ }
+ return output.toByteArray();
+ }
+
+ private static String sha256(byte[] bytes) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
+ StringBuilder hex = new StringBuilder(digest.length * 2);
+ for (byte value : digest) {
+ hex.append(String.format(Locale.ROOT, "%02x", value & 0xff));
+ }
+ return hex.toString();
+ } catch (NoSuchAlgorithmException impossible) {
+ throw new AssertionError("Every Java runtime must provide SHA-256", impossible);
+ }
+ }
+
+ private static String withDigest(String fileName, String digest) {
+ int extension = fileName.lastIndexOf('.');
+ return extension < 0 ? fileName + "-" + digest
+ : fileName.substring(0, extension) + "-" + digest + fileName.substring(extension);
+ }
+
+ private static void moveAtomically(Path source, Path destination) throws IOException {
+ try {
+ Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE);
+ } catch (AtomicMoveNotSupportedException ignored) {
+ Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+}
diff --git a/packages/jvm/src/test/java/io/github/kdyann/braillify/BraillifyTest.java b/packages/jvm/src/test/java/io/github/kdyann/braillify/BraillifyTest.java
new file mode 100644
index 00000000..044fcc3a
--- /dev/null
+++ b/packages/jvm/src/test/java/io/github/kdyann/braillify/BraillifyTest.java
@@ -0,0 +1,88 @@
+package io.github.kdyann.braillify;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import org.junit.jupiter.api.Test;
+
+class BraillifyTest {
+ @Test
+ void encodeAndUnicodeRepresentTheSameCells() {
+ byte[] cells = Braillify.encode("안녕하세요");
+ String unicode = Braillify.translateToUnicode("안녕하세요");
+
+ int[] codePoints = unicode.codePoints().toArray();
+ assertEquals(cells.length, codePoints.length);
+ for (int index = 0; index < cells.length; index++) {
+ assertEquals(0x2800 + Byte.toUnsignedInt(cells[index]), codePoints[index]);
+ }
+ }
+
+ @Test
+ void brailleFontResultMatchesTheCoreUnicodeRepresentation() {
+ assertEquals(Braillify.translateToUnicode("점자"),
+ Braillify.translateToBrailleFont("점자"));
+ }
+
+ @Test
+ void rejectsUnsupportedCharactersAsPublicErrors() {
+ assertThrows(BraillifyException.class, () -> Braillify.encode("😀"));
+ }
+
+ @Test
+ void rejectsNullBeforeEnteringNativeCode() {
+ assertThrows(NullPointerException.class, () -> Braillify.encode(null));
+ assertThrows(NullPointerException.class, () -> Braillify.translateToUnicode(null));
+ assertThrows(NullPointerException.class, () -> Braillify.translateToBrailleFont(null));
+ }
+
+ @Test
+ void rejectsMalformedUtf16WithoutReplacement() {
+ assertThrows(BraillifyException.class, () -> Braillify.encode("\uD800"));
+ assertThrows(BraillifyException.class, () -> Braillify.encode("\uDC00"));
+ assertThrows(BraillifyException.class, () -> Braillify.encode("\uDC00\uD800"));
+ }
+
+ @Test
+ void preservesEmbeddedNulUntilTheCoreRejectsIt() {
+ assertThrows(BraillifyException.class, () -> Braillify.encode("a\u0000b"));
+ }
+
+ @Test
+ void nativePanicsBecomeInternalErrors() {
+ NativeLibraryLoader.ensureLoaded();
+ assertThrows(BraillifyInternalException.class, Braillify::panicForTestingNative);
+ }
+
+ @Test
+ void callsFromSeveralThreadsDoNotLeakEncoderState() throws Exception {
+ byte[] expected = Braillify.encode("안녕 hello 123");
+ ExecutorService executor = Executors.newFixedThreadPool(8);
+ try {
+ List> calls = new ArrayList<>();
+ for (int index = 0; index < 128; index++) {
+ calls.add(() -> Braillify.encode("안녕 hello 123"));
+ }
+ List> futures = executor.invokeAll(calls);
+ for (Future future : futures) {
+ assertArrayEquals(expected, future.get());
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ void unicodeOutputContainsOnlyBrailleCodePoints() {
+ assertTrue(Braillify.translateToUnicode("hello 안녕 123").codePoints()
+ .allMatch(value -> value >= 0x2800 && value <= 0x28ff));
+ }
+}
diff --git a/packages/jvm/src/test/java/io/github/kdyann/braillify/NativeLibraryLoaderTest.java b/packages/jvm/src/test/java/io/github/kdyann/braillify/NativeLibraryLoaderTest.java
new file mode 100644
index 00000000..702f2b8d
--- /dev/null
+++ b/packages/jvm/src/test/java/io/github/kdyann/braillify/NativeLibraryLoaderTest.java
@@ -0,0 +1,142 @@
+package io.github.kdyann.braillify;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.jar.JarFile;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class NativeLibraryLoaderTest {
+ static Stream supportedPlatforms() {
+ return Stream.of(
+ Arguments.of("Linux", "amd64", "linux-x86_64"),
+ Arguments.of("Linux", "aarch64", "linux-aarch64"),
+ Arguments.of("Mac OS X", "x86_64", "macos-x86_64"),
+ Arguments.of("Darwin", "arm64", "macos-aarch64"),
+ Arguments.of("Windows 11", "amd64", "windows-x86_64"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("supportedPlatforms")
+ void normalizesSupportedPlatforms(String os, String arch, String expected) {
+ assertEquals(expected, NativeLibraryLoader.platform(os, arch, "OpenJDK Runtime", "OpenJDK VM"));
+ }
+
+ @Test
+ void detectsAndroidBeforeLinux() {
+ UnsupportedOperationException error = assertThrows(UnsupportedOperationException.class,
+ () -> NativeLibraryLoader.platform("Linux", "aarch64", "Android Runtime", "Dalvik"));
+ assertTrue(error.getMessage().contains("Android"));
+ }
+
+ @Test
+ void rejectsUnsupportedArchitectureWithObservedValues() {
+ UnsupportedOperationException error = assertThrows(UnsupportedOperationException.class,
+ () -> NativeLibraryLoader.platform("Linux", "riscv64", "OpenJDK", "OpenJDK"));
+ assertTrue(error.getMessage().contains("Linux"));
+ assertTrue(error.getMessage().contains("riscv64"));
+ }
+
+ @Test
+ void mapsLibraryNames() {
+ assertEquals("libbraillify_jvm.so", NativeLibraryLoader.libraryFileName("linux-x86_64"));
+ assertEquals("libbraillify_jvm.dylib", NativeLibraryLoader.libraryFileName("macos-aarch64"));
+ assertEquals("braillify_jvm.dll", NativeLibraryLoader.libraryFileName("windows-x86_64"));
+ }
+
+ @Test
+ void loadingIsIdempotent() {
+ NativeLibraryLoader.ensureLoaded();
+ NativeLibraryLoader.ensureLoaded();
+ }
+
+ @Test
+ void isolatedClassLoadersCanEachCallTheFatJar() throws Exception {
+ URL jar = Paths.get(System.getProperty("braillify.test.jar")).toUri().toURL();
+ String first = invokeFromIsolatedLoader(jar);
+ String second = invokeFromIsolatedLoader(jar);
+ assertEquals(first, second);
+ assertTrue(first.codePoints().allMatch(value -> value >= 0x2800 && value <= 0x28ff));
+ }
+
+ @Test
+ void loadsNativeLibraryFromFileOverride() throws Exception {
+ Path nativeLibrary = extractHostNativeLibrary("file-override");
+ String result = invokeWithNativeOverride(nativeLibrary);
+ assertTrue(result.codePoints().allMatch(value -> value >= 0x2800 && value <= 0x28ff));
+ }
+
+ @Test
+ void loadsNativeLibraryFromDirectoryOverride() throws Exception {
+ Path nativeLibrary = extractHostNativeLibrary("directory-override");
+ String result = invokeWithNativeOverride(nativeLibrary.getParent());
+ assertTrue(result.codePoints().allMatch(value -> value >= 0x2800 && value <= 0x28ff));
+ }
+
+ @Test
+ void wrapsMissingNativeOverrideWithHelpfulException() throws Exception {
+ Path missing = Files.createTempDirectory("braillify-missing-").resolve("missing-native-library");
+ InvocationTargetException error = assertThrows(InvocationTargetException.class,
+ () -> invokeWithNativeOverride(missing));
+ Throwable cause = error.getCause();
+ assertEquals("io.github.kdyann.braillify.BraillifyInternalException", cause.getClass().getName());
+ assertTrue(cause.getMessage().contains("braillify.native.path"));
+ assertTrue(cause.getMessage().contains(missing.toAbsolutePath().toString()));
+ }
+
+ private static String invokeFromIsolatedLoader(URL jar) throws Exception {
+ try (URLClassLoader loader = new URLClassLoader(new URL[] {jar}, null)) {
+ Class> api = Class.forName("io.github.kdyann.braillify.Braillify", true, loader);
+ Method method = api.getMethod("translateToUnicode", String.class);
+ return (String) method.invoke(null, "안녕");
+ }
+ }
+
+ private static String invokeWithNativeOverride(Path override) throws Exception {
+ String property = "braillify.native.path";
+ String previous = System.getProperty(property);
+ System.setProperty(property, override.toAbsolutePath().toString());
+ try {
+ URL jar = Paths.get(System.getProperty("braillify.test.jar")).toUri().toURL();
+ return invokeFromIsolatedLoader(jar);
+ } finally {
+ if (previous == null) {
+ System.clearProperty(property);
+ } else {
+ System.setProperty(property, previous);
+ }
+ }
+ }
+
+ private static Path extractHostNativeLibrary(String prefix) throws Exception {
+ Path jarPath = Paths.get(System.getProperty("braillify.test.jar"));
+ String platform = NativeLibraryLoader.currentPlatform();
+ String fileName = NativeLibraryLoader.libraryFileName(platform);
+ String resource = "META-INF/native/" + platform + "/" + fileName;
+ Path directory = Files.createTempDirectory("braillify-" + prefix + "-");
+ Path destination = directory.resolve(fileName);
+ try (JarFile jar = new JarFile(jarPath.toFile())) {
+ if (jar.getJarEntry(resource) == null) {
+ throw new AssertionError("Missing test native resource: " + resource);
+ }
+ try (InputStream input = jar.getInputStream(jar.getJarEntry(resource))) {
+ Files.copy(input, destination, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+ return destination;
+ }
+}