diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d24c8348..f2a8fbab 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -723,3 +723,65 @@ jobs: with: name: nuget-packages path: packages/dotnet/**/nupkg/*.nupkg + + # go + go-test: + name: Go Test - ${{ matrix.platform }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + # Windows x64 + - runner: windows-latest + target: x86_64-pc-windows-gnu + platform: windows-amd64 + # Linux x64 + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + platform: linux-amd64 + # Linux ARM64 + - runner: ubuntu-latest + target: aarch64-unknown-linux-gnu + platform: linux-arm64 + # macOS ARM64 + - runner: macos-14 + target: aarch64-apple-darwin + platform: darwin-arm64 + # macOS x64 + - runner: macos-13 + target: x86_64-apple-darwin + platform: darwin-amd64 + steps: + - uses: actions/checkout@v5 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + target: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM64) + if: matrix.platform == 'linux-arm64' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build native library + run: cargo build --release --target ${{ matrix.target }} -p braillify-go + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + + - name: Copy native library + run: | + mkdir -p packages/go/libs/${{ matrix.platform }} + cp target/${{ matrix.target }}/release/libbraillify_go.a packages/go/libs/${{ matrix.platform }}/ + shell: bash + + - name: Test + run: go test -v ./... + working-directory: packages/go diff --git a/Cargo.lock b/Cargo.lock index 8f27dc08..840fdf3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,13 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "braillify-go" +version = "2.0.0" +dependencies = [ + "braillify", +] + [[package]] name = "bstr" version = "1.12.1" diff --git a/packages/go/.gitignore b/packages/go/.gitignore new file mode 100644 index 00000000..46e1819d --- /dev/null +++ b/packages/go/.gitignore @@ -0,0 +1,4 @@ +*.test +*.exe +*.out +libs/ diff --git a/packages/go/Cargo.toml b/packages/go/Cargo.toml new file mode 100644 index 00000000..1a17dae8 --- /dev/null +++ b/packages/go/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "braillify-go" +version = "2.0.0" +edition = "2024" + +[lib] +name = "braillify_go" +crate-type = ["cdylib", "staticlib"] + +[dependencies] +braillify = { path = "../../libs/braillify", default-features = false } diff --git a/packages/go/braillify.go b/packages/go/braillify.go new file mode 100644 index 00000000..597e1d25 --- /dev/null +++ b/packages/go/braillify.go @@ -0,0 +1,16 @@ +package braillify + +// Encode converts Korean text to braille byte representation. +func Encode(text string) ([]byte, error) { + return cEncode(text) +} + +// EncodeToUnicode converts Korean text to braille Unicode string. +func EncodeToUnicode(text string) (string, error) { + return cEncodeToUnicode(text) +} + +// EncodeToBrailleFont converts Korean text to braille font string. +func EncodeToBrailleFont(text string) (string, error) { + return cEncodeToBrailleFont(text) +} diff --git a/packages/go/braillify_test.go b/packages/go/braillify_test.go new file mode 100644 index 00000000..e12bdfab --- /dev/null +++ b/packages/go/braillify_test.go @@ -0,0 +1,51 @@ +package braillify + +import "testing" + +func TestEncodeToUnicode(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"안녕하세요", "⠣⠒⠉⠻⠚⠠⠝⠬"}, + {"상상이상의", "⠇⠶⠇⠶⠕⠇⠶⠺"}, + {"1,000", "⠼⠁⠂⠚⠚⠚"}, + {"ATM", "⠠⠠⠁⠞⠍"}, + {"", ""}, + } + + for _, tt := range tests { + result, err := EncodeToUnicode(tt.input) + if err != nil { + t.Errorf("EncodeToUnicode(%q): unexpected error: %v", tt.input, err) + continue + } + t.Logf("EncodeToUnicode(%q) = %q", tt.input, result) + if result != tt.expected { + t.Errorf("EncodeToUnicode(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestEncode(t *testing.T) { + result, err := Encode("안녕") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + t.Logf("Encode(%q) = %v", "안녕", result) + if len(result) == 0 { + t.Error("expected non-empty byte slice") + } +} + +func TestEncodeToBrailleFont(t *testing.T) { + result, err := EncodeToBrailleFont("안녕하세요") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expected := "⠣⠒⠉⠻⠚⠠⠝⠬" + t.Logf("EncodeToBrailleFont(%q) = %q", "안녕하세요", result) + if result != expected { + t.Errorf("EncodeToBrailleFont = %q, want %q", result, expected) + } +} diff --git a/packages/go/cgo.go b/packages/go/cgo.go new file mode 100644 index 00000000..3048345d --- /dev/null +++ b/packages/go/cgo.go @@ -0,0 +1,85 @@ +package braillify + +/* +#cgo darwin,amd64 LDFLAGS: -L${SRCDIR}/libs/darwin-amd64 -lbraillify_go -lm -lpthread +#cgo darwin,arm64 LDFLAGS: -L${SRCDIR}/libs/darwin-arm64 -lbraillify_go -lm -lpthread +#cgo linux,amd64 LDFLAGS: -L${SRCDIR}/libs/linux-amd64 -lbraillify_go -lm -lpthread -ldl +#cgo linux,arm64 LDFLAGS: -L${SRCDIR}/libs/linux-arm64 -lbraillify_go -lm -lpthread -ldl +#cgo windows,amd64 LDFLAGS: -L${SRCDIR}/libs/windows-amd64 -lbraillify_go -lntdll -lws2_32 -lbcrypt -ladvapi32 -luserenv + +#include +#include +#include + +extern uint8_t* braillify_encode(const char* text, size_t* out_len); +extern char* braillify_encode_to_unicode(const char* text); +extern char* braillify_encode_to_braille_font(const char* text); +extern char* braillify_get_last_error(); +extern void braillify_free_string(char* ptr); +extern void braillify_free_bytes(uint8_t* ptr, size_t len); +*/ +import "C" + +import ( + "errors" + "runtime" + "unsafe" +) + +func cEncode(text string) ([]byte, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cText := C.CString(text) + defer C.free(unsafe.Pointer(cText)) + + var outLen C.size_t + result := C.braillify_encode(cText, &outLen) + if result == nil { + return nil, getLastError() + } + defer C.braillify_free_bytes(result, outLen) + + return C.GoBytes(unsafe.Pointer(result), C.int(outLen)), nil +} + +func cEncodeToUnicode(text string) (string, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cText := C.CString(text) + defer C.free(unsafe.Pointer(cText)) + + result := C.braillify_encode_to_unicode(cText) + if result == nil { + return "", getLastError() + } + defer C.braillify_free_string(result) + + return C.GoString(result), nil +} + +func cEncodeToBrailleFont(text string) (string, error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cText := C.CString(text) + defer C.free(unsafe.Pointer(cText)) + + result := C.braillify_encode_to_braille_font(cText) + if result == nil { + return "", getLastError() + } + defer C.braillify_free_string(result) + + return C.GoString(result), nil +} + +func getLastError() error { + errPtr := C.braillify_get_last_error() + if errPtr == nil { + return errors.New("braillify: unknown error") + } + defer C.braillify_free_string(errPtr) + return errors.New(C.GoString(errPtr)) +} diff --git a/packages/go/go.mod b/packages/go/go.mod new file mode 100644 index 00000000..d54c11dd --- /dev/null +++ b/packages/go/go.mod @@ -0,0 +1,3 @@ +module github.com/dev-five-git/braillify/packages/go + +go 1.21 diff --git a/packages/go/src/lib.rs b/packages/go/src/lib.rs new file mode 100644 index 00000000..81d07499 --- /dev/null +++ b/packages/go/src/lib.rs @@ -0,0 +1,303 @@ +use std::cell::RefCell; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +fn set_last_error(err: String) { + LAST_ERROR.with(|e| { + *e.borrow_mut() = Some(err); + }); +} + +fn clear_last_error() { + LAST_ERROR.with(|e| { + *e.borrow_mut() = None; + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn braillify_get_last_error() -> *mut c_char { + LAST_ERROR.with(|e| match e.borrow().as_ref() { + // Error messages never contain null bytes, so unwrap_or(null) + // is defensive dead code. + Some(msg) => CString::new(msg.clone()) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()), + None => ptr::null_mut(), + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode(text: *const c_char, out_len: *mut usize) -> *mut u8 { + clear_last_error(); + + if text.is_null() || out_len.is_null() { + set_last_error("Null pointer argument".to_string()); + return ptr::null_mut(); + } + + let c_str = unsafe { CStr::from_ptr(text) }; + let text_str = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("Invalid UTF-8: {}", e)); + return ptr::null_mut(); + } + }; + + match braillify::encode(text_str) { + Ok(result) => { + unsafe { *out_len = result.len() }; + let boxed = result.into_boxed_slice(); + Box::into_raw(boxed) as *mut u8 + } + Err(e) => { + set_last_error(e); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode_to_unicode(text: *const c_char) -> *mut c_char { + clear_last_error(); + + if text.is_null() { + set_last_error("Null pointer argument".to_string()); + return ptr::null_mut(); + } + + let c_str = unsafe { CStr::from_ptr(text) }; + let text_str = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("Invalid UTF-8: {}", e)); + return ptr::null_mut(); + } + }; + + // CString::new() cannot fail here: braille output only contains + // Unicode characters in U+2800..U+28FF, never null bytes. + // The Err branch is defensive dead code. + match braillify::encode_to_unicode(text_str) { + Ok(result) => match CString::new(result) { + Ok(c_string) => c_string.into_raw(), + Err(e) => { + set_last_error(format!("CString conversion error: {}", e)); + ptr::null_mut() + } + }, + Err(e) => { + set_last_error(e); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_encode_to_braille_font(text: *const c_char) -> *mut c_char { + clear_last_error(); + + if text.is_null() { + set_last_error("Null pointer argument".to_string()); + return ptr::null_mut(); + } + + let c_str = unsafe { CStr::from_ptr(text) }; + let text_str = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + set_last_error(format!("Invalid UTF-8: {}", e)); + return ptr::null_mut(); + } + }; + + // CString::new() cannot fail here: braille output only contains + // Unicode characters in U+2800..U+28FF, never null bytes. + // The Err branch is defensive dead code. + match braillify::encode_to_braille_font(text_str) { + Ok(result) => match CString::new(result) { + Ok(c_string) => c_string.into_raw(), + Err(e) => { + set_last_error(format!("CString conversion error: {}", e)); + ptr::null_mut() + } + }, + Err(e) => { + set_last_error(e); + ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_free_string(ptr: *mut c_char) { + if !ptr.is_null() { + unsafe { + drop(CString::from_raw(ptr)); + } + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn braillify_free_bytes(ptr: *mut u8, len: usize) { + if !ptr.is_null() { + unsafe { + let _ = Vec::from_raw_parts(ptr, len, len); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CString; + use std::ptr; + + #[test] + fn test_encode_to_unicode() { + let input = CString::new("안녕하세요").unwrap(); + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(!result.is_null()); + let c_str = unsafe { CString::from_raw(result) }; + assert_eq!(c_str.to_str().unwrap(), "⠣⠒⠉⠻⠚⠠⠝⠬"); + } + + #[test] + fn test_encode_to_unicode_empty() { + let input = CString::new("").unwrap(); + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(!result.is_null()); + let c_str = unsafe { CString::from_raw(result) }; + assert_eq!(c_str.to_str().unwrap(), ""); + } + + #[test] + fn test_encode_to_unicode_null() { + let result = unsafe { braillify_encode_to_unicode(ptr::null()) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_braille_font() { + let input = CString::new("안녕하세요").unwrap(); + let result = unsafe { braillify_encode_to_braille_font(input.as_ptr()) }; + assert!(!result.is_null()); + let c_str = unsafe { CString::from_raw(result) }; + assert_eq!(c_str.to_str().unwrap(), "⠣⠒⠉⠻⠚⠠⠝⠬"); + } + + #[test] + fn test_encode_to_braille_font_null() { + let result = unsafe { braillify_encode_to_braille_font(ptr::null()) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode() { + let input = CString::new("안녕").unwrap(); + let mut out_len: usize = 0; + let result = unsafe { braillify_encode(input.as_ptr(), &mut out_len) }; + assert!(!result.is_null()); + assert!(out_len > 0); + unsafe { braillify_free_bytes(result, out_len) }; + } + + #[test] + fn test_encode_null_text() { + let mut out_len: usize = 0; + let result = unsafe { braillify_encode(ptr::null(), &mut out_len) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_null_out_len() { + let input = CString::new("test").unwrap(); + let result = unsafe { braillify_encode(input.as_ptr(), ptr::null_mut()) }; + assert!(result.is_null()); + } + + #[test] + fn test_get_last_error_after_null() { + let _ = unsafe { braillify_encode_to_unicode(ptr::null()) }; + let err = braillify_get_last_error(); + assert!(!err.is_null()); + let err_str = unsafe { CString::from_raw(err) }; + assert!(err_str.to_str().unwrap().contains("Null pointer")); + } + + #[test] + fn test_free_string_null() { + unsafe { braillify_free_string(ptr::null_mut()) }; + } + + #[test] + fn test_free_bytes_null() { + unsafe { braillify_free_bytes(ptr::null_mut(), 0) }; + } + + #[test] + fn test_encode_invalid_utf8() { + let input = unsafe { CString::from_vec_unchecked(vec![0xFF, 0xFE]) }; + let mut out_len: usize = 0; + let result = unsafe { braillify_encode(input.as_ptr(), &mut out_len) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_unicode_invalid_utf8() { + let input = unsafe { CString::from_vec_unchecked(vec![0xFF, 0xFE]) }; + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_braille_font_invalid_utf8() { + let input = unsafe { CString::from_vec_unchecked(vec![0xFF, 0xFE]) }; + let result = unsafe { braillify_encode_to_braille_font(input.as_ptr()) }; + assert!(result.is_null()); + } + + #[test] + fn test_free_string_non_null() { + let s = CString::new("test").unwrap(); + let ptr = s.into_raw(); + unsafe { braillify_free_string(ptr) }; + } + + #[test] + fn test_get_last_error_none() { + let input = CString::new("a").unwrap(); + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(!result.is_null()); + unsafe { braillify_free_string(result) }; + let err = braillify_get_last_error(); + assert!(err.is_null()); + } + + #[test] + fn test_encode_invalid_char() { + let input = CString::new("😀").unwrap(); + let mut out_len: usize = 0; + let result = unsafe { braillify_encode(input.as_ptr(), &mut out_len) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_unicode_invalid_char() { + let input = CString::new("😀").unwrap(); + let result = unsafe { braillify_encode_to_unicode(input.as_ptr()) }; + assert!(result.is_null()); + } + + #[test] + fn test_encode_to_braille_font_invalid_char() { + let input = CString::new("😀").unwrap(); + let result = unsafe { braillify_encode_to_braille_font(input.as_ptr()) }; + assert!(result.is_null()); + } +}