Skip to content

Commit d773a07

Browse files
authored
Merge pull request #18 from DanMeon/feature/v0.6.0
feat: v0.6.0 — 페이지 PNG 렌더링 (VLM 입력)
2 parents 0fd49fc + 4394453 commit d773a07

22 files changed

Lines changed: 1068 additions & 32 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ jobs:
5959
- uses: actions/checkout@v6
6060
with:
6161
submodules: recursive
62+
# ^ skia-safe 가 Linux 에서 freetype / fontconfig 를 동적 링크 — ubuntu runner 에 미내장.
63+
# macOS / Windows 는 frameworks / 시스템 라이브러리로 자체 해결되어 별도 단계 불필요.
64+
- name: Install skia-safe system dependencies (Linux)
65+
run: |
66+
sudo apt-get update -qq
67+
sudo apt-get install -y --no-install-recommends libfreetype-dev libfontconfig1-dev
6268
- uses: dtolnay/rust-toolchain@stable
6369
- uses: Swatinem/rust-cache@v2
6470
with:
@@ -104,12 +110,14 @@ jobs:
104110
name: rhwp-python-linux-wheel
105111
path: dist/
106112
- run: uv pip install --reinstall dist/*.whl
113+
# ^ --no-sync: build-linux-wheel 가 만든 wheel 을 그대로 사용. uv 가 lock 기준으로 프로젝트
114+
# 를 editable 빌드하려 하면 native-skia 시스템 의존성 (freetype / fontconfig dev) 필요해서 fail.
107115
- name: Run pytest (not slow) with coverage
108-
run: uv run pytest tests/ -m "not slow" --cov=rhwp --cov-report=term-missing -v
116+
run: uv run --no-sync pytest tests/ -m "not slow" --cov=rhwp --cov-report=term-missing -v
109117
- name: Run pyright (normal)
110118
if: matrix.lint
111119
run: |
112-
uv run pyright python/ \
120+
uv run --no-sync pyright python/ \
113121
tests/test_smoke.py tests/test_parse.py tests/test_text_extraction.py \
114122
tests/test_errors.py tests/test_svg_rendering.py tests/test_pdf_rendering.py \
115123
tests/test_langchain_loader.py tests/test_langchain_loader_ir.py \
@@ -129,8 +137,8 @@ jobs:
129137
if: matrix.lint
130138
run: |
131139
set +e
132-
uv run pyright --outputjson tests/type_check_errors.py > pyright-errors.json
133-
count=$(uv run python -c "import json; print(json.load(open('pyright-errors.json'))['summary']['errorCount'])")
140+
uv run --no-sync pyright --outputjson tests/type_check_errors.py > pyright-errors.json
141+
count=$(uv run --no-sync python -c "import json; print(json.load(open('pyright-errors.json'))['summary']['errorCount'])")
134142
echo "intentional error count: $count"
135143
if [ "$count" != "4" ]; then
136144
echo "::error::expected 4 intentional errors, got $count"
@@ -187,7 +195,7 @@ jobs:
187195
name: rhwp-python-linux-wheel
188196
path: dist/
189197
- run: uv pip install --reinstall dist/*.whl
190-
- run: uv run pytest tests/ -m slow -v
198+
- run: uv run --no-sync pytest tests/ -m slow -v
191199

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

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Project-specific instructions. Inherits all rules from `~/.claude/CLAUDE.md` (gl
3333
- 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
3434
- When changing one path, change both
3535
- Markers: `slow` (PDF render), `langchain` (extras required). Default run: `pytest -m "not slow"`
36-
- 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
36+
- 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
3737
- `tests/type_check_errors.py` holds **exactly 4 intentional pyright errors** — CI validates that too. When editing, preserve count; don't fix them
3838

3939
### Git workflow

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.6.0] — 2026-05-10
11+
12+
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"`) 유지.
13+
14+
### Added
15+
16+
- `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"`) 으로 시작.
17+
- `Document.render_all_png() -> list[bytes]` — 모든 페이지 일괄 렌더링 (길이 == `page_count`). 메모리 모델은 SVG / PDF 와 동일.
18+
- `Document.export_png(output_dir, *, prefix=None) -> list[str]` — 모든 페이지를 PNG 파일로 저장. 다중 페이지 시 `{prefix}_{NNN}.png`, 단일 페이지 시 `{prefix}.png`. 디렉토리 자동 생성, 반환은 생성된 파일 경로 리스트.
19+
- 모듈-level `rhwp.arender_png(path, page, *, scale, dpi, max_pixels) -> bytes` async 함수 — `aparse` 와 동일 패턴 (파일 read 만 thread offload, render 는 호출 스레드). Document 가 thread 경계를 안 넘어 `unsendable` panic 회피.
20+
- 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 도구.
21+
- README § "페이지 PNG 렌더링 (VLM 입력)" 섹션 신설 — 사용 예 + Anthropic Vision API 호출 코드 + `max_pixels` 안내. MCP 도구 표 1행 추가 (8 도구 갱신).
22+
- 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 결정 매트릭스).
23+
1024
### Changed — 문서 시스템 대규모 개편
1125

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

2741
- `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` 통과로 시그니처 호환 직접 검증.
42+
- `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.
43+
- `testing` dependency-group 에 `pillow>=10` 추가 — `tests/test_render_png.py` 의 AC-3 (스케일 후 dimension 검증) 회귀 테스트가 디코드 라이브러리 필요. 사용자 wheel 의존성 / extras 영향 0.
2844
- 부수 — `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 섹션 텍스트 회귀 가드) 가 그대로 유지.
2945
- 부수 — 동 파일 이름 `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 가 보유).
3046

Cargo.toml

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

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

4850
[profile.release]
4951
lto = "fat"

README.md

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,68 @@ byte_size: int = doc.export_pdf("output.pdf")
6262

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

65+
## 페이지 PNG 렌더링 (VLM 입력)
66+
67+
VLM (Vision-Language Model — Claude / GPT-4V / Gemini Vision 등) 의 시각 입력
68+
용도. 텍스트 표면 (SVG / Markdown / IR) 으로는 평탄화되는 표 셀 병합 / 수식의
69+
공간 배치 / 그림 / 복잡 레이아웃의 시각 의미를 보존한다. 상류 `rhwp` v0.7.10
70+
의 native-skia raster pipeline (PR #599) 위 thin wrapper — 별도 extras 없이
71+
default wheel 에 통합 (skia binary 포함).
72+
73+
```python
74+
import rhwp
75+
76+
doc = rhwp.parse("report.hwp")
77+
78+
# 단일 페이지 — bytes (PNG magic 으로 시작)
79+
png: bytes = doc.render_png(page=0)
80+
png_2x: bytes = doc.render_png(page=0, scale=2.0) # 픽셀 너비 약 2배
81+
82+
# 모든 페이지 일괄 (메모리 모델 — 페이지 100 × 약 500 KB ≈ 50 MB)
83+
all_pngs: list[bytes] = doc.render_all_png()
84+
85+
# 디스크 export — page_001.png, page_002.png, ... (단일 페이지면 page.png)
86+
written: list[str] = doc.export_png("output/", prefix="page")
87+
88+
# Async 변형 (파일 read 만 thread offload, render 는 호출 스레드)
89+
import asyncio
90+
png = asyncio.run(rhwp.arender_png("report.hwp", 0, scale=1.5))
91+
```
92+
93+
**Anthropic Vision API 호출 예** — Claude 가 페이지 시각 정보를 직접 해석:
94+
95+
```python
96+
import base64
97+
import anthropic
98+
import rhwp
99+
100+
png_bytes = rhwp.parse("report.hwp").render_png(page=0, scale=1.5)
101+
client = anthropic.Anthropic()
102+
message = client.messages.create(
103+
model="claude-opus-4-7",
104+
max_tokens=1024,
105+
messages=[{
106+
"role": "user",
107+
"content": [
108+
{
109+
"type": "image",
110+
"source": {
111+
"type": "base64",
112+
"media_type": "image/png",
113+
"data": base64.b64encode(png_bytes).decode("ascii"),
114+
},
115+
},
116+
{"type": "text", "text": "이 페이지의 표 셀 병합 구조를 설명해."},
117+
],
118+
}],
119+
)
120+
print(message.content[0].text)
121+
```
122+
123+
`max_pixels` 는 DoS 방어용 픽셀 상한 (기본 8192 × 8192 = 67_108_864). 초과 시
124+
`ValueError("raster pixel count out of range: ...")`. 사용자가 명시 override
125+
가능 — 예: `doc.render_png(0, max_pixels=200_000_000)`.
126+
65127
## LangChain 통합
66128

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

174-
### 노출 도구 (7 종)
236+
### 노출 도구 (8 종)
175237

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

186249
> **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 패턴은 그대로 동작.
187250
@@ -201,7 +264,7 @@ pip install "rhwp-python[mcp-chunks]" # + chunks (RAG 청킹 — langchain-te
201264

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

206269
### 다른 클라이언트
207270

0 commit comments

Comments
 (0)