diff --git a/CHANGELOG.md b/CHANGELOG.md index f5d30516..06bcb65c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ ### 추가 +- 묶음(`doc.shapes.add_container`)에 선·글상자·그림·묶음 안 묶음 부재를 더한다: `ContainerMember.line`, + `ContainerMember.text_box`, `ContainerMember.picture`, `ContainerMember.group`. + - 선은 두 끝 중 왼쪽 위에 놓이고, 두 끝을 그 모서리 기준으로 지닌다. + - 글상자는 사각형에 `set_draw_text`와 같은 글을 담는다(이름·편집 가능·여백·글자 모양·문단 모양·세로 정렬). + 글은 묶음이 구역에 들어간 뒤 쓰여 문단 id가 구역에서 겹치지 않는다. 잘못된 세로 정렬은 문서를 바꾸기 전에 + 거부한다. + - 그림은 `doc.media.add_image`가 돌려준 이진 항목을 받아 주어진 크기에 그린다. 이진 항목이 비면 + `shape-container-picture-image`로 거부한다. + - 묶음 안 묶음은 꼬리(`sz`·`pos`·`outMargin`·`shapeComment`) 없는 부재다. 모든 깊이의 도형은 가장 바깥 묶음의 + 좌표에 놓인다. 한/글은 각 도형을 그 좌표의 자기 `transMatrix`에 그리므로, 안쪽 묶음 좌표에 둔 도형은 바깥 + 묶음의 왼쪽 위에 겹쳐 그려진다. + - 한/글이 저장한 묶음처럼 모든 부재는 `id`·`zOrder` 0, `numberingType="NONE"`이고, `groupLevel`은 깊이다 + (1, 2, 3…). + - 묶음은 부재를 복사해 조립한다. 넘긴 `ContainerMember`는 바뀌지 않으므로 같은 부재를 여러 묶음이나 한 묶음에 + 다시 써도 된다. - 표 구조 편집(`hwpx.table_patch.apply_table_ops`)에 열 삽입 `insert_column_by_clone`을 더한다. 격자 열 `ref_col`의 오른쪽(`side: "left"`면 왼쪽)에 그 열을 복제한 열 `count`개를 넣는다. - 새 열은 기준 열과 같은 폭이고, 표는 그만큼 넓어진다. 본문 폭을 넘어도 줄이지 않는다. diff --git a/README.md b/README.md index cad068e9..55a7c1f5 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ GitHub Release에 첨부되는 `py3-none-any` wheel을 문서와 함께 올리 - `add_shape()` / `add_control()`은 저수준 탈출구라, 그대로 저장하면 한/글이 열지 못하는 파일이 됩니다(호출 시 경고만 나옵니다). 도형은 `add_line()` / `add_rectangle()` / `add_ellipse()`를 쓰세요. -- 그림은 단순 개체 생성까지 지원합니다 (그룹·효과 미지원). +- 그림은 단순 개체 생성과 묶음 안 그림까지 지원합니다 (효과 미지원). - 암호화된 HWPX와 암호·배포용 HWP는 지원하지 않습니다. ## 기여하기 diff --git a/README_EN.md b/README_EN.md index 8ac034c6..e751b0e6 100644 --- a/README_EN.md +++ b/README_EN.md @@ -177,8 +177,8 @@ at all, and where uploads land, varies by plan and settings. - `add_shape()` / `add_control()` are low-level escape hatches — a document saved as-is will not open in Hancom, and the only signal is a warning at call time. For shapes use `add_line()` / `add_rectangle()` / `add_ellipse()`. -- Pictures: simple picture objects can be generated; complex ones (groups, - effects) cannot. +- Pictures: simple picture objects and pictures inside groups can be generated; + effects cannot. - Encrypted HWPX files and password-protected or distribution HWP files are not supported. ## Contributing diff --git a/docs/architecture/module-ownership.json b/docs/architecture/module-ownership.json index dc74d3cd..b101ebb3 100644 --- a/docs/architecture/module-ownership.json +++ b/docs/architecture/module-ownership.json @@ -543,6 +543,12 @@ "approvedBy": "hancom-merge-cells-grid", "rationale": "What a cell merge leaves behind in a table's grid: collapse_merged_grid() removes the rows a merge left without cells and the columns no cell starts at, as Hancom's merge does (Hancom does not open a table with an empty hp:tr), and moves the cell addresses, spans and cell zones past them back. A new module rather than more table.py lines: with the merge fix that owner file would pass the 1,600-line cap; HwpxOxmlTable.merge_cells calls it. Format-level only." }, + { + "path": "src/hwpx/oxml/container.py", + "disposition": "core", + "approvedBy": "hancom-group-members", + "rationale": "Groups (hp:container) and their members: ContainerMember (rect, ellipse, polygon, line, text_box, picture, group) and the assembly of members into a group at any depth, as Hancom saves its groups (members without the sz/pos/outMargin/shapeComment tail, id and zOrder 0, numberingType NONE, groupLevel = depth, every shape placed in the outermost group's space). Moved out of objects.py with the new member kinds: that owner file would pass the 1,600-line cap; objects.py re-exports ContainerMember and _create_container_element and its paragraph add_container calls this module. Format-level only." + }, { "path": "src/hwpx/oxml/table_structure.py", "disposition": "core", diff --git a/docs/error-codes.md b/docs/error-codes.md index e22417a8..1394db8a 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -287,6 +287,7 @@ except HwpxError as exc: | `shape-connector-side-invalid` | 연결선 끝의 변이 top/right/bottom/left 밖이다. | | `shape-connector-target-inline` | 연결선을 이으려는 도형이 글자처럼 놓였거나 instid 가 없다. | | `shape-container-no-members` | add_container 에 부재를 하나도 안 줬다. | +| `shape-container-picture-image` | 그룹의 그림 부재(ContainerMember.picture)에 그림 이진 항목을 안 줬다. | | `shape-curve-too-few-points` | add_curve 에 앵커를 2개(닫으면 3개) 미만으로 줬다. | | `shape-draw-text-vert-align` | 도형 글상자 vert_align 값이 OWPML 어휘(hp:subList/@vertAlign, TOP/CENTER/BOTTOM) 밖이다. | | `shape-drop-cap-anchor-detached` | 만든 드롭캡이 요청한 dropcapstyle 을 안 갖고 있다(방어적 분기). | diff --git a/docs/python-example-ledger.json b/docs/python-example-ledger.json index 6509f21e..5a2acd3b 100644 --- a/docs/python-example-ledger.json +++ b/docs/python-example-ledger.json @@ -16,8 +16,8 @@ "fenceCount": 115, "sourceSha256": "b924d97f4a04691b26d3098514b1e2db25faac02715aa9646fedc91ca5182444", "manualSha256": { - "README.md": "abce12159ada8d3d6006ff4fa695e78286db3ceb1bf382ff40974098f8e12dcc", - "README_EN.md": "efa11468f453229a5293105413e6e398f4e41f6c8b4f17af57b60ceb4090e627", + "README.md": "93d578be0712c8008cd8ad76456a8976ef0c7b448cf4539df63139464d4141bd", + "README_EN.md": "ea5e0819d85429278b85a5377de09fffafd9c27a9c9185a41a6c533ebaae0f95", "docs/quickstart.md": "44d8ca82ee70c0e3dfdf087713a4e4ce935e64cd3f6a546311f95b054a285a18", "docs/recipes-traversal.md": "b63f42b1a6ed23812688599474dcf83300509b86a94b5daceae679bfdbde9343", "docs/mutation-semantics.md": "4a9f27d95c67d117d149ca73aefe02e8b7064d04a021c471f81581e3961c3e1d", diff --git a/docs/support-matrix.md b/docs/support-matrix.md index fb3c2eb3..a272fed9 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -29,8 +29,8 @@ | 도형 저작(선·사각형·타원) | Parse·Preserve·Edit·Create·Render-verified | 전용 `add_line`·`add_rectangle`·`add_ellipse`. 기존 floating 도형은 `Shape.set_position`으로 오프셋만 편집 가능(글자처럼 취급·위치 요소 없음은 typed 거부; 아래 기존 렌더 실적이 새 이동 경로의 검증을 뜻하지 않음). 5.0.0에서 기하 네임스페이스(`hc:`)·선 bounding box·`resize()`의 기하 반영을 수정한 뒤 실한컴 12.30.0(build 6446)에서 **개봉 7/7**(선 수평·대각·수직, 사각형, 타원, resize 후 혼합, 그림). 렌더로 사각형 144×72pt+`#CCE5FF`, 타원 100×60pt+`#FFD9CC`, 대각선이 요청 박스를 실제 span, `resize(14400,7200)` 후 새 크기·DASH 반영까지 확인 | | 저수준 도형·컨트롤 탈출구 | Edit | `add_shape`·`add_control`은 건네받은 요소와 속성만 쓰고 OWPML 필수 하위 요소(`offset`·`orgSz`·`curSz`·`sz`·`pos`·유형별 기하)는 만들지 않는다. 그대로 저장한 문서는 실한컴 12.30.0이 **거부**한다(음성 대조로 확인). 생성 시점의 `UserWarning`은 여전히 하나뿐이지만(휘발성 — 호출 스택에만 남고 파일엔 안 남음), `validate_package`·`validate_editor_open_safety`는 이 위험을 **오류**로 보고하고(빈 `hp:ctrl`은 한컴이 멈춘다), 공개 저장 경로는 이런 문서를 쓰지 않는다(6.6 트레인㉒의 `warning`에서 격상). `hp:container` 부재는 오탐 안 됨(부재는 이 5종을 원래 안 가짐, DEV-016). 도형은 위의 전용 헬퍼를 쓸 것(전용 헬퍼는 경고하지 않는다) | | arc·polygon·curve·connectLine | Parse·Preserve·Create(experimental)·Render-verified(arc·polygon만) | **6.11 트레인㊳ 재발견**: `docs/openrate/report-v8.json`의 authored-polygon/authored-arc 스트라텀(각 15/15 render_checked·0 render_failed)이 애초부터 실재했고 원장(coverage-ledger)에도 `hp:polygon`/`hp:arc`로 이미 정확히 반영돼 있었다 — 하지만 이 행의 등급 문자열에 "Render-verified"가 한 번도 반영되지 않아 편집기 표면 인벤토리가 이 항목을 계속 "미실측"으로 잘못 보고해 왔다(순수 매트릭스 산문 갱신 누락, "차트" 행과 같은 종류의 결함, 이번 트레인에서 함께 바로잡음). curve·connectLine은 아래 서술대로 여전히 정직 보류(저작 API 자체가 없음, Render-verified 대상 아님). 전용 `add_polygon`(6.4+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimplePolygon.hwpx`) 리버스 — 꼭짓점은 `hc:pt`(core 네임스페이스, rect/ellipse와 같은 기하 네임스페이스 계약: 5.0.0 도형 네임스페이스 결함 수리와 동일 축), 자기 bbox 좌상단 원점 로컬 좌표계(정점 목록이 그 자기 `orgSz`와 정확히 일치, 실측). `resize()`는 이미 범용(`pt` 로컬명 스캔)이라 변경 없이 다각형에도 적용된다. 전용 `add_arc`(6.4+, `doc.shapes`): 실코퍼스(`SimpleArc.hwpx`) 리버스 — 스키마상 `hp:arc`는 좌표 3점(`center`/`ax1`/`ax2`)뿐 각도 필드가 아예 없다. 유일한 실 예시는 center가 자기 bbox 모서리에 앉고 ax1이 그 바로 아래, ax2가 그 바로 오른쪽인 사분원 하나뿐이라 그 한 배치만 점 단위로 실측 검증됐고, 나머지 세 모서리는 다른 모든 도형이 이미 쓰는 `hp:flip` 미러링으로 얻는다(새 점 계산 없음 — `corner` 인자, `TOP_LEFT`만 실측·나머지 3개는 유도). `arc_type`(NORMAL/PIE/CHORD)은 스키마 열거값 그대로 통과. **curve**: 전용 `add_curve`(`doc.shapes`) — 앵커를 지나는 곡선(`hp:seg type="CURVE"`)만 쓴다. 한/글은 곡선을 `sz`/`orgSz` 배율로 그리고 크기 상자는 스스로 다시 계산하지 않는다(점을 옮겨도 `orgSz`·`sz`가 그대로). 그래서 한/글 곡선이 갖는 상자 — 앵커를 지나는 Catmull-Rom 곡선(제어점은 이웃 점 차이의 1/6, 닫힌 곡선은 이웃을 돌려 쓰고 열린 곡선의 끝은 끝점 자신)을 구간마다 16단계 꺾은선으로 근사한 상자 — 를 쓰고, 앵커는 그 상자 좌상단 원점 로컬 좌표로 둔다(`SimpleCurve.hwpx`의 16636×21360과 정확히 같다). `LINE` 구간이 섞인 곡선과 묶음 안 곡선은 한/글 상자가 이 규칙과 맞지 않아 쓰지 않는다. **connectLine**: 전용 `add_connector`(`doc.shapes`) — 글자처럼 두지 않고 같은 기준(종이·쪽, 또는 연결선 문단의 단·문단)에서 왼쪽·위로 놓인 두 도형을 잇는 붙은 연결선(`subjectIDRef` = 도형 `instid`, `subjectIdx` 0 위·1 오른쪽·2 아래·3 왼쪽 가운데)을 직선(`STRAIGHT_NOARROW`)이나 꺾인 선(`STROKE_NOARROW`)으로 쓴다. 한/글은 붙은 연결선을 대상 도형의 현재 상자와 `subjectIdx`로 다시 그린다(저장 좌표는 그림에 쓰이지 않고, 다시 저장해도 고치지 않는다). 그래서 도형을 옮기거나 키워도 선이 따라가고, 저장 좌표는 다른 읽기 도구를 위해 대상 상자의 해당 변 가운데로 둔다(`SimpleConnectLine.hwpx`의 도형으로 다시 만든 연결선은 원본과 같은 자리·크기이고, 한/글이 그린 쪽도 원본과 같다). 호(`ARC`) 연결선은 제어점(`hp:controlPoints`)이 필요해 거부하고, 떼어 낸 연결선(`subjectIDRef="0"`, 한/글이 저장 좌표 그대로 그림)은 만들지 않는다. `hp:connectLine`의 `startPt`/`endPt`는 `hp:` 네임스페이스다(`hp:line`의 `hc:startPt`와 다름) | -| 그룹 개체(컨테이너) | Parse·Create(experimental)·Render-verified | 전용 `add_container`(6.5+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimpleContainer.hwpx` 3부재 + 실문서 2종 71개 컨테이너, 총 74개 표본) 리버스 — 부재는 독립 도형과 완전히 같은 구조(offset/orgSz/curSz/flip/rotationInfo/renderingInfo + 도형별 기하)를 유지하되 `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment — 그룹만 가짐)는 없고 `groupLevel="1"`(그룹 자신은 `"0"`), `renderingInfo`의 `transMatrix` 이동성분이 자기 `offset`과 일치, `numberingType="PICTURE"` 전량 관측. `ContainerMember.rect`/`.ellipse`/`.polygon`으로 부재를 그룹 로컬 좌표에 배치 — **`pic`/`arc`/`line`/`connectLine`/중첩 `container` 부재와 이미 배치된 도형의 재그룹은 이번 사이클 범위 밖**(전자는 같은 패턴으로 자연 확장 가능, `pic`은 media 배선이 추가로 필요). `resize()`는 한/글이 크기를 바꾼 묶음을 저장하는 모양대로 쓴다: 한/글은 묶음을 `sz` 크기로 그리며, `orgSz`는 그대로, `sz`·`curSz`는 새 크기, 묶음과 모든 부재(묶음 안 묶음의 부재도)의 첫 `scaMatrix`에 배율(부재는 오프셋 × (배율 − 1)의 이동 포함), 부재의 `curSz`는 `orgSz` × 배율이다. 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-container 스트라텀(2~4부재·rect/ellipse/polygon 슬롯별 회전), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) | -| 그림 삽입/치환 | Edit·Create·Render-verified | `add_picture`·`add_image`·`doc.media.replace_picture`. `doc.media.images`/`remove_image`는 header `binDataList`의 항목과 `content.hpf` 매니페스트에만 있는 이진 항목을 함께 다룬다(`remove_image`는 id·파트 경로·`BinaryItem`을 받고, 문서가 아직 가리키는 항목은 `force=True` 없이는 `media-item-in-use`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조). 효과 있는 복잡 개체 생성은 미지원. 그림을 포함한 그룹 생성은 위 "그룹 개체(컨테이너)" 참조(현재 그림 부재는 미지원). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-picture 스트라텀(크기 변주 4건 + `replace_picture` 자산 교체 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정) — `hp:pic`/`imgRect`/`imgDim`/`imgClip`/`effects`/`hc:img`는 `add_picture` 호출마다 무조건 방출되는 고정 스켈레톤이라(파라미터 유무 무관, 직접 확인) 이 영역째 원장 라우팅해도 위양성 없음 | +| 그룹 개체(컨테이너) | Parse·Create(experimental)·Render-verified | 전용 `add_container`(6.5+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimpleContainer.hwpx` 3부재 + 실문서 2종 71개 컨테이너, 총 74개 표본) 리버스 — 부재는 독립 도형과 완전히 같은 구조(offset/orgSz/curSz/flip/rotationInfo/renderingInfo + 도형별 기하)를 유지하되 `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment — 그룹만 가짐)는 없고 `groupLevel="1"`(그룹 자신은 `"0"`), `renderingInfo`의 `transMatrix` 이동성분이 자기 `offset`과 일치, `numberingType="PICTURE"` 전량 관측. `ContainerMember.rect`/`.ellipse`/`.polygon`/`.line`/`.text_box`/`.picture`/`.group`으로 부재를 그룹 로컬 좌표에 배치한다. 한/글이 저장한 묶음처럼 부재는 `id`·`zOrder` 0, `numberingType="NONE"`, `textWrap="TOP_AND_BOTTOM"`이고 `groupLevel`은 깊이(1·2·3…)다. 묶음 안 묶음은 꼬리 없는 부재이고, 모든 깊이의 도형은 가장 바깥 묶음의 좌표에 놓인다(한/글은 각 도형을 그 좌표의 자기 `transMatrix`에 그린다). 글상자 부재의 글은 묶음이 구역에 들어간 뒤 쓰여 문단 id가 구역에서 겹치지 않는다. **`arc`/`connectLine` 부재와 이미 배치된 도형의 재그룹은 범위 밖**. `resize()`는 한/글이 크기를 바꾼 묶음을 저장하는 모양대로 쓴다: 한/글은 묶음을 `sz` 크기로 그리며, `orgSz`는 그대로, `sz`·`curSz`는 새 크기, 묶음과 모든 부재(묶음 안 묶음의 부재도)의 첫 `scaMatrix`에 배율(부재는 오프셋 × (배율 − 1)의 이동 포함), 부재의 `curSz`는 `orgSz` × 배율이다. 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-container 스트라텀(2~4부재·rect/ellipse/polygon 슬롯별 회전), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) | +| 그림 삽입/치환 | Edit·Create·Render-verified | `add_picture`·`add_image`·`doc.media.replace_picture`. `doc.media.images`/`remove_image`는 header `binDataList`의 항목과 `content.hpf` 매니페스트에만 있는 이진 항목을 함께 다룬다(`remove_image`는 id·파트 경로·`BinaryItem`을 받고, 문서가 아직 가리키는 항목은 `force=True` 없이는 `media-item-in-use`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조). 효과 있는 복잡 개체 생성은 미지원. 그림을 포함한 그룹 생성은 위 "그룹 개체(컨테이너)" 참조(`ContainerMember.picture`로 그림 부재를 넣는다). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-picture 스트라텀(크기 변주 4건 + `replace_picture` 자산 교체 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정) — `hp:pic`/`imgRect`/`imgDim`/`imgClip`/`effects`/`hc:img`는 `add_picture` 호출마다 무조건 방출되는 고정 스켈레톤이라(파라미터 유무 무관, 직접 확인) 이 영역째 원장 라우팅해도 위양성 없음 | | 차트 | Create(experimental)·Preserve·Render-verified | `doc.shapes.add_chart`(5.3.0+): 데이터·계열·축 레이블에서 차트 개체를 생성한다. 기존 차트 part는 patch 저장 시 바이트 보존(497/497). 생성 어휘 밖의 차트 종류·서식은 미지원이며 기존 개체 보존으로만 다룬다. 실한컴 렌더 검증: `docs/openrate/report-v4.json` authored-chart 스트라텀(bar/line/pie, `hwpx_automation.office.charting.build_chart_ml`로 생성) 15/15 render_checked·0 render_failed — **이 v4 증거는 이미 존재했으나 "Render-verified" 등급이 이번(6.11 트레인㊳)까지 한 번도 이 행에 반영되지 않았다(순수 매트릭스 산문 갱신 누락, 재발견해서 바로잡음)**. `docs/openrate/report-v14.json` authored-chart 스트라텀(pie/bar/line 로테이션, core 저장소 자체 손빌드 최소 ChartML — automation 저장소 임포트 불가라 v4의 build_chart_ml을 재사용 못 함, 단방향 아키텍처) 5/5 render_checked·0 render_failed(2026-08-08 측정)로 두 번째 독립 배치 추가 — 같은 스트라텀명이라 원장이 두 report를 OR로 자동 누적. **축이 필요한 차트는 축 필수**: 한컴은 축(`c:axId`·`c:catAx`·`c:valAx`) 없는 막대 차트를 막대 없이 빈 차트로 그리고, 꺾은선(`report-v14`의 v14-chart-002)·영역·분산형·방사형·거품형은 렌더하거나 저장하다 멈춘다. 같은 차트에 축만 넣으면 정상이라 `add_chart`가 이런 입력을 `shape-chart-axes-missing`으로 거부한다. 축이 없는 원형·도넛은 받는다 | | 수식 | Parse·Create(experimental)·Render-verified | `add_equation`(EqEdit script 삽입, 5.2.0+)과 `hwpx.equation.latex_to_eqedit`(렌더 검증 토큰셋만 변환, 밖은 `UnsupportedLatexError`로 typed 거부). 저작 어휘 전 토큰을 실한컴 렌더 오라클 픽셀 실측(60수식 배터리)으로 확정했고, 기존 수식 개체는 파싱·patch 보존됨(미리보기 MathML 렌더는 뷰어/플러그인 계층) | | 문단 첫 글자 장식(드롭캡) | Create(experimental)·Render-verified | `doc.shapes.add_drop_cap`(6.12 트레인㊸, 갭②) — 편집기 메뉴 표면 역매핑(트레인㊷)이 찾은 신규 갭. **어휘가 문단 속성이 아니라 도형 공통속성이라는 게 첫 발견**: `dropcapstyle`(`hc:DropCapStyleType`, `None`/`DoubleLine`/`TripleLine`/`Margin`)은 `hh:paraPr`가 아니라 `AbstractShapeObjectType`(rect/picture/table/chart 등 모든 삽입 개체가 공유하는 공통 속성군, `ParaList XML schema.xml:2006`)에 산다 — 이 라이브러리의 기존 도형 빌더 전부가 이 속성을 `"None"`으로 고정 방출해 온 이유가 이거다. 실코퍼스 67파일 전수에서 `dropcapstyle="TripleLine"` 실사용은 `error__20230809__test.hwpx` 단 1건뿐(나머지 전부 `"None"`) — 그 1건을 역설계: 드롭캡은 문단 첫 글자를 잘라내는 게 아니라 **투명한 `hp:rect`(선 `style="NONE"`·채움/그림자 `alpha="0"`, `lock="1"`)를 run에 끼워 넣고, 그 안 `drawText/subList/p/run/t`에 키운 문자를 별도로 담는** 구조다. 배치는 일반 도형 빌더의 "흐르지 않는 개체" 기본값(`horzRelTo="COLUMN"`·`flowWithText="0"`)과 달리 `horzRelTo="PARA"`·`flowWithText="1"`, `curSz`는 `orgSz`와 다르게 `"0"`/`"0"`(리사이즈 안 됨 센티널로 추정), `outMargin right="850"`(다른 셋은 0). 정체불명 `hp:parameterset`(`name="539"`, 값 `"2"`)은 DEV-011 선례대로 의미 추정 없이 그대로 보존. **`TripleLine`과 `DoubleLine`을 쓴다**: 한/글은 같은 rect+drawText 구조에 parameterset 값 1인 `DoubleLine`(`TripleLine`은 2)을 읽고, 그리고, 그대로 저장하며, 상자 크기가 같으면 두 스타일의 화면이 같다(상자 옆으로 가는 줄 수는 스타일이 아니라 상자 높이가 정한다). `Margin`은 상자를 여백 쪽 어디에 두는지 근거가 없어 typed 거부로 남긴다(curve·connectLine과 같은 원칙). width/height도 실측된 폰트-크기→박스-크기 변환 공식이 없어 호출자가 직접 지정(자동 계산 시도 안 함). 원장 라우팅은 없음(`hp:rect`가 이미 "도형 저작" 영역에 등록돼 있어 재등록하면 그 카운트를 덮어씀, `hp:fieldBegin`류보다 더 확실한 공유 요소 사례). 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-drop-cap 스트라텀(문자 5종 로테이션 + width/height 변주 1건, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정) — 그때는 `TripleLine`만(`DoubleLine`은 뒤에 한/글이 다시 저장한 파일로 확인, `Margin`은 typed 거부) | diff --git a/src/hwpx/_document/ns/shapes.py b/src/hwpx/_document/ns/shapes.py index 93e981f1..988616f8 100644 --- a/src/hwpx/_document/ns/shapes.py +++ b/src/hwpx/_document/ns/shapes.py @@ -345,8 +345,8 @@ def add_container( section: "int | Section | None" = None, section_index: int | None = None, ) -> "Shape": - """도형을 그룹으로 묶는다(`ContainerMember.rect`/`.ellipse`/`.polygon`으로 - 각 부재를 그룹 로컬 좌표로 만들어 넘긴다).""" + """도형을 그룹으로 묶는다(`ContainerMember.rect`/`.ellipse`/`.polygon`/`.line`/ + `.text_box`/`.picture`/`.group`으로 각 부재를 그룹 로컬 좌표로 만들어 넘긴다).""" from .. import shapes as _shapes diff --git a/src/hwpx/_document/shapes.py b/src/hwpx/_document/shapes.py index 95464b97..cef335c8 100644 --- a/src/hwpx/_document/shapes.py +++ b/src/hwpx/_document/shapes.py @@ -467,7 +467,8 @@ def add_container( """Insert a group (````) wrapping *members*. Each member is built with :class:`hwpx.oxml.ContainerMember`'s - ``rect``/``ellipse``/``polygon`` classmethods, which take the member's + ``rect``/``ellipse``/``polygon``/``line``/``text_box``/``picture``/``group`` + classmethods, which take the member's position in the group's own local coordinate space (HWPUNIT, top-left anchored) alongside its usual shape parameters:: diff --git a/src/hwpx/data/contract_docs/support-matrix.md b/src/hwpx/data/contract_docs/support-matrix.md index fb3c2eb3..a272fed9 100644 --- a/src/hwpx/data/contract_docs/support-matrix.md +++ b/src/hwpx/data/contract_docs/support-matrix.md @@ -29,8 +29,8 @@ | 도형 저작(선·사각형·타원) | Parse·Preserve·Edit·Create·Render-verified | 전용 `add_line`·`add_rectangle`·`add_ellipse`. 기존 floating 도형은 `Shape.set_position`으로 오프셋만 편집 가능(글자처럼 취급·위치 요소 없음은 typed 거부; 아래 기존 렌더 실적이 새 이동 경로의 검증을 뜻하지 않음). 5.0.0에서 기하 네임스페이스(`hc:`)·선 bounding box·`resize()`의 기하 반영을 수정한 뒤 실한컴 12.30.0(build 6446)에서 **개봉 7/7**(선 수평·대각·수직, 사각형, 타원, resize 후 혼합, 그림). 렌더로 사각형 144×72pt+`#CCE5FF`, 타원 100×60pt+`#FFD9CC`, 대각선이 요청 박스를 실제 span, `resize(14400,7200)` 후 새 크기·DASH 반영까지 확인 | | 저수준 도형·컨트롤 탈출구 | Edit | `add_shape`·`add_control`은 건네받은 요소와 속성만 쓰고 OWPML 필수 하위 요소(`offset`·`orgSz`·`curSz`·`sz`·`pos`·유형별 기하)는 만들지 않는다. 그대로 저장한 문서는 실한컴 12.30.0이 **거부**한다(음성 대조로 확인). 생성 시점의 `UserWarning`은 여전히 하나뿐이지만(휘발성 — 호출 스택에만 남고 파일엔 안 남음), `validate_package`·`validate_editor_open_safety`는 이 위험을 **오류**로 보고하고(빈 `hp:ctrl`은 한컴이 멈춘다), 공개 저장 경로는 이런 문서를 쓰지 않는다(6.6 트레인㉒의 `warning`에서 격상). `hp:container` 부재는 오탐 안 됨(부재는 이 5종을 원래 안 가짐, DEV-016). 도형은 위의 전용 헬퍼를 쓸 것(전용 헬퍼는 경고하지 않는다) | | arc·polygon·curve·connectLine | Parse·Preserve·Create(experimental)·Render-verified(arc·polygon만) | **6.11 트레인㊳ 재발견**: `docs/openrate/report-v8.json`의 authored-polygon/authored-arc 스트라텀(각 15/15 render_checked·0 render_failed)이 애초부터 실재했고 원장(coverage-ledger)에도 `hp:polygon`/`hp:arc`로 이미 정확히 반영돼 있었다 — 하지만 이 행의 등급 문자열에 "Render-verified"가 한 번도 반영되지 않아 편집기 표면 인벤토리가 이 항목을 계속 "미실측"으로 잘못 보고해 왔다(순수 매트릭스 산문 갱신 누락, "차트" 행과 같은 종류의 결함, 이번 트레인에서 함께 바로잡음). curve·connectLine은 아래 서술대로 여전히 정직 보류(저작 API 자체가 없음, Render-verified 대상 아님). 전용 `add_polygon`(6.4+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimplePolygon.hwpx`) 리버스 — 꼭짓점은 `hc:pt`(core 네임스페이스, rect/ellipse와 같은 기하 네임스페이스 계약: 5.0.0 도형 네임스페이스 결함 수리와 동일 축), 자기 bbox 좌상단 원점 로컬 좌표계(정점 목록이 그 자기 `orgSz`와 정확히 일치, 실측). `resize()`는 이미 범용(`pt` 로컬명 스캔)이라 변경 없이 다각형에도 적용된다. 전용 `add_arc`(6.4+, `doc.shapes`): 실코퍼스(`SimpleArc.hwpx`) 리버스 — 스키마상 `hp:arc`는 좌표 3점(`center`/`ax1`/`ax2`)뿐 각도 필드가 아예 없다. 유일한 실 예시는 center가 자기 bbox 모서리에 앉고 ax1이 그 바로 아래, ax2가 그 바로 오른쪽인 사분원 하나뿐이라 그 한 배치만 점 단위로 실측 검증됐고, 나머지 세 모서리는 다른 모든 도형이 이미 쓰는 `hp:flip` 미러링으로 얻는다(새 점 계산 없음 — `corner` 인자, `TOP_LEFT`만 실측·나머지 3개는 유도). `arc_type`(NORMAL/PIE/CHORD)은 스키마 열거값 그대로 통과. **curve**: 전용 `add_curve`(`doc.shapes`) — 앵커를 지나는 곡선(`hp:seg type="CURVE"`)만 쓴다. 한/글은 곡선을 `sz`/`orgSz` 배율로 그리고 크기 상자는 스스로 다시 계산하지 않는다(점을 옮겨도 `orgSz`·`sz`가 그대로). 그래서 한/글 곡선이 갖는 상자 — 앵커를 지나는 Catmull-Rom 곡선(제어점은 이웃 점 차이의 1/6, 닫힌 곡선은 이웃을 돌려 쓰고 열린 곡선의 끝은 끝점 자신)을 구간마다 16단계 꺾은선으로 근사한 상자 — 를 쓰고, 앵커는 그 상자 좌상단 원점 로컬 좌표로 둔다(`SimpleCurve.hwpx`의 16636×21360과 정확히 같다). `LINE` 구간이 섞인 곡선과 묶음 안 곡선은 한/글 상자가 이 규칙과 맞지 않아 쓰지 않는다. **connectLine**: 전용 `add_connector`(`doc.shapes`) — 글자처럼 두지 않고 같은 기준(종이·쪽, 또는 연결선 문단의 단·문단)에서 왼쪽·위로 놓인 두 도형을 잇는 붙은 연결선(`subjectIDRef` = 도형 `instid`, `subjectIdx` 0 위·1 오른쪽·2 아래·3 왼쪽 가운데)을 직선(`STRAIGHT_NOARROW`)이나 꺾인 선(`STROKE_NOARROW`)으로 쓴다. 한/글은 붙은 연결선을 대상 도형의 현재 상자와 `subjectIdx`로 다시 그린다(저장 좌표는 그림에 쓰이지 않고, 다시 저장해도 고치지 않는다). 그래서 도형을 옮기거나 키워도 선이 따라가고, 저장 좌표는 다른 읽기 도구를 위해 대상 상자의 해당 변 가운데로 둔다(`SimpleConnectLine.hwpx`의 도형으로 다시 만든 연결선은 원본과 같은 자리·크기이고, 한/글이 그린 쪽도 원본과 같다). 호(`ARC`) 연결선은 제어점(`hp:controlPoints`)이 필요해 거부하고, 떼어 낸 연결선(`subjectIDRef="0"`, 한/글이 저장 좌표 그대로 그림)은 만들지 않는다. `hp:connectLine`의 `startPt`/`endPt`는 `hp:` 네임스페이스다(`hp:line`의 `hc:startPt`와 다름) | -| 그룹 개체(컨테이너) | Parse·Create(experimental)·Render-verified | 전용 `add_container`(6.5+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimpleContainer.hwpx` 3부재 + 실문서 2종 71개 컨테이너, 총 74개 표본) 리버스 — 부재는 독립 도형과 완전히 같은 구조(offset/orgSz/curSz/flip/rotationInfo/renderingInfo + 도형별 기하)를 유지하되 `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment — 그룹만 가짐)는 없고 `groupLevel="1"`(그룹 자신은 `"0"`), `renderingInfo`의 `transMatrix` 이동성분이 자기 `offset`과 일치, `numberingType="PICTURE"` 전량 관측. `ContainerMember.rect`/`.ellipse`/`.polygon`으로 부재를 그룹 로컬 좌표에 배치 — **`pic`/`arc`/`line`/`connectLine`/중첩 `container` 부재와 이미 배치된 도형의 재그룹은 이번 사이클 범위 밖**(전자는 같은 패턴으로 자연 확장 가능, `pic`은 media 배선이 추가로 필요). `resize()`는 한/글이 크기를 바꾼 묶음을 저장하는 모양대로 쓴다: 한/글은 묶음을 `sz` 크기로 그리며, `orgSz`는 그대로, `sz`·`curSz`는 새 크기, 묶음과 모든 부재(묶음 안 묶음의 부재도)의 첫 `scaMatrix`에 배율(부재는 오프셋 × (배율 − 1)의 이동 포함), 부재의 `curSz`는 `orgSz` × 배율이다. 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-container 스트라텀(2~4부재·rect/ellipse/polygon 슬롯별 회전), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) | -| 그림 삽입/치환 | Edit·Create·Render-verified | `add_picture`·`add_image`·`doc.media.replace_picture`. `doc.media.images`/`remove_image`는 header `binDataList`의 항목과 `content.hpf` 매니페스트에만 있는 이진 항목을 함께 다룬다(`remove_image`는 id·파트 경로·`BinaryItem`을 받고, 문서가 아직 가리키는 항목은 `force=True` 없이는 `media-item-in-use`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조). 효과 있는 복잡 개체 생성은 미지원. 그림을 포함한 그룹 생성은 위 "그룹 개체(컨테이너)" 참조(현재 그림 부재는 미지원). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-picture 스트라텀(크기 변주 4건 + `replace_picture` 자산 교체 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정) — `hp:pic`/`imgRect`/`imgDim`/`imgClip`/`effects`/`hc:img`는 `add_picture` 호출마다 무조건 방출되는 고정 스켈레톤이라(파라미터 유무 무관, 직접 확인) 이 영역째 원장 라우팅해도 위양성 없음 | +| 그룹 개체(컨테이너) | Parse·Create(experimental)·Render-verified | 전용 `add_container`(6.5+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimpleContainer.hwpx` 3부재 + 실문서 2종 71개 컨테이너, 총 74개 표본) 리버스 — 부재는 독립 도형과 완전히 같은 구조(offset/orgSz/curSz/flip/rotationInfo/renderingInfo + 도형별 기하)를 유지하되 `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment — 그룹만 가짐)는 없고 `groupLevel="1"`(그룹 자신은 `"0"`), `renderingInfo`의 `transMatrix` 이동성분이 자기 `offset`과 일치, `numberingType="PICTURE"` 전량 관측. `ContainerMember.rect`/`.ellipse`/`.polygon`/`.line`/`.text_box`/`.picture`/`.group`으로 부재를 그룹 로컬 좌표에 배치한다. 한/글이 저장한 묶음처럼 부재는 `id`·`zOrder` 0, `numberingType="NONE"`, `textWrap="TOP_AND_BOTTOM"`이고 `groupLevel`은 깊이(1·2·3…)다. 묶음 안 묶음은 꼬리 없는 부재이고, 모든 깊이의 도형은 가장 바깥 묶음의 좌표에 놓인다(한/글은 각 도형을 그 좌표의 자기 `transMatrix`에 그린다). 글상자 부재의 글은 묶음이 구역에 들어간 뒤 쓰여 문단 id가 구역에서 겹치지 않는다. **`arc`/`connectLine` 부재와 이미 배치된 도형의 재그룹은 범위 밖**. `resize()`는 한/글이 크기를 바꾼 묶음을 저장하는 모양대로 쓴다: 한/글은 묶음을 `sz` 크기로 그리며, `orgSz`는 그대로, `sz`·`curSz`는 새 크기, 묶음과 모든 부재(묶음 안 묶음의 부재도)의 첫 `scaMatrix`에 배율(부재는 오프셋 × (배율 − 1)의 이동 포함), 부재의 `curSz`는 `orgSz` × 배율이다. 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-container 스트라텀(2~4부재·rect/ellipse/polygon 슬롯별 회전), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) | +| 그림 삽입/치환 | Edit·Create·Render-verified | `add_picture`·`add_image`·`doc.media.replace_picture`. `doc.media.images`/`remove_image`는 header `binDataList`의 항목과 `content.hpf` 매니페스트에만 있는 이진 항목을 함께 다룬다(`remove_image`는 id·파트 경로·`BinaryItem`을 받고, 문서가 아직 가리키는 항목은 `force=True` 없이는 `media-item-in-use`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조). 효과 있는 복잡 개체 생성은 미지원. 그림을 포함한 그룹 생성은 위 "그룹 개체(컨테이너)" 참조(`ContainerMember.picture`로 그림 부재를 넣는다). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-picture 스트라텀(크기 변주 4건 + `replace_picture` 자산 교체 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정) — `hp:pic`/`imgRect`/`imgDim`/`imgClip`/`effects`/`hc:img`는 `add_picture` 호출마다 무조건 방출되는 고정 스켈레톤이라(파라미터 유무 무관, 직접 확인) 이 영역째 원장 라우팅해도 위양성 없음 | | 차트 | Create(experimental)·Preserve·Render-verified | `doc.shapes.add_chart`(5.3.0+): 데이터·계열·축 레이블에서 차트 개체를 생성한다. 기존 차트 part는 patch 저장 시 바이트 보존(497/497). 생성 어휘 밖의 차트 종류·서식은 미지원이며 기존 개체 보존으로만 다룬다. 실한컴 렌더 검증: `docs/openrate/report-v4.json` authored-chart 스트라텀(bar/line/pie, `hwpx_automation.office.charting.build_chart_ml`로 생성) 15/15 render_checked·0 render_failed — **이 v4 증거는 이미 존재했으나 "Render-verified" 등급이 이번(6.11 트레인㊳)까지 한 번도 이 행에 반영되지 않았다(순수 매트릭스 산문 갱신 누락, 재발견해서 바로잡음)**. `docs/openrate/report-v14.json` authored-chart 스트라텀(pie/bar/line 로테이션, core 저장소 자체 손빌드 최소 ChartML — automation 저장소 임포트 불가라 v4의 build_chart_ml을 재사용 못 함, 단방향 아키텍처) 5/5 render_checked·0 render_failed(2026-08-08 측정)로 두 번째 독립 배치 추가 — 같은 스트라텀명이라 원장이 두 report를 OR로 자동 누적. **축이 필요한 차트는 축 필수**: 한컴은 축(`c:axId`·`c:catAx`·`c:valAx`) 없는 막대 차트를 막대 없이 빈 차트로 그리고, 꺾은선(`report-v14`의 v14-chart-002)·영역·분산형·방사형·거품형은 렌더하거나 저장하다 멈춘다. 같은 차트에 축만 넣으면 정상이라 `add_chart`가 이런 입력을 `shape-chart-axes-missing`으로 거부한다. 축이 없는 원형·도넛은 받는다 | | 수식 | Parse·Create(experimental)·Render-verified | `add_equation`(EqEdit script 삽입, 5.2.0+)과 `hwpx.equation.latex_to_eqedit`(렌더 검증 토큰셋만 변환, 밖은 `UnsupportedLatexError`로 typed 거부). 저작 어휘 전 토큰을 실한컴 렌더 오라클 픽셀 실측(60수식 배터리)으로 확정했고, 기존 수식 개체는 파싱·patch 보존됨(미리보기 MathML 렌더는 뷰어/플러그인 계층) | | 문단 첫 글자 장식(드롭캡) | Create(experimental)·Render-verified | `doc.shapes.add_drop_cap`(6.12 트레인㊸, 갭②) — 편집기 메뉴 표면 역매핑(트레인㊷)이 찾은 신규 갭. **어휘가 문단 속성이 아니라 도형 공통속성이라는 게 첫 발견**: `dropcapstyle`(`hc:DropCapStyleType`, `None`/`DoubleLine`/`TripleLine`/`Margin`)은 `hh:paraPr`가 아니라 `AbstractShapeObjectType`(rect/picture/table/chart 등 모든 삽입 개체가 공유하는 공통 속성군, `ParaList XML schema.xml:2006`)에 산다 — 이 라이브러리의 기존 도형 빌더 전부가 이 속성을 `"None"`으로 고정 방출해 온 이유가 이거다. 실코퍼스 67파일 전수에서 `dropcapstyle="TripleLine"` 실사용은 `error__20230809__test.hwpx` 단 1건뿐(나머지 전부 `"None"`) — 그 1건을 역설계: 드롭캡은 문단 첫 글자를 잘라내는 게 아니라 **투명한 `hp:rect`(선 `style="NONE"`·채움/그림자 `alpha="0"`, `lock="1"`)를 run에 끼워 넣고, 그 안 `drawText/subList/p/run/t`에 키운 문자를 별도로 담는** 구조다. 배치는 일반 도형 빌더의 "흐르지 않는 개체" 기본값(`horzRelTo="COLUMN"`·`flowWithText="0"`)과 달리 `horzRelTo="PARA"`·`flowWithText="1"`, `curSz`는 `orgSz`와 다르게 `"0"`/`"0"`(리사이즈 안 됨 센티널로 추정), `outMargin right="850"`(다른 셋은 0). 정체불명 `hp:parameterset`(`name="539"`, 값 `"2"`)은 DEV-011 선례대로 의미 추정 없이 그대로 보존. **`TripleLine`과 `DoubleLine`을 쓴다**: 한/글은 같은 rect+drawText 구조에 parameterset 값 1인 `DoubleLine`(`TripleLine`은 2)을 읽고, 그리고, 그대로 저장하며, 상자 크기가 같으면 두 스타일의 화면이 같다(상자 옆으로 가는 줄 수는 스타일이 아니라 상자 높이가 정한다). `Margin`은 상자를 여백 쪽 어디에 두는지 근거가 없어 typed 거부로 남긴다(curve·connectLine과 같은 원칙). width/height도 실측된 폰트-크기→박스-크기 변환 공식이 없어 호출자가 직접 지정(자동 계산 시도 안 함). 원장 라우팅은 없음(`hp:rect`가 이미 "도형 저작" 영역에 등록돼 있어 재등록하면 그 카운트를 덮어씀, `hp:fieldBegin`류보다 더 확실한 공유 요소 사례). 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-drop-cap 스트라텀(문자 5종 로테이션 + width/height 변주 1건, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정) — 그때는 `TripleLine`만(`DoubleLine`은 뒤에 한/글이 다시 저장한 파일로 확인, `Margin`은 typed 거부) | diff --git a/src/hwpx/errors.py b/src/hwpx/errors.py index 0168bde4..b437162e 100644 --- a/src/hwpx/errors.py +++ b/src/hwpx/errors.py @@ -364,6 +364,7 @@ class Hwp5ConversionWarning(UserWarning): "shape-arc-corner-invalid": "add_arc 의 corner 인자가 지원하는 모서리(TOP_LEFT 등) 밖이다.", "shape-arc-type-invalid": "add_arc 의 arc_type 인자가 OWPML 어휘(NORMAL/PIE/CHORD) 밖이다.", "shape-container-no-members": "add_container 에 부재를 하나도 안 줬다.", + "shape-container-picture-image": "그룹의 그림 부재(ContainerMember.picture)에 그림 이진 항목을 안 줬다.", "shape-polygon-too-few-points": "add_polygon 에 꼭짓점을 3개 미만으로 줬다.", "shape-curve-too-few-points": "add_curve 에 앵커를 2개(닫으면 3개) 미만으로 줬다.", "shape-connector-target-inline": "연결선을 이으려는 도형이 글자처럼 놓였거나 instid 가 없다.", diff --git a/src/hwpx/oxml/container.py b/src/hwpx/oxml/container.py new file mode 100644 index 00000000..abd4e771 --- /dev/null +++ b/src/hwpx/oxml/container.py @@ -0,0 +1,377 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Groups (````): member shapes assembled into one drawing object, at any depth. + +Moved out of :mod:`hwpx.oxml.objects` (its 1,600-line owner-file cap) when groups learned pictures, text +boxes, lines and groups inside groups; ``objects`` re-exports :class:`ContainerMember` and +:func:`_create_container_element`. The shape builders stay in ``objects`` and are imported where they are +called, so either module can be imported first. + +In the groups Hancom saves, a member is a complete shape without the +``sz``/``pos``/``outMargin``/``shapeComment`` tail, with ``id="0"``, ``zOrder="0"``, +``numberingType="NONE"``, ``textWrap="TOP_AND_BOTTOM"`` and ``groupLevel`` its depth (1 directly in the +group, 2 in a group inside it, ...). A group inside a group is such a member too: its own envelope and +members, no tail; every shape at every depth is placed in the outermost group's space (Hancom draws each one +at its own transMatrix there). Only the outermost group carries the tail and ``numberingType="PICTURE"``. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Sequence +import xml.etree.ElementTree as ET + +from ._document_primitives import _HC, _HP, _append_child +from .shape_position import validate_draw_text_vert_align, validate_shape_size + +if TYPE_CHECKING: + from .section import HwpxOxmlSection + +#: The ``AbstractShapeObjectType`` tail only the outermost group keeps. +_MEMBER_DROPPED_TAIL = ("sz", "pos", "outMargin", "shapeComment") +#: Elements a group holds as members. +_MEMBER_TAGS = frozenset({"rect", "ellipse", "polygon", "line", "arc", "curve", "connectLine", "pic", "container"}) + + +@dataclass(frozen=True) +class _MemberText: + """Text a text-box member gets once its group is in a section (paragraph ids are section-wide).""" + + text: str + name: str + editable: bool + margin: dict[str, int] | None + char_pr_id_ref: str | int | None + para_pr_id_ref: str | int | None + vert_align: str | None + + +@dataclass(frozen=True) +class ContainerMember: + """One shape inside a group (````), placed at (*x*, *y*) + in the group's own top-left-anchored local coordinate space (HWPUNIT — + the same convention :func:`_create_polygon_element` uses for its own + vertices). Construct via :meth:`rect`, :meth:`ellipse`, :meth:`polygon`, + :meth:`line`, :meth:`text_box`, :meth:`picture` or :meth:`group` — not + the bare constructor, which expects an already-built element. + + A member is a complete, standalone shape element — the same + ``offset``/``orgSz``/``curSz``/``flip``/``rotationInfo``/ + ``renderingInfo`` envelope plus type geometry a freestanding shape + would have — except it drops the ``AbstractShapeObjectType`` tail + (``sz``/``pos``/``outMargin``/``shapeComment``; the *group* carries + that, not the member) and its ``groupLevel`` is its depth instead of + ``"0"``. :func:`_create_container_element` applies both when it + assembles the members passed here into the group. + """ + + element: ET.Element + x: int + y: int + text: _MemberText | None = None + members: tuple["ContainerMember", ...] = () + + @classmethod + def rect( + cls, + x: int, + y: int, + width: int, + height: int, + *, + ratio: int = 0, + line_color: str = "#000000", + line_width: str = "33", + fill_color: str | None = None, + ) -> "ContainerMember": + """A rectangle member — see :func:`_create_rectangle_element`.""" + + from .objects import _create_rectangle_element + + element = _create_rectangle_element( + width, height, ratio=ratio, line_color=line_color, + line_width=line_width, fill_color=fill_color, + ) + return cls(element, x, y) + + @classmethod + def ellipse( + cls, + x: int, + y: int, + width: int, + height: int, + *, + line_color: str = "#000000", + line_width: str = "33", + fill_color: str | None = None, + ) -> "ContainerMember": + """An ellipse member — see :func:`_create_ellipse_element`.""" + + from .objects import _create_ellipse_element + + element = _create_ellipse_element( + width, height, line_color=line_color, line_width=line_width, + fill_color=fill_color, + ) + return cls(element, x, y) + + @classmethod + def polygon( + cls, + x: int, + y: int, + points: Sequence[tuple[int, int]], + *, + line_color: str = "#000000", + line_width: str = "33", + fill_color: str | None = None, + closed: bool = True, + ) -> "ContainerMember": + """A polygon member — see :func:`_create_polygon_element`. It is + closed (the first vertex repeated at the end) unless *closed* is + false.""" + + from .objects import _closed_points, _create_polygon_element + + element = _create_polygon_element( + _closed_points(points) if closed else points, + line_color=line_color, line_width=line_width, fill_color=fill_color, + ) + return cls(element, x, y) + + @classmethod + def line( + cls, + start_x: int, + start_y: int, + end_x: int, + end_y: int, + *, + line_color: str = "#000000", + line_width: str = "33", + ) -> "ContainerMember": + """A line member from (*start_x*, *start_y*) to (*end_x*, *end_y*) in the + group's space. Like a freestanding line it is placed at the top-left + of its two ends, which it keeps relative to that corner.""" + + from .objects import _create_line_element + + x, y = min(start_x, end_x), min(start_y, end_y) + element = _create_line_element( + start_x - x, start_y - y, end_x - x, end_y - y, + line_color=line_color, line_width=line_width, + ) + return cls(element, x, y) + + @classmethod + def text_box( + cls, + x: int, + y: int, + width: int, + height: int, + text: str, + *, + ratio: int = 0, + line_color: str = "#000000", + line_width: str = "33", + fill_color: str | None = None, + name: str = "", + editable: bool = False, + margin: dict[str, int] | None = None, + char_pr_id_ref: str | int | None = None, + para_pr_id_ref: str | int | None = None, + vert_align: str | None = None, + ) -> "ContainerMember": + """A rectangle holding *text*, as :meth:`HwpxOxmlShape.set_draw_text` + writes it (same *name*, *editable*, *margin*, *char_pr_id_ref*, + *para_pr_id_ref* and *vert_align*). The text is written when the group + is inserted, so its paragraphs take ids unused in the section; a bad + *vert_align* is refused here, before any document is touched.""" + + validate_draw_text_vert_align(vert_align) + member = cls.rect(x, y, width, height, ratio=ratio, line_color=line_color, + line_width=line_width, fill_color=fill_color) + spec = _MemberText(text, name, editable, dict(margin) if margin else None, + char_pr_id_ref, para_pr_id_ref, vert_align) + return cls(member.element, x, y, text=spec) + + @classmethod + def picture( + cls, + x: int, + y: int, + width: int, + height: int, + image: Any, + ) -> "ContainerMember": + """A picture member showing *image*: the binary item + :meth:`doc.media.add_image ` + returned, or its id. The picture fills *width* × *height*.""" + + from .objects import _create_picture_element + + validate_shape_size(width, height) + image_id = str(image).strip() + if not image_id: + from ..errors import HwpxValueError + + raise HwpxValueError( + "a picture member needs the image's binary item", + code="shape-container-picture-image", + context={"image": repr(image)}, + suggestion="Pass what doc.media.add_image(data, format) returned.", + ) + return cls(_create_picture_element(image_id, width, height), x, y) + + @classmethod + def group(cls, x: int, y: int, members: Sequence["ContainerMember"]) -> "ContainerMember": + """A group inside the group, placed at (*x*, *y*) and holding *members* + in its own local space, as Hancom keeps a group that was grouped again.""" + + children = tuple(members) + return cls(_group_element(children, top=False), x, y, members=children) + + +def _member_size(member: "ContainerMember") -> tuple[int, int]: + org_sz = member.element.find(f"{_HP}orgSz") + if org_sz is None: # pragma: no cover - defensive, every builder sets this + return 0, 0 + return int(org_sz.get("width", "0")), int(org_sz.get("height", "0")) + + +def _group_element( + members: Sequence["ContainerMember"], + *, + top: bool, + treat_as_char: bool = True, +) -> ET.Element: + from .objects import _build_shape_base_children, _build_shape_common_children + + if not members: + from ..errors import HwpxValueError + + raise HwpxValueError( + "add_container requires at least one member", + code="shape-container-no-members", + context={}, + suggestion="Pass one or more ContainerMember instances " + "(ContainerMember.rect/.ellipse/.polygon/.line/.text_box/.picture/.group).", + ) + + min_x = min(m.x for m in members) + min_y = min(m.y for m in members) + width = max(m.x + _member_size(m)[0] for m in members) - min_x + height = max(m.y + _member_size(m)[1] for m in members) - min_y + + el = ET.Element(f"{_HP}container") + _build_shape_common_children(el, width, height, treat_as_char=treat_as_char) + el.set("numberingType", "PICTURE") + for member in members: + # A fresh copy (a group inside is assembled again from its members): the ContainerMember passed in is + # never changed, so the same one can go into several groups, or twice into one. + member_el = _group_element(member.members, top=False) if member.members else copy.deepcopy(member.element) + local_x, local_y = member.x - min_x, member.y - min_y + _place(member_el, local_x, local_y) + # Hancom draws every shape of a group at its own transMatrix in the outermost group's space, so the + # members of a group inside go where that group goes (its members are stored in that space too). + _shift_shapes_inside(member_el, local_x, local_y) + # Members share a small, non-unique id (almost always "0"); instid stays unique. + member_el.set("id", "0") + member_el.set("zOrder", "0") + member_el.set("numberingType", "NONE") + # Every member is TOP_AND_BOTTOM; the group's own placement decides how text goes around it. + member_el.set("textWrap", "TOP_AND_BOTTOM") + for tail_name in _MEMBER_DROPPED_TAIL: + tail_el = member_el.find(f"{_HP}{tail_name}") + if tail_el is not None: + member_el.remove(tail_el) + el.append(member_el) + + if top: + _set_group_levels(el, 1) + _build_shape_base_children(el, width, height) + # The outermost group (not its members) closes with an empty shapeComment, as hp:pic does. + _append_child(el, f"{_HP}shapeComment", {}) + return el + + +def _place(element: ET.Element, x: int, y: int) -> None: + """Set a member's offset, and the transMatrix translation that mirrors it.""" + + offset = element.find(f"{_HP}offset") + if offset is not None: + offset.set("x", str(x)) + offset.set("y", str(y)) + rendering_info = element.find(f"{_HP}renderingInfo") + trans = rendering_info.find(f"{_HC}transMatrix") if rendering_info is not None else None + if trans is not None: + trans.set("e3", str(x)) + trans.set("e6", str(y)) + + +def _shapes_inside(group: ET.Element) -> list[ET.Element]: + return [child for child in group if str(child.tag).rsplit("}", 1)[-1] in _MEMBER_TAGS] + + +def _shift_shapes_inside(element: ET.Element, dx: int, dy: int) -> None: + for shape in _shapes_inside(element) if element.tag == f"{_HP}container" else (): + offset = shape.find(f"{_HP}offset") + if offset is not None: + _place(shape, int(offset.get("x", "0")) + dx, int(offset.get("y", "0")) + dy) + _shift_shapes_inside(shape, dx, dy) + + +def _set_group_levels(group: ET.Element, level: int) -> None: + for shape in _shapes_inside(group): + shape.set("groupLevel", str(level)) + if shape.tag == f"{_HP}container": + _set_group_levels(shape, level + 1) + + +def _create_container_element( + members: Sequence["ContainerMember"], + *, + treat_as_char: bool = True, +) -> ET.Element: + """Build a complete ```` grouping *members*. + + The group is structured like any other drawing object — + ``numberingType="PICTURE"``, its own ``offset``/``orgSz``/``curSz``/ + ``flip``/``rotationInfo``/``renderingInfo`` + ``sz``/``pos``/ + ``outMargin``/``shapeComment`` tail — except its payload is complete + member shapes instead of geometry. ``orgSz`` is the union bounding box + of every member's own (*x*, *y*, ``orgSz``) in the group's local space, + so members never need to be pre-translated by the caller; a group inside + it does the same in its own space. + """ + + return _group_element(members, top=True, treat_as_char=treat_as_char) + + +def _write_member_texts( + members: Sequence["ContainerMember"], group: Any, section: "HwpxOxmlSection" +) -> None: + """Write the text of every text-box member, at any depth, into *group* — the group element now in + *section* (insertion copies the element, so its members are paired with *members* by order).""" + + from .objects import _write_draw_text + + placed = _shapes_inside(group) + for member, element in zip(members, placed): + if member.text is not None: + spec = member.text + _write_draw_text( + element, spec.text, section=section, name=spec.name, + editable=spec.editable, margin=spec.margin, char_pr_id_ref=spec.char_pr_id_ref, + para_pr_id_ref=spec.para_pr_id_ref, vert_align=spec.vert_align, + ) + draw_text = element.find(f"{_HP}drawText") + if draw_text is not None: # a member has no sz: its text width is its own width + draw_text.set("lastWidth", str(_member_size(member)[0])) + if member.members: + _write_member_texts(member.members, element, section) + + +__all__ = ["ContainerMember"] diff --git a/src/hwpx/oxml/objects.py b/src/hwpx/oxml/objects.py index a670ad0c..8b8ec044 100644 --- a/src/hwpx/oxml/objects.py +++ b/src/hwpx/oxml/objects.py @@ -3,7 +3,6 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING, Sequence import warnings import xml.etree.ElementTree as ET @@ -511,187 +510,6 @@ def _closed_points(points: Sequence[tuple[int, int]]) -> list[tuple[int, int]]: return closed -@dataclass(frozen=True) -class ContainerMember: - """One shape inside a group (````), placed at (*x*, *y*) - in the group's own top-left-anchored local coordinate space (HWPUNIT — - the same convention :func:`_create_polygon_element` uses for its own - vertices). Construct via :meth:`rect`, :meth:`ellipse`, or - :meth:`polygon` — not the bare constructor, which expects an - already-built element. - - Real corpus (``reader_writer__SimpleContainer.hwpx``, 3 members, plus - 71 more containers across two other real documents): a group member is - a complete, standalone shape element — the exact same - ``offset``/``orgSz``/``curSz``/``flip``/``rotationInfo``/ - ``renderingInfo`` envelope plus type geometry a freestanding shape - would have — except it drops the ``AbstractShapeObjectType`` tail - (``sz``/``pos``/``outMargin``/``shapeComment``; the *group* carries - that, not the member) and its ``groupLevel`` is ``"1"`` instead of - ``"0"``. :func:`_create_container_element` applies both adjustments - when it assembles the members passed here into the group. - """ - - element: ET.Element - x: int - y: int - - @classmethod - def rect( - cls, - x: int, - y: int, - width: int, - height: int, - *, - ratio: int = 0, - line_color: str = "#000000", - line_width: str = "33", - fill_color: str | None = None, - ) -> "ContainerMember": - """A rectangle member — see :func:`_create_rectangle_element`.""" - - element = _create_rectangle_element( - width, height, ratio=ratio, line_color=line_color, - line_width=line_width, fill_color=fill_color, - ) - return cls(element, x, y) - - @classmethod - def ellipse( - cls, - x: int, - y: int, - width: int, - height: int, - *, - line_color: str = "#000000", - line_width: str = "33", - fill_color: str | None = None, - ) -> "ContainerMember": - """An ellipse member — see :func:`_create_ellipse_element`.""" - - element = _create_ellipse_element( - width, height, line_color=line_color, line_width=line_width, - fill_color=fill_color, - ) - return cls(element, x, y) - - @classmethod - def polygon( - cls, - x: int, - y: int, - points: Sequence[tuple[int, int]], - *, - line_color: str = "#000000", - line_width: str = "33", - fill_color: str | None = None, - closed: bool = True, - ) -> "ContainerMember": - """A polygon member — see :func:`_create_polygon_element`. It is - closed (the first vertex repeated at the end) unless *closed* is - false.""" - - element = _create_polygon_element( - _closed_points(points) if closed else points, - line_color=line_color, line_width=line_width, fill_color=fill_color, - ) - return cls(element, x, y) - - -def _member_size(member: "ContainerMember") -> tuple[int, int]: - org_sz = member.element.find(f"{_HP}orgSz") - if org_sz is None: # pragma: no cover - defensive, every builder sets this - return 0, 0 - return int(org_sz.get("width", "0")), int(org_sz.get("height", "0")) - - -def _create_container_element( - members: Sequence["ContainerMember"], - *, - treat_as_char: bool = True, -) -> ET.Element: - """Build a complete ```` grouping *members*. - - Real corpus (74 containers total across 3 vendored files — - ``reader_writer__SimpleContainer.hwpx`` plus two "error__" regression - fixtures): the container itself is structured exactly like any other - drawing object — ``numberingType="PICTURE"`` uniformly observed, own - ``offset``/``orgSz``/``curSz``/``flip``/``rotationInfo``/ - ``renderingInfo`` + ``sz``/``pos``/``outMargin``/``shapeComment`` tail — - except its payload is complete member shapes instead of geometry. - ``orgSz`` is the union bounding box of every member's own (*x*, *y*, - ``orgSz``) in the group's local space, so members never need to be - pre-translated by the caller. - """ - if not members: - from ..errors import HwpxValueError - - raise HwpxValueError( - "add_container requires at least one member", - code="shape-container-no-members", - context={}, - suggestion="Pass one or more ContainerMember instances " - "(ContainerMember.rect/.ellipse/.polygon).", - ) - - min_x = min(m.x for m in members) - min_y = min(m.y for m in members) - max_x = max(m.x + _member_size(m)[0] for m in members) - max_y = max(m.y + _member_size(m)[1] for m in members) - width = max_x - min_x - height = max_y - min_y - - el = ET.Element(f"{_HP}container") - _build_shape_common_children(el, width, height, treat_as_char=treat_as_char) - el.set("numberingType", "PICTURE") - - for member in members: - member_el = member.element - local_x = member.x - min_x - local_y = member.y - min_y - - offset = member_el.find(f"{_HP}offset") - if offset is not None: - offset.set("x", str(local_x)) - offset.set("y", str(local_y)) - rendering_info = member_el.find(f"{_HP}renderingInfo") - if rendering_info is not None: - trans = rendering_info.find(f"{_HC}transMatrix") - if trans is not None: - # Real corpus (SimpleContainer.hwpx): a member's transMatrix - # translation component mirrors its own offset exactly. - trans.set("e3", str(local_x)) - trans.set("e6", str(local_y)) - - # Real corpus: group members share a small, non-unique id — "0" in - # 71 of 74 observed instances (SimpleContainer.hwpx's 3 members all - # share "2" instead, so the value itself carries no meaning). The - # unique identifier is instid, kept as-is from the member's own - # creation. - member_el.set("id", "0") - member_el.set("groupLevel", "1") - # Real corpus: every group member is TOP_AND_BOTTOM; the group's own - # placement decides how text goes around it. - member_el.set("textWrap", "TOP_AND_BOTTOM") - - # AbstractShapeObjectType tail (sz/pos/outMargin/shapeComment) is - # container-level only — every observed member carries none of it. - for tail_name in ("sz", "pos", "outMargin", "shapeComment"): - tail_el = member_el.find(f"{_HP}{tail_name}") - if tail_el is not None: - member_el.remove(tail_el) - - el.append(member_el) - - _build_shape_base_children(el, width, height) - # Real corpus: the container itself (not its members) closes with an - # empty shapeComment — the same tail hp:pic already appends. - _append_child(el, f"{_HP}shapeComment", {}) - return el - - def _create_picture_element( binary_item_id_ref: str, width: int, @@ -994,9 +812,11 @@ def _paragraph_add_container( (member envelope, ``groupLevel``, shared ``id`` convention). """ el = _create_container_element(members, treat_as_char=treat_as_char) - return self._insert_shape_element( + shape = self._insert_shape_element( el, run_attributes=run_attributes, char_pr_id_ref=char_pr_id_ref, ) + _write_member_texts(members, shape.element, self.section) # in the section now: paragraph ids unique there + return shape def _paragraph_shapes(self: "HwpxOxmlParagraph") -> list["HwpxOxmlShape"]: @@ -1586,3 +1406,7 @@ def __repr__(self) -> str: return f"" __all__ = ["Caption", "ContainerMember", "DrawText", "HwpxOxmlInlineObject", "HwpxOxmlShape"] + + +# Groups live in their own module (this owner file's 1,600-line cap); re-exported for existing imports. +from .container import ContainerMember, _create_container_element, _write_member_texts # noqa: E402 diff --git a/tests/data/import_breadth.json b/tests/data/import_breadth.json index 30d1f836..28d8ccf6 100644 --- a/tests/data/import_breadth.json +++ b/tests/data/import_breadth.json @@ -1,4 +1,4 @@ { "schemaVersion": "python-hwpx.import-breadth/v1", - "maxImportedModules": 98 + "maxImportedModules": 99 } diff --git a/tests/test_container_members.py b/tests/test_container_members.py new file mode 100644 index 00000000..8126a1ce --- /dev/null +++ b/tests/test_container_members.py @@ -0,0 +1,216 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Group members beyond rectangles, ellipses and polygons: lines, text boxes, pictures and groups in groups. + +In the groups Hancom saves, every member drops the ``sz``/``pos``/ +``outMargin``/``shapeComment`` tail, has ``id="0"``, ``zOrder="0"``, ``numberingType="NONE"`` and +``textWrap="TOP_AND_BOTTOM"``, and its ``groupLevel`` is its depth (1, 2, 3 ...). A group in a group is such +a member with its own envelope and members; only the outermost group has the tail and +``numberingType="PICTURE"``. Picture members keep ``img``/``imgRect``/``imgClip``/``inMargin``/ +``imgDim``/``effects``; text-box members keep ``drawText`` before the corner points. +""" + +from __future__ import annotations + +import struct +import zipfile +import zlib +import io +import xml.etree.ElementTree as ET + +import pytest + +from hwpx.document import HwpxDocument +from hwpx.errors import HwpxValueError +from hwpx.oxml import ContainerMember + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +HC = "{http://www.hancom.co.kr/hwpml/2011/core}" +_TAIL = ("sz", "pos", "outMargin", "shapeComment") + + +def _png() -> bytes: + def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + + raw = b"".join(b"\0" + b"\xc8\x3c\x3c" * 4 for _ in range(3)) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 4, 3, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) + + +def _saved_group(doc: HwpxDocument) -> ET.Element: + with zipfile.ZipFile(io.BytesIO(doc.to_bytes())) as archive: + root = ET.fromstring(archive.read("Contents/section0.xml")) + group = root.find(f".//{HP}container") + assert group is not None + return group + + +def _local(element: ET.Element) -> str: + return element.tag.split("}")[-1] + + +def _members(group: ET.Element) -> list[ET.Element]: + return [child for child in group if _local(child) in {"rect", "ellipse", "polygon", "line", "pic", "container"}] + + +def test_a_line_member_keeps_its_ends_relative_to_its_top_left() -> None: + doc = HwpxDocument.new() + doc.shapes.add_container([ContainerMember.rect(0, 0, 6000, 4000), ContainerMember.line(12000, 3000, 6000, 1000)]) + + line = _members(_saved_group(doc))[1] + + assert _local(line) == "line" + assert (line.find(f"{HP}offset").get("x"), line.find(f"{HP}offset").get("y")) == ("6000", "1000") + assert (line.find(f"{HC}startPt").get("x"), line.find(f"{HC}startPt").get("y")) == ("6000", "2000") + assert (line.find(f"{HC}endPt").get("x"), line.find(f"{HC}endPt").get("y")) == ("0", "0") + assert (line.find(f"{HP}orgSz").get("width"), line.find(f"{HP}orgSz").get("height")) == ("6000", "2000") + + +def test_text_box_members_get_their_text_with_paragraph_ids_unused_in_the_section() -> None: + doc = HwpxDocument.new() + doc.add_paragraph("before") + doc.shapes.add_container([ + ContainerMember.text_box(0, 0, 12000, 3000, "first"), + ContainerMember.text_box(0, 4000, 9000, 3000, "second", vert_align="TOP", margin={"left": 0}), + ]) + + group = _saved_group(doc) + boxes = _members(group) + texts = ["".join(t.text or "" for t in box.iter(f"{HP}t")) for box in boxes] + assert texts == ["first", "second"] + children = [_local(child) for child in boxes[0]] + assert children.index("drawText") < children.index("pt0") + assert [box.find(f"{HP}drawText").get("lastWidth") for box in boxes] == ["12000", "9000"] + assert boxes[1].find(f".//{HP}subList").get("vertAlign") == "TOP" + assert boxes[1].find(f".//{HP}textMargin").get("left") == "0" + with zipfile.ZipFile(io.BytesIO(doc.to_bytes())) as archive: + root = ET.fromstring(archive.read("Contents/section0.xml")) + ids = [p.get("id") for p in root.iter(f"{HP}p")] + assert len(ids) == len(set(ids)) + + +def test_a_bad_text_alignment_is_refused_before_the_document_changes() -> None: + doc = HwpxDocument.new() + before = doc.to_bytes() + + with pytest.raises(HwpxValueError): + ContainerMember.text_box(0, 0, 1000, 1000, "x", vert_align="MIDDLE") + + assert doc.to_bytes() == before + + +def test_a_picture_member_shows_the_image_it_was_given() -> None: + doc = HwpxDocument.new() + image = doc.media.add_image(_png(), "png") + doc.shapes.add_container([ContainerMember.picture(0, 0, 8000, 6000, image), ContainerMember.rect(9000, 0, 3000, 3000)]) + + picture = _members(_saved_group(doc))[0] + + assert _local(picture) == "pic" + assert picture.find(f"{HC}img").get("binaryItemIDRef") == str(image) + for name in ("imgRect", "imgClip", "inMargin", "imgDim", "effects"): + assert picture.find(f"{HP}{name}") is not None, name + assert all(picture.find(f"{HP}{name}") is None for name in _TAIL) + + +def test_a_picture_member_needs_an_image() -> None: + with pytest.raises(HwpxValueError, match="binary item"): + ContainerMember.picture(0, 0, 1000, 1000, " ") + + +def test_groups_in_groups_take_their_depth_as_group_level() -> None: + doc = HwpxDocument.new() + image = doc.media.add_image(_png(), "png") + doc.shapes.add_container([ + ContainerMember.group(1000, 1000, [ + ContainerMember.group(0, 0, [ContainerMember.rect(0, 0, 4000, 2000), ContainerMember.picture(4500, 0, 4000, 2000, image)]), + ContainerMember.line(0, 2500, 8500, 2500), + ]), + ContainerMember.rect(9500, 1000, 3000, 3000), + ]) + + group = _saved_group(doc) + assert (group.get("groupLevel"), group.get("numberingType")) == ("0", "PICTURE") + assert all(group.find(f"{HP}{name}") is not None for name in _TAIL) + inner, rect = _members(group) + deeper, line = _members(inner) + assert [m.get("groupLevel") for m in (inner, rect, deeper, line)] == ["1", "1", "2", "2"] + assert [m.get("groupLevel") for m in _members(deeper)] == ["3", "3"] + for member in (inner, rect, deeper, line, *_members(deeper)): + assert (member.get("id"), member.get("zOrder"), member.get("numberingType"), member.get("textWrap")) == ( + "0", "0", "NONE", "TOP_AND_BOTTOM") + assert all(member.find(f"{HP}{name}") is None for name in _TAIL) + # the outer group spans its members; the inner group sits at its local corner + assert (group.find(f"{HP}orgSz").get("width"), group.find(f"{HP}orgSz").get("height")) == ("11500", "3000") + assert (inner.find(f"{HP}offset").get("x"), inner.find(f"{HP}offset").get("y")) == ("0", "0") + assert (rect.find(f"{HP}offset").get("x"), rect.find(f"{HP}offset").get("y")) == ("8500", "0") + assert (inner.find(f"{HP}orgSz").get("width"), inner.find(f"{HP}orgSz").get("height")) == ("8500", "2501") # a level line is 1 high + + +def test_a_group_inside_needs_members() -> None: + with pytest.raises(HwpxValueError, match="at least one member"): + ContainerMember.group(0, 0, []) + + +def test_a_mixed_group_round_trips_and_passes_open_safety(tmp_path) -> None: + doc = HwpxDocument.new() + image = doc.media.add_image(_png(), "png") + doc.shapes.add_container([ + ContainerMember.picture(0, 0, 8000, 6000, image), + ContainerMember.text_box(8500, 0, 9000, 3000, "caption", fill_color="#DDEEFF"), + ContainerMember.line(8500, 3500, 17500, 3500), + ContainerMember.group(8500, 4000, [ContainerMember.ellipse(0, 0, 2000, 2000)]), + ], treat_as_char=False) + path = tmp_path / "mixed.hwpx" + doc.save_to_path(path) + + reopened = HwpxDocument.open(path) + + group = _saved_group(reopened) + assert [_local(m) for m in _members(group)] == ["pic", "rect", "line", "container"] + assert "caption" in "".join(t.text or "" for t in group.iter(f"{HP}t")) + + +def test_the_members_of_a_group_inside_go_where_that_group_goes() -> None: + # Hancom draws every shape of a group at its own transMatrix in the outermost group's space: a group + # inside at (8500, 4000) keeps its members there too, a level deeper at (8500 + 500, 4000 + 1000). + doc = HwpxDocument.new() + doc.shapes.add_container([ + ContainerMember.rect(0, 0, 8000, 6000), + ContainerMember.group(8500, 4000, [ + ContainerMember.ellipse(0, 0, 2000, 2000), + ContainerMember.group(500, 1000, [ContainerMember.rect(0, 0, 1000, 1000), ContainerMember.rect(1500, 0, 1000, 1000)]), + ]), + ]) + + def place(element: ET.Element) -> tuple[str, str, str, str]: + offset, trans = element.find(f"{HP}offset"), element.find(f"{HP}renderingInfo/{HC}transMatrix") + return offset.get("x"), offset.get("y"), trans.get("e3"), trans.get("e6") + + _, inner = _members(_saved_group(doc)) + ellipse, deeper = _members(inner) + assert place(inner) == ("8500", "4000", "8500", "4000") + assert place(ellipse) == ("8500", "4000", "8500", "4000") + assert place(deeper) == ("9000", "5000", "9000", "5000") + assert [place(m) for m in _members(deeper)] == [("9000", "5000", "9000", "5000"), ("10500", "5000", "10500", "5000")] + + +@pytest.mark.parametrize("twice_in_one", [False, True]) +def test_the_same_members_can_go_into_groups_again(twice_in_one: bool) -> None: + # Assembling copies the members: a ContainerMember used again is placed afresh, not moved a second time. + inner = ContainerMember.group(1000, 1000, [ContainerMember.rect(0, 0, 500, 500)]) + outer_rect = ContainerMember.rect(0, 0, 3000, 3000) + doc = HwpxDocument.new() + if twice_in_one: + doc.shapes.add_container([outer_rect, inner, inner]) + else: + doc.shapes.add_container([outer_rect, inner]) + doc.shapes.add_container([outer_rect, inner]) + + with zipfile.ZipFile(io.BytesIO(doc.to_bytes())) as archive: + root = ET.fromstring(archive.read("Contents/section0.xml")) + groups = [g for g in root.iter(f"{HP}container") if g.get("groupLevel") == "0"] + inner_rects = [(r.find(f"{HP}offset").get("x"), r.find(f"{HP}offset").get("y")) + for g in groups for i in _members(g) if _local(i) == "container" for r in _members(i)] + assert inner_rects == [("1000", "1000")] * 2 + assert inner.element.find(f"{HP}offset").get("x") == "0" diff --git a/tests/test_product_boundary.py b/tests/test_product_boundary.py index e7d8d324..aa3c563b 100644 --- a/tests/test_product_boundary.py +++ b/tests/test_product_boundary.py @@ -321,7 +321,7 @@ def test_real_tree_gate_runs_from_a_gitless_source_copy(tmp_path: Path) -> None: # +1: FormFit's glyph width table (form_fit/_glyph_table.py), read from the font files. # +1: row and column insertion and deletion and cell splits on the table object model # (oxml/table_structure.py), outside table.py because that owner file sits at its 1600-line cap. - assert report["classifiedFiles"] == 181 + assert report["classifiedFiles"] == 182 def test_gitless_cli_reproduces_literal_dynamic_import_failure_without_mutating_source(