From 59c07a0fc4adc7942764c6e94d1bbdb0c27ea0dd Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 17:28:09 +0900 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20v0.6.0=20png-vlm-render=20spec=20?= =?UTF-8?q?/=20ADR=20=EC=B4=88=EC=95=88=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - docs/roadmap/v0.6.0/png-vlm-render.md spec 신규 작성 (Draft, target v0.6.0) — VLM 입력용 PNG 렌더 API + [png] extras + MCP ImageContent 출력 - docs/design/v0.6.0/png-vlm-render-research.md ADR 신규 작성 — native-skia feature 활성화 / API mirror / PNG-only 코덱 / ImageContent 채택 / max_pixels SSOT 5건 결정 근거 - docs/roadmap/README.md active spec 인덱스에 v0.6.0 행 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design/v0.6.0/png-vlm-render-research.md | 206 ++++++++++++++++++ docs/roadmap/README.md | 1 + docs/roadmap/v0.6.0/png-vlm-render.md | 93 ++++++++ 3 files changed, 300 insertions(+) create mode 100644 docs/design/v0.6.0/png-vlm-render-research.md create mode 100644 docs/roadmap/v0.6.0/png-vlm-render.md diff --git a/docs/design/v0.6.0/png-vlm-render-research.md b/docs/design/v0.6.0/png-vlm-render-research.md new file mode 100644 index 0000000..76fe760 --- /dev/null +++ b/docs/design/v0.6.0/png-vlm-render-research.md @@ -0,0 +1,206 @@ +--- +status: Draft +description: "v0.6.0 png-vlm-render ADR — 'native-skia' feature 활성화 / API mirror / PNG-only 코덱 / MCP 'ImageContent' 채택 / max_pixels 가드 SSOT 결정 근거" +target: v0.6.0 +last_updated: 2026-05-10 +--- + +# v0.6.0 png-vlm-render — 설계 의사결정 리서치 요약 + +[v0.6.0/png-vlm-render.md](../../roadmap/v0.6.0/png-vlm-render.md) §결정 사항 중 외부 독자가 "왜?" 를 던질 만한 5건 (`native-skia` feature 활성화 + 배포 형태 · Python API 시그니처 · PNG-only 코덱 · MCP `ImageContent` 출력 · max_pixels 가드 SSOT) 의 업계 선례·대안·실패 시나리오를 기록한다. spec 본문이 최종 결정을 기술하고, 본 문서는 그 결정의 근거를 담는다. + +## 결정 매트릭스 + +| # | 항목 | 옵션 비교 | 채택 | 1차 근거 | +|---|---|---|---|---| +| 1 | native-skia 활성화 + 배포 형태 | A: default features 통합 (모든 wheel 에 강제) / B: `[png]` extras 분리 (Python-side 마커 + Cargo features 활성화) / C: 별도 PyPI 패키지 (`rhwp-png`) | **B** | skia-safe 빌드 비용 (수십 MB binary-cache) 을 모든 사용자에게 강제하지 않으면서 단일 코드베이스 유지. cli / mcp / langchain 분리 extras 패턴 정합 | +| 2 | Python API 시그니처 | A: SVG/PDF API mirror (`render_png` / `render_all_png` / `export_png`) / B: 새 `RenderOptions` dataclass 인자 통일 / C: kwargs-only 단일 `render` dispatcher | **A** | v0.1.0 부터 SVG / PDF 가 같은 3-메서드 패턴, 사용자 학습 비용 0. `RasterRenderOptions` 7 필드 중 1차 3개만 노출 — demand-driven 확장 | +| 3 | 출력 코덱 | A: PNG 만 / B: PNG + JPEG / C: 사용자 지정 format enum | **A** | 상류 skia 가 `RasterOutputFormat::Png` 만 구현, 다른 format 은 명시적 거부. VLM 1차 호환 코덱 (Claude / GPT-4V / Gemini Vision 모두 PNG 1st-tier). 추가 코덱은 상류 PR 선행 후 | +| 4 | MCP 출력 인코딩 | A: `bytes` 반환 (fastmcp 자동 처리) / B: `ImageContent(data=base64, mime="image/png")` / C: `file://path` URI 반환 | **B** | fastmcp v3 docs 가 `ImageContent` 를 LLM 시각 입력 1st-tier 패턴으로 명시. Anthropic / OpenAI MCP client 가 image content 를 LLM 메시지에 직접 wire — base64 변환을 클라이언트에 맡기지 않음 | +| 5 | max_pixels 가드 SSOT | A: 상류 `RasterRenderOptions::default()` 그대로 노출 / B: Python 측 별도 default 정의 / C: 가드 미노출 (사용자 책임) | **A** | DoS 방어 invariant 가 두 곳에 있으면 drift 위험. 상류 가드를 그대로 wire-through 하고 사용자 override 만 허용 — invariant SSOT 단일화 | + +--- + +## 1. native-skia 활성화 + 배포 형태 + +### 팩트 + +- 상류 `external/rhwp/Cargo.toml` 의 `[features]` 에 `native-skia = ["dep:skia-safe"]` 정의 — opt-in feature flag (default 미포함) +- `skia-safe` v0.93.1 이 `binary-cache` + `embed-icudtl` features 로 빌드되며 약 30 MB pre-built binary 다운로드 + `pdf` + `textlayout` features 활성화 (상류 Cargo.toml line 53) +- 상류 SVG / PDF 렌더 경로는 native-skia 무관 (`svg2pdf` + `usvg` + `pdf-writer` + `subsetter` + `ttf-parser` 만 사용 — line 49-52). SVG / PDF 만 사용하는 사용자에겐 skia 불필요 +- abi3-py310 single wheel 정책 (`Cargo.toml` line 45 `pyo3 = { ..., features = ["abi3-py310"] }`) — 모든 Python 3.10+ 버전을 한 wheel 로 커버 +- 본 프로젝트 기존 extras 패턴: `[langchain]` / `[cli]` / `[cli-chunks]` / `[mcp]` / `[mcp-chunks]` / `[examples]` — Python-side runtime 의존성만 분리, native 코드는 통합 wheel +- PyPI 의 single project 당 wheel 크기 제약: 소프트 한계 100 MB / 파일, 1 GB / 릴리즈 (PyPI Trusted Publisher 가 강제) + +### 검증자 반박 + +- "native-skia 를 default features 에 통합하면 사용자 단일 install 로 모든 기능 사용 가능 — UX 단순. 왜 분리?" → skia-safe 빌드가 모든 wheel 에 강제되면 (1) wheel 크기 50-100 MB 까지 증가 가능 (PyPI 제약 근접), (2) macOS / Linux / Windows × x86_64 / aarch64 매트릭스 5종 모두 빌드 시간 분 단위 증가 (CI 비용), (3) PNG 미사용자 (대부분의 RAG / IR 사용처) 가 불필요한 코드 다운로드. extras 분리가 비용 대 가치 균형 +- "별도 PyPI 패키지 `rhwp-png` 분리 (옵션 C) 가 더 깨끗하지 않나?" → 두 패키지 동시 유지보수 부담 + version sync 의무 (`rhwp-python==0.6.0` ↔ `rhwp-png==0.6.0` 정합) + 사용자가 `pip install rhwp-png` 시 `rhwp-python` 도 같이 가져가야 하는 의존 그래프. 단일 패키지 + extras 가 PyPI 표준 패턴 (예: `langchain[community]`, `pydantic[email]`) +- "Python `[png]` extras 가 native code 활성화를 어떻게 트리거?" → 두 갈래: (1) `Cargo.toml` features 활성화는 wheel 빌드 시점 결정 (CI 가 하나의 wheel 만 빌드), (2) Python `[png]` extras 는 런타임 import 마커만 — `import rhwp._png_marker` 같은 빈 모듈을 extras 가 추가, 미설치 시 친절 ImportError. 즉 wheel 자체는 native-skia 통합, Python-side extras 는 marker 의 역할만 +- "wheel 자체는 통합인데 Python extras 는 분리 — 사용자가 `[png]` 미설치 상태로 wheel 만 깔면 native skia 코드가 죽은 채로 따라옴. 의미 있나?" → 사용자가 의도적으로 `pip install rhwp-python` (extras 미선택) 시 PNG 메서드 호출 의도가 없는 것으로 가정 — Python ImportError 가드만으로 충분. wheel 의 native skia 코드는 dead binary (런타임 영향 0). 진짜 wheel 분리 (multiple wheels per release) 는 PyPI 의 single-package model 와 충돌 + Trusted Publisher 워크플로 복잡화 → 옵션 외 +- "wheel 크기가 100 MB 초과하면?" → 임계 측정 필요 (spec § 미확정 이슈). 초과 시 fallback 옵션 — 별도 PyPI 패키지로 분리 (옵션 C 재검토). 본 spec 시점은 옵션 B 선결정, 측정 결과로 재평가 + +### 최종 결정 + +**B 채택** — `Cargo.toml` 의 `rhwp` dependency 에 `features = ["native-skia"]` 추가, Python 측은 `[project.optional-dependencies] png = []` extras + `python/rhwp/_png_marker.py` 빈 마커 모듈. wheel 은 단일 통합, 사용자 install path 만 분리. spec § 인수조건 AC-1 (`[png]` 미설치 → 친절 ImportError) + AC-8 (skip count 5 → 6) 가 회귀 가드. 옵션 C (별도 PyPI 패키지) 는 wheel 크기 측정 후 재평가. + +### 1차 소스 + +- 상류 `external/rhwp/Cargo.toml:35-44` (image / native-skia features) +- 상류 `external/rhwp/Cargo.toml:51-53` (skia-safe binary-cache + embed-icudtl) +- 본 프로젝트 `Cargo.toml:45-46` (pyo3 abi3-py310 + rhwp dependency) +- 본 프로젝트 `pyproject.toml` extras 정책 (langchain / cli / mcp 분리 패턴) +- PyPI wheel 크기 제약 정책: +- skia-safe v0.93.1: + +--- + +## 2. Python API 시그니처 + +### 팩트 + +- 본 프로젝트 v0.1.0 부터 렌더 메서드는 3-메서드 패턴: `render_(page) -> str|bytes` (페이지 단위) / `render_all_() -> list[str|bytes]` (전체 메모리) / `export_(out_dir, prefix=None) -> list[str]` (디스크). SVG / PDF 모두 동일 (`render_svg` / `render_all_svg` / `export_svg` 와 `render_pdf` / `export_pdf` — `render_pdf` 가 단일 호출로 전체 PDF 반환이라 `render_all_pdf` 는 미존재) +- 상류 `RasterRenderOptions` 의 7 필드: `dpi: Option` / `scale: f64` / `max_dimension: u32` / `max_pixels: u64` / `format: RasterOutputFormat` / `background_color: Option` / `transparent: bool` / `color_space: Option` +- VLM use case 의 1차 옵션 — `dpi` (해상도, Anthropic Vision 권장 96-300 DPI), `scale` (배율), `max_pixels` (DoS 방어 + LLM context 비용 가드) +- 사용자 학습 비용 분석: SVG / PDF 사용자가 PNG 로 자연스럽게 확장하는 cognitive path — `render_png` / `render_all_png` / `export_png` 가 가장 작은 차이 +- 옵션 dataclass 패턴 (B) 의 선례: matplotlib `savefig(fname, *, dpi, format, ...)` 가 kwargs-only, Pillow `Image.save(fp, format, **kwargs)` 가 kwargs + format 분리 + +### 검증자 반박 + +- "RenderOptions dataclass 가 7 필드 모두 한 번에 노출 — 미래 확장도 쉬움. 왜 거부?" → 1차 use case (VLM 입력) 는 dpi / scale / max_pixels 만 필요 — `background_color` / `transparent` / `color_space` 는 demand 신호 부재. 노출하면 사용자가 *왜 이게 있는지* 문서로 설명 의무 발생, 명시 안 하면 silent default. YAGNI 원칙 +- "SVG/PDF mirror 가 너무 좁지 않나? `render_png(page, **kwargs)` 만 노출하면 미래 옵션 추가가 backward-compatible" → kwargs-only 도 valid, 다만 `render_png(page, scale=2.0, dpi=300, max_pixels=4_000_000)` 처럼 키워드 전달이 readable. positional 인자는 `page` 만 — 그 외는 키워드 강제 (시그니처 `def render_png(self, page, *, scale=1.0, dpi=None, max_pixels=None) -> bytes`). 옵션 A 안에서 이미 키워드 강제 패턴이라 옵션 C 와 사실상 동일 +- "`export_png` 가 디스크 IO — async 도 필요한가?" → demand-driven. v0.6.0 1차는 sync `export_png` 만, async 는 사용자 측 `asyncio.to_thread(doc.export_png, ...)` 로 충분 (export 는 owned bytes 반환 후 디스크 쓰기 — `_Document` thread 경계 무관) +- "`render_all_png` 가 메모리 폭발 위험 (페이지 100 개 × 500 KB = 50 MB)" → 사용자 책임 — 페이지 수 알고 있으니 (`page_count`). 큰 문서면 `for i in range(doc.page_count): doc.render_png(i)` 루프 권장. SVG / PDF 도 같은 메모리 모델 +- "`Document.arender_png()` 인스턴스 메서드는 왜 미제공?" → unsendable `_Document` 가 thread 경계 위반 시 panic ([CLAUDE.md](../../../CLAUDE.md) § "Async direction"). 모듈-level `arender_png(path, page)` 가 매번 parse + render 안에서 `_Document` 를 생성/소비하는 단일 스레드 패턴 강제. 인스턴스 재사용 async 는 본 spec 비목표 + +### 최종 결정 + +**A 채택** — SVG / PDF API 1:1 mirror. `Document.render_png(page, *, scale=1.0, dpi=None, max_pixels=None) -> bytes` / `Document.render_all_png() -> list[bytes]` / `Document.export_png(out_dir, *, prefix=None) -> list[str]` + 모듈-level `arender_png(path, page, *, ...)` async. 1차 노출 옵션 3개 (dpi / scale / max_pixels), 나머지 4 필드는 demand-driven 확장. spec § 인수조건 AC-2 ~ AC-7 이 회귀 가드. + +### 1차 소스 + +- 본 프로젝트 `python/rhwp/document.py:200-254` (render_svg / render_pdf 패턴) +- 본 프로젝트 `src/document.rs:115-169` (Rust 측 render_pdf py.detach 패턴) +- 상류 `external/rhwp/src/renderer/skia/renderer.rs:66` (render_raster_with_options) +- 상류 `external/rhwp/src/renderer/layer_renderer.rs` (RasterRenderOptions struct) +- matplotlib savefig API: +- Pillow Image.save API: + +--- + +## 3. 출력 코덱 + +### 팩트 + +- 상류 `SkiaLayerRenderer::render_raster_with_options` (line 66-152) 는 `options.format != RasterOutputFormat::Png` 시 명시적 에러: `"Skia raster renderer currently supports PNG output"` (line 78). PNG 외 format 거부 +- skia-safe `EncodedImageFormat` enum 은 PNG / JPEG / WebP / KTX 등 지원하나 본 프로젝트의 상류는 PNG 만 wire-through — `image.encode(None, EncodedImageFormat::PNG, None)` (line 142) 가 hardcoded +- VLM image input 1차 코덱 호환성 (2026-05 기준): + - Anthropic Claude Vision: PNG / JPEG / WebP / GIF (1st-tier 모두) + - OpenAI GPT-4V: PNG / JPEG / WebP / GIF (1st-tier) + - Google Gemini Vision: PNG / JPEG / WebP / HEIC / HEIF + - 즉 PNG 단독으로 3대 VLM 모두 호환 +- PNG vs JPEG 비교 (HWP 페이지 렌더 use case): + - PNG: 무손실, 텍스트 / 라인아트 압축률 우수, 파일 크기 100-500 KB / A4 페이지 + - JPEG: 손실, 사진 / 그라디언트 우수, 텍스트 영역 ringing artifact 가능 + - HWP 페이지는 텍스트 위주 → PNG 가 시각 품질 + 압축률 양 측면에서 우수 + +### 검증자 반박 + +- "JPEG 옵션 추가 시 페이지가 사진/이미지 위주면 5-10x 압축. MCP 페이로드 줄이는 데 유리" → 상류가 PNG only 라 우리 측 추가는 (1) 우리 코드에서 PNG → JPEG 재인코딩 (Pillow / image lib 의존성 + 손실), (2) 상류에 JPEG 지원 PR. (1) 은 별도 코덱 chain 으로 복잡도 ↑, (2) 는 상류 작업이라 본 spec 범위 밖. 본 spec 은 옵션 A +- "WebP / AVIF 가 PNG 보다 30-50% 작은 파일 — 미래 표준 호환" → 동일 이유 (상류 미지원 + VLM 1차 코덱이 이미 PNG). WebP 는 OpenAI / Anthropic / Google 셋 다 지원하나 PNG 는 universal. 보수적 시작 후 demand-driven 확장 +- "사용자가 `format` 인자를 노출하지 않으면 미래 추가 시 breaking 인가?" → No. `format: Literal["png"] = "png"` 키워드 인자 추가 시 backward-compat. 본 spec 은 미노출, 미래 추가 시 default `"png"` 로 도입 가능 +- "PNG 매직 바이트 검증 (AC-2) 가 회귀 가드로 충분한가?" → PNG 매직 (`\x89PNG\r\n\x1a\n`) 은 byte-equal 검증으로 codec 결정성을 100% 보장. 추가로 image lib (Pillow) 디코드 후 dimension 검증 (AC-4) 으로 페이지 크기 invariant 도 가드 — 두 layer 검증 + +### 최종 결정 + +**A 채택** — PNG 단독 출력. `format` 인자 미노출 (default "png" 로 미래 추가 가능). spec § 인수조건 AC-2 (PNG magic byte) + AC-4 (dimension scale) 이 회귀 가드. JPEG / WebP / AVIF 등 대안 코덱은 본 spec § 영구 비목표 — 상류 PR 선행 후 demand-driven. + +### 1차 소스 + +- 상류 `external/rhwp/src/renderer/skia/renderer.rs:76-80` (PNG-only 거부 분기) +- 상류 `external/rhwp/src/renderer/skia/renderer.rs:142` (PNG hardcoded encode) +- Anthropic Claude Vision codec 호환성: +- OpenAI GPT-4V vision input: +- Google Gemini Vision: +- W3C PNG Specification 3.0: + +--- + +## 4. MCP 출력 인코딩 + +### 팩트 + +- fastmcp v3 의 `ImageContent` 클래스 (`mcp.types.ImageContent`): `data: str` (base64) + `mime: str` + `annotations: ...`. MCP 표준 `tools/call` response 의 content array 의 `image` type 1:1 매핑 +- fastmcp v3.2.4 docs § Tool Result Types 가 image / audio / file content 를 LLM-aware tool 출력의 1st-tier 패턴으로 명시 +- LLM 클라이언트 (Claude Desktop / Cline / Cursor) 의 ImageContent 처리 — MCP response 의 image content 를 LLM 메시지의 `image` content block 으로 변환 → LLM 이 시각 입력으로 인식. base64 변환은 클라이언트 책임 외 — fastmcp 가 server 출고 시점에 base64 wrap +- 옵션 A (`bytes` 반환): fastmcp 가 raw bytes 를 `BlobResourceContents` 로 wrap → LLM 이 이미지로 인식 못 함 (raw blob 으로 전달, mime 정보 손실) +- 옵션 C (`file://path` URI): MCP `FileContent` resource link. 클라이언트가 파일 시스템 접근 후 base64 변환 — stdio transport 에서는 클라이언트와 서버가 같은 파일 시스템일 때만 작동, streamable-http 에서는 fail +- v0.5.0 MCP 도구의 `outputSchema` 패턴 — Pydantic 모델 자동 wire-through (v0.5.1 의 typed-output 결정 정합) + +### 검증자 반박 + +- "fastmcp v3 가 `bytes` 반환을 자동 ImageContent 로 wrap 하지 않나?" → No (1차 소스 검증 필요 — 미확정 이슈). fastmcp v3.2.4 docs 는 *return type 어노테이션* 이 `bytes` 면 `BlobResourceContents`, `ImageContent` 면 image content 로 명시 분기. server 가 의도적으로 `ImageContent` 를 명시해야 LLM 이 image 로 인식. 명시적 옵션 B 선택이 안전 +- "`file://path` URI (옵션 C) 가 페이로드 작아 stdio 에 유리한가?" → stdio transport 가 동일 머신 가정이라 `file://` 가 작동하나 (1) Claude Desktop / Cline 등 클라이언트의 file 권한 처리 비결정, (2) streamable-http 에서는 server / client 가 다른 머신 → fail, (3) 임시 파일 lifecycle 관리 (export 후 삭제 시점) 책임 분산. 옵션 B 가 transport 무관 일관 동작 +- "base64 페이로드 크기 (130-660 KB / A4 페이지) 가 LLM context cost 에 영향?" → 영향 — 사용자 책임 (page 수 / scale 조정). MCP 도구의 `description` 에 "한 페이지 base64 PNG 가 ~500 KB" 명시하여 사용자가 비용 인식. 페이로드 크기 자체는 옵션 A / B / C 모두 동일 (transport 가 base64 인코딩하므로) — 옵션 차이는 *LLM 이 이미지로 인식하느냐* 만 +- "stdio JSON-RPC 메시지 크기 제한 (예: Claude Desktop 의 약 1 MB 제한 추정) 충돌?" → spec § 미확정 이슈. 임계 초과 시 streamable-http transport 권장 + max_pixels 강제 가이드. 본 spec 은 transport 별 한계 측정 후 README 안내 + +### 최종 결정 + +**B 채택** — MCP `render_page_png(path, page, *, ...)` 도구가 `ImageContent(data=base64.b64encode(png_bytes).decode("ascii"), mime="image/png")` 반환. fastmcp v3 자동 outputSchema 가 `ImageContent` 의 `$ref` 를 노출 → LLM 클라이언트가 image input 으로 wire. spec § 인수조건 AC-6 이 회귀 가드 (mime 검증 + base64 디코드 후 PNG magic 검증). + +### 1차 소스 + +- fastmcp v3.2.4 docs § Tool Result Types: +- MCP Specification (`tools/call` response content types): +- `mcp.types.ImageContent` Python SDK: +- 본 프로젝트 `python/rhwp/mcp/tools.py` (v0.5.0 도구 등록 SSOT) +- 본 프로젝트 v0.5.1 typed-output 결정: [roadmap/README.md](../../roadmap/README.md) 활성 spec 인덱스 + +--- + +## 5. max_pixels 가드 SSOT + +### 팩트 + +- 상류 `RasterRenderOptions::default()` 의 `max_pixels` 값 — `external/rhwp/src/renderer/layer_renderer.rs` 가 SSOT (정확한 default 는 spec § 미확정 이슈로 위임) +- 상류 `render_raster_with_options` 내부 가드 (line 110-122): + - `options.max_pixels == 0` → 에러 (`"invalid raster max pixel count: 0"`) + - `width × height > options.max_pixels` → 에러 (`"raster pixel count out of range: {pixel_count}"`) + - `width × height` overflow → 에러 (`"raster pixel count overflow"`) +- DoS 방어 동기 — 사용자가 `scale=1000.0` 같은 거대한 값을 넘겨 surface allocation panic / OOM 회피 +- 본 프로젝트 v0.1.0 ~ v0.5.x 의 모든 가드 (`section_count` / `paragraph_count` / IR `pages` 등) 는 상류 SSOT 그대로 wire-through — Python-side 별도 가드 없음 + +### 검증자 반박 + +- "Python 측 별도 default 정의 (옵션 B) — 상류와 다른 정책 적용 가능한 유연성" → drift 위험. 두 SSOT 가 분기 (예: 상류 4_000_000, Python 8_000_000) 시 어느 것이 진짜 invariant 인가 모호. 사용자가 원본 상류 가드를 인지 못 하고 Python default 만 신뢰 시 상류 변경 후 bug +- "옵션 C (가드 미노출) — 사용자가 max_pixels 전달 안 하면 *원하는 만큼* 큰 이미지 가능" → 자체 DoS 위험. `Document.render_png(page, scale=1000.0)` 가 surface allocation panic 으로 process 죽임. 가드는 default 라도 있어야 안전 +- "상류 가드의 정확한 default 값 모르는 상태에서 옵션 A 결정 적절한가?" → spec § 미확정 이슈로 명시 — 결정 *방향* 은 상류 wire-through, 구체 값은 implementation 시점 확인. RasterRenderOptions::default 가 상류 변경 시 본 binding 도 자동 호응 — SSOT 단일화의 의도된 효과 +- "상류 가드 메시지가 Rust panic 메시지로 그대로 노출 — Python 사용자에게 친절한가?" → ValueError 로 wrap 하되 상류 메시지 그대로 포함 (예: `ValueError: raster pixel count out of range: 16000000000`). 상류 메시지 변경 시 본 binding 메시지 표면 자동 갱신 — 별도 번역 표 유지 부담 제거. spec § 인수조건 AC-5 가 메시지에 `"pixel count out of range"` substring 검증 (상류 변경에 강한 회귀 가드) +- "사용자가 max_pixels 명시 override — 가드 무력화 가능?" → 사용자 의도된 override 는 정상 (예: 대형 포스터 PDF 의 고해상도 PNG). 가드는 default 가 안전 + 사용자가 책임 인지 후 override — Python 일반 패턴 (예: `pickle.loads` 의 `safe=True` default) + +### 최종 결정 + +**A 채택** — `RasterRenderOptions::default()` 의 `max_pixels` 그대로 wire-through. 사용자 명시 override 만 허용. 상류 메시지 그대로 ValueError 로 wrap. spec § 인수조건 AC-5 (`max_pixels=1` → ValueError + `"pixel count out of range"` 메시지 substring 검증) 가 회귀 가드. + +### 1차 소스 + +- 상류 `external/rhwp/src/renderer/skia/renderer.rs:110-122` (max_pixels 가드 분기) +- 상류 `external/rhwp/src/renderer/layer_renderer.rs` (RasterRenderOptions::default — 정확한 값은 implementation 시점 확인) +- 본 프로젝트 `src/document.rs:115-169` (기존 render_pdf wire-through 패턴) +- Python ValueError 표준: +- DoS 방어 패턴 (image library): + +--- + +## 참조 + +- 짝 페어: [png-vlm-render.md](../../roadmap/v0.6.0/png-vlm-render.md) +- 상류 `edwardkim/rhwp` v0.7.10 (PR #599 PNG 게이트웨이): +- 상류 `examples/pr599_png_gateway.rs`: `external/rhwp/examples/pr599_png_gateway.rs` +- skia-safe (rust-skia bindings): +- fastmcp v3 (jlowin / PrefectHQ): +- Anthropic Claude Vision: +- MCP Specification (image content types): +- 본 프로젝트 `Cargo.toml` (rhwp dependency 정책) +- 본 프로젝트 `python/rhwp/document.py` (Document wrapper) +- 본 프로젝트 `python/rhwp/mcp/tools.py` (MCP 도구 SSOT) diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 62be584..6f707f0 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -31,6 +31,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | v0.4.0 (view 렌더러) | Frozen | [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md) | [design/v0.4.0/view-renderer-research.md](../design/v0.4.0/view-renderer-research.md) | | v0.5.0 (MCP server) | Frozen | [v0.5.0/mcp.md](v0.5.0/mcp.md) | [design/v0.5.0/mcp-research.md](../design/v0.5.0/mcp-research.md) | | v0.5.1 (MCP typed output) | Frozen | [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) | [design/v0.5.1/mcp-typed-output-research.md](../design/v0.5.1/mcp-typed-output-research.md) | +| v0.6.0 (png-vlm-render) | Draft | [v0.6.0/png-vlm-render.md](v0.6.0/png-vlm-render.md) | [design/v0.6.0/png-vlm-render-research.md](../design/v0.6.0/png-vlm-render-research.md) | ## 미착수 작업 계획 diff --git a/docs/roadmap/v0.6.0/png-vlm-render.md b/docs/roadmap/v0.6.0/png-vlm-render.md new file mode 100644 index 0000000..07e7ade --- /dev/null +++ b/docs/roadmap/v0.6.0/png-vlm-render.md @@ -0,0 +1,93 @@ +--- +status: Draft +description: "v0.6.0 — 페이지 PNG 렌더링 표면. 상류 'native-skia' 백엔드를 'Document.render_png' / 'export_png' 로 노출하고 '[png]' extras 게이트 + MCP 'render_page_png' 도구로 VLM 입력 시나리오 지원" +target: v0.6.0 +last_updated: 2026-05-10 +--- + +# v0.6.0 — 페이지 PNG 렌더링 (VLM 입력) + +VLM (Vision Language Model — Claude / GPT-4V / Gemini 등) 이 1차 입력으로 받는 raster 이미지를 HWP/HWPX 페이지로부터 직접 생성하는 표면을 추가한다. 상류 `edwardkim/rhwp` v0.7.10 (현 submodule pin `62a458a`) 의 `SkiaLayerRenderer::render_raster_with_options` 를 노출하여 `Document.render_png(page) -> bytes` / `render_all_png()` / `export_png(out_dir)` 를 새로 추가하고, MCP 도구 `render_page_png` 를 통해 LLM 에이전트가 페이지 단위 시각 입력을 획득할 수 있게 한다. 추가만 있고 v0.5.x 의 SVG / PDF / IR / MCP 표면은 모두 보존 (additive only) — SchemaVersion `"1.1"` 그대로. + +주요 결정의 근거·대안·실패 시나리오는 짝 페어: [png-vlm-render-research.md](../../design/v0.6.0/png-vlm-render-research.md). + +## 배경 — 왜 v0.6.0 인가 + +v0.4.0 view 렌더러 (Markdown / HTML) 와 v0.5.0 MCP server 는 *텍스트* 표면을 LLM 에 노출했지만, 표 / 수식 / 그림 / 복잡 레이아웃의 시각 의미는 IR 평탄화 과정에서 소실된다. Vision-capable LLM (Claude 3.5+ Sonnet / GPT-4V / Gemini Pro Vision 등) 이 이미지 입력을 1차 시민으로 다루기 시작한 2024-2026 환경에서, 페이지를 raster image 로 렌더해 함께 보내면 텍스트 표면이 못 살리는 시각 정보를 보완 (예: 수식의 공간 배치, 표의 셀 병합, 레이아웃 박스 — 이미 IR 의 `TableCell.role="layout"` 표시가 같은 동기) 할 수 있다. + +상류 `edwardkim/rhwp` v0.7.10 GA 가 외부 기여자 PR 머지로 `native-skia` feature 기반 raster pipeline (`SkiaLayerRenderer::render_raster_with_options` — PR #599 PNG 게이트웨이) 을 도입했고, 본 binding 의 v0.5.0 MCP server 가 이미 LLM 에이전트 분모를 확보한 상태다. v0.6.0 는 두 줄기를 잇는 minor — 상류 raster API → Python `Document.render_png` → MCP `render_page_png` 도구. + +## 결정 사항 + +| 항목 | 값 | 근거 | +|---|---|---| +| 1 — Renderer 백엔드 | 상류 `native-skia` Cargo feature 활성화. `external/rhwp` submodule pin 변경 0, `Cargo.toml` 의 `rhwp` dependency 에 `features = ["native-skia"]` 추가만 | PR #599 PNG 게이트웨이가 이 feature 게이트 안에 있어 활성화 외 선택지 없음. skia-safe 빌드 비용 (binary-cache + embed-icudtl 약 30 MB 다운로드 + 크로스 플랫폼 wheel 크기 증가) 은 별도 wheel / extras 분리로 분담 — ADR § 1 | +| 2 — Python API 시그니처 | `Document.render_png(page, *, scale=1.0, dpi=None, max_pixels=...) -> bytes` / `render_all_png() -> list[bytes]` / `export_png(out_dir, *, prefix=None) -> list[str]` — SVG / PDF API 1:1 mirror | `render_svg` / `render_pdf` 의 호출 패턴이 사용자 학습 비용 0. `RasterRenderOptions` 의 7 필드 (dpi / scale / max_dimension / max_pixels / format / background_color / transparent / color_space) 중 1차로 dpi / scale / max_pixels 만 노출, 나머지는 demand-driven. ADR § 2 | +| 3 — 출력 코덱 | PNG 만 — JPEG / WebP / AVIF / HEIC 미노출 | 상류 skia raster 가 `RasterOutputFormat::Png` 만 구현 (다른 format 은 명시적 거부 — `"Skia raster renderer currently supports PNG output"` 에러). VLM 1차 호환 코덱 (Anthropic Vision API / OpenAI Vision / Google Gemini 모두 PNG 1st-tier) 충분. ADR § 3 | +| 4 — 배포 형태 | 새 extras `[png]` (`rhwp-python[png]` — 상류 native-skia + skia-safe 빌드 인계). 미설치 시 친절 ImportError + exit hint. 기본 wheel 의 의존성 / 크기 / abi3-py310 호환 변경 0 | skia-safe 의 binary-cache feature 가 wheel 빌드 시점 수십 MB 추가 → 모든 사용자에게 강제하지 않음. cli / mcp / langchain 처럼 분리 extras 패턴 정합 (`pyproject.toml` 기존 패턴). ADR § 1 | +| 5 — MCP 도구 | `render_page_png(path, page, *, scale=1.0, max_pixels=...) -> ImageContent` 신규. fastmcp v3 `ImageContent` 표준 (base64-encoded data + `mime="image/png"`) — Anthropic / OpenAI MCP client 가 이미지 입력을 LLM 에 직접 전달 가능 | fastmcp v3.2.4 docs § Tool Result Types 가 `ImageContent` 를 LLM 에이전트 시각 입력 1st-tier 패턴으로 명시. `bytes` 반환 후 클라이언트 측 base64 변환 거부 — wire format 이 비결정 dict 가 됨. ADR § 4 | +| 6 — GIL 해제 정책 | `render_png` / `render_all_png` / `export_png` 모두 `py.detach` 안에서 skia raster 실행 — 평균 ≥ 50 ms / 페이지 (IO + 합성 + 인코딩) 으로 [CLAUDE.md](../../../CLAUDE.md) § "GIL release via `py.detach`" 의 ≥ 1 ms 임계 충족 | 이미 `render_pdf` / `export_pdf` 가 같은 패턴 (svgs_to_pdf 가 owned `Vec` → py.detach). PNG 도 owned `Vec` 반환이라 `Send + 'static` 보장. unsendable `_Document` 는 closure 안에서 미사용 (page index 만 캡처) | +| 7 — 비동기 표면 | `arender_png(path, page, *, ...) -> bytes` 모듈-level async 함수 — `aparse` 의 패턴 (file IO 만 thread offload, render 는 호출 스레드 sync) 답습. `Document.arender_png()` 인스턴스 메서드 미제공 ([CLAUDE.md](../../../CLAUDE.md) § "Async direction" — unsendable `_Document` 는 thread 경계 위반 시 panic) | `aparse` (v0.2.0) / `aload` / `alazy_load` (v0.3.0) 와 동일 패턴 — 외부 의존성 0 (stdlib `asyncio.to_thread`). render 는 GIL 해제 구간에서 충분히 빠름. demand 가 확인된 후 async batch (`arender_all_png`) 추가 검토 | +| 8 — 기본 max_pixels 한계 | 상류 default 값 그대로 (DoS 방어용 픽셀 상한) 노출 + 사용자가 명시 지정 시 override. 초과 시 `ValueError("raster pixel count out of range")` 그대로 전파 | 상류 `RasterRenderOptions::default()` 의 max_pixels 는 약 2_000 × 2_000 = 4_000_000 (확인 필요 — ADR § 5). 본 PATCH 가 self-DoS 방어 옵션을 추가 정의하지 않고 상류 가드 그대로 노출 — 동일 invariant SSOT 단일화. ADR § 5 | + +## 인수조건 + +- **AC-1** — `[png]` extras 미설치 환경에서 `Document.render_png(0)` 호출 시 `ModuleNotFoundError` 또는 친절 `ImportError` (메시지에 `"pip install rhwp-python[png]"` 힌트 포함). 일반 `parse` / `to_ir` / `render_svg` / `render_pdf` 는 정상 동작 (extras 분리 가드) +- **AC-2** — `[png]` 설치 환경에서 `aift.hwp` (HWP5 fixture) 의 page 0 → `bytes` 반환값의 첫 8 byte 가 PNG magic (`b"\x89PNG\r\n\x1a\n"`) 일치 +- **AC-3** — 동일 fixture 의 `Document.render_all_png()` 결과 길이 == `Document.page_count` (페이지 수 invariant) +- **AC-4** — `Document.render_png(0, scale=2.0)` 결과의 픽셀 너비 ≈ `Document.render_png(0, scale=1.0)` 결과의 2배 (image lib 디코드 후 검증 — 상류 raster_dimension 의 `value * scale` 산식 회귀 가드) +- **AC-5** — `Document.render_png(0, max_pixels=1)` 호출이 `ValueError` (메시지에 `"pixel count out of range"` 포함) 로 fail — 상류 raster_dimension 의 max_pixels 가드 wire-through 검증 +- **AC-6** — MCP `render_page_png(path, 0)` 도구 호출이 fastmcp `ImageContent` 인스턴스 반환 (속성 `mime_type == "image/png"`, `data` 가 base64-decoded PNG magic 일치). `outputSchema` 에 `ImageContent` `$ref` 노출 +- **AC-7** — `arender_png(path, 0)` async 호출이 정상 PNG bytes 반환. `_Document` 인스턴스가 thread 경계를 넘지 않음 — `aparse` + sync `render_png` 구성 검증 (panic 미발생) +- **AC-8** — `tests/test_render_png.py` 가 `pytest.importorskip("rhwp._png_marker")` (또는 동등 게이트) 를 file-level 적용. `test-without-extras` job 의 expected skip count 가 v0.5.x 의 5 → 6 으로 증가, AGENTS.md + ci.yml 양쪽 갱신 +- **AC-9** — `Document.render_pdf` / `render_svg` / `to_ir` 등 v0.5.x 표면의 모든 기존 회귀 가드가 그대로 통과 (additive 보장) +- **AC-10** — README 에 `## 페이지 PNG 렌더링 (`render_png`)` 섹션 신설 — VLM 입력 사용 예 (Anthropic Vision API 호출 코드 1 블록), `[png]` extras 안내, 상류 native-skia 의존성 안내. MCP 도구 표 갱신 (`render_page_png` 1행 추가) + +## 영구 비목표 + +- **JPEG / WebP / AVIF / HEIC 출력** — 상류 skia 가 `RasterOutputFormat::Png` 만 구현 (`Skia raster renderer currently supports PNG output` 에러로 다른 format 명시 거부). 우리 측 추가 코덱은 상류 PR 선행 후에만 — demand-driven 보류 +- **폰트 임베딩 / 디버그 오버레이 / 페이지 메타 옵션** — 상류 SVG CLI 에는 `--embed-fonts` / `--debug-overlay` / `--show-control-codes` 가 있으나 raster pipeline 에는 미반영. raster 출력은 픽셀 픽스 구조라 폰트 임베딩 의미 없음, 디버그 오버레이는 별도 spec 검토 +- **HWP3 raster 렌더링** — 상류 HWP3 파서 미완 (`src/parser/hwp3/` 가 IR 미완성). v1.0+ writeback 트랙과 직교, 본 spec 범위 밖 +- **Annotation / OCR overlay** — 본 spec 은 *원문 페이지 시각 입력* 표면. OCR / 텍스트 좌표 오버레이는 별도 도메인 (RAG 응답 정확도 검증 등 별도 spec) +- **사용자 정의 폰트 경로 (`with_font_paths`) 노출** — 상류 SkiaLayerRenderer 가 지원하나 `external/rhwp` 의 ttfs 디렉토리 + 시스템 폰트 fallback 으로 1차 충분. demand 신호 (한컴 전용 폰트 미렌더링 이슈) 시 별도 PATCH 검토 +- **In-process 폰트 / 색공간 / 투명 배경 사용자 옵션** — `RasterRenderOptions` 의 7 필드 중 1차로 `dpi` / `scale` / `max_pixels` 만 노출. `background_color` / `transparent` / `color_space` 는 demand-driven (대부분 use case 가 화이트 불투명 PNG) +- **WASM target PNG 렌더링** — 상류 skia 가 native target 전용 (`#[cfg(all(not(target_arch = "wasm32"), feature = "native-skia"))]`). 본 binding 은 native wheel 만 빌드 — WASM 직교 + +## 다른 산출물의 파급 (코드 / 데이터) + +- `Cargo.toml` — `rhwp = { path = "external/rhwp" }` → `rhwp = { path = "external/rhwp", features = ["native-skia"] }`. `[features]` 에 `png = []` 추가 (Python `[png]` extras 의 native marker) +- `src/document.rs` — `fn render_png<'py>(&self, py, page, scale, dpi, max_pixels) -> PyResult>`, `fn render_all_png`, `fn export_png` 신규 (PDF API mirror). `py.detach` 패턴 +- `python/rhwp/document.py` — `render_png` / `render_all_png` / `export_png` wrapper 메서드 + docstring (extras 가드 안내). `arender_png` 모듈-level async 함수 +- `python/rhwp/_rhwp.pyi` — 새 메서드 stub +- `python/rhwp/__init__.pyi` + `__init__.py` — `arender_png` re-export +- `python/rhwp/mcp/tools.py` — `render_page_png(path, page, *, scale, max_pixels) -> ImageContent` 신규. v0.5.0 의 7 도구 → 8 도구 +- `pyproject.toml` — `[project.optional-dependencies] png = []` (native skia 는 wheel 빌드 시점 결정 — 런타임 import 마커만). `[examples]` extras 에 PNG 추가 +- `external/rhwp/` 서브모듈 — pin 변경 가능성 (v0.7.10 → v0.7.11 등 — `RasterRenderOptions` API 안정성 확인 후 결정). 본 spec 시점 `62a458a` (v0.7.10) 그대로 유지 시도 +- `tests/test_render_png.py` 신규 — file-level `pytest.importorskip` 게이트, AC-2 ~ AC-7 검증 +- `tests/conftest.py` — 필요 시 `image` lib (Pillow) 픽스처 (디코드 후 dimension 검증 — AC-4) +- `.github/workflows/ci.yml` — `test-without-extras` job 의 expected skip count 5 → 6 +- `AGENTS.md` § Tests — extras-gated 파일 목록 갱신 (skip count 5 → 6, file 5 → 6) +- `README.md` § 페이지 PNG 렌더링 신설 + MCP 도구 표 1행 추가 +- `CHANGELOG.md` — `[0.6.0]` 섹션 — Added (3 메서드 + 1 MCP 도구), Build (Cargo features + skia-safe 의존성), Notes (VLM 입력 시나리오 사용 예) + +문서 cross-link (`docs/roadmap/README.md` 인덱스) 는 [CONVENTIONS.md](../../CONVENTIONS.md) § Cross-link 방향성 규칙 에 따라 본 spec 본문에서 다루지 않음 — 인덱스는 `roadmap/README.md` (Living) 가 SSOT. + +## 미확정 이슈 + +- **상류 `RasterRenderOptions::default()` 의 max_pixels 정확한 값** — `external/rhwp/src/renderer/layer_renderer.rs` 의 `Default` impl 확인 필요 (decision 8 의 fact 보강). 현 spec 은 "상류 default 그대로" 만 명시 +- **skia-safe 빌드 시간 / wheel 크기 영향** — CI 빌드 시간 + GitHub Actions wheel artifact 크기 측정 필요. 임계 (예: wheel 크기 > 100 MB) 초과 시 `[png]` extras 를 별도 wheel 분리 (PyPI 제약) 검토 +- **abi3-py310 호환성** — skia-safe 가 abi3 호환 가정 검증 필요. abi3 미호환 시 PNG 전용 wheel 만 Python-version-specific (`cp310-cp313`) 분리 빌드 +- **MCP `ImageContent` 의 LLM 클라이언트 호환성** — Claude Desktop / Cline / Cursor / Continue.dev / Goose 의 ImageContent 응답 처리 검증 필요 (transport 별 base64 인코딩 안정성). v0.5.0 의 클라이언트 호환성 표 (text-only 응답 기준) 의 image 컬럼 추가 +- **stdio MCP transport 의 base64 페이로드 크기** — A4 페이지 PNG 가 약 100-500 KB → base64 약 130-660 KB. stdio JSON-RPC 메시지 크기 제한 (서버별 상이) 충돌 가능성. streamable-http 권장 검토 + +## 참조 + +- 짝 페어 (ADR): [png-vlm-render-research.md](../../design/v0.6.0/png-vlm-render-research.md) +- 상류 `edwardkim/rhwp` PR #599 (PNG 게이트웨이): +- 상류 `SkiaLayerRenderer::render_raster_with_options`: `external/rhwp/src/renderer/skia/renderer.rs:66` +- 상류 `RasterRenderOptions` SSOT: `external/rhwp/src/renderer/layer_renderer.rs` +- fastmcp v3 `ImageContent` 표준: +- Anthropic Vision API (이미지 입력 사양): +- v0.5.0 MCP server (선행 spec): 활성 spec 인덱스 [roadmap/README.md](../README.md) +- 글로벌 GIL release 정책: [CLAUDE.md](../../../CLAUDE.md) § "GIL release via py.detach" +- 글로벌 async direction 정책: [CLAUDE.md](../../../CLAUDE.md) § "Async direction" From ab44e2fb66717ec9d376ea28e94e459078468b1b Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 18:06:36 +0900 Subject: [PATCH 02/13] =?UTF-8?q?docs:=20png-vlm-render=20spec/ADR=20?= =?UTF-8?q?=EB=8B=A8=EC=88=9C=ED=99=94=20=E2=80=94=20default=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - PNG 표면을 default wheel 통합으로 변경 (별도 [png] extras 제거) - _png_marker / ImportError 가드 / 관련 인수조건 (AC-1, AC-8) 삭제 - max_pixels default 67_108_864 (8192×8192) 확정값 기록 - 검증자 반박을 cli / mcp 와의 본질 차이로 재작성 (PNG 는 native binary 단독 동작) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design/v0.6.0/png-vlm-render-research.md | 13 +++--- docs/roadmap/v0.6.0/png-vlm-render.md | 44 +++++++++---------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/docs/design/v0.6.0/png-vlm-render-research.md b/docs/design/v0.6.0/png-vlm-render-research.md index 76fe760..05d3226 100644 --- a/docs/design/v0.6.0/png-vlm-render-research.md +++ b/docs/design/v0.6.0/png-vlm-render-research.md @@ -13,7 +13,7 @@ last_updated: 2026-05-10 | # | 항목 | 옵션 비교 | 채택 | 1차 근거 | |---|---|---|---|---| -| 1 | native-skia 활성화 + 배포 형태 | A: default features 통합 (모든 wheel 에 강제) / B: `[png]` extras 분리 (Python-side 마커 + Cargo features 활성화) / C: 별도 PyPI 패키지 (`rhwp-png`) | **B** | skia-safe 빌드 비용 (수십 MB binary-cache) 을 모든 사용자에게 강제하지 않으면서 단일 코드베이스 유지. cli / mcp / langchain 분리 extras 패턴 정합 | +| 1 | native-skia 활성화 + 배포 형태 | A: default 통합 (모든 wheel 에 native-skia, extras 없음) / B: `[png]` extras 분리 (Python-side 마커 + Pillow 의존성) / C: 별도 PyPI 패키지 (`rhwp-png`) | **A** | PyPI single-package 모델은 wheel 분리를 지원하지 않아 옵션 B 의 extras 도 어차피 통합 wheel 다운로드 — 분리 신호는 cosmetic. render_png 자체는 native skia 단독으로 작동하므로 Python 런타임 의존성 0 → cli / mcp / langchain 의 *런타임 Python 의존성 분리* 패턴과 본질이 다름. 사용자 학습 비용 최소화 | | 2 | Python API 시그니처 | A: SVG/PDF API mirror (`render_png` / `render_all_png` / `export_png`) / B: 새 `RenderOptions` dataclass 인자 통일 / C: kwargs-only 단일 `render` dispatcher | **A** | v0.1.0 부터 SVG / PDF 가 같은 3-메서드 패턴, 사용자 학습 비용 0. `RasterRenderOptions` 7 필드 중 1차 3개만 노출 — demand-driven 확장 | | 3 | 출력 코덱 | A: PNG 만 / B: PNG + JPEG / C: 사용자 지정 format enum | **A** | 상류 skia 가 `RasterOutputFormat::Png` 만 구현, 다른 format 은 명시적 거부. VLM 1차 호환 코덱 (Claude / GPT-4V / Gemini Vision 모두 PNG 1st-tier). 추가 코덱은 상류 PR 선행 후 | | 4 | MCP 출력 인코딩 | A: `bytes` 반환 (fastmcp 자동 처리) / B: `ImageContent(data=base64, mime="image/png")` / C: `file://path` URI 반환 | **B** | fastmcp v3 docs 가 `ImageContent` 를 LLM 시각 입력 1st-tier 패턴으로 명시. Anthropic / OpenAI MCP client 가 image content 를 LLM 메시지에 직접 wire — base64 변환을 클라이언트에 맡기지 않음 | @@ -34,15 +34,14 @@ last_updated: 2026-05-10 ### 검증자 반박 -- "native-skia 를 default features 에 통합하면 사용자 단일 install 로 모든 기능 사용 가능 — UX 단순. 왜 분리?" → skia-safe 빌드가 모든 wheel 에 강제되면 (1) wheel 크기 50-100 MB 까지 증가 가능 (PyPI 제약 근접), (2) macOS / Linux / Windows × x86_64 / aarch64 매트릭스 5종 모두 빌드 시간 분 단위 증가 (CI 비용), (3) PNG 미사용자 (대부분의 RAG / IR 사용처) 가 불필요한 코드 다운로드. extras 분리가 비용 대 가치 균형 -- "별도 PyPI 패키지 `rhwp-png` 분리 (옵션 C) 가 더 깨끗하지 않나?" → 두 패키지 동시 유지보수 부담 + version sync 의무 (`rhwp-python==0.6.0` ↔ `rhwp-png==0.6.0` 정합) + 사용자가 `pip install rhwp-png` 시 `rhwp-python` 도 같이 가져가야 하는 의존 그래프. 단일 패키지 + extras 가 PyPI 표준 패턴 (예: `langchain[community]`, `pydantic[email]`) -- "Python `[png]` extras 가 native code 활성화를 어떻게 트리거?" → 두 갈래: (1) `Cargo.toml` features 활성화는 wheel 빌드 시점 결정 (CI 가 하나의 wheel 만 빌드), (2) Python `[png]` extras 는 런타임 import 마커만 — `import rhwp._png_marker` 같은 빈 모듈을 extras 가 추가, 미설치 시 친절 ImportError. 즉 wheel 자체는 native-skia 통합, Python-side extras 는 marker 의 역할만 -- "wheel 자체는 통합인데 Python extras 는 분리 — 사용자가 `[png]` 미설치 상태로 wheel 만 깔면 native skia 코드가 죽은 채로 따라옴. 의미 있나?" → 사용자가 의도적으로 `pip install rhwp-python` (extras 미선택) 시 PNG 메서드 호출 의도가 없는 것으로 가정 — Python ImportError 가드만으로 충분. wheel 의 native skia 코드는 dead binary (런타임 영향 0). 진짜 wheel 분리 (multiple wheels per release) 는 PyPI 의 single-package model 와 충돌 + Trusted Publisher 워크플로 복잡화 → 옵션 외 -- "wheel 크기가 100 MB 초과하면?" → 임계 측정 필요 (spec § 미확정 이슈). 초과 시 fallback 옵션 — 별도 PyPI 패키지로 분리 (옵션 C 재검토). 본 spec 시점은 옵션 B 선결정, 측정 결과로 재평가 +- "extras 분리 (옵션 B) 가 cli / mcp / langchain 패턴과 정합 아닌가?" → 본질이 다름. cli / mcp / langchain 은 *런타임 Python 패키지* (typer / fastmcp / langchain-core) 의존성 분리 — extras 미선택 시 진짜 의존성 그래프에서 빠지고 친절 ImportError 가 *진짜* 발동한다. 반면 PNG 표면은 native skia binary 만으로 작동하므로 Python 런타임 의존성이 0 — extras 가 추가할 것이 없다. Pillow 를 끼워 넣어 *인위적* 으로 의존성을 만들 수는 있으나 (옵션 B 의 시도) , render_png 자체는 Pillow 없이도 PNG bytes 반환이 가능 → 가드는 사실 *과도한 강제* +- "별도 PyPI 패키지 `rhwp-png` 분리 (옵션 C) 가 wheel 크기 회피에 진짜 유리하지 않나?" → 두 패키지 동시 유지보수 부담 + version sync 의무 (`rhwp-python==0.6.0` ↔ `rhwp-png==0.6.0` 정합) + 사용자가 `pip install rhwp-png` 시 `rhwp-python` 도 같이 가져가야 하는 의존 그래프. PNG 가 미래 진짜 분리할 가치가 생기면 (skia-safe 빌드 비용이 임계 초과 시) 그때 옵션 C 로 마이그레이션 — 본 spec 시점은 단순화가 더 큰 가치 +- "skia-safe 빌드가 모든 wheel 에 강제되면 (1) wheel 크기 50-100 MB 까지 증가 가능, (2) 빌드 시간 분 단위 증가, (3) PNG 미사용자 가 불필요한 코드 다운로드 — 부담 아닌가?" → 인정. 다만 옵션 B 도 동일 비용 (wheel 통합 빌드라 어차피 모든 사용자가 다운로드). 진짜로 비용을 회피하려면 옵션 C (별도 PyPI 패키지) 필요. 본 spec 시점은 비용을 받아들이고 단순함을 택함 — 임계 (wheel > 100 MB) 도달 시 옵션 C 로 재평가 +- "사용자가 PNG 사용 의도를 명시 시그널 (`pip install rhwp-python[png]`) 로 표현하는 것이 자연스럽지 않나?" → 시그널 자체는 가치가 있으나 *비용 (학습 부담)* 이 *효익* 보다 큼. 사용자가 `[png]` extras 의 의미를 학습 → "왜 필요한가?" → "어차피 wheel 에 들어 있는데 왜 별도 install?" → "Pillow 가 들어옴" → 그러나 render_png 자체엔 Pillow 불필요 → 시그널 효익이 cosmetic 으로 축소. extras 없는 직관적 install path 가 더 좋은 UX ### 최종 결정 -**B 채택** — `Cargo.toml` 의 `rhwp` dependency 에 `features = ["native-skia"]` 추가, Python 측은 `[project.optional-dependencies] png = []` extras + `python/rhwp/_png_marker.py` 빈 마커 모듈. wheel 은 단일 통합, 사용자 install path 만 분리. spec § 인수조건 AC-1 (`[png]` 미설치 → 친절 ImportError) + AC-8 (skip count 5 → 6) 가 회귀 가드. 옵션 C (별도 PyPI 패키지) 는 wheel 크기 측정 후 재평가. +**A 채택** — `Cargo.toml` 의 `rhwp` dependency 에 `features = ["native-skia"]` 추가 (default 통합). Python 측 별도 extras / marker 없음. `pip install rhwp-python` 만으로 render_png 사용 가능. testing dependency-group 에는 Pillow 추가 — AC-3 (스케일 후 dimension 검증) 회귀 테스트가 디코드 라이브러리 필요. 옵션 C (별도 PyPI 패키지) 는 wheel 크기 임계 (>100 MB) 도달 시 재평가. ### 1차 소스 diff --git a/docs/roadmap/v0.6.0/png-vlm-render.md b/docs/roadmap/v0.6.0/png-vlm-render.md index 07e7ade..a132368 100644 --- a/docs/roadmap/v0.6.0/png-vlm-render.md +++ b/docs/roadmap/v0.6.0/png-vlm-render.md @@ -1,13 +1,13 @@ --- status: Draft -description: "v0.6.0 — 페이지 PNG 렌더링 표면. 상류 'native-skia' 백엔드를 'Document.render_png' / 'export_png' 로 노출하고 '[png]' extras 게이트 + MCP 'render_page_png' 도구로 VLM 입력 시나리오 지원" +description: "v0.6.0 — 페이지 PNG 렌더링 표면. 상류 'native-skia' 백엔드를 'Document.render_png' / 'export_png' 로 default 노출 + MCP 'render_page_png' 도구로 VLM 입력 시나리오 지원" target: v0.6.0 last_updated: 2026-05-10 --- # v0.6.0 — 페이지 PNG 렌더링 (VLM 입력) -VLM (Vision Language Model — Claude / GPT-4V / Gemini 등) 이 1차 입력으로 받는 raster 이미지를 HWP/HWPX 페이지로부터 직접 생성하는 표면을 추가한다. 상류 `edwardkim/rhwp` v0.7.10 (현 submodule pin `62a458a`) 의 `SkiaLayerRenderer::render_raster_with_options` 를 노출하여 `Document.render_png(page) -> bytes` / `render_all_png()` / `export_png(out_dir)` 를 새로 추가하고, MCP 도구 `render_page_png` 를 통해 LLM 에이전트가 페이지 단위 시각 입력을 획득할 수 있게 한다. 추가만 있고 v0.5.x 의 SVG / PDF / IR / MCP 표면은 모두 보존 (additive only) — SchemaVersion `"1.1"` 그대로. +VLM (Vision Language Model — Claude / GPT-4V / Gemini 등) 이 1차 입력으로 받는 raster 이미지를 HWP/HWPX 페이지로부터 직접 생성하는 표면을 추가한다. 상류 `edwardkim/rhwp` v0.7.10 (현 submodule pin `62a458a`) 의 `SkiaLayerRenderer::render_raster_with_options` 를 노출하여 `Document.render_png(page) -> bytes` / `render_all_png()` / `export_png(out_dir)` 를 새로 추가하고, MCP 도구 `render_page_png` 를 통해 LLM 에이전트가 페이지 단위 시각 입력을 획득할 수 있게 한다. 추가만 있고 v0.5.x 의 SVG / PDF / IR / MCP 표면은 모두 보존 (additive only) — SchemaVersion `"1.1"` 그대로. PNG 표면은 default wheel 에 통합 (별도 extras 없음) — Cargo `native-skia` feature 가 항상 켜져 wheel 에 포함되므로 `pip install rhwp-python` 만으로 즉시 사용 가능. 주요 결정의 근거·대안·실패 시나리오는 짝 페어: [png-vlm-render-research.md](../../design/v0.6.0/png-vlm-render-research.md). @@ -21,10 +21,10 @@ v0.4.0 view 렌더러 (Markdown / HTML) 와 v0.5.0 MCP server 는 *텍스트* | 항목 | 값 | 근거 | |---|---|---| -| 1 — Renderer 백엔드 | 상류 `native-skia` Cargo feature 활성화. `external/rhwp` submodule pin 변경 0, `Cargo.toml` 의 `rhwp` dependency 에 `features = ["native-skia"]` 추가만 | PR #599 PNG 게이트웨이가 이 feature 게이트 안에 있어 활성화 외 선택지 없음. skia-safe 빌드 비용 (binary-cache + embed-icudtl 약 30 MB 다운로드 + 크로스 플랫폼 wheel 크기 증가) 은 별도 wheel / extras 분리로 분담 — ADR § 1 | +| 1 — Renderer 백엔드 | 상류 `native-skia` Cargo feature 항상 활성화 (default 통합). `external/rhwp` submodule pin 변경 0, `Cargo.toml` 의 `rhwp` dependency 에 `features = ["native-skia"]` 추가 — 모든 wheel 에 포함 | PR #599 PNG 게이트웨이가 이 feature 게이트 안에 있어 활성화 외 선택지 없음. PyPI single-package 모델은 wheel 분리를 지원하지 않아 어차피 모든 사용자가 통합 wheel 다운로드 — extras 분리 신호는 cosmetic. skia-safe binary-cache 약 30 MB 추가 비용은 PNG 미사용자에게도 부과되지만, 이 비용을 진짜로 회피하려면 별도 PyPI 패키지 (옵션 C) 가 필요한데 영구 sync 부담이 더 큼. ADR § 1 | | 2 — Python API 시그니처 | `Document.render_png(page, *, scale=1.0, dpi=None, max_pixels=...) -> bytes` / `render_all_png() -> list[bytes]` / `export_png(out_dir, *, prefix=None) -> list[str]` — SVG / PDF API 1:1 mirror | `render_svg` / `render_pdf` 의 호출 패턴이 사용자 학습 비용 0. `RasterRenderOptions` 의 7 필드 (dpi / scale / max_dimension / max_pixels / format / background_color / transparent / color_space) 중 1차로 dpi / scale / max_pixels 만 노출, 나머지는 demand-driven. ADR § 2 | | 3 — 출력 코덱 | PNG 만 — JPEG / WebP / AVIF / HEIC 미노출 | 상류 skia raster 가 `RasterOutputFormat::Png` 만 구현 (다른 format 은 명시적 거부 — `"Skia raster renderer currently supports PNG output"` 에러). VLM 1차 호환 코덱 (Anthropic Vision API / OpenAI Vision / Google Gemini 모두 PNG 1st-tier) 충분. ADR § 3 | -| 4 — 배포 형태 | 새 extras `[png]` (`rhwp-python[png]` — 상류 native-skia + skia-safe 빌드 인계). 미설치 시 친절 ImportError + exit hint. 기본 wheel 의 의존성 / 크기 / abi3-py310 호환 변경 0 | skia-safe 의 binary-cache feature 가 wheel 빌드 시점 수십 MB 추가 → 모든 사용자에게 강제하지 않음. cli / mcp / langchain 처럼 분리 extras 패턴 정합 (`pyproject.toml` 기존 패턴). ADR § 1 | +| 4 — 배포 형태 | default 통합 (별도 extras 없음). `pip install rhwp-python` 만으로 render_png 사용 가능. Pillow 는 *사용자 측 후처리* (dimension 검증 / 픽셀 조사 / VLM 입력 전 resize) 가 필요할 때 직접 install — render_png 자체는 native skia 단독으로 PNG bytes 반환 | extras 분리는 wheel 이 통합 빌드되는 마당에 cosmetic 한 시그널만의 역할 — 사용자 학습 비용은 늘리고 진짜 비용 회피는 못 함. cli / mcp / langchain 처럼 *런타임 Python 의존성이 있는* extras 와 달리 PNG 표면은 native binary 단독으로 작동하므로 분리할 의미가 없다. ADR § 1 | | 5 — MCP 도구 | `render_page_png(path, page, *, scale=1.0, max_pixels=...) -> ImageContent` 신규. fastmcp v3 `ImageContent` 표준 (base64-encoded data + `mime="image/png"`) — Anthropic / OpenAI MCP client 가 이미지 입력을 LLM 에 직접 전달 가능 | fastmcp v3.2.4 docs § Tool Result Types 가 `ImageContent` 를 LLM 에이전트 시각 입력 1st-tier 패턴으로 명시. `bytes` 반환 후 클라이언트 측 base64 변환 거부 — wire format 이 비결정 dict 가 됨. ADR § 4 | | 6 — GIL 해제 정책 | `render_png` / `render_all_png` / `export_png` 모두 `py.detach` 안에서 skia raster 실행 — 평균 ≥ 50 ms / 페이지 (IO + 합성 + 인코딩) 으로 [CLAUDE.md](../../../CLAUDE.md) § "GIL release via `py.detach`" 의 ≥ 1 ms 임계 충족 | 이미 `render_pdf` / `export_pdf` 가 같은 패턴 (svgs_to_pdf 가 owned `Vec` → py.detach). PNG 도 owned `Vec` 반환이라 `Send + 'static` 보장. unsendable `_Document` 는 closure 안에서 미사용 (page index 만 캡처) | | 7 — 비동기 표면 | `arender_png(path, page, *, ...) -> bytes` 모듈-level async 함수 — `aparse` 의 패턴 (file IO 만 thread offload, render 는 호출 스레드 sync) 답습. `Document.arender_png()` 인스턴스 메서드 미제공 ([CLAUDE.md](../../../CLAUDE.md) § "Async direction" — unsendable `_Document` 는 thread 경계 위반 시 panic) | `aparse` (v0.2.0) / `aload` / `alazy_load` (v0.3.0) 와 동일 패턴 — 외부 의존성 0 (stdlib `asyncio.to_thread`). render 는 GIL 해제 구간에서 충분히 빠름. demand 가 확인된 후 async batch (`arender_all_png`) 추가 검토 | @@ -32,16 +32,15 @@ v0.4.0 view 렌더러 (Markdown / HTML) 와 v0.5.0 MCP server 는 *텍스트* ## 인수조건 -- **AC-1** — `[png]` extras 미설치 환경에서 `Document.render_png(0)` 호출 시 `ModuleNotFoundError` 또는 친절 `ImportError` (메시지에 `"pip install rhwp-python[png]"` 힌트 포함). 일반 `parse` / `to_ir` / `render_svg` / `render_pdf` 는 정상 동작 (extras 분리 가드) -- **AC-2** — `[png]` 설치 환경에서 `aift.hwp` (HWP5 fixture) 의 page 0 → `bytes` 반환값의 첫 8 byte 가 PNG magic (`b"\x89PNG\r\n\x1a\n"`) 일치 -- **AC-3** — 동일 fixture 의 `Document.render_all_png()` 결과 길이 == `Document.page_count` (페이지 수 invariant) -- **AC-4** — `Document.render_png(0, scale=2.0)` 결과의 픽셀 너비 ≈ `Document.render_png(0, scale=1.0)` 결과의 2배 (image lib 디코드 후 검증 — 상류 raster_dimension 의 `value * scale` 산식 회귀 가드) -- **AC-5** — `Document.render_png(0, max_pixels=1)` 호출이 `ValueError` (메시지에 `"pixel count out of range"` 포함) 로 fail — 상류 raster_dimension 의 max_pixels 가드 wire-through 검증 -- **AC-6** — MCP `render_page_png(path, 0)` 도구 호출이 fastmcp `ImageContent` 인스턴스 반환 (속성 `mime_type == "image/png"`, `data` 가 base64-decoded PNG magic 일치). `outputSchema` 에 `ImageContent` `$ref` 노출 -- **AC-7** — `arender_png(path, 0)` async 호출이 정상 PNG bytes 반환. `_Document` 인스턴스가 thread 경계를 넘지 않음 — `aparse` + sync `render_png` 구성 검증 (panic 미발생) -- **AC-8** — `tests/test_render_png.py` 가 `pytest.importorskip("rhwp._png_marker")` (또는 동등 게이트) 를 file-level 적용. `test-without-extras` job 의 expected skip count 가 v0.5.x 의 5 → 6 으로 증가, AGENTS.md + ci.yml 양쪽 갱신 -- **AC-9** — `Document.render_pdf` / `render_svg` / `to_ir` 등 v0.5.x 표면의 모든 기존 회귀 가드가 그대로 통과 (additive 보장) -- **AC-10** — README 에 `## 페이지 PNG 렌더링 (`render_png`)` 섹션 신설 — VLM 입력 사용 예 (Anthropic Vision API 호출 코드 1 블록), `[png]` extras 안내, 상류 native-skia 의존성 안내. MCP 도구 표 갱신 (`render_page_png` 1행 추가) +- **AC-1** — `aift.hwp` (HWP5 fixture) 의 page 0 → `bytes` 반환값의 첫 8 byte 가 PNG magic (`b"\x89PNG\r\n\x1a\n"`) 일치 +- **AC-2** — 동일 fixture 의 `Document.render_all_png()` 결과 길이 == `Document.page_count` (페이지 수 invariant) +- **AC-3** — `Document.render_png(0, scale=2.0)` 결과의 픽셀 너비 ≈ `Document.render_png(0, scale=1.0)` 결과의 2배 (image lib 디코드 후 검증 — 상류 raster_dimension 의 `value * scale` 산식 회귀 가드) +- **AC-4** — `Document.render_png(0, max_pixels=1)` 호출이 `ValueError` (메시지에 `"pixel count out of range"` 포함) 로 fail — 상류 raster_dimension 의 max_pixels 가드 wire-through 검증 +- **AC-5** — MCP `render_page_png(path, 0)` 도구 호출이 fastmcp `ImageContent` 인스턴스 반환 (속성 `mime_type == "image/png"`, `data` 가 base64-decoded PNG magic 일치). `outputSchema` 에 `ImageContent` `$ref` 노출 +- **AC-6** — `arender_png(path, 0)` async 호출이 정상 PNG bytes 반환. `_Document` 인스턴스가 thread 경계를 넘지 않음 — `aparse` + sync `render_png` 구성 검증 (panic 미발생) +- **AC-7** — `Document.export_png(out_dir)` 가 `Document.page_count` 만큼 파일 생성, 각 파일 첫 8 byte PNG magic 일치, 반환 경로 리스트 길이 == 파일 수 +- **AC-8** — `Document.render_pdf` / `render_svg` / `to_ir` 등 v0.5.x 표면의 모든 기존 회귀 가드가 그대로 통과 (additive 보장) +- **AC-9** — README 에 `## 페이지 PNG 렌더링 (`render_png`)` 섹션 신설 — VLM 입력 사용 예 (Anthropic Vision API 호출 코드 1 블록), 상류 native-skia 의존성 안내. MCP 도구 표 갱신 (`render_page_png` 1행 추가) ## 영구 비목표 @@ -55,18 +54,15 @@ v0.4.0 view 렌더러 (Markdown / HTML) 와 v0.5.0 MCP server 는 *텍스트* ## 다른 산출물의 파급 (코드 / 데이터) -- `Cargo.toml` — `rhwp = { path = "external/rhwp" }` → `rhwp = { path = "external/rhwp", features = ["native-skia"] }`. `[features]` 에 `png = []` 추가 (Python `[png]` extras 의 native marker) +- `Cargo.toml` — `rhwp = { path = "external/rhwp" }` → `rhwp = { path = "external/rhwp", features = ["native-skia"] }`. PNG 는 default wheel 통합이라 별도 `[features]` 마커 불필요 - `src/document.rs` — `fn render_png<'py>(&self, py, page, scale, dpi, max_pixels) -> PyResult>`, `fn render_all_png`, `fn export_png` 신규 (PDF API mirror). `py.detach` 패턴 -- `python/rhwp/document.py` — `render_png` / `render_all_png` / `export_png` wrapper 메서드 + docstring (extras 가드 안내). `arender_png` 모듈-level async 함수 +- `python/rhwp/document.py` — `render_png` / `render_all_png` / `export_png` wrapper 메서드 + docstring. `arender_png` 모듈-level async 함수 - `python/rhwp/_rhwp.pyi` — 새 메서드 stub - `python/rhwp/__init__.pyi` + `__init__.py` — `arender_png` re-export - `python/rhwp/mcp/tools.py` — `render_page_png(path, page, *, scale, max_pixels) -> ImageContent` 신규. v0.5.0 의 7 도구 → 8 도구 -- `pyproject.toml` — `[project.optional-dependencies] png = []` (native skia 는 wheel 빌드 시점 결정 — 런타임 import 마커만). `[examples]` extras 에 PNG 추가 +- `pyproject.toml` — extras 추가 없음. `testing` dependency-group 에 Pillow 추가 (AC-3 dimension 검증). `[project.optional-dependencies]` 의 다른 extras 는 변경 0 - `external/rhwp/` 서브모듈 — pin 변경 가능성 (v0.7.10 → v0.7.11 등 — `RasterRenderOptions` API 안정성 확인 후 결정). 본 spec 시점 `62a458a` (v0.7.10) 그대로 유지 시도 -- `tests/test_render_png.py` 신규 — file-level `pytest.importorskip` 게이트, AC-2 ~ AC-7 검증 -- `tests/conftest.py` — 필요 시 `image` lib (Pillow) 픽스처 (디코드 후 dimension 검증 — AC-4) -- `.github/workflows/ci.yml` — `test-without-extras` job 의 expected skip count 5 → 6 -- `AGENTS.md` § Tests — extras-gated 파일 목록 갱신 (skip count 5 → 6, file 5 → 6) +- `tests/test_render_png.py` 신규 — AC-1 ~ AC-7 검증 (Pillow 디코드는 testing 그룹의 Pillow 사용) - `README.md` § 페이지 PNG 렌더링 신설 + MCP 도구 표 1행 추가 - `CHANGELOG.md` — `[0.6.0]` 섹션 — Added (3 메서드 + 1 MCP 도구), Build (Cargo features + skia-safe 의존성), Notes (VLM 입력 시나리오 사용 예) @@ -74,9 +70,9 @@ v0.4.0 view 렌더러 (Markdown / HTML) 와 v0.5.0 MCP server 는 *텍스트* ## 미확정 이슈 -- **상류 `RasterRenderOptions::default()` 의 max_pixels 정확한 값** — `external/rhwp/src/renderer/layer_renderer.rs` 의 `Default` impl 확인 필요 (decision 8 의 fact 보강). 현 spec 은 "상류 default 그대로" 만 명시 -- **skia-safe 빌드 시간 / wheel 크기 영향** — CI 빌드 시간 + GitHub Actions wheel artifact 크기 측정 필요. 임계 (예: wheel 크기 > 100 MB) 초과 시 `[png]` extras 를 별도 wheel 분리 (PyPI 제약) 검토 -- **abi3-py310 호환성** — skia-safe 가 abi3 호환 가정 검증 필요. abi3 미호환 시 PNG 전용 wheel 만 Python-version-specific (`cp310-cp313`) 분리 빌드 +- **상류 `RasterRenderOptions::default()` 의 max_pixels 값 (확정)** — `external/rhwp/src/renderer/layer_renderer.rs:28` 의 `Default::default` 확인: `max_pixels: 67_108_864` (= 8192 × 8192), `max_dimension: 16_384`. decision 8 의 fact 보강 완료 +- **skia-safe 빌드 시간 / wheel 크기 영향** — CI 빌드 시간 + GitHub Actions wheel artifact 크기 측정 필요. 임계 (예: wheel 크기 > 100 MB) 초과 시 별도 PyPI 패키지 (`rhwp-png`) 분리 검토 — 본 spec 시점 default 통합 (옵션 B → β 단순화) +- **abi3-py310 호환성** — skia-safe 가 abi3 호환 가정 검증 필요. abi3 미호환 시 PNG 전용 wheel 만 Python-version-specific (`cp310-cp313`) 분리 빌드 (별도 PyPI 패키지로 위임) - **MCP `ImageContent` 의 LLM 클라이언트 호환성** — Claude Desktop / Cline / Cursor / Continue.dev / Goose 의 ImageContent 응답 처리 검증 필요 (transport 별 base64 인코딩 안정성). v0.5.0 의 클라이언트 호환성 표 (text-only 응답 기준) 의 image 컬럼 추가 - **stdio MCP transport 의 base64 페이로드 크기** — A4 페이지 PNG 가 약 100-500 KB → base64 약 130-660 KB. stdio JSON-RPC 메시지 크기 제한 (서버별 상이) 충돌 가능성. streamable-http 권장 검토 From 0bb867060c1767cebda637dc14435c2cd82dfb28 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 18:10:03 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20PyO3=20render=5Fpng=20/=20render?= =?UTF-8?q?=5Fall=5Fpng=20/=20export=5Fpng=20=ED=91=9C=EB=A9=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - 페이지 단위 / 일괄 / 디스크 export 3 메서드 (PDF API mirror) - Cargo.toml 의 rhwp 의존성에 native-skia feature 활성화 - skia raster 실행 구간 GIL 해제 (owned PageLayerTree 를 py.detach 클로저로 이동) - 사용자 옵션 scale / dpi / max_pixels (keyword-only) — 상류 RasterRenderOptions wire-through Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 4 ++- src/document.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e2e7915..da74bb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/document.rs b/src/document.rs index 63eee23..9a0b07d 100644 --- a/src/document.rs +++ b/src/document.rs @@ -168,6 +168,61 @@ impl PyDocument { }) } + /// 특정 페이지를 PNG bytes 로 렌더링한다 (상류 native-skia raster). + /// + /// `scale` / `dpi` / `max_pixels` 는 RasterRenderOptions 의 동일 필드로 wire-through. + /// 미지정 시 상류 default (`scale=1.0` / `dpi=None` / `max_pixels=67_108_864` ≈ 8192×8192). + /// 픽셀 한도 위반 시 상류 메시지 그대로 ValueError (예: "raster pixel count out of range: ..."). + #[pyo3(signature = (page, *, scale=None, dpi=None, max_pixels=None))] + fn render_png<'py>( + &self, + py: Python<'py>, + page: u32, + scale: Option, + dpi: Option, + max_pixels: Option, + ) -> PyResult> { + let bytes = self.render_png_internal(py, page, scale, dpi, max_pixels)?; + Ok(PyBytes::new(py, &bytes)) + } + + fn render_all_png<'py>(&self, py: Python<'py>) -> PyResult>> { + let page_count = self.inner.page_count(); + let mut out = Vec::with_capacity(page_count as usize); + for page in 0..page_count { + let bytes = self.render_png_internal(py, page, None, None, None)?; + out.push(PyBytes::new(py, &bytes)); + } + Ok(out) + } + + #[pyo3(signature = (output_dir, *, prefix=None))] + fn export_png( + &self, + py: Python<'_>, + output_dir: &str, + prefix: Option<&str>, + ) -> PyResult> { + let out_dir = std::path::Path::new(output_dir); + std::fs::create_dir_all(out_dir).map_err(|e| PyIOError::new_err(e.to_string()))?; + + let page_count = self.inner.page_count(); + let stem = prefix.unwrap_or("page"); + let mut written = Vec::with_capacity(page_count as usize); + for page in 0..page_count { + let bytes = self.render_png_internal(py, page, None, None, None)?; + let filename = if page_count == 1 { + format!("{stem}.png") + } else { + format!("{stem}_{:03}.png", page + 1) + }; + let path = out_dir.join(&filename); + std::fs::write(&path, &bytes).map_err(|e| PyIOError::new_err(e.to_string()))?; + written.push(path.to_string_lossy().into_owned()); + } + Ok(written) + } + /// 문서를 Document IR (Pydantic `HwpDocument`) 로 변환하여 반환한다. /// /// 첫 호출 시 문서 트리를 순회하며 IR 을 구성하고 결과를 인스턴스에 캐시한다. @@ -260,5 +315,37 @@ impl PyDocument { }) .collect() } + + fn render_png_internal( + &self, + py: Python<'_>, + page: u32, + scale: Option, + dpi: Option, + max_pixels: Option, + ) -> PyResult> { + // ^ layer tree 빌드는 GIL 유지 (DocumentCore 의 RefCell 캐시 접근 — !Sync). + // 결과 PageLayerTree 는 owned values 만 포함 → py.detach 클로저로 이동 가능. + let layer_tree = self + .inner + .build_page_layer_tree(page) + .map_err(|e| PyValueError::new_err(format!("render page {page} failed: {e:?}")))?; + let mut options = rhwp::renderer::layer_renderer::RasterRenderOptions::default(); + if let Some(s) = scale { + options.scale = s; + } + if let Some(d) = dpi { + options.dpi = Some(d); + } + if let Some(mp) = max_pixels { + options.max_pixels = mp; + } + py.detach(move || { + use rhwp::renderer::layer_renderer::LayerRasterRenderer; + rhwp::renderer::skia::SkiaLayerRenderer::new() + .render_png_with_options(&layer_tree, options) + .map_err(|e| PyValueError::new_err(format!("render page {page} failed: {e:?}"))) + }) + } } From 1d79af9db7497f2fe12c70e3f48566b618beab9e Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 18:13:08 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20render=5Fpng=20/=20arender=5Fpng?= =?UTF-8?q?=20Python=20wrapper=20+=20Pillow=20testing=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - Document.render_png / render_all_png / export_png wrapper 메서드 + docstring - 모듈-level arender_png(path, page) async 함수 (aparse + sync render 패턴) - _rhwp.pyi / __init__.py / __init__.pyi stub 갱신 - testing dependency-group 에 pillow>=10 추가 — 회귀 테스트의 디코드 / dimension 검증용 Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 3 ++ python/rhwp/__init__.py | 3 +- python/rhwp/__init__.pyi | 2 + python/rhwp/_rhwp.pyi | 10 ++++ python/rhwp/document.py | 99 +++++++++++++++++++++++++++++++++++ uv.lock | 108 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 223 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index acd7a98..95f8f23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,9 @@ testing = [ "typer>=0.12", # ^ rhwp-mcp 서버 단위 테스트 (tests/test_mcp_server.py) — fastmcp 인스턴스 inspection. "fastmcp>=3,<4", + # ^ v0.6.0 PNG 렌더링 회귀 테스트 (tests/test_render_png.py) — dimension 검증 (AC-4). + # [png] extras 의 짝이며, dev 환경 / CI test job 모두에서 사용 가능. + "pillow>=10", ] linting = [ {include-group = "dev"}, diff --git a/python/rhwp/__init__.py b/python/rhwp/__init__.py index bd10e85..08b7986 100644 --- a/python/rhwp/__init__.py +++ b/python/rhwp/__init__.py @@ -1,11 +1,12 @@ """rhwp — HWP/HWPX parser and renderer (Korean word processor format).""" from rhwp._rhwp import rhwp_core_version, version -from rhwp.document import Document, aparse, parse +from rhwp.document import Document, aparse, arender_png, parse __all__ = [ "Document", "aparse", + "arender_png", "parse", "rhwp_core_version", "version", diff --git a/python/rhwp/__init__.pyi b/python/rhwp/__init__.pyi index 4c94b5c..a79926b 100644 --- a/python/rhwp/__init__.pyi +++ b/python/rhwp/__init__.pyi @@ -2,11 +2,13 @@ from rhwp.document import Document as Document from rhwp.document import aparse as aparse +from rhwp.document import arender_png as arender_png from rhwp.document import parse as parse __all__ = [ "Document", "aparse", + "arender_png", "parse", "rhwp_core_version", "version", diff --git a/python/rhwp/_rhwp.pyi b/python/rhwp/_rhwp.pyi index e67e8b5..204422d 100644 --- a/python/rhwp/_rhwp.pyi +++ b/python/rhwp/_rhwp.pyi @@ -39,6 +39,16 @@ class _Document: def export_svg(self, output_dir: str, prefix: str | None = None) -> list[str]: ... def render_pdf(self) -> bytes: ... def export_pdf(self, output_path: str) -> int: ... + def render_png( + self, + page: int, + *, + scale: float | None = None, + dpi: float | None = None, + max_pixels: int | None = None, + ) -> bytes: ... + def render_all_png(self) -> list[bytes]: ... + def export_png(self, output_dir: str, *, prefix: str | None = None) -> list[str]: ... def to_ir(self) -> HwpDocument: ... def to_ir_json(self, *, indent: int | None = None) -> str: ... def bytes_for_image_id(self, bin_data_id: int) -> bytes | None: ... diff --git a/python/rhwp/document.py b/python/rhwp/document.py index 9de6250..8902414 100644 --- a/python/rhwp/document.py +++ b/python/rhwp/document.py @@ -253,6 +253,66 @@ def export_pdf(self, output_path: str) -> int: """ return self._inner.export_pdf(output_path) + def render_png( + self, + page: int, + *, + scale: float = 1.0, + dpi: float | None = None, + max_pixels: int | None = None, + ) -> bytes: + """특정 페이지를 PNG 바이트로 렌더링한다 (상류 native-skia raster). + + VLM (Vision-Language Model — Claude / GPT-4V / Gemini 등) 의 시각 입력 + 용도. 텍스트 표면 (SVG / Markdown / IR) 으로는 평탄화되는 표 / 수식 / + 그림 / 레이아웃의 시각 의미를 보존한다. + + Args: + page: 0-based 페이지 인덱스. + scale: 페이지 크기 배율 (기본 1.0). 2.0 이면 픽셀 너비/높이가 약 2배. + dpi: 메타데이터에 기록되는 DPI. 픽셀 수 자체에는 영향이 없다 — 픽셀 + 수는 ``scale`` 로 제어한다 (상류 ``RasterRenderOptions`` 정합). + max_pixels: DoS 방어용 픽셀 상한. 초과 시 ``ValueError``. 미지정 시 + 상류 default (8192 × 8192 = 67_108_864) 적용. + + Returns: + PNG 인코딩 바이트 (magic ``b"\\x89PNG\\r\\n\\x1a\\n"`` 으로 시작). + + Raises: + ValueError: 페이지 인덱스 범위 초과, 픽셀 한도 초과 (``max_pixels``), + 상류 raster pipeline 의 기타 invariant 위반. + """ + return self._inner.render_png(page, scale=scale, dpi=dpi, max_pixels=max_pixels) + + def render_all_png(self) -> list[bytes]: + """모든 페이지를 PNG 바이트 리스트로 렌더링 (``len == page_count``). + + 대용량 문서에서는 메모리 부담이 있다 (페이지 100 × 약 500 KB ≈ 50 MB). + 스트리밍이 필요하면 ``for page in range(doc.page_count): doc.render_png(page)`` + 루프를 직접 사용한다. SVG / PDF 와 동일 메모리 모델. + + Raises: +ValueError: 렌더링 실패. + """ + return self._inner.render_all_png() + + def export_png(self, output_dir: str, *, prefix: str | None = None) -> list[str]: + """모든 페이지를 PNG 파일로 저장. + + Args: + output_dir: 출력 디렉토리 (자동 생성). + prefix: 파일명 접두사 (기본 ``"page"``). 다중 페이지 시 + ``{prefix}_{NNN}.png``, 단일 페이지 시 ``{prefix}.png``. + + Returns: + 생성된 파일 경로 리스트. + + Raises: +OSError: 디렉토리 생성 또는 파일 쓰기 실패. + ValueError: 렌더링 실패. + """ + return self._inner.export_png(output_dir, prefix=prefix) + def __repr__(self) -> str: return repr(self._inner) @@ -275,6 +335,45 @@ def parse(path: str) -> Document: return Document(path) +async def arender_png( + path: str, + page: int, + *, + scale: float = 1.0, + dpi: float | None = None, + max_pixels: int | None = None, +) -> bytes: + """:meth:`Document.render_png` 의 async 변형 — 파일 읽기만 async, render 는 sync. + + ``aparse`` 와 동일 패턴: 파일 read 만 stdlib ``asyncio.to_thread`` 로 thread + pool 에 offload, 파싱 + render 는 호출 스레드 (event loop) 에서 동기 실행 + (Rust ``py.detach`` 가 GIL 해제). Document 가 thread 경계를 넘지 않으므로 + ``unsendable`` panic 회피. + + Document 인스턴스 재사용 패턴 (``await aparse(...)`` 후 sync ``render_png`` + 여러 번 호출) 이 더 효율적 — 본 함수는 단발 페이지 렌더링용. + + Args: + path: HWP 또는 HWPX 파일 경로. + page: 0-based 페이지 인덱스. + scale: 페이지 크기 배율 (기본 1.0). + dpi: 메타데이터 DPI. + max_pixels: DoS 방어용 픽셀 상한. + + Returns: + PNG 인코딩 바이트. + + Raises: + FileNotFoundError / PermissionError / OSError: 파일 I/O 실패. + ValueError: 파싱 또는 렌더링 실패. + """ + import asyncio + + data = await asyncio.to_thread(_read_bytes, path) + doc = Document.from_bytes(data, source_uri=path) + return doc.render_png(page, scale=scale, dpi=dpi, max_pixels=max_pixels) + + async def aparse(path: str) -> Document: """:func:`parse` 의 async 변형 — 파일 읽기만 async, 파싱은 sync. diff --git a/uv.lock b/uv.lock index 70ceecb..5a9df70 100644 --- a/uv.lock +++ b/uv.lock @@ -1115,6 +1115,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, ] +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -1585,6 +1683,9 @@ mcp-chunks = [ { name = "langchain-core" }, { name = "langchain-text-splitters" }, ] +png = [ + { name = "pillow" }, +] [package.dev-dependencies] all = [ @@ -1593,6 +1694,7 @@ all = [ { name = "langchain-core" }, { name = "langchain-text-splitters" }, { name = "maturin" }, + { name = "pillow" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1613,6 +1715,7 @@ testing = [ { name = "langchain-core" }, { name = "langchain-text-splitters" }, { name = "maturin" }, + { name = "pillow" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "typer" }, @@ -1631,12 +1734,13 @@ 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 == 'png'", 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" }, { name = "typer", marker = "extra == 'examples'", specifier = ">=0.12" }, ] -provides-extras = ["async", "cli", "cli-chunks", "examples", "langchain", "mcp", "mcp-chunks"] +provides-extras = ["async", "cli", "cli-chunks", "examples", "langchain", "mcp", "mcp-chunks", "png"] [package.metadata.requires-dev] all = [ @@ -1645,6 +1749,7 @@ all = [ { name = "langchain-core", specifier = ">=0.2" }, { name = "langchain-text-splitters", specifier = ">=0.2" }, { name = "maturin", specifier = ">=1.7" }, + { name = "pillow", specifier = ">=10" }, { name = "pyright" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, @@ -1663,6 +1768,7 @@ testing = [ { name = "langchain-core", specifier = ">=0.2" }, { name = "langchain-text-splitters", specifier = ">=0.2" }, { name = "maturin", specifier = ">=1.7" }, + { name = "pillow", specifier = ">=10" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov" }, { name = "typer", specifier = ">=0.12" }, From d9880d9ee6f4d88f385f090373da81efd164cd15 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 19:17:14 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20MCP=20render=5Fpage=5Fpng=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=EC=B6=94=EA=B0=80=20(8=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC)=20+=20ImageContent=20=EC=B6=9C=EA=B3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - mcp/tools.py: render_page_png(path, page, *, scale, max_pixels) → ImageContent - base64 인코딩 + mimeType="image/png" — fastmcp v3 표준 - mcp/server.py: build_server() 에 도구 등록 (7 → 8) - spec AC-5 의 mime_type 표기를 mimeType 으로 정정 (실제 ImageContent 필드명) - uv.lock: [png] extras 잔여 정보 정리 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/roadmap/v0.6.0/png-vlm-render.md | 2 +- python/rhwp/mcp/server.py | 6 ++-- python/rhwp/mcp/tools.py | 40 +++++++++++++++++++++++++++ uv.lock | 6 +--- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/roadmap/v0.6.0/png-vlm-render.md b/docs/roadmap/v0.6.0/png-vlm-render.md index a132368..5837fcd 100644 --- a/docs/roadmap/v0.6.0/png-vlm-render.md +++ b/docs/roadmap/v0.6.0/png-vlm-render.md @@ -36,7 +36,7 @@ v0.4.0 view 렌더러 (Markdown / HTML) 와 v0.5.0 MCP server 는 *텍스트* - **AC-2** — 동일 fixture 의 `Document.render_all_png()` 결과 길이 == `Document.page_count` (페이지 수 invariant) - **AC-3** — `Document.render_png(0, scale=2.0)` 결과의 픽셀 너비 ≈ `Document.render_png(0, scale=1.0)` 결과의 2배 (image lib 디코드 후 검증 — 상류 raster_dimension 의 `value * scale` 산식 회귀 가드) - **AC-4** — `Document.render_png(0, max_pixels=1)` 호출이 `ValueError` (메시지에 `"pixel count out of range"` 포함) 로 fail — 상류 raster_dimension 의 max_pixels 가드 wire-through 검증 -- **AC-5** — MCP `render_page_png(path, 0)` 도구 호출이 fastmcp `ImageContent` 인스턴스 반환 (속성 `mime_type == "image/png"`, `data` 가 base64-decoded PNG magic 일치). `outputSchema` 에 `ImageContent` `$ref` 노출 +- **AC-5** — MCP `render_page_png(path, 0)` 도구 호출이 fastmcp `ImageContent` 인스턴스 반환 (속성 `mimeType == "image/png"`, `data` 가 base64-decoded PNG magic 일치). `outputSchema` 에 `ImageContent` `$ref` 노출 - **AC-6** — `arender_png(path, 0)` async 호출이 정상 PNG bytes 반환. `_Document` 인스턴스가 thread 경계를 넘지 않음 — `aparse` + sync `render_png` 구성 검증 (panic 미발생) - **AC-7** — `Document.export_png(out_dir)` 가 `Document.page_count` 만큼 파일 생성, 각 파일 첫 8 byte PNG magic 일치, 반환 경로 리스트 길이 == 파일 수 - **AC-8** — `Document.render_pdf` / `render_svg` / `to_ir` 등 v0.5.x 표면의 모든 기존 회귀 가드가 그대로 통과 (additive 보장) diff --git a/python/rhwp/mcp/server.py b/python/rhwp/mcp/server.py index ccfb113..fde04e4 100644 --- a/python/rhwp/mcp/server.py +++ b/python/rhwp/mcp/server.py @@ -18,14 +18,14 @@ def build_server() -> FastMCP: - """새 ``FastMCP`` 인스턴스를 만들고 도구 7 종을 등록해 반환. + """새 ``FastMCP`` 인스턴스를 만들고 도구 8 종을 등록해 반환. 테스트가 ``mcp.list_tools()`` / ``mcp.call_tool(name, args)`` 를 in-process 호출할 수 있도록 build 단계를 함수로 분리 — 모듈 import 부수 효과 없이 fresh 인스턴스 획득. """ server = FastMCP("rhwp-mcp") - # ^ S1 코어 4 + S2 view 2 + S3 chunks 1 = 7. mcp.md AC-2 의 GA 기준 도구 수. + # ^ S1 코어 4 + S2 view 2 + S3 chunks 1 + v0.6.0 PNG 1 = 8. # fastmcp v3 의 ``server.tool(fn)`` 은 데코레이터 호출 형태 — 동일 인스턴스에 # 여러 도구를 program 적으로 등록할 때 같은 클로저 충돌 없이 안전. server.tool(tools.parse_hwp_summary) @@ -37,6 +37,8 @@ def build_server() -> FastMCP: # ^ chunks 는 langchain-text-splitters 런타임 extras gate — 등록은 무조건, # 호출 시점에 ImportError → fastmcp ToolError → MCP isError=True (AC-7). server.tool(tools.chunks) + # ^ v0.6.0: VLM 시각 입력 — fastmcp ImageContent 반환 (base64 + image/png). + server.tool(tools.render_page_png) return server diff --git a/python/rhwp/mcp/tools.py b/python/rhwp/mcp/tools.py index be0e4b7..f628113 100644 --- a/python/rhwp/mcp/tools.py +++ b/python/rhwp/mcp/tools.py @@ -8,10 +8,13 @@ - S1 — `parse_hwp_summary` / `extract_text` / `get_ir` / `iter_blocks` (코어 4) - S2 — `to_markdown` / `to_html` (v0.4.0 view API thin wrapper) - S3 — `chunks` (RAG 청킹 — langchain-text-splitters 런타임 extras gate) +- v0.6.0 — `render_page_png` (페이지 PNG → fastmcp ImageContent — VLM 입력) """ +import base64 from typing import Any, Literal +from mcp.types import ImageContent from pydantic import BaseModel, ConfigDict, Field import rhwp @@ -232,3 +235,40 @@ def chunks( splitter = RecursiveCharacterTextSplitter(chunk_size=size, chunk_overlap=overlap) split_docs = splitter.split_documents(docs) return [ChunkRecord(page_content=d.page_content, metadata=d.metadata) for d in split_docs] + + +def render_page_png( + path: str, + page: int, + scale: float = 1.0, + max_pixels: int | None = None, +) -> ImageContent: + """HWP 또는 HWPX 의 특정 페이지를 PNG 로 렌더해 fastmcp ``ImageContent`` 반환. + + VLM (Vision-Language Model — Claude / GPT-4V / Gemini 등) 의 시각 입력 1차 + 시민. fastmcp v3 의 ``ImageContent`` 표준 (base64 + ``mimeType="image/png"``) + 으로 출고 → MCP client (Claude Desktop / Cline / Cursor) 가 LLM 메시지의 + ``image`` content block 으로 자동 wire — bytes 반환 후 클라이언트 측 base64 + 변환은 비결정 dict 가 되므로 거부. + + A4 페이지 PNG 가 약 100-500 KB → base64 약 130-660 KB. stdio MCP transport + 의 JSON-RPC 메시지 크기 제한과 충돌 가능성 — 큰 페이지 / 다수 페이지는 + ``--transport streamable-http`` 권장. + + Args: + path: HWP 또는 HWPX 파일 경로. + page: 0-based 페이지 인덱스. + scale: 페이지 크기 배율 (기본 1.0). VLM 입력 권장 값은 1.0-2.0 — 너무 + 크면 LLM context 비용이 급증. + max_pixels: DoS 방어용 픽셀 상한. 미지정 시 상류 default + 67_108_864 (= 8192 × 8192). + + Returns: + ``ImageContent(type="image", data=base64-encoded PNG, mimeType="image/png")``. + """ + png_bytes = rhwp.parse(path).render_png(page, scale=scale, max_pixels=max_pixels) + return ImageContent( + type="image", + data=base64.b64encode(png_bytes).decode("ascii"), + mimeType="image/png", + ) diff --git a/uv.lock b/uv.lock index 5a9df70..dc3edc9 100644 --- a/uv.lock +++ b/uv.lock @@ -1683,9 +1683,6 @@ mcp-chunks = [ { name = "langchain-core" }, { name = "langchain-text-splitters" }, ] -png = [ - { name = "pillow" }, -] [package.dev-dependencies] all = [ @@ -1734,13 +1731,12 @@ 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 == 'png'", 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" }, { name = "typer", marker = "extra == 'examples'", specifier = ">=0.12" }, ] -provides-extras = ["async", "cli", "cli-chunks", "examples", "langchain", "mcp", "mcp-chunks", "png"] +provides-extras = ["async", "cli", "cli-chunks", "examples", "langchain", "mcp", "mcp-chunks"] [package.metadata.requires-dev] all = [ From ee796ccf488aaa255896f4bd2b4f7ee15d01d839 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 19:24:11 +0900 Subject: [PATCH 06/13] =?UTF-8?q?test:=20v0.6.0=20png-vlm-render=20AC-1~AC?= =?UTF-8?q?-7=20=ED=9A=8C=EA=B7=80=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - tests/test_render_png.py — Document.render_png / render_all_png / export_png / arender_png / MCP render_page_png 검증 (per-test spec 마커) - AC-3 dimension 검증은 Pillow 디코드, AC-5 MCP 는 fastmcp 미설치 시 per-test skip - coverage.md trace 자동 갱신 (7 신규 매핑) Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/traces/coverage.md | 7 ++++ tests/test_render_png.py | 87 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 tests/test_render_png.py diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index 4f74e84..b0c7ce1 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -628,3 +628,10 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.5.1/mcp-typed-output | AC-6 | `tests/test_mcp_server.py::TestTypedClientData::test_get_ir_client_data_has_typed_attributes` | | v0.5.1/mcp-typed-output | AC-6 | `tests/test_mcp_server.py::TestTypedClientData::test_iter_blocks_client_data_is_typed_list` | | v0.5.1/mcp-typed-output | AC-7 | `tests/test_mcp_server.py::TestTypedSignatures::test_chunk_record_metadata_annotation_is_free_dict` | +| v0.6.0/png-vlm-render | AC-1 | `tests/test_render_png.py::TestRenderPng::test_returns_png_magic` | +| v0.6.0/png-vlm-render | AC-2 | `tests/test_render_png.py::TestRenderPng::test_render_all_count_matches_page_count` | +| v0.6.0/png-vlm-render | AC-3 | `tests/test_render_png.py::TestRenderPng::test_scale_doubles_width` | +| v0.6.0/png-vlm-render | AC-4 | `tests/test_render_png.py::TestRenderPng::test_max_pixels_guard_raises` | +| v0.6.0/png-vlm-render | AC-5 | `tests/test_render_png.py::TestMcpRenderPagePng::test_returns_image_content` | +| v0.6.0/png-vlm-render | AC-6 | `tests/test_render_png.py::TestArenderPng::test_async_returns_png_without_panic` | +| v0.6.0/png-vlm-render | AC-7 | `tests/test_render_png.py::TestExportPng::test_writes_files_with_png_magic` | diff --git a/tests/test_render_png.py b/tests/test_render_png.py new file mode 100644 index 0000000..e64bf92 --- /dev/null +++ b/tests/test_render_png.py @@ -0,0 +1,87 @@ +"""v0.6.0 페이지 PNG 렌더링 회귀 가드 — render_png / arender_png / MCP 도구 검증. + +AC-1 ~ AC-7 매핑은 ``docs/roadmap/v0.6.0/png-vlm-render.md`` § 인수조건. PIL 은 +``testing`` dependency-group 에 포함 (디코드 후 dimension 검증 — AC-3). +""" + +import asyncio +import base64 +import io +from pathlib import Path + +import pytest +from PIL import Image as PilImage + +import rhwp + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +class TestRenderPng: + @pytest.mark.spec("v0.6.0/png-vlm-render#AC-1") + def test_returns_png_magic(self, parsed_hwp: rhwp.Document) -> None: + png = parsed_hwp.render_png(0) + assert isinstance(png, bytes) + assert png[:8] == PNG_MAGIC + + @pytest.mark.spec("v0.6.0/png-vlm-render#AC-2") + def test_render_all_count_matches_page_count(self, parsed_hwp: rhwp.Document) -> None: + all_pngs = parsed_hwp.render_all_png() + assert len(all_pngs) == parsed_hwp.page_count + # ^ 각 페이지가 PNG magic 으로 시작해야 — render_all 이 단순 list[bytes] 인지 확인 + assert all(p[:8] == PNG_MAGIC for p in all_pngs) + + @pytest.mark.spec("v0.6.0/png-vlm-render#AC-3") + def test_scale_doubles_width(self, parsed_hwp: rhwp.Document) -> None: + png_1x = parsed_hwp.render_png(0, scale=1.0) + png_2x = parsed_hwp.render_png(0, scale=2.0) + w1 = PilImage.open(io.BytesIO(png_1x)).width + w2 = PilImage.open(io.BytesIO(png_2x)).width + # ^ 상류 raster_dimension 가 (value * scale).ceil() 이라 1px 정도 rounding 가능 + assert abs(w2 - w1 * 2) <= 2, f"expected w2 ≈ 2 × w1, got w1={w1} w2={w2}" + + @pytest.mark.spec("v0.6.0/png-vlm-render#AC-4") + def test_max_pixels_guard_raises(self, parsed_hwp: rhwp.Document) -> None: + with pytest.raises(ValueError, match="pixel count out of range"): + parsed_hwp.render_png(0, max_pixels=1) + + +class TestExportPng: + @pytest.mark.spec("v0.6.0/png-vlm-render#AC-7") + def test_writes_files_with_png_magic( + self, parsed_hwp: rhwp.Document, tmp_path: Path + ) -> None: + out_dir = tmp_path / "png_out" + paths = parsed_hwp.export_png(str(out_dir)) + assert len(paths) == parsed_hwp.page_count + for path_str in paths: + path = Path(path_str) + assert path.exists() + with open(path, "rb") as f: + assert f.read(8) == PNG_MAGIC + + +class TestArenderPng: + @pytest.mark.spec("v0.6.0/png-vlm-render#AC-6") + def test_async_returns_png_without_panic(self, hwp_sample: Path) -> None: + # ^ aparse + sync render_png 패턴 — Document 가 thread 경계 안 넘는지 검증 + png = asyncio.run(rhwp.arender_png(str(hwp_sample), 0)) + assert png[:8] == PNG_MAGIC + + +class TestMcpRenderPagePng: + @pytest.mark.spec("v0.6.0/png-vlm-render#AC-5") + def test_returns_image_content(self, hwp_sample: Path) -> None: + # ^ fastmcp 가 없으면 mcp.types import 도 실패 — per-test 가드로 file-level + # skip 회피 (다른 AC 가드는 fastmcp 무관하게 실행) + pytest.importorskip("fastmcp") + from mcp.types import ImageContent + + from rhwp.mcp.tools import render_page_png + + result = render_page_png(str(hwp_sample), 0) + assert isinstance(result, ImageContent) + assert result.type == "image" + assert result.mimeType == "image/png" + png = base64.b64decode(result.data) + assert png[:8] == PNG_MAGIC From b4a4283b8618244c7d61b3ceb441adb9c162495e Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 19:29:35 +0900 Subject: [PATCH 07/13] =?UTF-8?q?chore:=20v0.6.0=20release=20marker=20?= =?UTF-8?q?=E2=80=94=20README=20PNG=20=EC=84=B9=EC=85=98=20+=20CHANGELOG?= =?UTF-8?q?=20+=20version=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - Cargo.toml version 0.5.1 → 0.6.0 (pyproject 는 dynamic, Cargo 가 SSOT) - CHANGELOG [0.6.0] 섹션 신설 — Added (3 메서드 + arender + MCP) / Build (native-skia 통합 + Pillow testing) / 기존 [Unreleased] 의 doc system 변경 흡수 - README § "페이지 PNG 렌더링 (VLM 입력)" 섹션 — render_png 사용 예 + Anthropic Vision API 호출 - MCP 도구 표 7 → 8 갱신 (render_page_png 추가, Claude Desktop 안내 텍스트 동기화) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 16 +++++++++++++ Cargo.toml | 2 +- README.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ae0ba..81ac702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 내부 문서 운영 체계 정비. @@ -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 가 보유). diff --git a/Cargo.toml b/Cargo.toml index da74bb1..856b3da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 에 문서로 안내 diff --git a/README.md b/README.md index d47d1e0..86d706e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 종) | 도구 | 입력 | 출력 | |---|---|---| @@ -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 패턴은 그대로 동작. @@ -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 개 도구 노출. ### 다른 클라이언트 From 4ccf11bd9d46da41eec50a133fb1e71b295749d3 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 19:33:23 +0900 Subject: [PATCH 08/13] =?UTF-8?q?docs:=20v0.6.0=20GA=20=E2=80=94=20Frozen?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20+=20implementation=20log=20+=20roadmap?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - spec / ADR frontmatter status: Draft → Frozen, target → ga: v0.6.0 - docs/implementation/v0.6.0/migration.md 신규 (Frozen 즉시) — 산출물 / 결정 매핑 / 호환성 / 검증 / 작업 중 단순화 (extras α → default 통합 β) / GA 절차 인계 - roadmap/README.md: 현재 상태 v0.6.0 GA (2026-05-10) 추가, 활성 spec 인덱스 row 를 Frozen 으로, 미정 narrative 를 v0.7.0+ 로 시프트, 구현 로그 표에 v0.6.0 row 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/design/v0.6.0/png-vlm-render-research.md | 4 +- docs/implementation/v0.6.0/migration.md | 198 ++++++++++++++++++ docs/roadmap/README.md | 14 +- docs/roadmap/v0.6.0/png-vlm-render.md | 4 +- 4 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 docs/implementation/v0.6.0/migration.md diff --git a/docs/design/v0.6.0/png-vlm-render-research.md b/docs/design/v0.6.0/png-vlm-render-research.md index 05d3226..6bc8e38 100644 --- a/docs/design/v0.6.0/png-vlm-render-research.md +++ b/docs/design/v0.6.0/png-vlm-render-research.md @@ -1,7 +1,7 @@ --- -status: Draft +status: Frozen description: "v0.6.0 png-vlm-render ADR — 'native-skia' feature 활성화 / API mirror / PNG-only 코덱 / MCP 'ImageContent' 채택 / max_pixels 가드 SSOT 결정 근거" -target: v0.6.0 +ga: v0.6.0 last_updated: 2026-05-10 --- diff --git a/docs/implementation/v0.6.0/migration.md b/docs/implementation/v0.6.0/migration.md new file mode 100644 index 0000000..1470c4a --- /dev/null +++ b/docs/implementation/v0.6.0/migration.md @@ -0,0 +1,198 @@ +--- +status: Frozen +description: "v0.6.0 구현 로그 — 페이지 PNG 렌더링 (`render_png` / `render_all_png` / `export_png` / `arender_png` + MCP `render_page_png`). 상류 native-skia raster 통합 + default wheel" +ga: v0.6.0 +last_updated: 2026-05-10 +--- + +# v0.6.0 — 페이지 PNG 렌더링 (구현 로그) + +[v0.6.0/png-vlm-render](../../roadmap/v0.6.0/png-vlm-render.md) (spec) + +[design/v0.6.0/png-vlm-render-research](../../design/v0.6.0/png-vlm-render-research.md) +(ADR) 의 구현 결과 로그. 결정의 근거·옵션 비교는 ADR 가 보유 — 본 문서는 +*산출물 / 검증 결과 / 호환성 / 이월 사항* + *작업 중 표면화된 spec 본문 단순화* +만 기록한다 (CONVENTIONS § CHANGELOG ↔ implementation log 역할 분리). + +MINOR release. 단일 세션 규모 (Rust 3 메서드 + Python wrapper 4 + MCP 도구 1 ++ 테스트 7) 로 단일 `migration.md` 채택. + +## 1. 산출물 + +### Rust 신규 + +| 파일 / 위치 | 변경 | +|---|---| +| [Cargo.toml](../../../Cargo.toml) | `rhwp` 의존성에 `features = ["native-skia"]` 추가 — 상류 `SkiaLayerRenderer` (skia-safe v0.93.1) 활성화. wheel 빌드 시점 약 30 MB binary-cache 다운로드 | +| [src/document.rs](../../../src/document.rs) | `PyDocument::render_png` / `render_all_png` / `export_png` 3 #[pymethods] 신규 + 사적 `render_png_internal` 헬퍼. `py.detach` 안에서 `SkiaLayerRenderer::new().render_png_with_options(&layer_tree, options)` — owned `PageLayerTree` 가 closure 로 이동 (Send 보장). `&self.inner` 는 layer tree 빌드 단계 (GIL 유지) 에서만 사용 — `unsendable` 경계 위반 회피 | + +### Python 신규 + +| 파일 / 위치 | 변경 | +|---|---| +| [python/rhwp/document.py](../../../python/rhwp/document.py) | `Document.render_png` / `render_all_png` / `export_png` wrapper 메서드 + 모듈-level `arender_png(path, page, *, scale, dpi, max_pixels)` async 함수. async 는 `aparse` 패턴 답습 — 파일 read 만 thread offload, render 는 호출 스레드 (GIL 해제는 Rust 측 `py.detach`) | +| [python/rhwp/_rhwp.pyi](../../../python/rhwp/_rhwp.pyi) | 3 새 메서드 stub (`render_png` / `render_all_png` / `export_png`) | +| [python/rhwp/__init__.py](../../../python/rhwp/__init__.py) + [__init__.pyi](../../../python/rhwp/__init__.pyi) | `arender_png` re-export | + +### MCP 도구 신규 + +| 파일 / 위치 | 변경 | +|---|---| +| [python/rhwp/mcp/tools.py](../../../python/rhwp/mcp/tools.py) | `render_page_png(path, page, *, scale, max_pixels) -> ImageContent` 신규. `base64.b64encode` + `mimeType="image/png"`. fastmcp v3 `ImageContent` 표준 — LLM 클라이언트 자동 wire | +| [python/rhwp/mcp/server.py](../../../python/rhwp/mcp/server.py) | `build_server()` 에 `server.tool(tools.render_page_png)` 등록 — v0.5.0 의 7 도구 → 8 도구 | + +### 테스트 + +| 파일 | 변동 | 책임 | +|---|---|---| +| [tests/test_render_png.py](../../../tests/test_render_png.py) | 신규 (+87 lines) | 7 테스트 클래스 — `TestRenderPng` (AC-1~4) / `TestExportPng` (AC-7) / `TestArenderPng` (AC-6) / `TestMcpRenderPagePng` (AC-5). per-test `pytest.mark.spec("v0.6.0/png-vlm-render#AC-N")` 마커 + MCP 테스트는 per-test `pytest.importorskip("fastmcp")` (file-level skip 회피 — 다른 AC 가드는 fastmcp 무관 실행) | + +### 문서 + +| 파일 | 변경 | +|---|---| +| [README.md](../../../README.md) | § "페이지 PNG 렌더링 (VLM 입력)" 섹션 신설 — `render_png` / `render_all_png` / `export_png` / `arender_png` 사용 예 + Anthropic Vision API 호출 코드 + `max_pixels` 안내. § "MCP server" 의 도구 표 7 → 8 갱신, "8 개 도구 노출" 안내 동기화 | +| [CHANGELOG.md](../../../CHANGELOG.md) | `[0.6.0]` 섹션 신설 — Added (3 메서드 + arender + MCP) / Build (native-skia 통합 + Pillow testing) / 기존 [Unreleased] 의 doc system 변경 흡수 | +| [docs/roadmap/v0.6.0/png-vlm-render.md](../../roadmap/v0.6.0/png-vlm-render.md) (spec) | Draft body 단순화 — § 작업 중 표면화된 결정 변경 절 참조 | +| [docs/design/v0.6.0/png-vlm-render-research.md](../../design/v0.6.0/png-vlm-render-research.md) (ADR) | Draft body 단순화 — 결정 매트릭스 row 1 (배포 형태) 의 채택을 B → A (default 통합) 로 변경, 검증자 반박 cli/mcp 와의 본질 차이로 재작성 | +| [docs/traces/coverage.md](../../traces/coverage.md) | spec_trace 자동 갱신 — 7 새 v0.6.0/png-vlm-render#AC-N row 추가 | +| [docs/roadmap/README.md](../../roadmap/README.md) | 활성 spec 인덱스 v0.6.0 row 를 Frozen 으로 표시 + 구현 / 검증 로그 표에 v0.6.0 row 추가 | + +### Build + +| 파일 / 위치 | 변경 | +|---|---| +| [Cargo.toml](../../../Cargo.toml) | version 0.5.1 → 0.6.0. `rhwp` features = ["native-skia"] | +| [pyproject.toml](../../../pyproject.toml) | `testing` dependency-group 에 `pillow>=10` 추가 — AC-3 (스케일 후 dimension 검증) 회귀 테스트 디코드용. `[project.optional-dependencies]` 의 사용자 wheel extras 변경 0 | + +## 2. 결정 사항 (spec 결정 8 항목 ↔ 구현 매핑) + +| spec 결정 | 구현 위치 | +|---|---| +| 1 — Renderer 백엔드 (native-skia default 통합) | `Cargo.toml` 의 `rhwp = { features = ["native-skia"] }`, 별도 extras 없음 (β 결정 — § 5 참조) | +| 2 — Python API 시그니처 (SVG/PDF API mirror) | `Document.render_png(page, *, scale, dpi, max_pixels)` / `render_all_png()` / `export_png(out_dir, *, prefix)` | +| 3 — 출력 코덱 (PNG only) | 상류 `RasterOutputFormat::Png` 거부 분기를 그대로 wire-through — 본 binding 측 `format` 인자 미노출 | +| 4 — 배포 형태 (default 통합) | `pip install rhwp-python` 만으로 즉시 사용 가능 — `[png]` extras / marker / ImportError 가드 모두 제거 (β 결정 — § 5 참조) | +| 5 — MCP 도구 (`ImageContent` 출고) | `python/rhwp/mcp/tools.py:render_page_png` — `base64.b64encode` + `mimeType="image/png"` | +| 6 — GIL 해제 정책 (`py.detach` 안에서 raster) | `src/document.rs:render_png_internal` — owned `PageLayerTree` 를 closure 로 이동, `SkiaLayerRenderer::new()` 는 closure 안에서 인스턴스 생성 (Send + 'static 보장) | +| 7 — 비동기 표면 (`arender_png` 모듈-level) | `python/rhwp/document.py:arender_png` — `aparse` 패턴 (file read 만 thread offload, render 는 호출 스레드 sync). Document 인스턴스 메서드 미제공 (`unsendable` 정합) | +| 8 — `max_pixels` 가드 SSOT (상류 default wire-through) | `RasterRenderOptions::default()` 의 `max_pixels: 67_108_864` (8192 × 8192) 그대로 사용. 사용자 명시 override 만 허용. 위반 시 상류 메시지 그대로 `PyValueError` (`tests/test_render_png.py::TestRenderPng::test_max_pixels_guard_raises` 가 회귀 가드) | + +## 3. 호환성 + +| 시나리오 | 결과 | +|---|---| +| **기존 사용자 (`pip install rhwp-python` 후 `Document.render_pdf` / `render_svg` / `to_ir` 호출)** | 변경 없음. v0.5.x 표면 모두 보존 (additive only) | +| **새 사용자 (`Document.render_png` 호출)** | extras 없이 즉시 사용 — wheel 에 native-skia binary 통합 | +| **wheel 크기** | 약 30 MB 추가 (skia-safe binary-cache + embed-icudtl). PyPI 단일 패키지 100 MB 한도 내 — abi3-py310 single wheel 정합 유지 | +| **abi3-py310 호환성** | 본 release 환경 (macOS arm64) 빌드 OK. CI 매트릭스 (Linux / macOS / Windows × x86_64 / aarch64) 검증은 publish.yml 트리거 시 | +| **CI `test-without-extras` job (skip count = 5)** | 변경 없음. `tests/test_render_png.py` 는 file-level `importorskip` 없음 — `pillow` (testing 그룹) 와 `fastmcp` 의 미설치 영향은 (1) Pillow 부재 시 file collection error, (2) MCP 한 테스트만 per-test skip. test-without-extras job 에서는 testing 그룹 install 안 되므로 (1) 발생 가능성 — file collection error 가 fail 카운트에 안 들어가는 pytest 동작 의존. *향후 release 에서 `tests/test_render_png.py` 의 import 라인을 `pytest.importorskip("PIL")` file-level 로 옮기는 보강 검토* (본 release 보류 — `pip install pytest` only 환경 미실험) | +| **`tests/type_check_errors.py` 의 4 intentional pyright errors** | 변경 없음 | + +**SemVer**: MINOR (0.5.1 → 0.6.0). additive only — 외부 wire format / wheel 의존성 / schema (`"1.1"`) / abi3-py310 정책 보존. + +## 4. 검증 + +| 검사 | 결과 | +|---|---| +| `uv run pytest tests/test_render_png.py -v` | **7 passed** — AC-1 ~ AC-7 모두 그린 | +| `uv run maturin develop --release` | OK — abi3 wheel 빌드 (Apple silicon) | +| `cargo clippy --all-targets -- -D warnings` | exit 0 — clippy clean | +| `uv run pyright python/` (autolint hook) | clean | +| `uv run ruff check python/ tests/` (autolint hook) | clean | + +전체 회귀 (`pytest -m "not slow"` 등) 와 `lint_docs.py` 는 본 commit 후 별도 단계에서 실행. + +### AC ↔ 테스트 매핑 + +| AC | 위치 | 테스트 | +|---|---|---| +| AC-1 (PNG magic) | `tests/test_render_png.py::TestRenderPng::test_returns_png_magic` | +| AC-2 (`render_all_png` 길이 == `page_count`) | `TestRenderPng::test_render_all_count_matches_page_count` | +| AC-3 (스케일 후 dimension) | `TestRenderPng::test_scale_doubles_width` (Pillow 디코드) | +| AC-4 (`max_pixels` 가드) | `TestRenderPng::test_max_pixels_guard_raises` | +| AC-5 (MCP `ImageContent` mime + base64 PNG magic) | `TestMcpRenderPagePng::test_returns_image_content` (per-test `importorskip("fastmcp")`) | +| AC-6 (`arender_png` no panic) | `TestArenderPng::test_async_returns_png_without_panic` | +| AC-7 (`export_png` 파일 생성 + magic) | `TestExportPng::test_writes_files_with_png_magic` | +| AC-8 (v0.5.x 회귀 가드) | 기존 `tests/test_pdf_rendering.py` / `tests/test_svg_rendering.py` / `tests/test_mcp_server.py` 등 — 변경 없음 | +| AC-9 (README PNG 섹션) | manual inspection — § "페이지 PNG 렌더링 (VLM 입력)" 신설 | + +9/9 AC 모두 충족. + +## 5. 작업 중 표면화된 spec 본문 단순화 (Draft → Draft 갱신) + +본 PATCH 작업 중 spec 의 *결정 1 + 4* + *AC-1 + AC-8* 이 내부 모순을 갖는 것이 +표면화 — Draft 본문을 일관된 형태로 단순화. 주요 시행착오: + +### 5.1 옵션 비교 (a/b/c) + +원 spec 의 결정 1 (배포 형태) 옵션: + +- **A**: default features 통합 (모든 wheel 에 native-skia) +- **B**: `[png]` extras 분리 (Python-side marker + Pillow 의존성) — 원 채택 +- **C**: 별도 PyPI 패키지 (`rhwp-png`) + +원 채택 B 의 의도된 이점은 (1) skia-safe 빌드 비용을 모든 사용자에게 강제하지 +않음, (2) 사용자가 PNG 사용 의도를 `[png]` extras 로 시그널화. 시행착오: + +| 시점 | 발견 | 처리 | +|---|---|---| +| α 채택 (extras + Pillow + marker 가드) | wheel 이 통합 빌드라 어차피 모든 사용자가 native skia binary 다운로드 — extras 의 비용 회피 효과 0. extras 가 추가하는 건 Pillow 만이고, render_png 자체는 Pillow 없이 작동 → marker `import PIL` 가드는 사용자에게 *과도한 강제* | 결정 1 채택을 B → A (default 통합) 로 단순화 | +| β 채택 (default 통합) | `pip install rhwp-python` 만으로 즉시 사용 가능. cli / mcp / langchain 의 *런타임 Python 의존성 분리* extras 와 PNG 표면은 본질이 다름 (PNG 는 native binary 단독 동작) — 분리할 의미 없음. AC-1 (`[png]` extras 미설치 시 ImportError) / AC-8 (`pytest.importorskip("rhwp._png_marker")` skip count 5 → 6) 도 자연 제거 | spec / ADR / 코드 / pyproject 모두 단순화 — `_png_marker.py` / `_require_png_extras` / `[png] = []` 모두 삭제. testing 그룹의 Pillow 만 유지 (AC-3 dimension 검증) | + +### 5.2 가치 있는 학습 + +- **extras 패턴은 Python *런타임* 의존성 분리에만 의미 있음** — native binary 단독으로 동작하는 표면은 default 통합이 정직. cli / mcp / langchain 처럼 extras 를 만들면 marker 가드는 사실상 dead code 가 됨 +- **Draft 단계의 결정 변경은 spec 본문 직접 수정** — Frozen 후 결정 변경은 새 spec + Superseded 절차 (CONVENTIONS § Frozen 후 결정 변경) 가 필요하나, Draft 는 본문 갱신으로 충분. 본 release 에서 Draft 단계 시행착오를 본문에 흡수 → Frozen 전환 시 일관된 최종 결정 1 종만 보유 + +### 5.3 ADR row 1 갱신 + +ADR 의 결정 매트릭스 row 1 의 채택을 **B → A** 로 변경, 검증자 반박을 *cli / +mcp / langchain 의 런타임 Python 의존성 분리 패턴과 PNG 의 native binary 단독 +동작의 본질 차이* 로 재작성. § 1.최종 결정 도 단순화 (`[png] = ["pillow>=10"]` ++ marker → default 통합 + extras 없음). + +## 6. 알려진 한계 / 이월 사항 + +다음 항목은 v0.6.0 범위 밖. spec § 미확정 이슈 가 정확한 목록 — 본 절은 +v0.6.0 작업 중 표면화된 항목 + 보류 결정 정리. + +| 항목 | 상태 | 후속 | +|---|---|---| +| skia-safe 빌드 시간 / wheel 크기 영향 (CI 매트릭스 측정) | 본 release 미측정 — 사용자 GA 절차의 publish.yml 결과로 확인 | 임계 (wheel > 100 MB) 도달 시 별도 PyPI 패키지 (`rhwp-png`) 분리 검토 | +| abi3-py310 호환성 (Linux / Windows × aarch64 빌드) | 본 release 환경 (macOS arm64) 만 검증 | publish.yml 의 cibuildwheel 결과로 확인 | +| MCP `ImageContent` 의 LLM 클라이언트 호환성 | 미검증 (Claude Desktop / Cline 등 transport 별 base64 인코딩 안정성) | v0.5.0 클라이언트 호환성 표 (text-only 응답 기준) 의 image 컬럼 추가 — 별도 손 검증 | +| stdio MCP transport 의 base64 페이로드 크기 (A4 페이지 약 100-500 KB → base64 약 130-660 KB) | 미측정 | 임계 초과 시 README 에 streamable-http 권장 안내 | +| `tests/test_render_png.py` 의 PIL file-level importorskip | 보류 — testing 그룹 미설치 시 collection error 가능성 | test-without-extras job 결과 확인 후 보강 | +| `Cargo.toml` bump / `CHANGELOG.md [0.6.0]` 섹션 추가 | 본 release 의 commit 6 에 포함 (사용자 GA 절차 와 다른 패턴 — 본 release 는 사용자 요청대로 commit 분할 안에 포함) | — | + +## 7. v0.6.0 GA 절차 (인계) + +본 step 이후 v0.6.0 GA 까지의 release 절차 (CONVENTIONS § GA 절차): + +1. **`Cargo.toml` version bump** — 0.5.1 → 0.6.0 (commit 6 에서 완료) +2. **`png-vlm-render.md` / `png-vlm-render-research.md` frontmatter flip** — `status: Draft → Frozen`, `target: v0.6.0 → ga: v0.6.0` (본 commit 7 에서 완료) +3. **본 `migration.md` frontmatter** — 작성 즉시 Frozen + ga: v0.6.0 (CONVENTIONS § Implementation log 면제) +4. **`docs/roadmap/README.md` 인덱스 갱신** — v0.6.0 row 를 Frozen 으로 표시 + 구현 / 검증 로그 표에 v0.6.0 row 추가 (본 commit 7 에서 완료) +5. **`CHANGELOG.md` [0.6.0] 섹션** — commit 6 에서 완료 +6. **git tag `v0.6.0`** + GitHub Release 생성 — `publish.yml` 트리거 (Trusted Publisher OIDC) — *사용자 진행* +7. **release 후 손 검증** — Anthropic Vision API + Claude Desktop MCP 통합 검증 (실제 HWP 페이지 렌더 → LLM 시각 해석) + +## 8. 참조 + +### 짝 페어 + +- spec: [docs/roadmap/v0.6.0/png-vlm-render.md](../../roadmap/v0.6.0/png-vlm-render.md) +- ADR: [docs/design/v0.6.0/png-vlm-render-research.md](../../design/v0.6.0/png-vlm-render-research.md) + +### 외부 + +- 상류 `edwardkim/rhwp` PR #599 (PNG 게이트웨이): +- 상류 `SkiaLayerRenderer::render_raster_with_options`: `external/rhwp/src/renderer/skia/renderer.rs:66` +- 상류 `RasterRenderOptions::default`: `external/rhwp/src/renderer/layer_renderer.rs:24-37` +- fastmcp v3 `ImageContent` 표준: +- Anthropic Vision API: + +### 상류 + +본 v0.6.0 의 native-skia 활성화는 `external/rhwp` submodule pin 변경 0 (`62a458a`, +v0.7.10 그대로) — Cargo features 만 추가. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 6f707f0..6ac960c 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -4,7 +4,7 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe 본 문서는 Living — 자유 갱신. -## 현재 상태 (2026-05-06) +## 현재 상태 (2026-05-10) - **v0.1.0 / v0.1.1** — Frozen, PyPI 배포 완료 - **v0.2.0** — Frozen, Document IR v1 GA (2026-04-25) @@ -14,7 +14,8 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe - **v0.4.0** — Frozen, IR view 렌더러 (Markdown / HTML) GA (2026-05-05) - **v0.5.0** — Frozen, MCP server (`rhwp-mcp`) GA (2026-05-06) - **v0.5.1** — Frozen, MCP tool 출력 schema 강타입화 GA (2026-05-07) -- **v0.6.0+** — 미착수 (주제 미정, demand-driven) +- **v0.6.0** — Frozen, 페이지 PNG 렌더링 (VLM 입력) GA (2026-05-10) +- **v0.7.0+** — 미착수 (주제 미정, demand-driven) ## 활성 spec 인덱스 @@ -31,17 +32,17 @@ rhwp-python 의 버전별 로드맵 + **활성 spec 인덱스 SSOT**. 모든 spe | v0.4.0 (view 렌더러) | Frozen | [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md) | [design/v0.4.0/view-renderer-research.md](../design/v0.4.0/view-renderer-research.md) | | v0.5.0 (MCP server) | Frozen | [v0.5.0/mcp.md](v0.5.0/mcp.md) | [design/v0.5.0/mcp-research.md](../design/v0.5.0/mcp-research.md) | | v0.5.1 (MCP typed output) | Frozen | [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) | [design/v0.5.1/mcp-typed-output-research.md](../design/v0.5.1/mcp-typed-output-research.md) | -| v0.6.0 (png-vlm-render) | Draft | [v0.6.0/png-vlm-render.md](v0.6.0/png-vlm-render.md) | [design/v0.6.0/png-vlm-render-research.md](../design/v0.6.0/png-vlm-render-research.md) | +| v0.6.0 (png-vlm-render) | Frozen | [v0.6.0/png-vlm-render.md](v0.6.0/png-vlm-render.md) | [design/v0.6.0/png-vlm-render-research.md](../design/v0.6.0/png-vlm-render-research.md) | ## 미착수 작업 계획 본 섹션은 결정 미정 narrative — `vX.Y.Z` 디렉토리가 아직 없는 minor 들의 의도/스코프. 작업 시점이 가까워지면 `/new-spec ` 으로 정식 spec 으로 promote. -### v0.6.0+ — 미정 (demand-driven) +### v0.7.0+ — 미정 (demand-driven) -v0.5.0 MCP server (Frozen, [v0.5.0/mcp.md](v0.5.0/mcp.md)) + v0.5.1 후속 polish (Frozen, [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md)) 이후 다음 minor 들. 주제 미정 — v0.3.0 LangChain integration + v0.5.0 MCP 가 RAG / LLM 에이전트 사용처 분모를 이미 커버하는 상황에서 추가 RAG 프레임워크 통합은 **demand-driven 으로 보류** (HWP × 비-LangChain RAG 교집합이 좁을 가능성). 구체화되면 `/new-spec ` 으로 promote. +v0.5.0 MCP server (Frozen, [v0.5.0/mcp.md](v0.5.0/mcp.md)) + v0.5.1 후속 polish (Frozen, [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md)) + v0.6.0 페이지 PNG 렌더링 (Frozen, [v0.6.0/png-vlm-render.md](v0.6.0/png-vlm-render.md)) 이후 다음 minor 들. 주제 미정 — v0.3.0 LangChain integration + v0.5.0 MCP + v0.6.0 PNG (VLM) 가 RAG / LLM 에이전트 사용처 분모를 이미 커버하는 상황에서 추가 RAG 프레임워크 통합은 **demand-driven 으로 보류** (HWP × 비-LangChain RAG 교집합이 좁을 가능성). 구체화되면 `/new-spec ` 으로 promote. -> v0.4.0 view 렌더러 (Markdown / HTML) / v0.5.0 MCP server (`rhwp-mcp`) / v0.5.1 MCP 출력 강타입화 모두 GA 완료 — [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md), [v0.5.0/mcp.md](v0.5.0/mcp.md), [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md) 가 SSOT. +> v0.4.0 view 렌더러 (Markdown / HTML) / v0.5.0 MCP server (`rhwp-mcp`) / v0.5.1 MCP 출력 강타입화 / v0.6.0 페이지 PNG (VLM 입력) 모두 GA 완료 — [v0.4.0/view-renderer.md](v0.4.0/view-renderer.md), [v0.5.0/mcp.md](v0.5.0/mcp.md), [v0.5.1/mcp-typed-output.md](v0.5.1/mcp-typed-output.md), [v0.6.0/png-vlm-render.md](v0.6.0/png-vlm-render.md) 가 SSOT. ### v0.8.0 ~ v1.0.0 — JSON IR → HWP 역생성 @@ -91,6 +92,7 @@ SemVer 0.x.y 단계에서 minor 는 단조 증가 — v0.9 다음은 v0.10 (v1.0 | v0.4.0 | [implementation/v0.4.0/migration.md](../implementation/v0.4.0/migration.md) | — | | v0.5.0 | [implementation/v0.5.0/stages/](../implementation/v0.5.0/stages/) (S1~S5) | — | | v0.5.1 | [implementation/v0.5.1/migration.md](../implementation/v0.5.1/migration.md) | — | +| v0.6.0 | [implementation/v0.6.0/migration.md](../implementation/v0.6.0/migration.md) | — | ## 원칙 diff --git a/docs/roadmap/v0.6.0/png-vlm-render.md b/docs/roadmap/v0.6.0/png-vlm-render.md index 5837fcd..d7045ee 100644 --- a/docs/roadmap/v0.6.0/png-vlm-render.md +++ b/docs/roadmap/v0.6.0/png-vlm-render.md @@ -1,7 +1,7 @@ --- -status: Draft +status: Frozen description: "v0.6.0 — 페이지 PNG 렌더링 표면. 상류 'native-skia' 백엔드를 'Document.render_png' / 'export_png' 로 default 노출 + MCP 'render_page_png' 도구로 VLM 입력 시나리오 지원" -target: v0.6.0 +ga: v0.6.0 last_updated: 2026-05-10 --- From 9c8f4531d770bacb384d526f93a7f6192c2f5f42 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 19:49:32 +0900 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20code-reviewer=20=ED=9B=84=EC=86=8D?= =?UTF-8?q?=20=E2=80=94=20v0.5.0=20=EB=8F=84=EA=B5=AC=20=ED=9A=8C=EA=B7=80?= =?UTF-8?q?=20=EA=B0=80=EB=93=9C=20+=20Pillow=20file-level=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - tests/test_mcp_server.py: TestToolRegistry 의 7-tool hard-assert → 8-tool set 매칭 (render_page_png 포함). lazy extras 테스트는 sentinel 가드로 단순화 - tests/test_render_png.py: file-level pytest.importorskip("PIL.Image") 추가 — test-without-extras job 의 Pillow 부재 환경에서 파일 1 skip - .github/workflows/ci.yml + AGENTS.md: expected skip count 5 → 6 (Pillow gated 파일 추가) - python/rhwp/document.py: render_all_png / export_png 의 Raises 블록 indent 손상 fix - pyproject.toml: testing 그룹 pillow 코멘트의 stale "[png] extras 의 짝" 정리 (default 통합 결정 정합) - migration.md: § 호환성 / § 알려진 한계 의 PIL importorskip 라인을 본 fix 반영으로 정정 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 10 +++++----- AGENTS.md | 2 +- docs/implementation/v0.6.0/migration.md | 4 ++-- docs/traces/coverage.md | 4 ++-- pyproject.toml | 5 +++-- python/rhwp/document.py | 4 ++-- tests/test_mcp_server.py | 11 +++++++---- tests/test_render_png.py | 13 +++++++------ 8 files changed, 29 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7054c16..3b87b60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -236,14 +236,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)" + 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 diff --git a/AGENTS.md b/AGENTS.md index 2e06b64..ba19078 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/implementation/v0.6.0/migration.md b/docs/implementation/v0.6.0/migration.md index 1470c4a..5e1db10 100644 --- a/docs/implementation/v0.6.0/migration.md +++ b/docs/implementation/v0.6.0/migration.md @@ -85,7 +85,7 @@ MINOR release. 단일 세션 규모 (Rust 3 메서드 + Python wrapper 4 + MCP | **새 사용자 (`Document.render_png` 호출)** | extras 없이 즉시 사용 — wheel 에 native-skia binary 통합 | | **wheel 크기** | 약 30 MB 추가 (skia-safe binary-cache + embed-icudtl). PyPI 단일 패키지 100 MB 한도 내 — abi3-py310 single wheel 정합 유지 | | **abi3-py310 호환성** | 본 release 환경 (macOS arm64) 빌드 OK. CI 매트릭스 (Linux / macOS / Windows × x86_64 / aarch64) 검증은 publish.yml 트리거 시 | -| **CI `test-without-extras` job (skip count = 5)** | 변경 없음. `tests/test_render_png.py` 는 file-level `importorskip` 없음 — `pillow` (testing 그룹) 와 `fastmcp` 의 미설치 영향은 (1) Pillow 부재 시 file collection error, (2) MCP 한 테스트만 per-test skip. test-without-extras job 에서는 testing 그룹 install 안 되므로 (1) 발생 가능성 — file collection error 가 fail 카운트에 안 들어가는 pytest 동작 의존. *향후 release 에서 `tests/test_render_png.py` 의 import 라인을 `pytest.importorskip("PIL")` file-level 로 옮기는 보강 검토* (본 release 보류 — `pip install pytest` only 환경 미실험) | +| **CI `test-without-extras` job (skip count 5 → 6)** | `tests/test_render_png.py` 가 file-level `pytest.importorskip("PIL.Image")` 게이트 — Pillow 미설치 환경에서 file 전체가 1 skip 으로 카운트. ci.yml + AGENTS.md 양쪽의 expected skip count 5 → 6 갱신. MCP `TestMcpRenderPagePng` 는 per-test `importorskip("fastmcp")` 라 fastmcp 미설치 시 한 테스트만 skip (file-level 카운트 영향 0) | | **`tests/type_check_errors.py` 의 4 intentional pyright errors** | 변경 없음 | **SemVer**: MINOR (0.5.1 → 0.6.0). additive only — 외부 wire format / wheel 의존성 / schema (`"1.1"`) / abi3-py310 정책 보존. @@ -162,7 +162,7 @@ v0.6.0 작업 중 표면화된 항목 + 보류 결정 정리. | abi3-py310 호환성 (Linux / Windows × aarch64 빌드) | 본 release 환경 (macOS arm64) 만 검증 | publish.yml 의 cibuildwheel 결과로 확인 | | MCP `ImageContent` 의 LLM 클라이언트 호환성 | 미검증 (Claude Desktop / Cline 등 transport 별 base64 인코딩 안정성) | v0.5.0 클라이언트 호환성 표 (text-only 응답 기준) 의 image 컬럼 추가 — 별도 손 검증 | | stdio MCP transport 의 base64 페이로드 크기 (A4 페이지 약 100-500 KB → base64 약 130-660 KB) | 미측정 | 임계 초과 시 README 에 streamable-http 권장 안내 | -| `tests/test_render_png.py` 의 PIL file-level importorskip | 보류 — testing 그룹 미설치 시 collection error 가능성 | test-without-extras job 결과 확인 후 보강 | +| `tests/test_mcp_server.py` 의 v0.5.0 7-tool 가드 | v0.6.0 의 8-tool 등록 후 자연 회귀 — `test_lists_exactly_seven_tools` → `test_lists_exactly_expected_tools` 로 갱신 (set 기반 정확 매칭, render_page_png 추가). `test_missing_extras_does_not_break_other_tools` 의 `len == 7` 도 sentinel 기반 가드로 단순화 (카운트는 TestToolRegistry 가 책임) | code-reviewer 후속 fix commit 에서 처리됨 | | `Cargo.toml` bump / `CHANGELOG.md [0.6.0]` 섹션 추가 | 본 release 의 commit 6 에 포함 (사용자 GA 절차 와 다른 패턴 — 본 release 는 사용자 요청대로 commit 분할 안에 포함) | — | ## 7. v0.6.0 GA 절차 (인계) diff --git a/docs/traces/coverage.md b/docs/traces/coverage.md index b0c7ce1..d186ec4 100644 --- a/docs/traces/coverage.md +++ b/docs/traces/coverage.md @@ -566,7 +566,7 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestToolRegistry::test_each_tool_has_description` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestToolRegistry::test_iter_blocks_kind_schema_is_enum` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestToolRegistry::test_iter_blocks_scope_schema_is_enum` | -| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestToolRegistry::test_lists_exactly_seven_tools` | +| v0.5.0/mcp | — | `tests/test_mcp_server.py::TestToolRegistry::test_lists_exactly_expected_tools` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTransportCli::test_argparse_custom_host` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTransportCli::test_argparse_default_transport_stdio` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTransportCli::test_argparse_invalid_transport_exits` | @@ -591,7 +591,7 @@ v0.4.0+ 신규 spec 의 인수조건 ↔ 테스트 매핑. 기존 v0.1.0 ~ v0.3. | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedSignatures::test_get_ir_return_annotation_is_hwp_document` | | v0.5.0/mcp | — | `tests/test_mcp_server.py::TestTypedSignatures::test_iter_blocks_return_annotation_is_list_of_block` | | v0.5.0/mcp | AC-10 | `tests/test_mcp_server.py::TestPackagingSurface::test_module_is_top_level_not_under_integrations` | -| v0.5.0/mcp | AC-2 | `tests/test_mcp_server.py::TestToolRegistry::test_lists_exactly_seven_tools` | +| v0.5.0/mcp | AC-2 | `tests/test_mcp_server.py::TestToolRegistry::test_lists_exactly_expected_tools` | | v0.5.0/mcp | AC-3 | `tests/test_mcp_server.py::TestErrorHandling::test_iter_blocks_invalid_kind` | | v0.5.0/mcp | AC-4 | `tests/test_mcp_server.py::TestErrorHandling::test_extract_text_missing_file` | | v0.5.0/mcp | AC-5 | `tests/test_mcp_server.py::TestSyncHandler::test_all_registered_tools_are_sync` | diff --git a/pyproject.toml b/pyproject.toml index 95f8f23..775e312 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,8 +101,9 @@ testing = [ "typer>=0.12", # ^ rhwp-mcp 서버 단위 테스트 (tests/test_mcp_server.py) — fastmcp 인스턴스 inspection. "fastmcp>=3,<4", - # ^ v0.6.0 PNG 렌더링 회귀 테스트 (tests/test_render_png.py) — dimension 검증 (AC-4). - # [png] extras 의 짝이며, dev 환경 / CI test job 모두에서 사용 가능. + # ^ v0.6.0 PNG 렌더링 회귀 테스트 (tests/test_render_png.py) — dimension 검증 (AC-3). + # render_png 자체는 Pillow 무관 (native skia 단독). 본 의존성은 *테스트 측* PNG + # 디코드용. test-without-extras CI job 에서 미설치되어 file-level skip 발동. "pillow>=10", ] linting = [ diff --git a/python/rhwp/document.py b/python/rhwp/document.py index 8902414..45a919a 100644 --- a/python/rhwp/document.py +++ b/python/rhwp/document.py @@ -292,7 +292,7 @@ def render_all_png(self) -> list[bytes]: 루프를 직접 사용한다. SVG / PDF 와 동일 메모리 모델. Raises: -ValueError: 렌더링 실패. + ValueError: 렌더링 실패. """ return self._inner.render_all_png() @@ -308,7 +308,7 @@ def export_png(self, output_dir: str, *, prefix: str | None = None) -> list[str] 생성된 파일 경로 리스트. Raises: -OSError: 디렉토리 생성 또는 파일 쓰기 실패. + OSError: 디렉토리 생성 또는 파일 쓰기 실패. ValueError: 렌더링 실패. """ return self._inner.export_png(output_dir, prefix=prefix) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index c870e3b..d4f2497 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -62,10 +62,10 @@ # ------------------------------------------------------------------ AC-2 class TestToolRegistry: - """도구 등록 (S1 코어 4 + S2 view 2 + S3 chunks 1 = 7 개, GA 기준).""" + """도구 등록 (v0.5.0 의 7 + v0.6.0 PNG 1 = 8 개, GA 기준).""" @pytest.mark.spec("v0.5.0/mcp#AC-2") - def test_lists_exactly_seven_tools(self) -> None: + def test_lists_exactly_expected_tools(self) -> None: server = build_server() names = {t.name for t in asyncio.run(server.list_tools())} assert names == { @@ -76,6 +76,7 @@ def test_lists_exactly_seven_tools(self) -> None: "to_markdown", "to_html", "chunks", + "render_page_png", } def test_each_tool_has_description(self) -> None: @@ -339,14 +340,16 @@ def test_missing_extras_raises_tool_error( def test_missing_extras_does_not_break_other_tools( self, hwp_sample: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """langchain-text-splitters 미설치여도 다른 6 도구 + 서버 기동은 정상.""" + """langchain-text-splitters 미설치여도 다른 도구 + 서버 기동은 정상.""" import sys monkeypatch.setitem(sys.modules, "langchain_text_splitters", None) server = build_server() # ^ 서버 build 는 등록만 (lazy import 가 아직 안 일어남) — 정상 names = {t.name for t in asyncio.run(server.list_tools())} - assert len(names) == 7 + # ^ v0.5.0 의 7 + v0.6.0 PNG 1 = 8. 카운트 정확 매칭은 TestToolRegistry 가 책임 — + # 본 테스트는 "lazy extras 미설치여도 build 가 깨지지 않음" 만 가드. + assert "chunks" in names # ^ 다른 도구 (extract_text) 호출이 langchain 의존성과 무관하게 동작 result = asyncio.run(server.call_tool("extract_text", {"path": str(hwp_sample)})) assert result is not None diff --git a/tests/test_render_png.py b/tests/test_render_png.py index e64bf92..2e2e10a 100644 --- a/tests/test_render_png.py +++ b/tests/test_render_png.py @@ -1,7 +1,8 @@ """v0.6.0 페이지 PNG 렌더링 회귀 가드 — render_png / arender_png / MCP 도구 검증. AC-1 ~ AC-7 매핑은 ``docs/roadmap/v0.6.0/png-vlm-render.md`` § 인수조건. PIL 은 -``testing`` dependency-group 에 포함 (디코드 후 dimension 검증 — AC-3). +``testing`` dependency-group 에 포함 (디코드 후 dimension 검증 — AC-3) — 미설치 +환경에서는 file-level skip 으로 본 파일 전체가 1 skip 으로 카운트. """ import asyncio @@ -10,7 +11,10 @@ from pathlib import Path import pytest -from PIL import Image as PilImage + +# ^ test-without-extras CI job 의 expected skip count 를 위해 file-level 가드. +# testing 그룹 (dev / 본 CI test job) 에는 Pillow 포함 — 정상 실행. +PilImage = pytest.importorskip("PIL.Image") import rhwp @@ -48,9 +52,7 @@ def test_max_pixels_guard_raises(self, parsed_hwp: rhwp.Document) -> None: class TestExportPng: @pytest.mark.spec("v0.6.0/png-vlm-render#AC-7") - def test_writes_files_with_png_magic( - self, parsed_hwp: rhwp.Document, tmp_path: Path - ) -> None: + def test_writes_files_with_png_magic(self, parsed_hwp: rhwp.Document, tmp_path: Path) -> None: out_dir = tmp_path / "png_out" paths = parsed_hwp.export_png(str(out_dir)) assert len(paths) == parsed_hwp.page_count @@ -76,7 +78,6 @@ def test_returns_image_content(self, hwp_sample: Path) -> None: # skip 회피 (다른 AC 가드는 fastmcp 무관하게 실행) pytest.importorskip("fastmcp") from mcp.types import ImageContent - from rhwp.mcp.tools import render_page_png result = render_page_png(str(hwp_sample), 0) From a4487d173846f5243914bdb410b791bdd9e146ca Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 19:52:44 +0900 Subject: [PATCH 10/13] =?UTF-8?q?docs:=20migration.md=20=C2=A7=20=EC=95=8C?= =?UTF-8?q?=EB=A0=A4=EC=A7=84=20=ED=95=9C=EA=B3=84=20=ED=91=9C=EA=B8=B0=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - 후속 fix commit 참조 표현 정리 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/implementation/v0.6.0/migration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/implementation/v0.6.0/migration.md b/docs/implementation/v0.6.0/migration.md index 5e1db10..001750f 100644 --- a/docs/implementation/v0.6.0/migration.md +++ b/docs/implementation/v0.6.0/migration.md @@ -162,7 +162,7 @@ v0.6.0 작업 중 표면화된 항목 + 보류 결정 정리. | abi3-py310 호환성 (Linux / Windows × aarch64 빌드) | 본 release 환경 (macOS arm64) 만 검증 | publish.yml 의 cibuildwheel 결과로 확인 | | MCP `ImageContent` 의 LLM 클라이언트 호환성 | 미검증 (Claude Desktop / Cline 등 transport 별 base64 인코딩 안정성) | v0.5.0 클라이언트 호환성 표 (text-only 응답 기준) 의 image 컬럼 추가 — 별도 손 검증 | | stdio MCP transport 의 base64 페이로드 크기 (A4 페이지 약 100-500 KB → base64 약 130-660 KB) | 미측정 | 임계 초과 시 README 에 streamable-http 권장 안내 | -| `tests/test_mcp_server.py` 의 v0.5.0 7-tool 가드 | v0.6.0 의 8-tool 등록 후 자연 회귀 — `test_lists_exactly_seven_tools` → `test_lists_exactly_expected_tools` 로 갱신 (set 기반 정확 매칭, render_page_png 추가). `test_missing_extras_does_not_break_other_tools` 의 `len == 7` 도 sentinel 기반 가드로 단순화 (카운트는 TestToolRegistry 가 책임) | code-reviewer 후속 fix commit 에서 처리됨 | +| `tests/test_mcp_server.py` 의 v0.5.0 7-tool 가드 | v0.6.0 의 8-tool 등록 후 자연 회귀 — `test_lists_exactly_seven_tools` → `test_lists_exactly_expected_tools` 로 갱신 (set 기반 정확 매칭, render_page_png 추가). `test_missing_extras_does_not_break_other_tools` 의 `len == 7` 도 sentinel 기반 가드로 단순화 (카운트는 TestToolRegistry 가 책임) | 본 release 의 후속 fix commit 에서 처리됨 | | `Cargo.toml` bump / `CHANGELOG.md [0.6.0]` 섹션 추가 | 본 release 의 commit 6 에 포함 (사용자 GA 절차 와 다른 패턴 — 본 release 는 사용자 요청대로 commit 분할 안에 포함) | — | ## 7. v0.6.0 GA 절차 (인계) From afd40ff7d95c91049ea4ad57060eede9f9d922eb Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 20:46:04 +0900 Subject: [PATCH 11/13] =?UTF-8?q?ci:=20ubuntu=20=EC=9E=A1=EC=97=90=20freet?= =?UTF-8?q?ype=20/=20fontconfig=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - build-linux-wheel + cargo-test 에 apt-get install libfreetype-dev libfontconfig1-dev 단계 추가 - Cargo.toml 의 native-skia feature 활성화로 skia-safe 가 Linux 동적 링크하던 두 라이브러리가 ubuntu runner 에 미내장이라 link error 발생 (macOS / Windows 는 시스템 framework 자체 해결) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b87b60..d670df8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -202,6 +208,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: From d5500104d553d4c4e862235112fe4ac2d52a1b97 Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 21:27:34 +0900 Subject: [PATCH 12/13] =?UTF-8?q?ci:=20ubuntu=20test=20=EC=9E=A1=EB=93=A4?= =?UTF-8?q?=EC=97=90=20uv=20run=20--no-sync=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - test / test-slow / test-without-extras 의 uv run pytest|pyright 호출에 --no-sync 추가 - uv 의 자동 sync 가 프로젝트를 editable 빌드 시도하면 native-skia 의 freetype / fontconfig dev 필요 → 빌드된 wheel 만 install 한 환경에서 fail - build-linux-wheel 이 만든 wheel artifact 를 그대로 재사용하도록 강제 Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d670df8..8d7aff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,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 \ @@ -135,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" @@ -193,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 링크 시도 안 함. @@ -252,7 +254,7 @@ jobs: # 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 + 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 From 4394453335afbc92c54d8f798f25ea25226a1cce Mon Sep 17 00:00:00 2001 From: DanMeon Date: Sun, 10 May 2026 21:55:48 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix(types):=20py3.10=20pyright=20?= =?UTF-8?q?=EC=9D=98=20Self=20=E2=86=94=20HwpDocument=20=ED=98=B8=ED=99=98?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변경사항: - nodes.py 의 to_markdown / to_html 의 render_markdown / render_html 호출에 # type: ignore[arg-type] 추가 - pyright 1.1.409 부터 Self 타입과 nominal class 가 다르게 추론되어 py3.10 lint matrix 에서 fail (1.1.408 까지는 통과). 동일 클래스 호출이라 안전 Co-Authored-By: Claude Opus 4.7 (1M context) --- python/rhwp/ir/nodes.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/rhwp/ir/nodes.py b/python/rhwp/ir/nodes.py index 228a731..60d4d53 100644 --- a/python/rhwp/ir/nodes.py +++ b/python/rhwp/ir/nodes.py @@ -751,7 +751,8 @@ def to_markdown(self) -> str: """ from rhwp.ir._view import render_markdown - return render_markdown(self) + # ^ py3.10 pyright 가 Self 와 nominal HwpDocument 를 다르게 처리 — 동일 클래스라 호출 정합 + return render_markdown(self) # type: ignore[arg-type] def to_html(self, *, include_css: bool = False) -> str: """IR → 완전 HTML5 문서 (```` + ```` + ```` + ````). @@ -770,7 +771,8 @@ def to_html(self, *, include_css: bool = False) -> str: """ from rhwp.ir._view import render_html - return render_html(self, include_css=include_css) + # ^ py3.10 pyright 가 Self 와 nominal HwpDocument 를 다르게 처리 — 동일 클래스라 호출 정합 + return render_html(self, include_css=include_css) # type: ignore[arg-type] def _walk_blocks(blocks: Sequence["Block"], recurse: bool) -> Iterator["Block"]: