diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d7aff9..dbe77d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,7 +154,10 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest] + # ^ macos-latest 는 상류 edwardkim/rhwp#823 (headless macOS 에서 PNG 렌더 + # hang — CoreText downloadable lookup IPC 영구 대기) 해결 시 복귀. + # 현재 GHA macos runner 에서 30분+ hang 으로 wheel 검증이 불가. + os: [windows-latest] defaults: run: shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ac702..9bc565d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.1] — 2026-05-18 + +PATCH release. v0.6.0 (Frozen, 2026-05-10) 의 GitHub Release / PyPI publish 가 누락된 상태에서 발견된 release 인프라 정합화 + 후속 polish 를 한 묶음 PATCH 로 발행한다. 사용자 영향: PyPI 첫 게시 패키지가 `v0.5.1` 다음 `v0.6.1` 로 점프 — v0.6.0 의 모든 표면 (페이지 PNG 렌더링 + 문서 시스템 개편) 은 변경 없이 그대로 포함하며 `[0.6.0]` 섹션은 historical record 로 보존. 외부 공개 API / IR schema (`"1.1"`) 변경 0. + +### Added + +- `examples/07_render_png.py` 신규 — v0.6.0 PNG 표면의 typer 진입점 예제 (단일 페이지 / `--all` 일괄 / `--scale` / `--max-pixels` / `--output-dir` / `--prefix`). Pillow 가 있으면 디코드 dimension 까지 검증, 없으면 PNG magic + 길이만 출력 (graceful degrade). README §"페이지 PNG 렌더링 (VLM 입력)" 의 typer 진입점 시연. +- `examples/README.md` §7 항목 + "일곱 스크립트" 안내. `[examples]` extras 에 Pillow 추가 (07 디코드 검증용). +- `benches/bench_gil.py` 에 `png_task` 추가 — `parse + render_png(page=0)` 의 ThreadPoolExecutor worker 1/2/4/8 별 wall-clock 비교 (기존 `parse + render_pdf` 패턴 동형). `--json` 플래그 출고 옵션 — drift 추적 / ADR 첨부 재활용. v0.6.0 spec row-6 의 "≥50 ms 임계 충족" rationale 을 closed-loop 으로 실측 검증. + +### Changed + +- `rhwp.parse` / `rhwp.aparse` / `rhwp.arender_png` / `Document.__init__` 가 `path: str` → `path: str | os.PathLike[str]` 수용. 내부에서 `str(path)` 정규화 후 Rust `_Document(&str)` 위임. 사용자가 `pathlib.Path` 인스턴스를 그대로 넘길 수 있다 — IDE 자동완성 정합. v0.5.x 의 `str` 호출 시그니처는 그대로 유지 (additive widening, breaking 아님). + +### Build + +- `external/rhwp` submodule pin `62a458a` (v0.7.10) → `1899ef9b` (v0.7.12). v0.6.0 Build 섹션 disclosure 의 v0.7.10 record 와 wheel binary 의 불일치를 해소. 본 binding 관점 변경 0 — 공개 API / IR schema (`"1.1"`) / wheel 의존성 모두 동일, `cargo check` clean + `maturin develop --release` clean + IR baseline byte-equal (`tests/test_view_baseline.py` 2/2 pass) + 회귀 가드 592 통과 직접 검증. 상류 v0.7.11 + v0.7.12 GA 흡수 — Renderer 시각 회귀 fix 다수 + Text IR v2 (`GlyphRun` / `GlyphOutline` variant, rhwp-python 미소비) + HWP3 ch=9 탭 spec 정합 + skia-safe `0.93.1` → `0.97.0` binary-cache. macOS PNG headless hang (상류 [#823](https://github.com/edwardkim/rhwp/issues/823)) 은 v0.7.12 에서도 미해결 — 별도 issue 진행. +- `Cargo.toml` 의 `version` `0.6.0` → `0.6.1`. `pyproject.toml` 은 `dynamic = ["version"]` 으로 자동 추종. +- `[project.optional-dependencies] examples` 에 `pillow>=10` 추가 — 07 예제의 dimension 디코드 검증 옵션. 미설치 시 graceful degrade (PNG magic + 길이만). + +### Notes + +- v0.6.0 publish 누락의 회복 경로 — `[0.6.0]` historical record 보존 + v0.6.1 = v0.6.0 표면 + 본 PATCH 변경. SemVer 측 단조 증가 (PyPI 는 게시되지 않은 v0.6.0 의 부재를 허용). +- `tests/type_check_errors.py` 의 의도된 pyright 에러 4건 + `test-without-extras` job 의 expected skip count 6 변동 없음. + ## [0.6.0] — 2026-05-10 MINOR release. 페이지 PNG 렌더링 표면을 추가하여 VLM (Vision-Language Model — Claude / GPT-4V / Gemini Vision 등) 의 시각 입력 시나리오를 지원한다. 상류 `rhwp` v0.7.10 (PR #599 PNG 게이트웨이) 의 `SkiaLayerRenderer::render_raster_with_options` 위 thin wrapper — `Document.render_png(page) -> bytes` / `render_all_png()` / `export_png(out_dir)` 3 메서드 + 모듈-level `arender_png(path, page)` async + MCP 도구 `render_page_png` (fastmcp `ImageContent` 출고) 신규. `[png]` extras 분리 없이 default wheel 통합 (Cargo `native-skia` feature 항상 활성화 — skia binary 약 30 MB 추가) — `pip install rhwp-python` 만으로 즉시 사용 가능. 추가만 있고 v0.5.x 의 SVG / PDF / IR / MCP 표면은 모두 보존 (additive only), schema (`"1.1"`) 유지. diff --git a/Cargo.toml b/Cargo.toml index 856b3da..ffbf5ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rhwp-python" -version = "0.6.0" +version = "0.6.1" edition = "2021" # ^ rust-version 미명시 — 상위 rhwp crate 정책(stable Rust, MSRV unclaimed) 준수. # PyO3 0.28 이 Rust 1.83+ 요구하지만, 이는 README 에 문서로 안내 diff --git a/benches/bench_gil.py b/benches/bench_gil.py index 02996db..b5658ae 100644 --- a/benches/bench_gil.py +++ b/benches/bench_gil.py @@ -1,12 +1,18 @@ -"""GIL 해제 효과 측정 — 단일 vs 멀티스레드 parse / render_pdf 처리 시간. +"""GIL 해제 효과 측정 — 단일 vs 멀티스레드 parse / render_pdf / render_png 처리 시간. -`py.detach` 를 적용한 `parse()` 와 `render_pdf()` 가 `ThreadPoolExecutor` 에서 -실제 병렬 실행되는지 (GIL 해제 작동) 확인. +`py.detach` 를 적용한 `parse()` / `render_pdf()` / `render_png()` 가 +`ThreadPoolExecutor` 에서 실제 병렬 실행되는지 (GIL 해제 작동) 확인. `#[pyclass(unsendable)]` 제약: `Document` 객체는 생성된 스레드에서만 유효. -벤치는 각 워커가 parse → 추출 / render_pdf → bytes 까지 완결 후 int 반환 (현업 패턴). +벤치는 각 워커가 parse → 추출 / render_pdf → bytes / render_png → bytes 까지 +완결 후 int 반환 (현업 패턴). + +옵션: + --json 결과를 stdin 친화 JSON 으로 출력 (drift 추적 / ADR 첨부용) """ +import argparse +import json import os import time from concurrent.futures import ThreadPoolExecutor @@ -33,6 +39,13 @@ def pdf_task(path: str) -> int: return len(pdf) +def png_task(path: str) -> int: + # ^ parse + render_png(page=0) 를 한 워커에서 처리. bytes 길이만 반환 + doc = rhwp.parse(path) + png = doc.render_png(0) + return len(png) + + def bench(task, file_list: list[str], workers: int, repeats: int) -> float: times = [] for _ in range(repeats): @@ -47,49 +60,85 @@ def bench(task, file_list: list[str], workers: int, repeats: int) -> float: return min(times) -def main() -> None: - files = [ - str(SAMPLES / "aift.hwp"), - str(SAMPLES / "table-vpos-01.hwpx"), - str(SAMPLES / "tac-img-02.hwpx"), - ] - parse_workload = files * 3 # ^ 9 태스크 (3 파일 × 3회 반복) +def _run_section(task, file_list, worker_list, repeats): + rows = [] + baseline = bench(task, file_list, workers=1, repeats=repeats) + rows.append({"workers": 1, "seconds": baseline, "speedup": 1.0}) + for w in worker_list: + t = bench(task, file_list, workers=w, repeats=repeats) + rows.append({"workers": w, "seconds": t, "speedup": baseline / t}) + return rows - print(f"시스템 코어 수: {os.cpu_count()}") - print(f"rhwp 버전: {rhwp.version()} / rhwp core: {rhwp.rhwp_core_version()}") + +def _print_table(title: str, subtitle: str, rows: list[dict], task_count: int) -> None: print() print("=" * 72) - print("Parse 벤치마크 — 9개 파일 (aift + table-vpos + tac-img, 각 3회)") + print(title) + print(subtitle) print("=" * 72) print(f"{'워커 수':<12} {'처리 시간':<15} {'단일 대비':<15} {'이상적 가속':<15}") print("-" * 72) - - baseline = bench(parse_task, parse_workload, workers=1, repeats=3) - print(f"{'1 (순차)':<12} {f'{baseline * 1000:.0f}ms':<15} {'1.00x':<15} {'1.00x':<15}") - - for workers in [2, 4, 8]: - t = bench(parse_task, parse_workload, workers=workers, repeats=3) - speedup = baseline / t - ideal = min(workers, len(parse_workload)) + for r in rows: + ideal = min(r["workers"], task_count) + label = "1 (순차)" if r["workers"] == 1 else str(r["workers"]) print( - f"{workers:<12} {f'{t * 1000:.0f}ms':<15} " - f"{f'{speedup:.2f}x':<15} {f'{ideal:.0f}x (이상치)':<15}" + f"{label:<12} {f'{r['seconds'] * 1000:.0f}ms':<15} " + f"{f'{r['speedup']:.2f}x':<15} {f'{ideal:.0f}x (이상치)':<15}" ) - print() - print("=" * 72) - print("PDF 렌더링 벤치마크 — 3개 문서 (parse + render_pdf 워커 내 완결)") - print("=" * 72) - print(f"{'워커 수':<12} {'처리 시간':<15} {'단일 대비':<15}") - print("-" * 72) - pdf_baseline = bench(pdf_task, files, workers=1, repeats=2) - print(f"{'1 (순차)':<12} {f'{pdf_baseline * 1000:.0f}ms':<15} {'1.00x':<15}") +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--json", action="store_true", help="결과를 JSON 으로 stdout 에 dump" + ) + args = parser.parse_args() - for workers in [2, 3]: - t = bench(pdf_task, files, workers=workers, repeats=2) - speedup = pdf_baseline / t - print(f"{workers:<12} {f'{t * 1000:.0f}ms':<15} {f'{speedup:.2f}x':<15}") + files = [ + str(SAMPLES / "aift.hwp"), + str(SAMPLES / "table-vpos-01.hwpx"), + str(SAMPLES / "tac-img-02.hwpx"), + ] + parse_workload = files * 3 # ^ 9 태스크 (3 파일 × 3회 반복) + + parse_rows = _run_section(parse_task, parse_workload, [2, 4, 8], repeats=3) + pdf_rows = _run_section(pdf_task, files, [2, 3], repeats=2) + png_rows = _run_section(png_task, files, [2, 3], repeats=2) + + if args.json: + payload = { + "system": { + "cpu_count": os.cpu_count(), + "rhwp_version": rhwp.version(), + "rhwp_core_version": rhwp.rhwp_core_version(), + }, + "parse": {"task_count": len(parse_workload), "rows": parse_rows}, + "pdf": {"task_count": len(files), "rows": pdf_rows}, + "png": {"task_count": len(files), "rows": png_rows}, + } + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return + + print(f"시스템 코어 수: {os.cpu_count()}") + print(f"rhwp 버전: {rhwp.version()} / rhwp core: {rhwp.rhwp_core_version()}") + _print_table( + "Parse 벤치마크 — 9개 파일 (aift + table-vpos + tac-img, 각 3회)", + "", + parse_rows, + len(parse_workload), + ) + _print_table( + "PDF 렌더링 벤치마크 — 3개 문서 (parse + render_pdf 워커 내 완결)", + "", + pdf_rows, + len(files), + ) + _print_table( + "PNG 렌더링 벤치마크 — 3개 문서 (parse + render_png(0) 워커 내 완결)", + "", + png_rows, + len(files), + ) if __name__ == "__main__": diff --git a/examples/07_render_png.py b/examples/07_render_png.py new file mode 100644 index 0000000..89361fb --- /dev/null +++ b/examples/07_render_png.py @@ -0,0 +1,96 @@ +"""HWP/HWPX 페이지를 PNG 로 렌더링하는 예제 (VLM 입력). + +사용법: + python examples/07_render_png.py path/to/file.hwp + python examples/07_render_png.py path/to/file.hwp --page 2 --scale 2.0 + python examples/07_render_png.py path/to/file.hwp --all + python examples/07_render_png.py path/to/file.hwp --all --scale 1.5 --output-dir ./out + +설치: + pip install "rhwp-python[examples]" +""" + +import io +from pathlib import Path as PathLibPath + +import rhwp +import typer + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _describe(data: bytes) -> str: + size_kb = len(data) / 1024 + magic_ok = data.startswith(PNG_MAGIC) + parts = [f"{size_kb:.1f} KB", "PNG magic OK" if magic_ok else "PNG magic FAIL"] + try: + from PIL import Image + + img = Image.open(io.BytesIO(data)) + parts.append(f"{img.size[0]}×{img.size[1]}") + except ImportError: + parts.append("Pillow 미설치 (dimension 검증 생략)") + return ", ".join(parts) + + +def main( + path: PathLibPath = typer.Argument(..., help="HWP 또는 HWPX 파일 경로"), + page: int = typer.Option(0, "--page", "-p", help="0-based 페이지 인덱스 (단일 모드)"), + all_pages: bool = typer.Option(False, "--all", help="전 페이지 일괄 렌더링 (--page 무시)"), + scale: float = typer.Option(1.0, "--scale", "-s", help="픽셀 너비/높이 배율 (default 1.0)"), + max_pixels: int | None = typer.Option( + None, "--max-pixels", help="DoS 가드 픽셀 상한 (default 8192×8192)" + ), + output_dir: PathLibPath = typer.Option( + PathLibPath("./render_output"), "--output-dir", "-o", help="출력 디렉토리" + ), + prefix: str = typer.Option("page", "--prefix", help="PNG 파일명 접두사"), +) -> None: + """HWP/HWPX 를 파싱한 뒤 PNG 로 렌더링. + + 단일 페이지 (기본): `--page` 인덱스 한 장을 `{prefix}.png` 로 저장. + 전체 페이지 (`--all`): `{prefix}_{NNN}.png` 패턴으로 저장 (1-based 0-padded 3자리). + `--scale` 또는 `--max-pixels` 가 기본값과 다르면 page 단위 루프로 처리 (export_png 가 두 인자 미수령). + """ + if not path.exists(): + typer.echo(f"파일이 없습니다: {path}", err=True) + raise typer.Exit(code=1) + + output_dir.mkdir(parents=True, exist_ok=True) + + typer.echo(f"파싱 중: {path}") + doc = rhwp.parse(str(path)) + typer.echo(f" 페이지 수: {doc.page_count}") + + if all_pages: + typer.echo(f"\n[PNG, all pages] {output_dir}/{prefix}*.png (scale={scale})") + custom_opts = scale != 1.0 or max_pixels is not None + if custom_opts: + for p in range(doc.page_count): + data = doc.render_png(p, scale=scale, max_pixels=max_pixels) + if doc.page_count == 1: + out = output_dir / f"{prefix}.png" + else: + out = output_dir / f"{prefix}_{p + 1:03d}.png" + out.write_bytes(data) + typer.echo(f" {out} ({_describe(data)})") + else: + paths = doc.export_png(str(output_dir), prefix=prefix) + for p in paths: + size_kb = PathLibPath(p).stat().st_size / 1024 + typer.echo(f" {p} ({size_kb:.1f} KB)") + else: + if page >= doc.page_count: + typer.echo(f"--page {page} 가 페이지 수 {doc.page_count} 를 초과합니다.", err=True) + raise typer.Exit(code=1) + out = output_dir / f"{prefix}.png" + typer.echo(f"\n[PNG, page {page}] {out} (scale={scale}, max_pixels={max_pixels})") + data = doc.render_png(page, scale=scale, max_pixels=max_pixels) + out.write_bytes(data) + typer.echo(f" {_describe(data)}") + + typer.echo(f"\n완료. 결과물: {output_dir}/") + + +if __name__ == "__main__": + typer.run(main) diff --git a/examples/README.md b/examples/README.md index 1e305af..619909c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,11 +6,13 @@ ## 사전 준비 ```bash -# 01 ~ 06 예제 전부 한 방에 설치 (typer + langchain-core + text-splitters + fastmcp) +# 01 ~ 07 예제 전부 한 방에 설치 (typer + langchain-core + text-splitters + fastmcp + pillow) pip install "rhwp-python[examples]" ``` > 06 의 `chunks` MCP 도구는 `langchain-text-splitters` 가 필요한데 위 한 줄에 포함됨. +> 07 의 디코드 dimension 검증에는 Pillow 가 필요한데 위 한 줄에 포함됨 (미설치 시 +> PNG magic + 길이만 출력하는 graceful degrade). > 통합 레이어만 필요하면 (예제 러너 없이 직접 `HwpLoader` 사용) `pip install "rhwp-python[langchain]"` 만으로 충분하다. ## 스크립트 @@ -95,6 +97,30 @@ in-process round-trip — 7 도구 (`parse_hwp_summary` / `extract_text` / `get_ 옵션: - `--skip-chunks` : `[mcp-chunks]` extras 미설치 환경에서 chunks 호출 스킵 +### 7. 페이지 PNG 렌더링 (VLM 입력) — `07_render_png.py` + +```bash +python examples/07_render_png.py path/to/your/file.hwp +python examples/07_render_png.py path/to/your/file.hwp --page 2 --scale 2.0 +python examples/07_render_png.py path/to/your/file.hwp --all +python examples/07_render_png.py path/to/your/file.hwp --all --scale 1.5 -o ./out +``` + +`Document.render_png(page)` / `Document.export_png(dir)` 두 표면을 시연. 단일 페이지 +모드 (기본) 는 한 장을 `{prefix}.png` 로 저장 — VLM (Claude Vision / GPT-4V / Gemini) +입력용으로 가장 흔한 형태. `--all` 모드는 전 페이지를 `{prefix}_{NNN}.png` 로 저장한다 +(`--scale` / `--max-pixels` 가 기본값과 다르면 page 단위 루프, 아니면 `export_png` +일괄 호출 — 두 API 의 trade-off 학습 포인트). Pillow 가 설치돼 있으면 디코드 dimension +까지 출력, 미설치 시 PNG magic + 길이만 출력 (graceful degrade). + +옵션: +- `--page / -p INT` : 0-based 페이지 인덱스 (기본 0). `--all` 지정 시 무시 +- `--all` : 전 페이지 일괄 렌더링 +- `--scale / -s FLOAT` : 픽셀 너비/높이 배율 (기본 1.0). VLM 해상도 ↑ 시 `2.0` ~ `3.0` +- `--max-pixels INT` : DoS 가드 픽셀 상한 (기본 8192×8192 = 67_108_864) +- `--output-dir / -o PATH` : 출력 디렉토리 (기본 `./render_output`) +- `--prefix TEXT` : PNG 파일명 접두사 (기본 `page`) + ## 릴리스 전 실제 HWP 검증 -릴리스 직전 **본인의 업무 HWP 파일 3종 (일반 문서 / 장문 / HWPX)** 으로 여섯 스크립트를 순서대로 돌려 출력을 육안 확인한다. 한컴오피스 뷰어로 연 원본과 대조해 섹션/문단/페이지 수치, SVG/PDF 렌더, IR 의 block/table 구조, LangChain Document 매핑, MCP 도구 7 종이 깨지지 않는지 본다. +릴리스 직전 **본인의 업무 HWP 파일 3종 (일반 문서 / 장문 / HWPX)** 으로 일곱 스크립트를 순서대로 돌려 출력을 육안 확인한다. 한컴오피스 뷰어로 연 원본과 대조해 섹션/문단/페이지 수치, SVG/PDF/PNG 렌더, IR 의 block/table 구조, LangChain Document 매핑, MCP 도구 7 종이 깨지지 않는지 본다. diff --git a/pyproject.toml b/pyproject.toml index 775e312..c2b1b73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,13 +60,15 @@ cli-chunks = [ "langchain-core>=0.2", "langchain-text-splitters>=0.2", ] -# ^ examples 는 01~06 예제 스크립트 일괄 실행용 우산 extras — typer + langchain-core + -# text-splitters + fastmcp (06 MCP 데모) 합집합. v0.5.0+ 는 fastmcp v3 (jlowin) 포함. +# ^ examples 는 01~07 예제 스크립트 일괄 실행용 우산 extras — typer + langchain-core + +# text-splitters + fastmcp (06 MCP 데모) + pillow (07 PNG dimension 검증) 합집합. +# v0.5.0+ 는 fastmcp v3 (jlowin), v0.6.1+ 는 pillow 포함. examples = [ "typer>=0.12", "langchain-core>=0.2", "langchain-text-splitters>=0.2", "fastmcp>=3,<4", + "pillow>=10", ] # ^ rhwp-mcp MCP 서버 (v0.5.0+). standalone fastmcp v3 (jlowin) — 2026-05 기준 # 현업 표준 (MCP 서버 약 70% 사용). 공식 mcp SDK 안의 FastMCP v1 은 frozen 상태고 diff --git a/python/rhwp/document.py b/python/rhwp/document.py index 45a919a..973bd3f 100644 --- a/python/rhwp/document.py +++ b/python/rhwp/document.py @@ -32,6 +32,7 @@ Document 객체에 대해 ``to_ir()`` 재호출은 동일 인스턴스를 반환 (identity 보존). """ +import os from typing import TYPE_CHECKING from rhwp._rhwp import _Document @@ -39,6 +40,8 @@ if TYPE_CHECKING: from rhwp.ir.nodes import HwpDocument, PictureBlock +StrPath = str | os.PathLike[str] + class Document: """파싱된 HWP / HWPX 문서. @@ -56,8 +59,8 @@ class Document: __slots__ = ("_inner",) - def __init__(self, path: str) -> None: - self._inner: _Document = _Document(path) + def __init__(self, path: StrPath) -> None: + self._inner: _Document = _Document(str(path)) @classmethod def _from_rust(cls, rust_doc: _Document) -> "Document": @@ -317,11 +320,11 @@ def __repr__(self) -> str: return repr(self._inner) -def parse(path: str) -> Document: +def parse(path: StrPath) -> Document: """HWP5 또는 HWPX 파일을 파싱하여 Document 반환. Args: - path: HWP 또는 HWPX 파일 경로. + path: HWP 또는 HWPX 파일 경로 (``str`` 또는 ``os.PathLike[str]``). Returns: 파싱된 Document. @@ -336,7 +339,7 @@ def parse(path: str) -> Document: async def arender_png( - path: str, + path: StrPath, page: int, *, scale: float = 1.0, @@ -354,7 +357,7 @@ async def arender_png( 여러 번 호출) 이 더 효율적 — 본 함수는 단발 페이지 렌더링용. Args: - path: HWP 또는 HWPX 파일 경로. + path: HWP 또는 HWPX 파일 경로 (``str`` 또는 ``os.PathLike[str]``). page: 0-based 페이지 인덱스. scale: 페이지 크기 배율 (기본 1.0). dpi: 메타데이터 DPI. @@ -369,12 +372,13 @@ async def arender_png( """ import asyncio - data = await asyncio.to_thread(_read_bytes, path) - doc = Document.from_bytes(data, source_uri=path) + path_str = str(path) + data = await asyncio.to_thread(_read_bytes, path_str) + doc = Document.from_bytes(data, source_uri=path_str) return doc.render_png(page, scale=scale, dpi=dpi, max_pixels=max_pixels) -async def aparse(path: str) -> Document: +async def aparse(path: StrPath) -> Document: """:func:`parse` 의 async 변형 — 파일 읽기만 async, 파싱은 sync. ``#[pyclass(unsendable)]`` 제약 상 Document 는 스레드 경계를 넘을 수 없다. @@ -392,7 +396,7 @@ async def aparse(path: str) -> Document: aiofiles 도 동일 한계. 단발 read 이므로 실용 영향 없음. Args: - path: HWP 또는 HWPX 파일 경로. + path: HWP 또는 HWPX 파일 경로 (``str`` 또는 ``os.PathLike[str]``). Returns: 파싱된 Document. 호출 스레드에 묶인다. @@ -405,8 +409,9 @@ async def aparse(path: str) -> Document: """ import asyncio - data = await asyncio.to_thread(_read_bytes, path) - return Document.from_bytes(data, source_uri=path) + path_str = str(path) + data = await asyncio.to_thread(_read_bytes, path_str) + return Document.from_bytes(data, source_uri=path_str) def _read_bytes(path: str) -> bytes: diff --git a/uv.lock b/uv.lock index dc3edc9..59d302e 100644 --- a/uv.lock +++ b/uv.lock @@ -1669,6 +1669,7 @@ examples = [ { name = "fastmcp" }, { name = "langchain-core" }, { name = "langchain-text-splitters" }, + { name = "pillow" }, { name = "typer" }, ] langchain = [ @@ -1731,6 +1732,7 @@ requires-dist = [ { name = "langchain-text-splitters", marker = "extra == 'examples'", specifier = ">=0.2" }, { name = "langchain-text-splitters", marker = "extra == 'langchain'", specifier = ">=0.2" }, { name = "langchain-text-splitters", marker = "extra == 'mcp-chunks'", specifier = ">=0.2" }, + { name = "pillow", marker = "extra == 'examples'", specifier = ">=10" }, { name = "pydantic", specifier = ">=2.5,<3" }, { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12" }, { name = "typer", marker = "extra == 'cli-chunks'", specifier = ">=0.12" },