From 8cd536b90018f2526da280537b13fc22d754aaaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=A0=95=EC=84=9D?= Date: Tue, 12 May 2026 14:44:52 +0900 Subject: [PATCH 1/3] Expose rhwp native exports to Swift callers The native binding crate already provides a stable C ABI for text and Markdown export, so the Swift port starts as a thin SwiftPM package over that ABI instead of duplicating parser logic. The wrapper keeps file-based export semantics intact while giving Swift callers typed results and errors. Constraint: Existing portable surface is the bindings/Native C ABI Rejected: Rewrite the parser in Swift | would duplicate the Rust document model before the ABI is proven in apps Confidence: high Scope-risk: narrow Directive: Keep Swift API additions aligned with bindings/Native symbols until an XCFramework packaging step is introduced Tested: cargo build --manifest-path bindings/Native/Cargo.toml Tested: swift test -Xlinker -L../../bindings/Native/target/debug Not-tested: iOS app bundle/XCFramework runtime packaging --- bindings/README.md | 1 + bindings/swift/.gitignore | 2 + bindings/swift/Package.swift | 31 +++++ bindings/swift/README.md | 32 +++++ .../Sources/CRhwpNative/module.modulemap | 5 + .../Sources/CRhwpNative/rhwp_native_ffi.h | 16 +++ bindings/swift/Sources/Rhwp/Rhwp.swift | 115 ++++++++++++++++++ .../swift/Tests/RhwpTests/RhwpTests.swift | 45 +++++++ 8 files changed, 247 insertions(+) create mode 100644 bindings/swift/.gitignore create mode 100644 bindings/swift/Package.swift create mode 100644 bindings/swift/README.md create mode 100644 bindings/swift/Sources/CRhwpNative/module.modulemap create mode 100644 bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h create mode 100644 bindings/swift/Sources/Rhwp/Rhwp.swift create mode 100644 bindings/swift/Tests/RhwpTests/RhwpTests.swift diff --git a/bindings/README.md b/bindings/README.md index 1455ef088..23c07ad65 100644 --- a/bindings/README.md +++ b/bindings/README.md @@ -4,5 +4,6 @@ This directory separates the shared native ABI from language-specific bindings. - `Native/`: Rust `cdylib` crate that exposes the C ABI used by bindings. - `csharp/`: C# P/Invoke wrapper over the shared native library. +- `swift/`: Swift Package wrapper over the shared native library. Add new language bindings as sibling folders under `bindings/`. diff --git a/bindings/swift/.gitignore b/bindings/swift/.gitignore new file mode 100644 index 000000000..61d0fb609 --- /dev/null +++ b/bindings/swift/.gitignore @@ -0,0 +1,2 @@ +.build/ +DerivedData/ diff --git a/bindings/swift/Package.swift b/bindings/swift/Package.swift new file mode 100644 index 000000000..2ba7db63f --- /dev/null +++ b/bindings/swift/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "Rhwp", + platforms: [ + .macOS(.v12), + .iOS(.v13), + ], + products: [ + .library( + name: "Rhwp", + targets: ["Rhwp"] + ), + ], + targets: [ + .systemLibrary( + name: "CRhwpNative", + path: "Sources/CRhwpNative" + ), + .target( + name: "Rhwp", + dependencies: ["CRhwpNative"] + ), + .testTarget( + name: "RhwpTests", + dependencies: ["Rhwp"] + ), + ] +) diff --git a/bindings/swift/README.md b/bindings/swift/README.md new file mode 100644 index 000000000..3578cc8c9 --- /dev/null +++ b/bindings/swift/README.md @@ -0,0 +1,32 @@ +# RHWP Swift Binding + +Swift wrapper for the shared native ABI in `bindings/Native`. + +The package exposes: + +- `Rhwp.exportText(inputFile:outputDirectory:page:)` +- `Rhwp.exportMarkdown(inputFile:outputDirectory:page:)` + +Both methods return `RhwpExportResult` and throw `RhwpError` when the native +call fails. + +## Build the Native Library + +From the repository root: + +```sh +cargo build --manifest-path bindings/Native/Cargo.toml +``` + +The Swift module links against `rhwp_native_ffi`, so the built dynamic library +must be discoverable by the app or test host at link/runtime. + +For local SwiftPM tests on macOS: + +```sh +cd bindings/swift +swift test -Xlinker -L../../bindings/Native/target/debug +``` + +For app integration, package the native library as an `XCFramework` or place it +in the app bundle and configure the appropriate library search path. diff --git a/bindings/swift/Sources/CRhwpNative/module.modulemap b/bindings/swift/Sources/CRhwpNative/module.modulemap new file mode 100644 index 000000000..38f82ba8b --- /dev/null +++ b/bindings/swift/Sources/CRhwpNative/module.modulemap @@ -0,0 +1,5 @@ +module CRhwpNative [system] { + header "rhwp_native_ffi.h" + link "rhwp_native_ffi" + export * +} diff --git a/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h b/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h new file mode 100644 index 000000000..9ab413dfa --- /dev/null +++ b/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h @@ -0,0 +1,16 @@ +#ifndef RHWP_NATIVE_FFI_H +#define RHWP_NATIVE_FFI_H + +#ifdef __cplusplus +extern "C" { +#endif + +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); +void rhwp_string_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/bindings/swift/Sources/Rhwp/Rhwp.swift b/bindings/swift/Sources/Rhwp/Rhwp.swift new file mode 100644 index 000000000..6bc541e46 --- /dev/null +++ b/bindings/swift/Sources/Rhwp/Rhwp.swift @@ -0,0 +1,115 @@ +import CRhwpNative +import Foundation + +public enum RhwpPage: Sendable, Equatable { + case all + case index(Int32) + + var ffiValue: Int32 { + switch self { + case .all: + return -1 + case let .index(page): + return page + } + } +} + +public struct RhwpExportResult: Codable, Sendable, Equatable { + public let ok: Bool + public let pageCount: Int? + public let files: [String]? + public let imageCount: Int? + public let error: String? + + public var outputFiles: [URL] { + (files ?? []).map(URL.init(fileURLWithPath:)) + } +} + +public enum RhwpError: Error, LocalizedError, Equatable { + case nativeReturnedNull + case invalidUTF8 + case invalidJSON(String) + case exportFailed(String) + + public var errorDescription: String? { + switch self { + case .nativeReturnedNull: + return "Native rhwp call returned a null result pointer." + case .invalidUTF8: + return "Native rhwp call returned invalid UTF-8." + case let .invalidJSON(payload): + return "Native rhwp call returned invalid JSON: \(payload)" + case let .exportFailed(message): + return message + } + } +} + +public enum Rhwp { + public static func exportText( + inputFile: URL, + outputDirectory: URL, + page: RhwpPage = .all + ) throws -> RhwpExportResult { + try callNative( + inputFile: inputFile, + outputDirectory: outputDirectory, + page: page, + function: rhwp_export_text + ) + } + + public static func exportMarkdown( + inputFile: URL, + outputDirectory: URL, + page: RhwpPage = .all + ) throws -> RhwpExportResult { + try callNative( + inputFile: inputFile, + outputDirectory: outputDirectory, + page: page, + function: rhwp_export_markdown + ) + } + + private static func callNative( + inputFile: URL, + outputDirectory: URL, + page: RhwpPage, + function: (UnsafePointer?, UnsafePointer?, Int32) -> UnsafeMutablePointer? + ) throws -> RhwpExportResult { + let pointer = inputFile.path.withCString { inputPath in + outputDirectory.path.withCString { outputPath in + function(inputPath, outputPath, page.ffiValue) + } + } + + 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: RhwpExportResult + do { + result = try JSONDecoder().decode(RhwpExportResult.self, from: data) + } catch { + throw RhwpError.invalidJSON(payload) + } + + if result.ok { + return result + } + + throw RhwpError.exportFailed(result.error ?? "rhwp export failed.") + } +} diff --git a/bindings/swift/Tests/RhwpTests/RhwpTests.swift b/bindings/swift/Tests/RhwpTests/RhwpTests.swift new file mode 100644 index 000000000..63f0be948 --- /dev/null +++ b/bindings/swift/Tests/RhwpTests/RhwpTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import Rhwp + +final class RhwpTests: XCTestCase { + func testPageEncoding() { + XCTAssertEqual(RhwpPage.all.ffiValue, -1) + XCTAssertEqual(RhwpPage.index(0).ffiValue, 0) + XCTAssertEqual(RhwpPage.index(7).ffiValue, 7) + } + + func testResultOutputFiles() { + let result = RhwpExportResult( + ok: true, + pageCount: 1, + files: ["/tmp/page.txt"], + imageCount: nil, + error: nil + ) + + XCTAssertEqual(result.outputFiles, [URL(fileURLWithPath: "/tmp/page.txt")]) + } + + func testExportTextCallsNativeLibrary() throws { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let inputFile = repoRoot.appendingPathComponent("samples/KTX.hwp") + let outputDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("rhwp-swift-\(UUID().uuidString)") + + let result = try Rhwp.exportText( + inputFile: inputFile, + outputDirectory: outputDirectory, + page: .index(0) + ) + + XCTAssertTrue(result.ok) + XCTAssertGreaterThan(result.pageCount ?? 0, 0) + XCTAssertEqual(result.outputFiles.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: result.outputFiles[0].path)) + } +} From fc11419d8743aec0535477c77a2735e4e5b23468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=A0=95=EC=84=9D?= Date: Tue, 12 May 2026 15:15:37 +0900 Subject: [PATCH 2/3] Package rhwp Swift bindings as an XCFramework Swift app integration needs an Apple-native binary artifact, so the native FFI crate now emits a static library and the release helper assembles device, simulator, and macOS slices into RhwpNative.xcframework with a zipped archive and checksum. Constraint: Xcode consumes native Rust code most cleanly through XCFramework artifacts Rejected: Commit generated XCFramework output | binary release artifacts should stay under ignored dist/swift Confidence: high Scope-risk: narrow Directive: Keep the generated archive out of git; rerun scripts/package-swift-xcframework.sh for release assets Tested: ./scripts/package-swift-xcframework.sh Tested: swift test -Xlinker -L../../bindings/Native/target/debug Not-tested: importing the generated XCFramework into a real iOS app target --- .gitignore | 1 + bindings/Native/Cargo.toml | 2 +- bindings/swift/README.md | 14 ++- scripts/package-swift-xcframework.sh | 122 +++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 3 deletions(-) create mode 100755 scripts/package-swift-xcframework.sh diff --git a/.gitignore b/.gitignore index 675829f8b..32aa16cbb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ Cargo.lock # Output (렌더링 결과물) /output/ +/dist/swift/ # Docker .docker/ diff --git a/bindings/Native/Cargo.toml b/bindings/Native/Cargo.toml index e97b24a29..977a7f035 100644 --- a/bindings/Native/Cargo.toml +++ b/bindings/Native/Cargo.toml @@ -6,7 +6,7 @@ publish = false [lib] name = "rhwp_native_ffi" -crate-type = ["cdylib"] +crate-type = ["cdylib", "staticlib"] [dependencies] rhwp_core = { package = "rhwp", path = "../.." } diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 3578cc8c9..f76a52362 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -28,5 +28,15 @@ cd bindings/swift swift test -Xlinker -L../../bindings/Native/target/debug ``` -For app integration, package the native library as an `XCFramework` or place it -in the app bundle and configure the appropriate library search path. +For app integration, package the native library as an `XCFramework` from the +repository root: + +```sh +./scripts/package-swift-xcframework.sh +``` + +The archive is written under `dist/swift/` and contains +`RhwpNative.xcframework`, `LICENSE`, and this README. + +By default, the iOS simulator slice includes Apple Silicon (`arm64`). Set +`INCLUDE_IOS_SIM_X86_64=1` when an Intel simulator slice is also required. diff --git a/scripts/package-swift-xcframework.sh b/scripts/package-swift-xcframework.sh new file mode 100755 index 000000000..d9b02bd16 --- /dev/null +++ b/scripts/package-swift-xcframework.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Package the RHWP native C ABI as an Apple XCFramework for Swift callers. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VERSION="$(awk -F '"' '/^version = / { print $2; exit }' "$ROOT/Cargo.toml")" +NATIVE_MANIFEST="$ROOT/bindings/Native/Cargo.toml" +SWIFT_DIR="$ROOT/bindings/swift" +HEADER="$SWIFT_DIR/Sources/CRhwpNative/rhwp_native_ffi.h" +DIST_DIR="$ROOT/dist/swift" +BUILD_DIR="$DIST_DIR/build" +HEADERS_DIR="$BUILD_DIR/Headers" +XCFRAMEWORK_NAME="RhwpNative.xcframework" +XCFRAMEWORK="$BUILD_DIR/$XCFRAMEWORK_NAME" +ARCHIVE_NAME="rhwp-native-v${VERSION}-apple-xcframework.zip" +ARCHIVE_PATH="$DIST_DIR/$ARCHIVE_NAME" + +DEVICE_TARGET="aarch64-apple-ios" +SIM_TARGETS=("aarch64-apple-ios-sim") +MACOS_TARGETS=("aarch64-apple-darwin" "x86_64-apple-darwin") + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "error: XCFramework packaging requires macOS." + exit 1 +fi + +if ! command -v cargo >/dev/null 2>&1; then + echo "error: cargo is required." + exit 1 +fi + +if ! command -v rustup >/dev/null 2>&1; then + echo "error: rustup is required." + exit 1 +fi + +if ! command -v xcodebuild >/dev/null 2>&1; then + echo "error: xcodebuild is required." + exit 1 +fi + +if [[ "${INCLUDE_IOS_SIM_X86_64:-0}" == "1" ]]; then + SIM_TARGETS+=("x86_64-apple-ios") +fi + +echo "=== RHWP Swift XCFramework packaging ===" +echo "version: $VERSION" +echo "output: $ARCHIVE_PATH" + +rm -rf "$DIST_DIR" +mkdir -p "$HEADERS_DIR" +cp "$HEADER" "$HEADERS_DIR/" + +rustup target add "$DEVICE_TARGET" "${SIM_TARGETS[@]}" "${MACOS_TARGETS[@]}" + +build_target() { + local target="$1" + echo "[build] $target" + cargo build \ + --release \ + --manifest-path "$NATIVE_MANIFEST" \ + --target "$target" +} + +build_target "$DEVICE_TARGET" +for target in "${SIM_TARGETS[@]}"; do + build_target "$target" +done +for target in "${MACOS_TARGETS[@]}"; do + build_target "$target" +done + +staticlib_for() { + local target="$1" + echo "$ROOT/bindings/Native/target/$target/release/librhwp_native_ffi.a" +} + +DEVICE_LIB="$(staticlib_for "$DEVICE_TARGET")" +SIM_LIB="$BUILD_DIR/librhwp_native_ffi-ios-simulator.a" +MACOS_LIB="$BUILD_DIR/librhwp_native_ffi-macos.a" + +SIM_LIBS=() +for target in "${SIM_TARGETS[@]}"; do + SIM_LIBS+=("$(staticlib_for "$target")") +done + +MACOS_LIBS=() +for target in "${MACOS_TARGETS[@]}"; do + MACOS_LIBS+=("$(staticlib_for "$target")") +done + +echo "[package] universal simulator static library" +lipo -create "${SIM_LIBS[@]}" -output "$SIM_LIB" + +echo "[package] universal macOS static library" +lipo -create "${MACOS_LIBS[@]}" -output "$MACOS_LIB" + +rm -rf "$XCFRAMEWORK" +xcodebuild -create-xcframework \ + -library "$DEVICE_LIB" -headers "$HEADERS_DIR" \ + -library "$SIM_LIB" -headers "$HEADERS_DIR" \ + -library "$MACOS_LIB" -headers "$HEADERS_DIR" \ + -output "$XCFRAMEWORK" + +cp "$ROOT/LICENSE" "$BUILD_DIR/LICENSE" +cp "$SWIFT_DIR/README.md" "$BUILD_DIR/README.md" + +echo "[package] archive" +( + cd "$BUILD_DIR" + zip -qry "$ARCHIVE_PATH" "$XCFRAMEWORK_NAME" LICENSE README.md +) + +( + cd "$DIST_DIR" + shasum -a 256 "$ARCHIVE_NAME" > SHA256SUMS.txt +) + +echo "=== done ===" +echo "$ARCHIVE_PATH" +echo "$DIST_DIR/SHA256SUMS.txt" From 12b07e1b275598bc8b12383c01c03be11c6fa161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EC=A0=95=EC=84=9D?= Date: Tue, 12 May 2026 15:25:20 +0900 Subject: [PATCH 3/3] Read HWP text directly into SwiftUI Swift app callers need document content in memory, not a TXT export side effect. The native ABI now exposes rhwp_read_text, Swift decodes that into page models, and the package includes a SwiftUI text view that loads an HWP URL and displays extracted pages. Constraint: SwiftUI display should not depend on temporary export files Rejected: Reuse rhwp_export_text for UI display | it writes TXT files and returns paths instead of document content Confidence: high Scope-risk: moderate Directive: Keep read APIs side-effect free; export APIs remain the file-writing surface Tested: cargo test --manifest-path bindings/Native/Cargo.toml Tested: cargo build --manifest-path bindings/Native/Cargo.toml Tested: swift test -Xlinker -L../../bindings/Native/target/debug Tested: ./scripts/package-swift-xcframework.sh Tested: unpacked XCFramework ZIP macOS slice linked to rhwp_read_text against samples/KTX.hwp Not-tested: rendering inside a live iOS simulator app target --- bindings/Native/src/lib.rs | 46 ++++++++++ bindings/swift/README.md | 21 ++++- .../Sources/CRhwpNative/rhwp_native_ffi.h | 1 + bindings/swift/Sources/Rhwp/Rhwp.swift | 67 ++++++++++++++ .../Sources/Rhwp/RhwpDocumentTextView.swift | 92 +++++++++++++++++++ .../swift/Tests/RhwpTests/RhwpTests.swift | 29 ++++-- 6 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 bindings/swift/Sources/Rhwp/RhwpDocumentTextView.swift diff --git a/bindings/Native/src/lib.rs b/bindings/Native/src/lib.rs index 0445c35bc..8d02ef26f 100644 --- a/bindings/Native/src/lib.rs +++ b/bindings/Native/src/lib.rs @@ -47,6 +47,14 @@ pub extern "C" fn rhwp_export_markdown( }) } +#[no_mangle] +pub extern "C" fn rhwp_read_text(input_path: *const c_char, page: i32) -> *mut c_char { + ffi_result(|| { + let input_path = read_utf8(input_path, "input_path")?; + read_text(Path::new(&input_path), normalize_page(page)?) + }) +} + #[no_mangle] pub extern "C" fn rhwp_string_free(ptr: *mut c_char) { if ptr.is_null() { @@ -94,6 +102,25 @@ fn export_text_to_dir( Ok(success_json(page_count, &written, None)) } +fn read_text(input_path: &Path, target_page: Option) -> Result { + let data = fs::read(input_path) + .map_err(|e| format!("파일을 읽을 수 없습니다 - {}: {}", input_path.display(), e))?; + let doc = HwpDocument::from_bytes(&data).map_err(|e| format!("HWP 파싱 실패 - {}", e))?; + let page_count = doc.page_count(); + let pages = select_pages(page_count, target_page)?; + + let mut extracted = Vec::new(); + for page_num in pages { + let mut text = doc + .extract_page_text_native(page_num) + .map_err(|e| format!("페이지 {} 텍스트 추출 실패 - {:?}", page_num, e))?; + ensure_trailing_newline(&mut text); + extracted.push((page_num, text)); + } + + Ok(text_json(page_count, &extracted)) +} + fn export_markdown_to_dir( input_path: &Path, output_dir: &Path, @@ -313,6 +340,25 @@ fn error_json(error: &str) -> String { format!("{{\"ok\":false,\"error\":\"{}\"}}", json_escape(error)) } +fn text_json(page_count: u32, pages: &[(u32, String)]) -> String { + let pages_json = pages + .iter() + .map(|(index, text)| { + format!( + "{{\"index\":{},\"text\":\"{}\"}}", + index, + json_escape(text) + ) + }) + .collect::>() + .join(","); + + format!( + "{{\"ok\":true,\"pageCount\":{},\"pages\":[{}]}}", + page_count, pages_json + ) +} + fn json_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { diff --git a/bindings/swift/README.md b/bindings/swift/README.md index f76a52362..4c3fb3c6c 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -4,11 +4,28 @@ Swift wrapper for the shared native ABI in `bindings/Native`. The package exposes: +- `Rhwp.readText(inputFile:page:)` - `Rhwp.exportText(inputFile:outputDirectory:page:)` - `Rhwp.exportMarkdown(inputFile:outputDirectory:page:)` +- `RhwpDocumentTextView(inputFile:page:)` for SwiftUI text display -Both methods return `RhwpExportResult` and throw `RhwpError` when the native -call fails. +The export methods return `RhwpExportResult`; direct reads return +`RhwpDocumentText`. All methods throw `RhwpError` when the native call fails. + +## SwiftUI Display + +```swift +import Rhwp +import SwiftUI + +struct DocumentScreen: View { + let fileURL: URL + + var body: some View { + RhwpDocumentTextView(inputFile: fileURL) + } +} +``` ## Build the Native Library diff --git a/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h b/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h index 9ab413dfa..2e63c9b5f 100644 --- a/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h +++ b/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h @@ -7,6 +7,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); 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 6bc541e46..014a0a116 100644 --- a/bindings/swift/Sources/Rhwp/Rhwp.swift +++ b/bindings/swift/Sources/Rhwp/Rhwp.swift @@ -27,6 +27,26 @@ public struct RhwpExportResult: Codable, Sendable, Equatable { } } +public struct RhwpTextPage: Codable, Sendable, Equatable, Identifiable { + public let index: Int + public let text: String + + public var id: Int { + index + } +} + +public struct RhwpDocumentText: Codable, Sendable, Equatable { + public let ok: Bool + public let pageCount: Int? + public let pages: [RhwpTextPage]? + public let error: String? + + public var text: String { + (pages ?? []).map(\.text).joined(separator: "\n") + } +} + public enum RhwpError: Error, LocalizedError, Equatable { case nativeReturnedNull case invalidUTF8 @@ -48,6 +68,17 @@ public enum RhwpError: Error, LocalizedError, Equatable { } public enum Rhwp { + public static func readText( + inputFile: URL, + page: RhwpPage = .all + ) throws -> RhwpDocumentText { + try callNativeText( + inputFile: inputFile, + page: page, + function: rhwp_read_text + ) + } + public static func exportText( inputFile: URL, outputDirectory: URL, @@ -112,4 +143,40 @@ public enum Rhwp { throw RhwpError.exportFailed(result.error ?? "rhwp export failed.") } + + private static func callNativeText( + inputFile: URL, + page: RhwpPage, + function: (UnsafePointer?, Int32) -> UnsafeMutablePointer? + ) throws -> RhwpDocumentText { + let pointer = inputFile.path.withCString { inputPath in + function(inputPath, page.ffiValue) + } + + 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: RhwpDocumentText + do { + result = try JSONDecoder().decode(RhwpDocumentText.self, from: data) + } catch { + throw RhwpError.invalidJSON(payload) + } + + if result.ok { + return result + } + + throw RhwpError.exportFailed(result.error ?? "rhwp read failed.") + } } diff --git a/bindings/swift/Sources/Rhwp/RhwpDocumentTextView.swift b/bindings/swift/Sources/Rhwp/RhwpDocumentTextView.swift new file mode 100644 index 000000000..7f517b5cd --- /dev/null +++ b/bindings/swift/Sources/Rhwp/RhwpDocumentTextView.swift @@ -0,0 +1,92 @@ +import Foundation +import SwiftUI + +@available(iOS 13.0, macOS 10.15, *) +public struct RhwpDocumentTextView: View { + private let inputFile: URL + private let page: RhwpPage + + @State private var document: RhwpDocumentText? + @State private var errorMessage: String? + @State private var isLoading = false + + public init(inputFile: URL, page: RhwpPage = .all) { + self.inputFile = inputFile + self.page = page + } + + public var body: some View { + Group { + if let document { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(document.pages ?? []) { page in + RhwpTextPageView(page: page) + } + } + .padding() + } + } else if let errorMessage { + Text(errorMessage) + .font(.body) + .foregroundColor(.secondary) + .padding() + } else if isLoading { + Text("문서를 여는 중입니다.") + .font(.body) + .foregroundColor(.secondary) + .padding() + } else { + Color.clear + .onAppear(perform: load) + } + } + .onAppear(perform: load) + } + + private func load() { + guard !isLoading, document == nil, errorMessage == nil else { + return + } + + isLoading = true + do { + document = try Rhwp.readText(inputFile: inputFile, page: page) + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } +} + +@available(iOS 13.0, macOS 10.15, *) +public struct RhwpTextPageView: View { + private let page: RhwpTextPage + + public init(page: RhwpTextPage) { + self.page = page + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("페이지 \(page.index + 1)") + .font(.caption) + .foregroundColor(.secondary) + Text(page.text) + .font(.body) + .textSelectionIfAvailable() + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private extension Text { + @ViewBuilder + func textSelectionIfAvailable() -> some View { + if #available(iOS 15.0, macOS 12.0, *) { + self.textSelection(.enabled) + } else { + self + } + } +} diff --git a/bindings/swift/Tests/RhwpTests/RhwpTests.swift b/bindings/swift/Tests/RhwpTests/RhwpTests.swift index 63f0be948..827ae5924 100644 --- a/bindings/swift/Tests/RhwpTests/RhwpTests.swift +++ b/bindings/swift/Tests/RhwpTests/RhwpTests.swift @@ -20,14 +20,20 @@ final class RhwpTests: XCTestCase { XCTAssertEqual(result.outputFiles, [URL(fileURLWithPath: "/tmp/page.txt")]) } + func testReadTextCallsNativeLibraryWithoutExportingTxt() throws { + let inputFile = repoRoot().appendingPathComponent("samples/KTX.hwp") + + let document = try Rhwp.readText(inputFile: inputFile, page: .index(0)) + + XCTAssertTrue(document.ok) + XCTAssertGreaterThan(document.pageCount ?? 0, 0) + XCTAssertEqual(document.pages?.count, 1) + XCTAssertEqual(document.pages?.first?.index, 0) + XCTAssertFalse(document.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + func testExportTextCallsNativeLibrary() throws { - let repoRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - let inputFile = repoRoot.appendingPathComponent("samples/KTX.hwp") + let inputFile = repoRoot().appendingPathComponent("samples/KTX.hwp") let outputDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("rhwp-swift-\(UUID().uuidString)") @@ -42,4 +48,13 @@ final class RhwpTests: XCTestCase { XCTAssertEqual(result.outputFiles.count, 1) XCTAssertTrue(FileManager.default.fileExists(atPath: result.outputFiles[0].path)) } + + private func repoRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } }