diff --git a/bindings/Native/src/lib.rs b/bindings/Native/src/lib.rs index 8d02ef26f..29b8367df 100644 --- a/bindings/Native/src/lib.rs +++ b/bindings/Native/src/lib.rs @@ -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() { @@ -121,6 +130,41 @@ fn read_text(input_path: &Path, target_page: Option) -> Result Result { + 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, @@ -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() { @@ -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)) + } +} diff --git a/bindings/swift/Examples/write_text_ffi.swift b/bindings/swift/Examples/write_text_ffi.swift new file mode 100644 index 000000000..4f436d60f --- /dev/null +++ b/bindings/swift/Examples/write_text_ffi.swift @@ -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, UnsafePointer) -> UnsafeMutablePointer? +typealias StringFreeFn = @convention(c) (UnsafeMutablePointer) -> 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) diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 0850c7eba..1cb9f8876 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -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 @@ -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. 네이티브 라이브러리 빌드 @@ -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 diff --git a/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h b/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h index 2e63c9b5f..74746c5bb 100644 --- a/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h +++ b/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h @@ -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 diff --git a/bindings/swift/Sources/Rhwp/Rhwp.swift b/bindings/swift/Sources/Rhwp/Rhwp.swift index 014a0a116..7d5797d59 100644 --- a/bindings/swift/Sources/Rhwp/Rhwp.swift +++ b/bindings/swift/Sources/Rhwp/Rhwp.swift @@ -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 @@ -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, @@ -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?, UnsafePointer?) -> UnsafeMutablePointer? + ) 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.") + } } diff --git a/bindings/swift/Tests/RhwpTests/RhwpTests.swift b/bindings/swift/Tests/RhwpTests/RhwpTests.swift index 827ae5924..5d32ddcf9 100644 --- a/bindings/swift/Tests/RhwpTests/RhwpTests.swift +++ b/bindings/swift/Tests/RhwpTests/RhwpTests.swift @@ -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() diff --git a/src/wasm_api/tests.rs b/src/wasm_api/tests.rs index 146166950..972a74294 100644 --- a/src/wasm_api/tests.rs +++ b/src/wasm_api/tests.rs @@ -224,6 +224,74 @@ assert_eq!(&bytes[0..4], &[0xD0, 0xCF, 0x11, 0xE0]); } + fn assert_minimal_hwp_writer_streams(bytes: &[u8]) { + let mut cfb = crate::parser::cfb_reader::CfbReader::open(bytes) + .expect("writer output must be readable as HWP CFB"); + + assert!(cfb.has_stream("/FileHeader")); + assert!(cfb.has_stream("/DocInfo")); + assert!(cfb.has_stream("/BodyText/Section0")); + + let header = cfb + .read_file_header() + .expect("writer output must include a readable FileHeader stream"); + assert_eq!(header.len(), 256); + + let parsed = crate::parser::parse_hwp(bytes) + .expect("writer output must be reloadable through the HWP parser"); + assert_eq!(parsed.doc_properties.section_count, 1); + assert_eq!(parsed.sections.len(), 1); + assert_eq!(parsed.header.version.major, 5); + } + + fn create_blank_hwp_writer_document() -> HwpDocument { + let mut doc = HwpDocument::create_empty(); + doc.create_blank_document_native() + .expect("blank HWP template should initialize an editable writer document"); + doc + } + + #[test] + fn test_hwp_writer_contract_blank_document_roundtrip() { + let doc = create_blank_hwp_writer_document(); + + let bytes = doc + .export_hwp_native() + .expect("blank document must export as HWP bytes"); + + assert!(bytes.len() > 512); + assert_eq!(&bytes[0..4], &[0xD0, 0xCF, 0x11, 0xE0]); + assert_minimal_hwp_writer_streams(&bytes); + + let reloaded = HwpDocument::from_bytes(&bytes) + .expect("exported blank HWP must reload"); + assert_eq!(reloaded.page_count(), 1); + } + + #[test] + fn test_hwp_writer_contract_inserted_text_roundtrip() { + let mut doc = create_blank_hwp_writer_document(); + let text = "한글 English 123"; + + doc.insert_text_native(0, 0, 0, text) + .expect("text insertion should prepare writer input"); + let bytes = doc + .export_hwp_native() + .expect("edited document must export as HWP bytes"); + + assert_minimal_hwp_writer_streams(&bytes); + + let reloaded = HwpDocument::from_bytes(&bytes) + .expect("exported edited HWP must reload"); + let paragraph_text = &reloaded.document.sections[0].paragraphs[0].text; + assert!( + paragraph_text.contains("한글") + && paragraph_text.contains("English") + && paragraph_text.contains("123"), + "reloaded paragraph should preserve mixed text, got: {paragraph_text:?}" + ); + } + #[test] fn test_hwp_error_display() { let err = HwpError::InvalidFile("테스트".to_string());