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
35 changes: 24 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
# ^ skia-safe 가 Linux 에서 freetype / fontconfig 를 동적 링크 — ubuntu runner 에 미내장.
# macOS / Windows 는 frameworks / 시스템 라이브러리로 자체 해결되어 별도 단계 불필요.
- name: Install skia-safe system dependencies (Linux)
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libfreetype-dev libfontconfig1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
Expand Down Expand Up @@ -104,12 +110,14 @@ jobs:
name: rhwp-python-linux-wheel
path: dist/
- run: uv pip install --reinstall dist/*.whl
# ^ --no-sync: build-linux-wheel 가 만든 wheel 을 그대로 사용. uv 가 lock 기준으로 프로젝트
# 를 editable 빌드하려 하면 native-skia 시스템 의존성 (freetype / fontconfig dev) 필요해서 fail.
- name: Run pytest (not slow) with coverage
run: uv run pytest tests/ -m "not slow" --cov=rhwp --cov-report=term-missing -v
run: uv run --no-sync pytest tests/ -m "not slow" --cov=rhwp --cov-report=term-missing -v
- name: Run pyright (normal)
if: matrix.lint
run: |
uv run pyright python/ \
uv run --no-sync pyright python/ \
tests/test_smoke.py tests/test_parse.py tests/test_text_extraction.py \
tests/test_errors.py tests/test_svg_rendering.py tests/test_pdf_rendering.py \
tests/test_langchain_loader.py tests/test_langchain_loader_ir.py \
Expand All @@ -129,8 +137,8 @@ jobs:
if: matrix.lint
run: |
set +e
uv run pyright --outputjson tests/type_check_errors.py > pyright-errors.json
count=$(uv run python -c "import json; print(json.load(open('pyright-errors.json'))['summary']['errorCount'])")
uv run --no-sync pyright --outputjson tests/type_check_errors.py > pyright-errors.json
count=$(uv run --no-sync python -c "import json; print(json.load(open('pyright-errors.json'))['summary']['errorCount'])")
echo "intentional error count: $count"
if [ "$count" != "4" ]; then
echo "::error::expected 4 intentional errors, got $count"
Expand Down Expand Up @@ -187,7 +195,7 @@ jobs:
name: rhwp-python-linux-wheel
path: dist/
- run: uv pip install --reinstall dist/*.whl
- run: uv run pytest tests/ -m slow -v
- run: uv run --no-sync pytest tests/ -m slow -v

# * Rust unit tests — src/ir.rs 의 #[cfg(test)] 모듈 실행
# Cargo.toml 의 default features 에서 extension-module 이 빠져 있어 libpython 링크 시도 안 함.
Expand All @@ -202,6 +210,11 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
# ^ skia-safe 가 Linux 에서 freetype / fontconfig 를 동적 링크 — build-linux-wheel 와 동일.
- name: Install skia-safe system dependencies (Linux)
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libfreetype-dev libfontconfig1-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
Expand Down Expand Up @@ -236,14 +249,14 @@ jobs:
- run: uv pip install dist/*.whl
- name: Run pytest — extras-gated tests must auto-skip via importorskip
# ^ 파일-레벨 importorskip 은 해당 파일 전체를 skip 1개로 카운트.
# v0.5.0 S1 기준 gated 파일: test_langchain_loader.py + test_langchain_loader_ir.py
# v0.6.0 기준 gated 파일: test_langchain_loader.py + test_langchain_loader_ir.py
# (langchain-core), test_ir_schema_export.py (jsonschema), test_cli.py (typer),
# test_mcp_server.py (fastmcp) → 총 5 파일. test_async.py 는 v0.3.0 부터
# stdlib 만 사용 (aiofiles 의존성 제거).
# test_mcp_server.py (fastmcp), test_render_png.py (Pillow) → 총 6 파일.
# test_async.py 는 v0.3.0 부터 stdlib 만 사용 (aiofiles 의존성 제거).
run: |
uv run pytest tests/ -m "not slow" -v | tee pytest-output.txt
if ! grep -qE '(^|[^0-9])5 skipped([^0-9]|$)' pytest-output.txt; then
echo "::error::expected 5 extras-gated files to auto-skip via importorskip (langchain×2, jsonschema, typer, fastmcp)"
uv run --no-sync pytest tests/ -m "not slow" -v | tee pytest-output.txt
if ! grep -qE '(^|[^0-9])6 skipped([^0-9]|$)' pytest-output.txt; then
echo "::error::expected 6 extras-gated files to auto-skip via importorskip (langchain×2, jsonschema, typer, fastmcp, Pillow)"
exit 1
fi

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Project-specific instructions. Inherits all rules from `~/.claude/CLAUDE.md` (gl
- Real HWP fixtures live in the submodule: `external/rhwp/samples/aift.hwp` (HWP5), `table-vpos-01.hwpx` (HWPX). `tests/conftest.py` + `benches/bench_gil.py` reference this path
- When changing one path, change both
- Markers: `slow` (PDF render), `langchain` (extras required). Default run: `pytest -m "not slow"`
- Extras-gated test files use module-level `pytest.importorskip` so the whole file counts as **1 skip** when the extra is missing. Current gated files: `test_langchain_loader.py` + `test_langchain_loader_ir.py` (langchain-core), `test_ir_schema_export.py` (jsonschema), `test_cli.py` (typer), `test_mcp_server.py` (fastmcp) → CI's `test-without-extras` job validates **exactly 5 skipped** (see `.github/workflows/ci.yml`). When adding a new extras-gated file, bump the count in both AGENTS.md and ci.yml
- Extras-gated test files use module-level `pytest.importorskip` so the whole file counts as **1 skip** when the extra is missing. Current gated files: `test_langchain_loader.py` + `test_langchain_loader_ir.py` (langchain-core), `test_ir_schema_export.py` (jsonschema), `test_cli.py` (typer), `test_mcp_server.py` (fastmcp), `test_render_png.py` (Pillow) → CI's `test-without-extras` job validates **exactly 6 skipped** (see `.github/workflows/ci.yml`). When adding a new extras-gated file, bump the count in both AGENTS.md and ci.yml
- `tests/type_check_errors.py` holds **exactly 4 intentional pyright errors** — CI validates that too. When editing, preserve count; don't fix them

### Git workflow
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [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"`) 유지.

### Added

- `Document.render_png(page, *, scale=1.0, dpi=None, max_pixels=None) -> bytes` 신규 — 페이지 단위 PNG 렌더링. `scale` 은 픽셀 너비/높이 배율, `dpi` 는 메타데이터 DPI (픽셀 수에 영향 없음), `max_pixels` 는 DoS 방어용 픽셀 상한 (미지정 시 상류 default 67_108_864 = 8192×8192). 반환 bytes 는 PNG magic (`b"\x89PNG\r\n\x1a\n"`) 으로 시작.
- `Document.render_all_png() -> list[bytes]` — 모든 페이지 일괄 렌더링 (길이 == `page_count`). 메모리 모델은 SVG / PDF 와 동일.
- `Document.export_png(output_dir, *, prefix=None) -> list[str]` — 모든 페이지를 PNG 파일로 저장. 다중 페이지 시 `{prefix}_{NNN}.png`, 단일 페이지 시 `{prefix}.png`. 디렉토리 자동 생성, 반환은 생성된 파일 경로 리스트.
- 모듈-level `rhwp.arender_png(path, page, *, scale, dpi, max_pixels) -> bytes` async 함수 — `aparse` 와 동일 패턴 (파일 read 만 thread offload, render 는 호출 스레드). Document 가 thread 경계를 안 넘어 `unsendable` panic 회피.
- MCP 도구 `render_page_png(path, page, *, scale, max_pixels) -> ImageContent` — fastmcp v3 의 `ImageContent` 표준 (base64 + `mimeType="image/png"`). LLM 클라이언트 (Claude Desktop / Cline / Cursor 등) 가 응답을 LLM 메시지의 `image` content block 으로 자동 wire. v0.5.0 의 7 도구 → 8 도구.
- README § "페이지 PNG 렌더링 (VLM 입력)" 섹션 신설 — 사용 예 + Anthropic Vision API 호출 코드 + `max_pixels` 안내. MCP 도구 표 1행 추가 (8 도구 갱신).
- spec / ADR / 구현 로그: [docs/roadmap/v0.6.0/png-vlm-render.md](docs/roadmap/v0.6.0/png-vlm-render.md) (Frozen, 9 인수조건 / 8 결정 / 7 영구 비목표) / [docs/design/v0.6.0/png-vlm-render-research.md](docs/design/v0.6.0/png-vlm-render-research.md) (Frozen, 5 결정 매트릭스).

### Changed — 문서 시스템 대규모 개편

본 변경은 메타 — 사용자 facing API / wheel 영향 0. 내부 문서 운영 체계 정비.
Expand All @@ -25,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Build

- `external/rhwp` submodule pin `0fb3e67` (post-v0.7.8) → `62a458a` (v0.7.10). 상류 v0.7.10 GA 흡수 — 외부 기여자 PR 머지 + AI 파이프라인 / VLM 연동 + CLI 바이너리 릴리즈 파이프라인 + macOS cross-compile fix (Issue #612). 본 binding 관점 변경 0 — 공개 API / IR schema (`"1.1"`) / wheel 의존성 모두 동일, `cargo build --release` 통과로 시그니처 호환 직접 검증.
- `Cargo.toml` 의 `rhwp` 의존성에 `features = ["native-skia"]` 추가 — 상류 `SkiaLayerRenderer` (skia-safe v0.93.1) 활성화. wheel 빌드 시점 약 30 MB binary-cache 다운로드, abi3-py310 single wheel 정합 유지 (Python 3.10 ~ 3.13+ 동일 wheel). PNG 표면을 default 통합한 결정 근거는 [docs/design/v0.6.0/png-vlm-render-research.md](docs/design/v0.6.0/png-vlm-render-research.md) § 1.
- `testing` dependency-group 에 `pillow>=10` 추가 — `tests/test_render_png.py` 의 AC-3 (스케일 후 dimension 검증) 회귀 테스트가 디코드 라이브러리 필요. 사용자 wheel 의존성 / extras 영향 0.
- 부수 — `test_submodule_pin_matches_changelog_record` 제거 ([tests/test_ir_marker_char_offset.py](tests/test_ir_marker_char_offset.py)). 본래 v0.3.1 의 *deliberate* pin bump (v0.7.7 → 0fb3e67) 가 release-readiness 시점 기재됐는지 가드한 일회성 AC-13 의 일부였으나, GA shipped 후에도 영구 runtime 가드로 잔존해 일상 sync 마다 CHANGELOG 갱신을 강제하는 anti-pattern (1회성 release-gating AC 의 영구 테스트화 + 문서 텍스트 매칭 runtime test) 화. CHANGELOG 갱신은 릴리즈 시점 의무 (`publish.yml::verify-version` 로 version 일치 가드, 사람 review 가 핀 bump 정합성 점검) 로 이양. AC-13 의 historical record 검증은 동 파일 `test_changelog_records_pin_bump` (Frozen v0.3.1 섹션 텍스트 회귀 가드) 가 그대로 유지.
- 부수 — 동 파일 이름 `test_v0_3_1_marker_char_offset.py` → `test_ir_marker_char_offset.py` (`test_ir_*` 패턴 통일, 본 spec lifecycle 은 `pytest.mark.spec("v0.3.1/...")` marker 가 보유).

Expand Down
6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rhwp-python"
version = "0.5.1"
version = "0.6.0"
edition = "2021"
# ^ rust-version 미명시 — 상위 rhwp crate 정책(stable Rust, MSRV unclaimed) 준수.
# PyO3 0.28 이 Rust 1.83+ 요구하지만, 이는 README 에 문서로 안내
Expand Down Expand Up @@ -43,7 +43,9 @@ extension-module = ["pyo3/extension-module"]

[dependencies]
pyo3 = { version = "0.28", features = ["abi3-py310"] }
rhwp = { path = "external/rhwp" }
# ^ native-skia: 상류 SkiaLayerRenderer (PR #599 PNG 게이트웨이) 활성화.
# skia-safe binary-cache 가 빌드 시점 약 30 MB pre-built binary 다운로드.
rhwp = { path = "external/rhwp", features = ["native-skia"] }

[profile.release]
lto = "fat"
Expand Down
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,68 @@ byte_size: int = doc.export_pdf("output.pdf")

`rhwp.Document(path)` 는 `rhwp.parse(path)` 와 동일하게 동작.

## 페이지 PNG 렌더링 (VLM 입력)

VLM (Vision-Language Model — Claude / GPT-4V / Gemini Vision 등) 의 시각 입력
용도. 텍스트 표면 (SVG / Markdown / IR) 으로는 평탄화되는 표 셀 병합 / 수식의
공간 배치 / 그림 / 복잡 레이아웃의 시각 의미를 보존한다. 상류 `rhwp` v0.7.10
의 native-skia raster pipeline (PR #599) 위 thin wrapper — 별도 extras 없이
default wheel 에 통합 (skia binary 포함).

```python
import rhwp

doc = rhwp.parse("report.hwp")

# 단일 페이지 — bytes (PNG magic 으로 시작)
png: bytes = doc.render_png(page=0)
png_2x: bytes = doc.render_png(page=0, scale=2.0) # 픽셀 너비 약 2배

# 모든 페이지 일괄 (메모리 모델 — 페이지 100 × 약 500 KB ≈ 50 MB)
all_pngs: list[bytes] = doc.render_all_png()

# 디스크 export — page_001.png, page_002.png, ... (단일 페이지면 page.png)
written: list[str] = doc.export_png("output/", prefix="page")

# Async 변형 (파일 read 만 thread offload, render 는 호출 스레드)
import asyncio
png = asyncio.run(rhwp.arender_png("report.hwp", 0, scale=1.5))
```

**Anthropic Vision API 호출 예** — Claude 가 페이지 시각 정보를 직접 해석:

```python
import base64
import anthropic
import rhwp

png_bytes = rhwp.parse("report.hwp").render_png(page=0, scale=1.5)
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-opus-4-7",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64.b64encode(png_bytes).decode("ascii"),
},
},
{"type": "text", "text": "이 페이지의 표 셀 병합 구조를 설명해."},
],
}],
)
print(message.content[0].text)
```

`max_pixels` 는 DoS 방어용 픽셀 상한 (기본 8192 × 8192 = 67_108_864). 초과 시
`ValueError("raster pixel count out of range: ...")`. 사용자가 명시 override
가능 — 예: `doc.render_png(0, max_pixels=200_000_000)`.

## LangChain 통합

```bash
Expand Down Expand Up @@ -171,7 +233,7 @@ pip install "rhwp-python[mcp]" # 도구 6 종 (parse / extract / IR /
pip install "rhwp-python[mcp-chunks]" # + chunks (RAG 청킹 — langchain-text-splitters)
```

### 노출 도구 (7 종)
### 노출 도구 (8 종)

| 도구 | 입력 | 출력 |
|---|---|---|
Expand All @@ -182,6 +244,7 @@ pip install "rhwp-python[mcp-chunks]" # + chunks (RAG 청킹 — langchain-te
| `to_markdown` | `path` | `str` — GFM Markdown (v0.4.0 view API thin wrapper) |
| `to_html` | `path`, `include_css` | `str` — HTML5 문서 (v0.4.0 view API thin wrapper) |
| `chunks` | `path`, `mode`, `size`, `overlap`, `include_furniture` | `list[ChunkRecord]` — LangChain `RecursiveCharacterTextSplitter` 적용 청크. `[mcp-chunks]` extras 필요 |
| `render_page_png` | `path`, `page`, `scale`, `max_pixels?` | `ImageContent` — base64 PNG + `mimeType="image/png"`. VLM 시각 입력용 (v0.6.0+) |

> **v0.5.1 마이그 노트** — 출력 시그니처가 dict / list[dict] 에서 Pydantic 모델로 강화됐습니다 (PATCH). fastmcp Client 의 `result.structured_content` (raw dict, MCP wire format) 는 v0.5.0 과 byte-equal — 외부 LLM 프롬프트 / 후처리 코드 영향 0. 다만 `result.data` 사용 패턴은 변경: v0.5.0 의 `result.data["body"]` (dict 인덱싱) → v0.5.1 의 `result.data.body` (typed attribute) 또는 `result.data.model_dump()["body"]`. `iter_blocks` 의 list element 는 fastmcp v3 의 `oneOf` deserialization 한계로 dict 폴백 — `block["kind"]` access 패턴은 그대로 동작.

Expand All @@ -201,7 +264,7 @@ pip install "rhwp-python[mcp-chunks]" # + chunks (RAG 청킹 — langchain-te

(macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`. Windows:
`%APPDATA%\Claude\claude_desktop_config.json`.) Claude Desktop 재시작 후 도구
아이콘에 7 개 도구 노출.
아이콘에 8 개 도구 노출.

### 다른 클라이언트

Expand Down
Loading
Loading