Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

## [Unreleased]
### 추가
- `hwpx.tools.markdown_export.export_markdown()`와 `HwpxDocument.export_rich_markdown()`을 추가해 풍부한 Markdown 변환을 지원합니다. 인라인 서식(굵게/기울임/색상/하이라이트), 표 병합 셀(colspan/rowspan HTML), 중첩 표 재귀, `rect`/`ellipse`/`polygon` 도형 내부 paragraph, BinData 이미지 추출, `Ⅰ.`/`1.` 패턴 기반 헤딩 감지(`# `/`## `), 각주·미주(정확 위치 마커 + `fn1`/`en1` 일련번호 + 본문 인라인 서식), 하이퍼링크(`[text](url)`) 보존을 한 번에 처리합니다. 기존 `HwpxDocument.export_markdown()`은 그대로 유지됩니다.
- `HwpxOxmlNote`에 본문 paragraph 접근/편집 helper를 추가했습니다: `body_paragraph` property, `add_run(text, *, char_pr_id_ref=..., bold=..., italic=..., underline=..., attributes=...)`, `add_hyperlink(url, display_text, *, char_pr_id_ref=...)`. XML 직접 조작 없이 각주 본문에 혼합 서식 run과 하이퍼링크를 추가할 수 있습니다.
- 새 컨버터와 helper에 대한 회귀 테스트 27개를 `tests/test_markdown_export.py`에 추가했습니다 (인라인 서식, 표 병합/중첩, 각주 정확 위치 + 일련번호 + 본문 인라인 서식 + 하이퍼링크, heading 감지, BinData 추출, 라운드트립 charPrIDRef 보존, 입력 형식 4가지).
- `src/hwpx/tools/_schemas/owpml/`에 2011 Hancom 네임스페이스용 subset XSD 번들을 추가했습니다 (`header.xsd`, `body.xsd`, `paralist.xsd`, `core.xsd`, `xml.xsd`, `NOTICE`).
- `hwpx.oxml.load_compound_schema()`와 `SchemaImportError`를 추가해 offline compound XSD 로딩을 지원합니다.
- fixture matrix 기반 Phase 1 validation 리포트(`shared/hwpx/HWPX_STACK_VALIDATION_2026-04-20_pre-phase1.md`, `..._post-phase1.md`)와 회귀 테스트를 추가했습니다.
Expand All @@ -13,6 +16,9 @@
- `HwpxDocument.validate()`는 기본 `strict=False`로 동작하며, `validate_on_save_strict` 옵션으로 저장 시 strict 검증을 제어할 수 있습니다.
- 패키지 배포물(sdist/wheel)에 OWPML subset schema bundle이 포함되도록 package-data를 확장했습니다.

### 수정
- `HwpxOxmlParagraph.add_footnote()`/`add_endnote()`의 `char_pr_id_ref` 인자가 외부 호스팅 run에만 적용되고 각주 **본문 run은 항상 `charPrIDRef="0"`** 으로 하드코딩되던 문제를 수정했습니다. 인자가 사용자 의도대로 본문 run에도 적용됩니다. 회귀 테스트: `TestNoteHelpers::test_add_footnote_cpr_applies_to_body`.

## [2.9.1] - 2026-04-27

상호운용성(interop) 버그 묶음 릴리즈입니다. 외부 기여자들이 보고하고 수정한 세 가지 문제를 정리합니다.
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,47 @@ hwpx-validate-package 보고서.hwpx
hwpx-analyze-template 보고서.hwpx
```

### 4. 풍부한 Markdown 변환 (서식·표·각주·이미지 보존)

`export_markdown()`는 단순 평문 추출이고, `export_rich_markdown()`는 인라인 서식(`**굵게**`, `*기울임*`, `~~취소선~~`),
표(중첩 포함, colspan/rowspan 안전), 도형 텍스트, 이미지, 각주/미주, 하이퍼링크, 제목(`#`/`##`) 자동 감지까지 보존한다.

```python
from hwpx import HwpxDocument

doc = HwpxDocument.open("보고서.hwpx")

md = doc.export_rich_markdown(
image_dir="out/images", # BinData 이미지를 디스크에 추출
image_ref_prefix="images/", # 마크다운 내 ![](images/...) 경로 접두
detect_headings=True, # styleIDRef 기반 #/## 자동
)
print(md)
```

문자열·경로·바이트도 그대로 받는다:

```python
from hwpx.tools.markdown_export import export_markdown

md = export_markdown("보고서.hwpx") # 경로
md = export_markdown(open("a.hwpx", "rb").read()) # bytes
```

