Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`) 유지.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 에 문서로 안내
Expand Down
121 changes: 85 additions & 36 deletions benches/bench_gil.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand All @@ -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__":
Expand Down
96 changes: 96 additions & 0 deletions examples/07_render_png.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 28 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]"` 만으로 충분하다.

## 스크립트
Expand Down Expand Up @@ -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 종이 깨지지 않는지 본다.
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 상태고
Expand Down
Loading
Loading