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/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/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..4c3fb3c6c --- /dev/null +++ b/bindings/swift/README.md @@ -0,0 +1,59 @@ +# RHWP Swift Binding + +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 + +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 + +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` 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/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..2e63c9b5f --- /dev/null +++ b/bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h @@ -0,0 +1,17 @@ +#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); +char *rhwp_read_text(const char *input_path, 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..014a0a116 --- /dev/null +++ b/bindings/swift/Sources/Rhwp/Rhwp.swift @@ -0,0 +1,182 @@ +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 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 + 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 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, + 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.") + } + + 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 new file mode 100644 index 000000000..827ae5924 --- /dev/null +++ b/bindings/swift/Tests/RhwpTests/RhwpTests.swift @@ -0,0 +1,60 @@ +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 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 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)) + } + + private func repoRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } +} 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"