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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Cargo.lock

# Output (렌더링 결과물)
/output/
/dist/swift/

# Docker
.docker/
Expand Down
2 changes: 1 addition & 1 deletion bindings/Native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ publish = false

[lib]
name = "rhwp_native_ffi"
crate-type = ["cdylib"]
crate-type = ["cdylib", "staticlib"]

[dependencies]
rhwp_core = { package = "rhwp", path = "../.." }
46 changes: 46 additions & 0 deletions bindings/Native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<u32>) -> Result<String, String> {
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,
Expand Down Expand Up @@ -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::<Vec<_>>()
.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() {
Expand Down
1 change: 1 addition & 0 deletions bindings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
2 changes: 2 additions & 0 deletions bindings/swift/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.build/
DerivedData/
31 changes: 31 additions & 0 deletions bindings/swift/Package.swift
Original file line number Diff line number Diff line change
@@ -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"]
),
]
)
59 changes: 59 additions & 0 deletions bindings/swift/README.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions bindings/swift/Sources/CRhwpNative/module.modulemap
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module CRhwpNative [system] {
header "rhwp_native_ffi.h"
link "rhwp_native_ffi"
export *
}
17 changes: 17 additions & 0 deletions bindings/swift/Sources/CRhwpNative/rhwp_native_ffi.h
Original file line number Diff line number Diff line change
@@ -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
182 changes: 182 additions & 0 deletions bindings/swift/Sources/Rhwp/Rhwp.swift
Original file line number Diff line number Diff line change
@@ -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<CChar>?, UnsafePointer<CChar>?, Int32) -> UnsafeMutablePointer<CChar>?
) 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<CChar>?, Int32) -> UnsafeMutablePointer<CChar>?
) 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.")
}
}
Loading