Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions bindings/Native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ pub extern "C" fn rhwp_read_text(input_path: *const c_char, page: i32) -> *mut c
})
}

#[no_mangle]
pub extern "C" fn rhwp_write_text(output_path: *const c_char, text: *const c_char) -> *mut c_char {
ffi_result(|| {
let output_path = read_utf8(output_path, "output_path")?;
let text = read_utf8(text, "text")?;
write_text_to_file(Path::new(&output_path), &text)
})
}

#[no_mangle]
pub extern "C" fn rhwp_string_free(ptr: *mut c_char) {
if ptr.is_null() {
Expand Down Expand Up @@ -121,6 +130,41 @@ fn read_text(input_path: &Path, target_page: Option<u32>) -> Result<String, Stri
Ok(text_json(page_count, &extracted))
}

fn write_text_to_file(output_path: &Path, text: &str) -> Result<String, String> {
let mut doc = HwpDocument::create_empty();
doc.create_blank_document_native()
.map_err(|e| format!("빈 HWP 문서 생성 실패 - {}", e))?;

if !text.is_empty() {
doc.insert_text_native(0, 0, 0, text)
.map_err(|e| format!("텍스트 삽입 실패 - {}", e))?;
}

let bytes = doc
.export_hwp_native()
.map_err(|e| format!("HWP 생성 실패 - {}", e))?;

if let Some(parent) = output_path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"출력 폴더를 생성할 수 없습니다 - {}: {}",
parent.display(),
e
)
})?;
}
}

fs::write(output_path, &bytes)
.map_err(|e| format!("HWP 저장 실패 - {}: {}", output_path.display(), e))?;

let reloaded =
HwpDocument::from_bytes(&bytes).map_err(|e| format!("생성 HWP 재검증 실패 - {}", e))?;

Ok(write_json(reloaded.page_count(), output_path, bytes.len()))
}

fn export_markdown_to_dir(
input_path: &Path,
output_dir: &Path,
Expand Down Expand Up @@ -359,6 +403,15 @@ fn text_json(page_count: u32, pages: &[(u32, String)]) -> String {
)
}

fn write_json(page_count: u32, output_path: &Path, byte_count: usize) -> String {
format!(
"{{\"ok\":true,\"pageCount\":{},\"file\":\"{}\",\"byteCount\":{}}}",
page_count,
json_escape(&output_path.display().to_string()),
byte_count
)
}

fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
Expand All @@ -374,3 +427,44 @@ fn json_escape(s: &str) -> String {
}
out
}

#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};

#[test]
fn write_text_to_file_creates_reloadable_hwp() {
let output_dir = unique_temp_dir();
let output_path = output_dir.join("created.hwp");

let result = write_text_to_file(&output_path, "한글 English 123")
.expect("write_text_to_file should create HWP bytes");

assert!(result.contains("\"ok\":true"));
assert!(result.contains("\"pageCount\":1"));
assert!(result.contains("\"byteCount\":"));

let data = fs::read(&output_path).expect("created HWP should exist");
assert!(data.len() > 512);
assert_eq!(&data[0..4], &[0xD0, 0xCF, 0x11, 0xE0]);

let doc = HwpDocument::from_bytes(&data).expect("created HWP should reload");
let text = doc
.extract_page_text_native(0)
.expect("created HWP should expose page text");
assert!(text.contains("한글"));
assert!(text.contains("English"));
assert!(text.contains("123"));

let _ = fs::remove_dir_all(output_dir);
}

fn unique_temp_dir() -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("rhwp-native-write-{}-{}", std::process::id(), suffix))
}
}
85 changes: 85 additions & 0 deletions bindings/swift/Examples/write_text_ffi.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env swift
/// rhwp_write_text FFI 직접 호출 예제.
///
/// 사용법 (레포 루트에서):
/// cargo build --manifest-path bindings/Native/Cargo.toml --release
/// swift bindings/swift/Examples/write_text_ffi.swift
///
/// rhwp_write_text(outputPath, text) -> JSON 문자열 반환.
///
/// 반환 JSON 형식:
/// {"ok":true,"pageCount":1,"file":".../created.hwp","byteCount":12345}

