diff --git a/CHANGELOG.md b/CHANGELOG.md index b325c2cb..8a9f0705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ 모든 중요한 변경 사항은 이 문서에 기록됩니다. 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)과 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다. +## [Unreleased] + +### 추가 + +- 표 구조 편집(`hwpx.table_patch.apply_table_ops`)에 열 삽입 `insert_column_by_clone`을 더한다. 격자 열 + `ref_col`의 오른쪽(`side: "left"`면 왼쪽)에 그 열을 복제한 열 `count`개를 넣는다. + - 새 열은 기준 열과 같은 폭이고, 표는 그만큼 넓어진다. 본문 폭을 넘어도 줄이지 않는다. + - 새 칸은 같은 행 기준 칸의 테두리·문단 모양·글자 모양을 따른다. 글은 행 삽입(`insert_row_by_clone`)처럼 + 복제하고, `blank: true`면 한/글처럼 빈 문단 하나만 둔다. + - 새 열을 가로지르는 합친 칸은 그 열만큼 colSpan과 폭이 는다. 기준 열에서 끝나는(왼쪽이면 시작하는) + 합친 칸 옆에는 그 칸 서식의 빈 칸이 들어가고, 기준 열에서 세로로 합친 칸은 같은 행들에 걸쳐 복제된다. + - 칸 영역(`hp:cellzone`)의 열 주소도 함께 옮긴다. + +### 고침 + +- 표 구조 편집(`apply_table_ops`)이 `hp:cellAddr`·`hp:cellSz`가 없는 칸(다른 프로그램이 쓴 표)에서 + `AssertionError`로 멈추거나 엉뚱한 이유로 거부하던 것을 고친다. 행·열 삭제와 삽입, 열 폭 설정과 맞춤, 표 나누기, + 행 순서 바꾸기가 이제 그런 표를 표 안의 표처럼 거부한다. +- 표의 열을 모두 지우는 `delete_column`(1열 표의 열 포함)이 `ZeroDivisionError`로 멈추던 것을 고친다. 행을 모두 + 지우는 `delete_row`도 남는 격자 탓으로만 거부했다. 한/글도 마지막 행·열은 지우지 않는다. 이제 둘 다 거부하고 + `delete_table`을 쓰라고 알린다. + ## [6.8.0] - 2026-10-06 `import hwpx`가 HWP 5.0 모듈과 `hwpx.tools`를 처음 쓸 때 읽도록 바꿔 가벼워진 릴리스입니다. FormFit은 글꼴 diff --git a/docs/editor-menu-reverse-map.md b/docs/editor-menu-reverse-map.md index 09a61040..09872baa 100644 --- a/docs/editor-menu-reverse-map.md +++ b/docs/editor-menu-reverse-map.md @@ -154,7 +154,7 @@ Windows 한컴 전용 표면은 이 스캔으로 부재를 단정할 수 없다. | 셀 테두리/배경 | [대응 영역] | `HwpxOxmlTable.set_cell_border_fill`(+ `ensure_border_fill`)로 테두리, `set_cell_shading`(+ `ensure_shading_border_fill`)로 배경색, `set_cell_fill_image`/`set_cell_fill_gradient`로 이미지/그라데이션 배경까지 전부 확인(`oxml/table.py:775-843`) — 표 구조 변경/문단·표 저작/편집 영역이 대응 | | 표 나누기 | ✅ [대응 영역] | ~~신규 갭~~ **트레인㊸ 갭⑤에서 해소**(`f7e4e67`) — `apply_table_ops`의 `split_table` op가 물리 행 인덱스에서 표를 둘로 나눈다(병합 셀이 경계를 걸치면 fail-closed 거부), v16 render-verified | | 표 붙이기 | ✅ [대응 영역] | ~~신규 갭~~ **트레인㊸ 갭⑤에서 해소**(`f7e4e67`) — `apply_table_ops`의 `merge_table` op가 그 역연산을 수행한다(colCnt 불일치·실텍스트 존재 시 거부), v16 render-verified | -| 줄/칸 추가하기… | [대응 영역] | 표 구조 변경(`insert_row_by_clone`) | +| 줄/칸 추가하기… | [대응 영역] | 표 구조 변경(`insert_row_by_clone`, `insert_column_by_clone`) | | 줄/칸 지우기… | [대응 영역] | 표 구조 변경(`delete_row`/`delete_column`) | | 셀 나누기… | [대응 영역] | 표 구조 변경(`split_cell_vertical`) | | 셀 합치기 | [대응 영역] | 표 생성(`merge_cells`) | diff --git a/src/hwpx/table_patch.py b/src/hwpx/table_patch.py index 415bbf79..85c0f72a 100644 --- a/src/hwpx/table_patch.py +++ b/src/hwpx/table_patch.py @@ -27,7 +27,7 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping, Sequence +from typing import Any, Callable, Iterable, Mapping, Sequence from .errors import HwpxError from .oxml.table_sizes import effective_cell_margin_source @@ -854,9 +854,16 @@ def _ss(chunk: str, tag: str, attr: str, val: int) -> str: def _guard_flat(table: str) -> None: + """Refuse a table structure edits cannot handle: one holding a table, or one with a + cell missing the address or size OWPML requires of it.""" # the table's own plus any nested ones; >1 open == nested if len(re.findall(r" 1: raise TableStructureError("nested tables are unsupported for structure edits") + for tc in _S_TC.findall(table): + if None in (_si(tc, "cellAddr", "colAddr"), _si(tc, "cellAddr", "rowAddr"), _si(tc, "cellSz", "width")): + raise TableStructureError( + "a cell without hp:cellAddr or hp:cellSz is unsupported for structure edits" + ) def _parse_table(table: str) -> tuple[str, list[str], str]: @@ -970,6 +977,10 @@ def _delete_columns(table: str, del_cols: Iterable[int]) -> str: ncol = max(widths) + 1 freed = sum(widths[c] for c in del_cols) survivors = [c for c in range(ncol) if c not in del_cols] + if not survivors: + raise TableStructureError( + "delete_column: deleting every column would leave no table -- delete the table instead (delete_table)" + ) targets = [c for c in survivors if c > dmax and c != survivors[-1]] or survivors add, rem = divmod(freed, len(targets)) nw = {c: widths[c] for c in survivors} @@ -1054,6 +1065,10 @@ def _delete_rows(table: str, del_rows: Iterable[int]) -> str: _guard_flat(table) del_rows = sorted(set(del_rows), reverse=True) prefix, rows, suffix = _parse_table(table) + if set(range(len(rows))) <= set(del_rows): + raise TableStructureError( + "delete_row: deleting every row would leave no table -- delete the table instead (delete_table)" + ) for empty in del_rows: if empty >= len(rows): raise TableStructureError(f"row index {empty} out of range") @@ -1247,6 +1262,22 @@ def _clone_row_template(rows: list[str], ref_row: int) -> str: return (opening.group(0) if opening else "") + "".join(tc for _, tc in sorted(cells, key=lambda c: c[0])) + "" +def _fresh_ids(table: str, used_ids: set[int] | None) -> Callable[[re.Match[str]], str]: + """A ``_PARA_ID_RE`` substitution giving each paragraph the lowest id used neither in + *table* nor in *used_ids* (the ids of the whole document).""" + occupied = set(used_ids or ()) | {int(m.group(2)) for m in _PARA_ID_RE.finditer(table)} + next_id = 1 + + def fresh_id(match: re.Match[str]) -> str: + nonlocal next_id + while next_id in occupied: + next_id += 1 + occupied.add(next_id) + return match.group(1) + str(next_id) + match.group(3) + + return fresh_id + + def _insert_row_by_clone( table: str, ref_row: int, count: int = 1, *, used_ids: set[int] | None = None ) -> str: @@ -1277,16 +1308,7 @@ def shift(tc: str): # build the clones from the ORIGINAL rows, before the shift ref = _clone_row_template(rows, ref_row) shifted = [_map_cells(r, shift) for r in rows] - occupied = set(used_ids or ()) | {int(m.group(2)) for m in _PARA_ID_RE.finditer(table)} - next_id = 1 - - def fresh_id(match: re.Match[str]) -> str: - nonlocal next_id - while next_id in occupied: - next_id += 1 - occupied.add(next_id) - return match.group(1) + str(next_id) + match.group(3) - + fresh_id = _fresh_ids(table, used_ids) clones = [] for k in range(1, count + 1): clone = _map_cells(ref, lambda tc: _ss(tc, "cellAddr", "rowAddr", ref_row + k)) @@ -1296,6 +1318,85 @@ def fresh_id(match: re.Match[str]) -> str: return _rebuild(prefix, new_rows, suffix, rowcnt=len(new_rows)) +def _shift_zone_columns(prefix: str, first: int, count: int) -> str: + """The ``hp:cellzone`` entries of a table head with *count* columns inserted from grid + column *first* on: a zone from there on moves over, a zone running on across it grows.""" + + def shift(match: re.Match[str]) -> str: + zone = match.group(0) + start, end = _si(zone, "cellzone", "startColAddr"), _si(zone, "cellzone", "endColAddr") + if start is None or end is None or end < first: + return zone + if start >= first: + zone = _ss(zone, "cellzone", "startColAddr", start + count) + return _ss(zone, "cellzone", "endColAddr", end + count) + + return re.sub(r"]*>", shift, prefix) + + +def _insert_column_by_clone( + table: str, + ref_col: int, + count: int = 1, + *, + side: str = "right", + blank: bool = False, + used_ids: set[int] | None = None, +) -> str: + """Insert *count* columns right (or with *side* ``"left"``, left) of grid column *ref_col* + by cloning it (formatting preserved, paragraph ids refreshed). Columns past them shift, + and the table grows by the new columns, each as wide as *ref_col*, as Hancom's column + insertion widens it. With *blank* each new cell holds one empty paragraph of its cell's + paragraph and character shape, as Hancom inserts them. + + Merged cells follow Hancom's insertion as in :func:`_insert_row_by_clone`: a cell running + on across the new columns grows its colSpan and width over them instead of being cloned, + and next to a merged cell that ends (or, on the left, starts) at *ref_col* each new + column gets an empty cell of its format and rows.""" + _guard_flat(table) + if side not in ("left", "right"): + raise TableStructureError(f"insert_column_by_clone: side must be 'left' or 'right', got {side!r}") + if count < 1: + return table + prefix, rows, suffix = _parse_table(table) + columns = _grid_width(rows) + if not 0 <= ref_col < columns: + raise TableStructureError(f"ref col {ref_col} out of range") + widths = _uniform_col_widths(rows) or _grid_col_widths(table) + if widths is None: + raise TableStructureError( + "insert_column_by_clone: the column widths are underivable from the grid -- refusing (fail-closed)" + ) + width = widths[ref_col] + first = ref_col + 1 if side == "right" else ref_col # the first new column + fresh_id = _fresh_ids(table, used_ids) + + def clones(tc: str) -> str: + if blank or (_si(tc, "cellSpan", "colSpan") or 1) > 1: + tc = _ss(_ss(_empty_cell_like(tc), "cellSpan", "colSpan", 1), "cellSz", "width", width) + return "".join( + _PARA_ID_RE.sub(fresh_id, _ss(tc, "cellAddr", "colAddr", first + k)) for k in range(count) + ) + + def widen(tc: str) -> str: + col, span = _si(tc, "cellAddr", "colAddr"), _si(tc, "cellSpan", "colSpan") or 1 + assert col is not None # required hp:tc attr + if col < first <= col + span - 1: # runs on across the new columns -> grows over them + tc = _ss(tc, "cellSpan", "colSpan", span + count) + return _ss(tc, "cellSz", "width", (_si(tc, "cellSz", "width") or 0) + count * width) + moved = _ss(tc, "cellAddr", "colAddr", col + count) if col >= first else tc + if side == "right" and col + span - 1 == ref_col: + return moved + clones(tc) + if side == "left" and col == ref_col: + return clones(tc) + moved + return moved + + new_rows = [_map_cells(row, widen) for row in rows] + prefix = _ss(prefix, "sz", "width", (_si(prefix, "sz", "width") or 0) + count * width) + prefix = _shift_zone_columns(prefix, first, count) + return _rebuild(prefix, new_rows, suffix, colcnt=columns + count) + + def _insert_block_by_clone(table: str, r0: int, r1: int, count: int = 1) -> str: """Clone a contiguous **vertical-merge block** (physical rows ``r0..r1``) *count* times, preserving the block's internal span pattern (FR-001). @@ -1515,6 +1616,9 @@ def _widths_arg(o: Mapping[str, Any]) -> dict[int, int]: "delete_row": lambda t, o: _delete_rows(t, o["rows"] if "rows" in o else [o["row"]]), "reorder_rows": lambda t, o: _reorder_rows(t, [int(x) for x in o["order"]]), "insert_row_by_clone": lambda t, o: _insert_row_by_clone(t, o["ref_row"], int(o.get("count", 1))), + "insert_column_by_clone": lambda t, o: _insert_column_by_clone( + t, int(o["ref_col"]), int(o.get("count", 1)), side=str(o.get("side", "right")), + blank=bool(o.get("blank", False))), "insert_block_by_clone": lambda t, o: _insert_block_by_clone(t, int(o["ref_rows"][0]), int(o["ref_rows"][1]), int(o.get("count", 1))), "set_column_widths": lambda t, o: _set_column_widths(t, _widths_arg(o)), "autofit_columns": lambda t, o: _autofit_columns(t, min_frac=float(o.get("min_frac", 0.06)), damp=float(o.get("damp", 0.5))), @@ -1525,6 +1629,16 @@ def _widths_arg(o: Mapping[str, Any]) -> dict[int, int]: } +#: The ops whose new paragraphs take ids unused in the whole document. +_CLONE_OPS = { + "insert_row_by_clone": lambda t, o, ids: _insert_row_by_clone( + t, o["ref_row"], int(o.get("count", 1)), used_ids=ids), + "insert_column_by_clone": lambda t, o, ids: _insert_column_by_clone( + t, int(o["ref_col"]), int(o.get("count", 1)), side=str(o.get("side", "right")), + blank=bool(o.get("blank", False)), used_ids=ids), +} + + def _set_row_heights(table: str, heights: Mapping[int, int]) -> str: """행높이 명시 설정(HWPUNIT, 1pt=100) — Stage 3 간격 프리미티브. @@ -1920,8 +2034,11 @@ def apply_table_ops( each changed table back so untouched bytes stay identical. Op dicts: ``{op: 'delete_column'|'delete_row'|'delete_table'| - 'insert_row_by_clone'|'insert_block_by_clone'|'split_table'|'merge_table'| - 'fill_cell', section_path?, table_index, ...}``. ``insert_block_by_clone`` + 'insert_row_by_clone'|'insert_column_by_clone'|'insert_block_by_clone'| + 'split_table'|'merge_table'|'fill_cell', section_path?, table_index, ...}``. + ``insert_column_by_clone`` takes ``ref_col`` + ``count`` (+ ``side: 'left'``, + ``blank`` for empty new cells) and inserts right (left) of that grid column, + widening the table. ``insert_block_by_clone`` takes ``ref_rows: [r0, r1]`` (a vertical-merge block) + ``count``; ``delete_column`` derives widths from the merged grid when no uniform ``colSpan==1`` row exists (FR-003). @@ -2026,13 +2143,10 @@ def _log(name, sp, ti, status, **extra) -> None: elif name == "merge_table": new_section, dims_after = _merge_tables(section, spans, ti) elif name in _STRUCT_OPS: - if name == "insert_row_by_clone": + if name in _CLONE_OPS: used_ids = {int(m.group(2)) for data in sections.values() for m in _PARA_ID_RE.finditer(data.decode("utf-8"))} - new_table = _insert_row_by_clone( - section[ts:te].decode("utf-8"), op["ref_row"], - int(op.get("count", 1)), used_ids=used_ids, - ) + new_table = _CLONE_OPS[name](section[ts:te].decode("utf-8"), op, used_ids) else: new_table = _STRUCT_OPS[name](section[ts:te].decode("utf-8"), op) _validate_or_raise(new_table) diff --git a/tests/fixtures/hancom_saved/table_insert_column_base.hwpx b/tests/fixtures/hancom_saved/table_insert_column_base.hwpx new file mode 100644 index 00000000..a75953ba Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_base.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_formats2_base.hwpx b/tests/fixtures/hancom_saved/table_insert_column_formats2_base.hwpx new file mode 100644 index 00000000..16be5802 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_formats2_base.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_formats2_side1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_formats2_side1.hwpx new file mode 100644 index 00000000..efdf24e2 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_formats2_side1.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_formats_base.hwpx b/tests/fixtures/hancom_saved/table_insert_column_formats_base.hwpx new file mode 100644 index 00000000..278df11e Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_formats_base.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_formats_side1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_formats_side1.hwpx new file mode 100644 index 00000000..81d00e5b Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_formats_side1.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_full_width0_base.hwpx b/tests/fixtures/hancom_saved/table_insert_column_full_width0_base.hwpx new file mode 100644 index 00000000..8e590b48 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_full_width0_base.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_full_width0_side1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_full_width0_side1.hwpx new file mode 100644 index 00000000..b0531402 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_full_width0_side1.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_merge_r0c01_base.hwpx b/tests/fixtures/hancom_saved/table_insert_column_merge_r0c01_base.hwpx new file mode 100644 index 00000000..92673fe2 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_merge_r0c01_base.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_merge_r0c01_side1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_merge_r0c01_side1.hwpx new file mode 100644 index 00000000..ba0d0896 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_merge_r0c01_side1.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_merge_r0c12_base.hwpx b/tests/fixtures/hancom_saved/table_insert_column_merge_r0c12_base.hwpx new file mode 100644 index 00000000..9101839d Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_merge_r0c12_base.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_merge_r0c12_side1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_merge_r0c12_side1.hwpx new file mode 100644 index 00000000..fa504d80 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_merge_r0c12_side1.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_side0_count1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_side0_count1.hwpx new file mode 100644 index 00000000..7c30cb15 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_side0_count1.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_side1_count1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_side1_count1.hwpx new file mode 100644 index 00000000..ca0f5e20 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_side1_count1.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_side1_count3.hwpx b/tests/fixtures/hancom_saved/table_insert_column_side1_count3.hwpx new file mode 100644 index 00000000..c7283fa4 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_side1_count3.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_vmerge_c1r01_base.hwpx b/tests/fixtures/hancom_saved/table_insert_column_vmerge_c1r01_base.hwpx new file mode 100644 index 00000000..7edd507e Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_vmerge_c1r01_base.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_vmerge_c1r01_side1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_vmerge_c1r01_side1.hwpx new file mode 100644 index 00000000..9d14cbaf Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_vmerge_c1r01_side1.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_wide_col1_all_base.hwpx b/tests/fixtures/hancom_saved/table_insert_column_wide_col1_all_base.hwpx new file mode 100644 index 00000000..a466c947 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_wide_col1_all_base.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_insert_column_wide_col1_all_side1.hwpx b/tests/fixtures/hancom_saved/table_insert_column_wide_col1_all_side1.hwpx new file mode 100644 index 00000000..6a030b77 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_insert_column_wide_col1_all_side1.hwpx differ diff --git a/tests/test_insert_column_by_clone.py b/tests/test_insert_column_by_clone.py new file mode 100644 index 00000000..a030960b --- /dev/null +++ b/tests/test_insert_column_by_clone.py @@ -0,0 +1,200 @@ +"""``insert_column_by_clone`` inserts grid columns the way Hancom's column insertion does. + +``tests/fixtures/hancom_saved/table_insert_column_*`` are 3x3 tables made by Hangul (``*_base``) and the +same tables after Hangul inserted columns right (``side0_count1``: left) of a cell of column 1 (the other +files). Hangul's rules: + +- A new column is as wide as the column it is inserted right of, and the table grows by it, past the + text width if need be. +- Each new cell takes the border fill, paragraph shape and character shape of the cell of the same row + in that column, and holds one empty paragraph. +- A cell merged across the new columns grows its colSpan over them (Hangul leaves its stored width as it + was and draws it by the grid), right of a cell merged from the left that ends at the column each new + column gets a cell of its own, and a cell merged down the column is cloned with its rows. + +``blank=True`` gives Hangul's empty cells; by default the new cells keep the text of the cells they +clone, as ``insert_row_by_clone`` does. +""" + +from __future__ import annotations + +import io +import re +import zipfile +from pathlib import Path + +import pytest +from lxml import etree + +from hwpx.document import HwpxDocument +from hwpx.table_patch import TableStructureError, _insert_column_by_clone, apply_table_ops + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +FIXTURES = Path(__file__).parent / "fixtures" / "hancom_saved" + + +def _table(data: bytes) -> etree._Element: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + root = etree.fromstring(archive.read("Contents/section0.xml")) + return next(root.iter(HP + "tbl")) + + +def _cells(data: bytes) -> list[list[tuple]]: + """Each row's cells as (row, col, rowSpan, colSpan, width, height, border fill, paragraph shape, + character shape of the first paragraph (0 without a run), text).""" + rows = [] + for tr in _table(data).findall(HP + "tr"): + cells = [] + for tc in tr.findall(HP + "tc"): + addr, span, size = tc.find(HP + "cellAddr"), tc.find(HP + "cellSpan"), tc.find(HP + "cellSz") + paragraph = tc.find(HP + "subList").find(HP + "p") + run = paragraph.find(HP + "run") + cells.append(( + int(addr.get("rowAddr")), int(addr.get("colAddr")), + int(span.get("rowSpan")), int(span.get("colSpan")), + int(size.get("width")), int(size.get("height")), + tc.get("borderFillIDRef"), paragraph.get("paraPrIDRef"), + run.get("charPrIDRef") if run is not None else "0", + "".join(t.text or "" for t in tc.iter(HP + "t")), + )) + rows.append(cells) + return rows + + +def _insert(data: bytes, ref_col: int, count: int = 1, **options) -> bytes: + op = {"op": "insert_column_by_clone", "table_index": 0, "ref_col": ref_col, "count": count, **options} + result = apply_table_ops(data, [op]) + assert result.ok, result.skipped + return result.data + + +def _saved(name: str) -> bytes: + return (FIXTURES / f"table_insert_column_{name}.hwpx").read_bytes() + + +@pytest.mark.parametrize( + ("base", "inserted", "count", "side"), + [ + ("base", "side1_count1", 1, "right"), + ("base", "side1_count3", 3, "right"), + ("base", "side0_count1", 1, "left"), + ("wide_col1_all_base", "wide_col1_all_side1", 1, "right"), + ("merge_r0c01_base", "merge_r0c01_side1", 1, "right"), + ("merge_r0c12_base", "merge_r0c12_side1", 1, "right"), + ("vmerge_c1r01_base", "vmerge_c1r01_side1", 1, "right"), + ("formats_base", "formats_side1", 1, "right"), + ("formats2_base", "formats2_side1", 1, "right"), + ("full_width0_base", "full_width0_side1", 1, "right"), + ], +) +def test_blank_columns_are_the_ones_hancom_inserts(base: str, inserted: str, count: int, side: str) -> None: + data = _insert(_saved(base), 1, count, side=side, blank=True) + + mine, hancom = _cells(data), _cells(_saved(inserted)) + assert _table(data).get("colCnt") == _table(_saved(inserted)).get("colCnt") + assert _table(data).find(HP + "sz").attrib == _table(_saved(inserted)).find(HP + "sz").attrib + without_spanned_widths = [ + [cell[:4] + (None if cell[3] > 1 else cell[4],) + cell[5:] for cell in row] for row in mine + ] + assert without_spanned_widths == [ + [cell[:4] + (None if cell[3] > 1 else cell[4],) + cell[5:] for cell in row] for row in hancom + ] + + +def test_a_cell_merged_across_the_new_column_is_written_as_wide_as_its_columns() -> None: + data = _insert(_saved("merge_r0c12_base"), 1, blank=True) + + merged = _cells(data)[0][1] + assert merged[3] == 3 + assert merged[4] == 3 * 13984 # Hangul keeps the old 27968 and draws the cell by the grid + assert _cells(_saved("merge_r0c12_side1"))[0][1][4] == 2 * 13984 + + +def test_by_default_the_new_cells_keep_the_text_they_clone() -> None: + data = _insert(_saved("formats_base"), 1) + + for row, cells in enumerate(_cells(data)): + assert [cell[9] for cell in cells] == [f"r{row}c0", f"r{row}c1", f"r{row}c1", f"r{row}c2"] + assert [cell[6:9] for cell in _cells(data)[1][1:3]] == [("4", "1", "1"), ("4", "1", "1")] + + +def test_left_of_a_column_merged_cells_follow_the_same_rules() -> None: + document = HwpxDocument.new() + table = document.add_table(rows=2, cols=4) + for row in range(2): + for col in range(4): + table.cell(row, col).text = f"r{row}c{col}" + table.merge_cells("A1:B1") + table.merge_cells("C1:D1") + + data = _insert(document.to_bytes(), 1, side="left") + rows = _cells(data) + assert [cell[1:4] + (cell[9],) for cell in rows[0]] == [(0, 1, 3, "r0c0r0c1"), (3, 1, 2, "r0c2r0c3")] + assert [cell[1] for cell in rows[1]] == [0, 1, 2, 3, 4] + assert rows[1][1][9] == rows[1][2][9] == "r1c1" + + data = _insert(document.to_bytes(), 2, side="left") + rows = _cells(data) + assert [cell[1:4] + (cell[9],) for cell in rows[0]] == [ + (0, 1, 2, "r0c0r0c1"), (2, 1, 1, ""), (3, 1, 2, "r0c2r0c3"), + ] + assert rows[0][1][4] == rows[1][2][4] + + +def test_new_paragraphs_take_ids_unused_in_the_document() -> None: + document = HwpxDocument.new() + table = document.add_table(rows=2, cols=2) + table.cell(0, 0).text = "a" + + data = _insert(document.to_bytes(), 0, count=2) + + with zipfile.ZipFile(io.BytesIO(data)) as archive: + section = archive.read("Contents/section0.xml").decode("utf-8") + ids = re.findall(r']*\bid="(\d+)"', section) + assert len(ids) == len(set(ids)) + + +def _bare_table(zones: str = "") -> str: + rows = "".join( + "" + "".join( + f'' + f"{row}{col}" + f'' + f'' + for col in range(3) + ) + "" + for row in range(2) + ) + return f'{zones}{rows}' + + +def test_cell_zones_move_with_their_columns() -> None: + zones = ( + "" + '' + '' + '' + "" + ) + + table = _insert_column_by_clone(_bare_table(zones), 1) + + found = re.findall(r'startColAddr="(\d+)" endRowAddr="\d+" endColAddr="(\d+)"', table) + assert found == [("0", "0"), ("0", "3"), ("3", "3")] + assert 'colCnt="4"' in table and ' None: + with pytest.raises(TableStructureError, match="out of range"): + _insert_column_by_clone(_bare_table(), 3) + with pytest.raises(TableStructureError, match="side"): + _insert_column_by_clone(_bare_table(), 0, side="above") + nested = _bare_table().replace("00", '00') + with pytest.raises(TableStructureError, match="nested"): + _insert_column_by_clone(nested, 0) + result = apply_table_ops(_saved("base"), [{"op": "insert_column_by_clone", "table_index": 0, "ref_col": 3}]) + assert not result.ok and "out of range" in result.skipped[0].reason diff --git a/tests/test_table_ops_cells_without_geometry.py b/tests/test_table_ops_cells_without_geometry.py new file mode 100644 index 00000000..8ef52937 --- /dev/null +++ b/tests/test_table_ops_cells_without_geometry.py @@ -0,0 +1,61 @@ +"""Table structure edits refuse a table whose cells lack the address or size OWPML requires. + +Such tables turn up in documents written by other programs: ``hp:tc`` holding paragraphs straight, with no +``hp:subList``, ``hp:cellAddr``, ``hp:cellSpan`` or ``hp:cellSz``. Every structure edit used to stop on an +``AssertionError`` there; it now reports the table as refused, as it does a table holding a table. +""" + +from __future__ import annotations + +import io +import re +import zipfile + +import pytest + +from hwpx.document import HwpxDocument +from hwpx.table_patch import apply_table_ops + +SECTION = "Contents/section0.xml" + + +def _document_with_bare_cells() -> bytes: + document = HwpxDocument.new() + table = document.add_table(rows=3, cols=3) + for row in range(3): + for col in range(3): + table.cell(row, col).text = f"r{row}c{col}" + source = io.BytesIO(document.to_bytes()) + target = io.BytesIO() + with zipfile.ZipFile(source) as original, zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as rewritten: + for info in original.infolist(): + data = original.read(info.filename) + if info.filename == SECTION: + text = data.decode("utf-8") + text = re.sub(r"]*/>", "", text) + data = text.encode("utf-8") + rewritten.writestr(info, data) + return target.getvalue() + + +@pytest.mark.parametrize( + "op", + [ + {"op": "delete_column", "col": 1}, + {"op": "delete_row", "row": 1}, + {"op": "insert_row_by_clone", "ref_row": 0}, + {"op": "insert_block_by_clone", "ref_rows": [0, 1]}, + {"op": "insert_column_by_clone", "ref_col": 0}, + {"op": "set_column_widths", "widths": [1000, 2000, 3000]}, + {"op": "autofit_columns"}, + {"op": "reorder_rows", "order": [2, 1, 0]}, + {"op": "split_table", "split_row": 1}, + ], +) +def test_a_structure_edit_refuses_cells_without_an_address_or_a_size(op: dict) -> None: + data = _document_with_bare_cells() + + result = apply_table_ops(data, [{**op, "table_index": 0}]) + + assert not result.ok + assert "without hp:cellAddr or hp:cellSz" in result.skipped[0].reason diff --git a/tests/test_table_ops_delete_every_line.py b/tests/test_table_ops_delete_every_line.py new file mode 100644 index 00000000..40eef023 --- /dev/null +++ b/tests/test_table_ops_delete_every_line.py @@ -0,0 +1,44 @@ +"""Deleting every row or every column of a table is refused, as Hangul refuses it. + +Hangul does not delete the only column of a one-column table or the only row of a one-row table: the table +stays as it was. ``delete_column`` used to stop on a ``ZeroDivisionError`` there, and ``delete_row`` refused +it for an unrelated reason (the grid it left). Both now point to ``delete_table``. +""" + +from __future__ import annotations + +import pytest + +from hwpx.document import HwpxDocument +from hwpx.table_patch import apply_table_ops + + +def _document(rows: int, cols: int) -> bytes: + document = HwpxDocument.new() + table = document.add_table(rows=rows, cols=cols) + table.cell(0, 0).text = "a" + return document.to_bytes() + + +@pytest.mark.parametrize( + ("rows", "cols", "op"), + [ + (2, 1, {"op": "delete_column", "col": 0}), + (2, 3, {"op": "delete_column", "cols": [0, 1, 2]}), + (1, 2, {"op": "delete_row", "row": 0}), + (3, 2, {"op": "delete_row", "rows": [2, 0, 1]}), + ], +) +def test_deleting_every_row_or_column_is_refused(rows: int, cols: int, op: dict) -> None: + data = _document(rows, cols) + + result = apply_table_ops(data, [{**op, "table_index": 0}]) + + assert not result.ok + assert result.data == data + assert "would leave no table" in result.skipped[0].reason and "delete_table" in result.skipped[0].reason + + +def test_deleting_all_but_one_still_works() -> None: + assert apply_table_ops(_document(2, 3), [{"op": "delete_column", "cols": [0, 1], "table_index": 0}]).ok + assert apply_table_ops(_document(3, 2), [{"op": "delete_row", "rows": [0, 1], "table_index": 0}]).ok