### 5. 각주 본문에 혼합 서식 / 하이퍼링크 추가

`HwpxOxmlNote`에 `body_paragraph`, `add_run`, `add_hyperlink` helper가 있어 각주 본문을
직접 paragraph로 다루지 않고도 인라인 서식·링크를 손쉽게 채울 수 있다.

```python
para = section.paragraphs[0]
note = para.add_footnote("") # 빈 각주 생성 후 본문 구성
note.add_run("자세한 내용은 ", )
note.add_run("정부 공식 사이트", bold=True)
note.add_run("를 참고하라: ")
note.add_hyperlink("https://www.kasa.go.kr", "우주항공청")
```

처음에는 `open/new -> edit/extract -> save_to_path` 흐름만 잡으면 된다. 패키지 구조, XML 파트, 템플릿 회귀 점검은 필요할 때만 확장하면 된다.

## 어디부터 읽으면 되나
Expand Down
8 changes: 8 additions & 0 deletions src/hwpx/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -1299,6 +1299,14 @@ def export_markdown(self, **kwargs: object) -> str:
from .tools.exporter import export_markdown
return export_markdown(self, **kwargs) # type: ignore[arg-type]

def export_rich_markdown(self, **kwargs: object) -> str:
"""Export rich Markdown preserving inline styles, tables, footnotes, hyperlinks, images, and shape text.

Keyword args forwarded to :func:`~hwpx.tools.markdown_export.export_markdown`.
"""
from .tools.markdown_export import export_markdown as _rich
return _rich(self, **kwargs) # type: ignore[arg-type]

# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
Expand Down
57 changes: 56 additions & 1 deletion src/hwpx/oxml/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,58 @@ def text(self, value: str) -> None:
t.text = _sanitize_text(value)
self.paragraph.section.mark_dirty()

@property
def body_paragraph(self) -> "HwpxOxmlParagraph":
"""Return the note's body ``<hp:p>`` wrapped as :class:`HwpxOxmlParagraph`.

The body lives inside ``<hp:subList>`` and is distinct from
:attr:`paragraph`, which is the *hosting* paragraph (where the note
marker is inserted). Use this to add runs with mixed formatting
directly into the note body:

>>> note = para.add_footnote("기본 ")
>>> note.add_run("청색", char_pr_id_ref=5)
"""
p = self.element.find(f".//{_HP}p")
if p is None:
raise ValueError("note has no body paragraph element")
return HwpxOxmlParagraph(p, self.paragraph.section)

def add_run(
self,
text: str = "",
*,
char_pr_id_ref: str | int | None = None,
bold: bool = False,
italic: bool = False,
underline: bool = False,
attributes: dict[str, str] | None = None,
) -> "HwpxOxmlRun":
"""Append a run to the note body paragraph (delegates to body_paragraph.add_run)."""
return self.body_paragraph.add_run(
text,
char_pr_id_ref=char_pr_id_ref,
bold=bold,
italic=italic,
underline=underline,
attributes=attributes,
)

def add_hyperlink(
self,
url: str,
display_text: str,
*,
char_pr_id_ref: str | int | None = None,
) -> "HwpxOxmlInlineObject":
"""Append a hyperlink to the note body paragraph.

Convenience wrapper around ``body_paragraph.add_hyperlink``.
"""
return self.body_paragraph.add_hyperlink(
url, display_text, char_pr_id_ref=char_pr_id_ref
)


def _default_sublist_attributes() -> dict[str, str]:
"""Return standard attributes for a ``<hp:subList>`` element.
Expand Down Expand Up @@ -3364,7 +3416,10 @@ def _add_note(
sublist = _append_child(note_element, f"{_HP}subList", _default_sublist_attributes())
p_attrs = {"id": _paragraph_id(), **_DEFAULT_PARAGRAPH_ATTRS}
paragraph = _append_child(sublist, f"{_HP}p", p_attrs)
note_run = _append_child(paragraph, f"{_HP}run", {"charPrIDRef": "0"})
# 본문 run의 charPrIDRef도 인자를 따라가도록 적용 (host run과 동일 스타일).
# None이면 "0"(default).
body_cpr = "0" if char_pr_id_ref is None else str(char_pr_id_ref)
note_run = _append_child(paragraph, f"{_HP}run", {"charPrIDRef": body_cpr})
t = _append_child(note_run, f"{_HP}t", {})
t.text = _sanitize_text(text)
self.section.mark_dirty()
Expand Down
Loading