import Foundation

typealias WriteTextFn = @convention(c) (UnsafePointer<CChar>, UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar>?
typealias StringFreeFn = @convention(c) (UnsafeMutablePointer<CChar>) -> Void

let scriptDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
let repoRoot = scriptDir
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let libPath = repoRoot
.appendingPathComponent("bindings/Native/target/release/librhwp_native_ffi.dylib")
.path

guard let handle = dlopen(libPath, RTLD_NOW) else {
let err = String(cString: dlerror())
print("ERROR: dylib 로드 실패 — \(err)")
print(" cargo build --manifest-path bindings/Native/Cargo.toml --release 를 먼저 실행하세요.")
exit(1)
}

guard let writeSym = dlsym(handle, "rhwp_write_text"),
let freeSym = dlsym(handle, "rhwp_string_free") else {
print("ERROR: FFI 심볼을 찾을 수 없습니다.")
exit(1)
}

let writeText = unsafeBitCast(writeSym, to: WriteTextFn.self)
let freeStr = unsafeBitCast(freeSym, to: StringFreeFn.self)

let outputPath: String
if CommandLine.arguments.count > 1 {
outputPath = CommandLine.arguments[1]
} else {
outputPath = repoRoot.appendingPathComponent("output/ffi-created.hwp").path
}

let body: String
if CommandLine.arguments.count > 2 {
body = CommandLine.arguments.dropFirst(2).joined(separator: " ")
} else {
body = "한글 English 123"
}

print("출력: \(outputPath)")
print("본문: \(body)")
print()

guard let resultPtr = outputPath.withCString({ outputPathPtr in
body.withCString { bodyPtr in
writeText(outputPathPtr, bodyPtr)
}
}) else {
print("ERROR: rhwp_write_text 반환값 null — 출력 경로를 확인하세요.")
exit(1)
}

let jsonStr = String(cString: resultPtr)

if let data = jsonStr.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ok = json["ok"] as? Bool, ok,
let file = json["file"] as? String,
let byteCount = json["byteCount"] as? Int {
print("=== HWP 생성 성공 ===")
print("파일: \(file)")
print("크기: \(byteCount) bytes")
} else {
print("JSON 파싱 실패 또는 에러 응답:")
print(String(jsonStr.prefix(500)))
}

freeStr(resultPtr)
11 changes: 10 additions & 1 deletion bindings/swift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ Swift wrapper for the shared native ABI in `bindings/Native`.
The package exposes:

- `Rhwp.readText(inputFile:page:)`
- `Rhwp.writeText(_:outputFile:)`
- `Rhwp.exportText(inputFile:outputDirectory:page:)`
- `Rhwp.exportMarkdown(inputFile:outputDirectory:page:)`
- `RhwpDocumentTextView(inputFile:page:)` for SwiftUI text display

The export methods return `RhwpExportResult`; direct reads return
`RhwpDocumentText`. All methods throw `RhwpError` when the native call fails.
`RhwpDocumentText`; writes return `RhwpWriteResult`. All methods throw
`RhwpError` when the native call fails.

## SwiftUI Display

Expand Down Expand Up @@ -48,6 +50,7 @@ swift test -Xlinker -L../../bindings/Native/target/debug
## Examples

`Examples/read_text_ffi.swift` — FFI 직접 호출로 HWP 파일의 텍스트를 읽어 출력하는 예제.
`Examples/write_text_ffi.swift` — FFI 직접 호출로 단일 문단 HWP 파일을 생성하는 예제.

```sh
# 1. 네이티브 라이브러리 빌드
Expand All @@ -58,6 +61,12 @@ swift bindings/swift/Examples/read_text_ffi.swift

# 3. 특정 파일 + 특정 페이지
swift bindings/swift/Examples/read_text_ffi.swift samples/aift.hwp 0

# 4. HWP 생성 예제 (기본 출력: output/ffi-created.hwp)
swift bindings/swift/Examples/write_text_ffi.swift

# 5. 출력 경로 + 본문 지정
swift bindings/swift/Examples/write_text_ffi.swift output/hello.hwp "한글 English 123"
```

## XCFramework
Expand Down
1 change: 1 addition & 0 deletions bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ extern "C" {
char *rhwp_export_text(const char *input_path, const char *output_dir, int page);
char *rhwp_export_markdown(const char *input_path, const char *output_dir, int page);
char *rhwp_read_text(const char *input_path, int page);
char *rhwp_write_text(const char *output_path, const char *text);
void rhwp_string_free(char *value);

#ifdef __cplusplus
Expand Down
61 changes: 61 additions & 0 deletions bindings/swift/Sources/Rhwp/Rhwp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ public struct RhwpDocumentText: Codable, Sendable, Equatable {
}
}

public struct RhwpWriteResult: Codable, Sendable, Equatable {
public let ok: Bool
public let pageCount: Int?
public let file: String?
public let byteCount: Int?
public let error: String?

public var outputFile: URL? {
file.map(URL.init(fileURLWithPath:))
}
}

public enum RhwpError: Error, LocalizedError, Equatable {
case nativeReturnedNull
case invalidUTF8
Expand Down Expand Up @@ -79,6 +91,17 @@ public enum Rhwp {
)
}

public static func writeText(
_ text: String,
outputFile: URL
) throws -> RhwpWriteResult {
try callNativeWrite(
outputFile: outputFile,
text: text,
function: rhwp_write_text
)
}

public static func exportText(
inputFile: URL,
outputDirectory: URL,
Expand Down Expand Up @@ -179,4 +202,42 @@ public enum Rhwp {

throw RhwpError.exportFailed(result.error ?? "rhwp read failed.")
}

private static func callNativeWrite(
outputFile: URL,
text: String,
function: (UnsafePointer<CChar>?, UnsafePointer<CChar>?) -> UnsafeMutablePointer<CChar>?
) throws -> RhwpWriteResult {
let pointer = outputFile.path.withCString { outputPath in
text.withCString { body in
function(outputPath, body)
}
}

guard let pointer else {
throw RhwpError.nativeReturnedNull
}

defer {
rhwp_string_free(pointer)
}

guard let payload = String(validatingUTF8: pointer) else {
throw RhwpError.invalidUTF8
}

let data = Data(payload.utf8)
let result: RhwpWriteResult
do {
result = try JSONDecoder().decode(RhwpWriteResult.self, from: data)
} catch {
throw RhwpError.invalidJSON(payload)
}

if result.ok {
return result
}

throw RhwpError.exportFailed(result.error ?? "rhwp write failed.")
}
}
19 changes: 19 additions & 0 deletions bindings/swift/Tests/RhwpTests/RhwpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ final class RhwpTests: XCTestCase {
XCTAssertTrue(FileManager.default.fileExists(atPath: result.outputFiles[0].path))
}

func testWriteTextCreatesReloadableHwp() throws {
let outputFile = FileManager.default.temporaryDirectory
.appendingPathComponent("rhwp-swift-\(UUID().uuidString)")
.appendingPathComponent("created.hwp")

let result = try Rhwp.writeText("한글 English 123", outputFile: outputFile)

XCTAssertTrue(result.ok)
XCTAssertEqual(result.outputFile, outputFile)
XCTAssertEqual(result.pageCount, 1)
XCTAssertGreaterThan(result.byteCount ?? 0, 512)
XCTAssertTrue(FileManager.default.fileExists(atPath: outputFile.path))

let document = try Rhwp.readText(inputFile: outputFile, page: .index(0))
XCTAssertTrue(document.text.contains("한글"))
XCTAssertTrue(document.text.contains("English"))
XCTAssertTrue(document.text.contains("123"))
}

private func repoRoot() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
Expand Down
Loading