From b9ab128e19025dfa03e990c1c90b2a27a615e429 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:08:41 +0900 Subject: [PATCH 1/3] fix(table-patch): line spacing in cells holding tables and fresh numbers for copies of copies set_cell_line_spacing now changes a cell's own paragraphs when the cell holds a table and never reaches into a table held by another cell. Nested tables copied with a cell, insert_block_by_clone and clone_table number tables, objects and paragraphs as Hancom numbers a pasted table, so copying a copy repeats no id. split_cell_vertical leaves its new parts empty. The object model's column width edits refuse to leave a cell narrower than the table nested in it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 19 ++++ docs/nested-cell-fill-safety.md | 20 +++- src/hwpx/oxml/table_sizes.py | 37 ++++++ src/hwpx/oxml/table_structure.py | 3 +- src/hwpx/table_patch.py | 125 +++++++++------------ tests/test_cell_line_spacing.py | 87 ++++++++++++++ tests/test_nested_table_structure_edits.py | 67 +++++++++++ 7 files changed, 283 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3f3da0..ce5f58e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ 모든 중요한 변경 사항은 이 문서에 기록됩니다. 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)과 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다. +## [Unreleased] + +### 고침 + +- 셀 줄간격(`set_cell_line_spacing`)이 표를 품은 칸도 바꾼다. 칸 자신의 문단(표를 담은 문단 포함)만 줄간격을 바꾸고 그 + 문단들의 줄 캐시만 지우며, 안쪽 표와 글상자는 바이트 그대로 둔다. 전에는 그런 칸을 거부했고, 다른 칸에 표가 든 표에서 + 행을 고르면 안쪽 표의 같은 주소 칸까지 바꿨다. +- 복제한 것을 다시 복제해도 번호가 겹치지 않는다. 표를 품은 행을 복제한 뒤 열을 복제하면(또는 반대로) 안쪽 표의 `id`가 + 겹쳤고, `insert_block_by_clone`을 두 번 하면 문단 `id`가 겹쳤다. 이제 복제된 표는 한/글이 붙여 넣은 표처럼 문서에 없는 + 새 `id`와 문서 맨 위 `zOrder`를 받고, 문단은 문서에 없는 `id`를 받는다. +- `insert_block_by_clone`이 그림·도형·필드·메모가 든 칸을 행 복제처럼 복제한다. 전에는 같은 번호로 복사했다. 차트, 연결된 + 글상자, 칸 안 머리말·꼬리말 정의가 든 블록은 거부한다. +- `clone_table`이 표와 그 안의 표·개체에 한/글이 붙여 넣은 표처럼 새 번호를 준다. 전에는 문단 `id`만 바꿔 표 `id`와 개체 + 번호가 원본과 같았다. 차트, 연결된 글상자, 칸 안 머리말·꼬리말 정의가 든 표는 거부한다. +- `split_cell_vertical`이 새로 생긴 칸을 빈 문단 하나로 둔다(칸 나누기와 같음). 전에는 원래 칸의 문단을 글자만 비워 + 복사해서, 그 칸의 그림·도형이 같은 번호로 겹쳤다. +- 객체 모델 `set_column_widths`·`equalize_column_widths`가 칸을 안쪽 표보다 좁게 만들면 바이트 편집처럼 거부하고 표를 + 그대로 둔다(`TableStructureError`). + ## [6.11.0] - 2026-11-02 표 안에 표가 든 문서를 끝까지 다루고, 쪽 수 추정과 FormFit을 한/글에 더 가깝게 맞춘 릴리스입니다. 바이트 보존 편집이 diff --git a/docs/nested-cell-fill-safety.md b/docs/nested-cell-fill-safety.md index 82c7944c..3640edef 100644 --- a/docs/nested-cell-fill-safety.md +++ b/docs/nested-cell-fill-safety.md @@ -44,15 +44,25 @@ deleted row or column) or stays in the first part of a split cell. cell's (see "Cloning cell content" below); `blank` clones hold no table. - An edit that would leave a cell narrower than the table nested in it (its width plus the cell's side margins) refuses, unless the cell was already that - narrow. `autofit_columns` holds such a column at that width. + narrow. `autofit_columns` holds such a column at that width. The object + model's `set_column_widths` and `equalize_column_widths` refuse the same way + and leave the table as it was. - `merge_table` merges with the next table on the same nesting level. +- `set_cell_line_spacing` changes the line spacing of the addressed cell's own + paragraphs, including the one holding a table, and drops only their line + caches; a table or text box in the cell keeps its bytes. Address an inner + table by its own `table_index` to change its cells. ## Cloning cell content -`insert_row_by_clone`, `insert_column_by_clone` and `split_cell` copy a cell's -content the way Hancom numbers what it pastes (observed by copying and pasting -in Hangul and comparing the saved HWPX): +`insert_row_by_clone`, `insert_column_by_clone`, `insert_block_by_clone`, +`split_cell` and `clone_table` copy content the way Hancom numbers what it +pastes (observed by copying and pasting in Hangul and comparing the saved +HWPX). Copying copies again never repeats a number: paragraphs take ids unused +in the document. +- A table copied, whole or nested in a copied cell, gets a fresh `id` and a + `zOrder` above the document's, as a pasted table does. - Pictures, shapes, equations, footnotes and endnotes get a fresh `id` and `instid` (two different numbers) used nowhere in the document, and a `zOrder` above the document's. The shapes inside a group keep their `id` and `zOrder` @@ -66,6 +76,8 @@ in Hangul and comparing the saved HWPX): - A connector copied loses its ends (`subjectIDRef="0"`), as Hancom's does. - Content that points into another part or definition refuses: a chart, linked text boxes, a header or footer defined in the cell. +- `split_cell_vertical` keeps the content in the first part; each new part + holds one empty paragraph of the cell's format. ## Evidence and downstream use diff --git a/src/hwpx/oxml/table_sizes.py b/src/hwpx/oxml/table_sizes.py index 28916283..c26970b4 100644 --- a/src/hwpx/oxml/table_sizes.py +++ b/src/hwpx/oxml/table_sizes.py @@ -87,6 +87,42 @@ def _column_floors(table: "HwpxOxmlTable") -> list[int]: return floors +def _keeping_nested_fit(edit: Any) -> Any: + """*edit* (a width edit of a table) refused, the table left as it was, when it leaves a cell + narrower than the table nested in it (its width plus the cell's side margins) unless the cell + was already that narrow -- as the byte edits refuse it (:func:`hwpx.table_patch._check_nested_fit`).""" + + from copy import deepcopy + from functools import wraps + + @wraps(edit) + def run(table: "HwpxOxmlTable", *args: Any) -> None: + from lxml import etree # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing + + from ..table_patch import TableStructureError, _check_nested_fit + + element = table.element + if element.find(f".//{_HP}tbl") is None: + return edit(table, *args) + saved = deepcopy(element) + before = etree.tostring(element, encoding="unicode", with_tail=False) + edit(table, *args) + try: + _check_nested_fit(before, etree.tostring(element, encoding="unicode", with_tail=False)) + except TableStructureError: + for child in list(element): + element.remove(child) + element.attrib.clear() + element.attrib.update(saved.attrib) + element.text = saved.text + element.extend(list(saved)) + raise + return None + + return run + + +@_keeping_nested_fit def set_column_widths(table: "HwpxOxmlTable", weights: Sequence[int | float]) -> None: if len(weights) != table.column_count: raise ValueError("column width weights must match table column count") @@ -256,6 +292,7 @@ def _zone_moves( return moves +@_keeping_nested_fit def equalize_column_widths(table: "HwpxOxmlTable") -> None: grid = table._build_cell_grid() rows = _rows_of_cells(table.iter_grid(), table.row_count) diff --git a/src/hwpx/oxml/table_structure.py b/src/hwpx/oxml/table_structure.py index 779aec18..3a1ef782 100644 --- a/src/hwpx/oxml/table_structure.py +++ b/src/hwpx/oxml/table_structure.py @@ -51,8 +51,7 @@ def insert_rows(self, ref_row: int, count: int = 1, *, side: str = "below", blan same column in *ref_row* and, unless *blank*, its text; with *blank* it holds one empty paragraph of that format, as Hancom inserts it. A cell merged across the new rows grows over them, and next to a merged cell that ends (above: starts) at *ref_row* each new row gets an - empty cell of its format. A table holding a table is refused - (:class:`hwpx.table_patch.TableStructureError`). + empty cell of its format. A table nested in a copied cell is copied with fresh numbers. """ self._restructure({ diff --git a/src/hwpx/table_patch.py b/src/hwpx/table_patch.py index 2e30e4cd..2cce692b 100644 --- a/src/hwpx/table_patch.py +++ b/src/hwpx/table_patch.py @@ -984,20 +984,10 @@ def _hide_nested_tables(table: str) -> tuple[str, list[str]]: return "".join(parts), nested -_S_TBL_OPEN_RE = re.compile(r"]*>") - - -def _clone_nested_table(table: str, copy: int, fresh_id: Callable[[re.Match[str]], str]) -> str: - """Copy *copy* (1, 2, ...) of nested *table* for a cloned cell: fresh paragraph - ids, table ``id``/``instid`` bumped as :func:`_split_table` bumps them; the - rest of its content copied as :func:`_clone_content` copies a cell's.""" - bump = 960000 + copy * 7919 - renumbered = _S_TBL_OPEN_RE.sub( - lambda m: re.sub(r'(\b(?:id|instid)=")(\d+)(")', - lambda a: a.group(1) + str((int(a.group(2)) + bump) & 0x7FFFFFFF) + a.group(3), m.group(0)), - table, - ) - return _clone_content(renumbered, fresh_id) +def _clone_nested_table(table: str, fresh_id: Callable[[re.Match[str]], str]) -> str: + """A copy of nested *table* for a cloned cell, copied as :func:`_clone_content` copies a + cell's content: the table, like a pasted one, takes a fresh ``id`` and a ``zOrder`` on top.""" + return _clone_content(table, fresh_id) def _restore_nested_tables(table: str, nested: Sequence[str], *, used_ids: set[int] | None = None) -> str: @@ -1010,7 +1000,7 @@ def _restore_nested_tables(table: str, nested: Sequence[str], *, used_ids: set[i def put(match: re.Match[str]) -> str: k = int(match.group(1)) seen[k] = seen.get(k, -1) + 1 - return nested[k] if not seen[k] else _clone_nested_table(nested[k], seen[k], fresh_id) + return nested[k] if not seen[k] else _clone_nested_table(nested[k], fresh_id) return _NESTED_MARK_RE.sub(put, table) @@ -1338,10 +1328,6 @@ def _reorder_rows(table: str, order: Sequence[int]) -> str: _PARA_ID_RE = re.compile(r'(]*\bid=")(\d+)(")') -def _refresh_ids(row: str, bump: int) -> str: - return _PARA_ID_RE.sub(lambda m: m.group(1) + str((int(m.group(2)) + bump) & 0x7FFFFFFF) + m.group(3), row) - - def _bump_table_id(table: str, bump: int) -> str: """Bump ````'s own ``id``/``instid`` (if present) -- used by split_table so its new second table doesn't share the original's id @@ -1354,16 +1340,6 @@ def _bump_table_id(table: str, bump: int) -> str: return table -def _blank_cell_text(tc: str) -> str: - """Empty every ```` in a cell + drop stale linesegarray (keep formatting). - - The layout cache references text positions of the OLD text; leaving it on an - emptied paragraph makes Hangul reject the file ("stale lineseg beyond text - length"). Stripping it forces recomputation.""" - tc = re.sub(r"(]*>).*?()", r"\1\2", tc, flags=re.S) - return _drop_line_caches(_NESTED_MARK_RE.sub("", tc)) # a blank cell holds no nested table - - def _drop_line_caches(tc: str) -> str: """*tc* without its paragraphs' layout caches (``linesegarray``), for Hancom to lay out anew.""" return re.sub( @@ -1384,12 +1360,12 @@ def _insert_tc_in_order(row: str, new_tc: str, col: int) -> str: return row[:at] + new_tc + row[at:] -def _split_cell_vertical(table: str, row: int, col: int, sizes: Sequence[int]) -> str: +def _split_cell_vertical(table: str, row: int, col: int, sizes: Sequence[int], *, used_ids: set[int] | None = None) -> str: """Split the rowSpan cell at (row, col) into ``len(sizes)`` stacked cells with the given rowSpans (``sum(sizes)`` must equal the current rowSpan). The first - keeps (row, col); each subsequent boundary gets a NEW cell cloned from the - original (borderFill/cellSz/paraPr inherited, text blanked, ids refreshed) - inserted in colAddr order. Grid-validated; fail-closed.""" + keeps (row, col) and the content; each subsequent boundary gets a NEW cell of the + original's format (borderFill/cellSz, its first paragraph's shape) holding one empty + paragraph with a fresh id, inserted in colAddr order. Grid-validated; fail-closed.""" _guard_flat(table) sizes = [int(s) for s in sizes] if len(sizes) < 2 or any(s < 1 for s in sizes): @@ -1408,6 +1384,7 @@ def _split_cell_vertical(table: str, row: int, col: int, sizes: Sequence[int]) - if sum(sizes) != cur: raise TableStructureError(f"split_cell_vertical: sizes sum {sum(sizes)} != current rowSpan {cur}") + fresh_id = _fresh_ids(table, used_ids) rows[row] = rows[row].replace(target, _ss(target, "cellSpan", "rowSpan", sizes[0]), 1) boundary = row for i in range(1, len(sizes)): @@ -1416,7 +1393,7 @@ def _split_cell_vertical(table: str, row: int, col: int, sizes: Sequence[int]) - raise TableStructureError("split_cell_vertical: boundary beyond table rows") new_tc = _ss(target, "cellSpan", "rowSpan", sizes[i]) new_tc = _ss(new_tc, "cellAddr", "rowAddr", boundary) - new_tc = _refresh_ids(_blank_cell_text(new_tc), 7000 + i) + new_tc = _PARA_ID_RE.sub(fresh_id, _empty_cell_like(new_tc)) rows[boundary] = _insert_tc_in_order(rows[boundary], new_tc, col) out = _rebuild(prefix, rows, suffix) _validate_or_raise(out) @@ -1641,9 +1618,10 @@ def _clone_content(content: str, fresh_id: Callable[[re.Match[str]], str]) -> st """Copy cell content as Hancom pastes a copy: fresh paragraph ids here; objects (pictures, shapes, equations, notes), fields and memos marked for fresh numbers (:func:`_number_clones`). A field keeps its ``fieldid`` and name and a bookmark its name, as Hancom's copy does; a - connector copied loses its ends (``subjectIDRef="0"``). Content that points into another part + connector copied loses its ends (``subjectIDRef="0"``). A table copied takes a fresh ``id`` and + a ``zOrder`` on top, as Hancom numbers a pasted table. Content that points into another part or definition (a chart, linked text boxes, a header or footer) refuses. A nested table's - marker is copied as is; its table gets fresh ids on restore.""" + marker is copied as is; its table is copied on restore.""" serial = next(_clone_serial) def mark(kind: str, value: str) -> str: @@ -1657,7 +1635,7 @@ def opening(match: re.Match[str]) -> str: raise TableStructureError( "cannot clone content with local identities or references; use empty-cell insertion" ) - if tag in ("hp:p", "hp:tbl"): + if tag == "hp:p": return match.group(0) memo = tag == "hp:fieldBegin" and attrs.get("type") == "MEMO" # Hancom saves the shapes inside a group with zOrder 0 and ids of 0 or shared within the @@ -1893,15 +1871,15 @@ def widen(tc: str) -> str: return _rebuild(prefix, new_rows, suffix, colcnt=columns + count) -def _insert_block_by_clone(table: str, r0: int, r1: int, count: int = 1) -> str: +def _insert_block_by_clone(table: str, r0: int, r1: int, count: int = 1, *, used_ids: set[int] | None = None) -> str: """Clone a contiguous **vertical-merge block** (physical rows ``r0..r1``) *count* times, preserving the block's internal span pattern (FR-001). The 성취기준 A~E unit is one such block: a leading cell with ``rowSpan=N`` plus ``N`` rows of ``rowSpan==1`` cells. :func:`_insert_row_by_clone` would grow the leading cell over one new row; this clones the whole unit, offsets the - clones' ``rowAddr`` by the block height, shifts every row below, refreshes - paragraph ids, and grows ``rowCnt``. Fail-closed (Constitution VI): the block must + clones' ``rowAddr`` by the block height, shifts every row below, and grows ``rowCnt``; + the content is copied as :func:`_clone_content` copies it. Fail-closed (Constitution VI): the block must be a clean merge unit — no cell inside spans out of ``[r0,r1]`` and no cell outside straddles the boundary — else it raises. Formatting (borderFill/paraPr/ charPr/cellSz) is carried verbatim from the reference block. @@ -1940,6 +1918,7 @@ def shift(tc: str): shifted = [_map_cells(r, shift) for r in rows] block = rows[r0:r1 + 1] + fresh_id = _fresh_ids(table, used_ids) clones: list[str] = [] for k in range(1, count + 1): def _clone_shift(tc: str, _k: int = k) -> str: @@ -1948,8 +1927,7 @@ def _clone_shift(tc: str, _k: int = k) -> str: return _ss(tc, "cellAddr", "rowAddr", ra + _k * block_h) for row in block: - clone = _map_cells(row, _clone_shift) - clone = _refresh_ids(clone, 100003 * k + 7) + clone = _clone_content(_map_cells(row, _clone_shift), fresh_id) clones.append(clone) new_rows = shifted[: r1 + 1] + clones + shifted[r1 + 1:] return _rebuild(prefix, new_rows, suffix, rowcnt=len(new_rows)) @@ -2190,6 +2168,10 @@ def _widths_arg(o: Mapping[str, Any]) -> dict[int, int]: "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), + "insert_block_by_clone": lambda t, o, ids: _insert_block_by_clone( + t, int(o["ref_rows"][0]), int(o["ref_rows"][1]), int(o.get("count", 1)), used_ids=ids), + "split_cell_vertical": lambda t, o, ids: _split_cell_vertical( + t, int(o["row"]), int(o["col"]), o["sizes"], used_ids=ids), } @@ -2310,7 +2292,8 @@ def _apply_cell_line_spacing( op: {op:'set_cell_line_spacing', section_path?, table_index|table_anchor, cells:[[r,c],...] 또는 rows:[r,...], line_spacing:int(PERCENT)}. 대상 셀의 각 문단 paraPr을 lineSpacing 변형으로 재매핑하고 linesegarray를 제거한다 - (stale 캐시 줄겹침 방지). 중첩 표를 품은 셀은 refuse.""" + (stale 캐시 줄겹침 방지). 표를 품은 셀은 셀 자신의 문단만 바꾸고 안쪽 표·글상자는 + 바이트 그대로 둔다.""" import io import zipfile with zipfile.ZipFile(io.BytesIO(source_bytes), "r") as zf: @@ -2354,38 +2337,32 @@ def _apply_cell_line_spacing( transcript.append(entry) continue ts, te = spans[ti] - table = section[ts:te].decode("utf-8") + table = section[ts:te] want_cells = {(int(r), int(c)) for r, c in op.get("cells", [])} want_rows = {int(r) for r in op.get("rows", [])} touched = 0 out_table = table - for cell_match in sorted(re.finditer(r"", table, re.S), key=lambda x: -x.start()): - blk = cell_match.group(0) - ra = re.search(r']*\browAddr="(\d+)"', blk) - ca = re.search(r']*\bcolAddr="(\d+)"', blk) - if ra is None or ca is None: - continue - r, c = int(ra.group(1)), int(ca.group(1)) - if not ((r, c) in want_cells or r in want_rows): - continue - if "", new_blk, re.S), key=lambda x: -x.start()): - pblk = pm.group(0) - pid = re.search(r'\bparaPrIDRef="(\d+)"', pblk) + for ps, pe in reversed(_all_paragraph_spans(blk)): + pblk = new_blk[ps:pe] + open_end = pblk.index(b">") + 1 + pid = re.search(rb'\bparaPrIDRef="(\d+)"', pblk[:open_end]) if pid is None: continue - header, new_id = _materialize_parapr(header, pid.group(1), spacing, cache) - pblk2 = pblk.replace(f'paraPrIDRef="{pid.group(1)}"', f'paraPrIDRef="{new_id}"', 1) - pblk2 = _strip_paragraph_layout_cache(pblk2.encode("utf-8")).decode("utf-8") - new_blk = new_blk[: pm.start()] + pblk2 + new_blk[pm.end():] + header, new_id = _materialize_parapr(header, pid.group(1).decode(), spacing, cache) + open_tag = pblk[:open_end].replace(pid.group(0), f'paraPrIDRef="{new_id}"'.encode(), 1) + new_blk = new_blk[:ps] + _own_lineseg_dropped(open_tag + pblk[open_end:]) + new_blk[pe:] if new_blk != blk: - out_table = out_table[: cell_match.start()] + new_blk + out_table[cell_match.end():] + out_table = out_table[:cell.start] + new_blk + out_table[cell.end:] touched += 1 if out_table != table: - parts[sp] = section[:ts] + out_table.encode("utf-8") + section[te:] + parts[sp] = section[:ts] + out_table + section[te:] changed_parts.add(sp) entry.update({"tableIndex": ti, "lineSpacing": spacing, "cellsTouched": touched, "status": "applied" if touched else "refused: no matching cells"}) @@ -2501,9 +2478,13 @@ def _delete_table(section: bytes, ts: int, te: int) -> bytes: return section[:ps] + _own_lineseg_dropped(section[ps:ts] + section[te:pe]) + section[pe:] -def _clone_table(section: bytes, ts: int, te: int, count: int) -> bytes: +def _clone_table(section: bytes, ts: int, te: int, count: int, *, used_ids: set[int] | None = None, + context: str = "") -> bytes: """*count* copies of the table after its paragraph: copies of the paragraph - when it holds nothing else, otherwise new paragraphs holding only the table.""" + when it holds nothing else, otherwise new paragraphs holding only the table. + Each copy is numbered as Hancom numbers a pasted table (:func:`_clone_content`): + fresh paragraph ids unused in *used_ids*, and the table, the tables nested in it + and its objects fresh numbers unused in *context* (the document's sections).""" if count < 1: raise TableStructureError("clone_table: count must be >= 1") ps, pe = _p_wrapper_span(section, ts) @@ -2511,7 +2492,8 @@ def _clone_table(section: bytes, ts: int, te: int, count: int) -> bytes: block = section[ps:pe].decode("utf-8") else: block = _paragraph_for(section, ps, ts, section[ts:te].decode("utf-8")) - clones = "".join(_bumped_ids(block, 900000 + k * 7919) for k in range(1, count + 1)) + fresh_id = _fresh_ids(section.decode("utf-8"), used_ids) + clones = _number_clones("".join(_clone_content(block, fresh_id) for _ in range(count)), context) return section[:pe] + clones.encode("utf-8") + section[pe:] @@ -2725,7 +2707,11 @@ def _log(name, sp, ti, status, **extra) -> None: dims_after = "deleted" elif name == "clone_table": count = int(op.get("count", 1)) - new_section = _clone_table(section, ts, te, count) + new_section = _clone_table( + section, ts, te, count, + used_ids={int(m.group(2)) for data in sections.values() + for m in _PARA_ID_RE.finditer(data.decode("utf-8"))}, + context="".join(data.decode("utf-8") for data in sections.values())) dims_after = f"cloned x{count}" elif name == "split_table": split_row_raw = op.get("split_row") @@ -2746,7 +2732,8 @@ def _log(name, sp, ti, status, **extra) -> None: context = "".join(data.decode("utf-8") for data in sections.values()) if name in _CLONE_OPS else "" new_table = _restructure_table(section[ts:te].decode("utf-8"), op, used_ids=used_ids, context=context) - if name in {"delete_column", "delete_row", "insert_row_by_clone", "insert_column_by_clone", "split_cell"}: + if name in {"delete_column", "delete_row", "insert_row_by_clone", "insert_column_by_clone", + "insert_block_by_clone", "split_cell"}: if table_height is None: from .oxml.table_structure import _ByteTableHeight table_height = _ByteTableHeight(source_bytes) diff --git a/tests/test_cell_line_spacing.py b/tests/test_cell_line_spacing.py index 288a6b8b..b0b70b24 100644 --- a/tests/test_cell_line_spacing.py +++ b/tests/test_cell_line_spacing.py @@ -88,3 +88,90 @@ def test_dry_run_reports_would_apply(work, tmp_path): entry = next(t for t in res.transcript if t["op"] == "set_cell_line_spacing") assert entry["status"] == "would_apply" assert not out.exists() + + +_LINESEG = (b'') + + +def _nested_source() -> bytes: + """A 3x3 table whose cell (1, 1) holds a 2x2 table and some text; every paragraph has a line cache.""" + import io + + from hwpx.document import HwpxDocument + + document = HwpxDocument.new() + outer = document.add_table(3, 3) + for row in range(3): + for col in range(3): + outer.cell(row, col).text = f"outer:{row}:{col}" + inner = outer.cell(1, 1).paragraphs[0].add_table(2, 2) + for row in range(2): + for col in range(2): + inner.cell(row, col).text = f"inner:{row}:{col}" + data = document.to_bytes() + out = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(out, "w") as target: + for info in source.infolist(): + payload = source.read(info.filename) + if info.filename == "Contents/section0.xml": + payload = re.sub(rb".*?", b"", payload, flags=re.S) + payload = payload.replace(b"", _LINESEG + b"") + target.writestr(info, payload) + return out.getvalue() + + +def _tables(data: bytes) -> list[bytes]: + from hwpx.table_patch import iter_table_spans, section_parts + + section = section_parts(data)["Contents/section0.xml"] + return [section[s:e] for s, e in iter_table_spans(section)] + + +def _own_paragraphs(cell: bytes) -> list[bytes]: + from hwpx.table_patch import cell_paragraph_spans + + return [cell[s:e] for s, e in cell_paragraph_spans(cell)] + + +def _cell(table: bytes, row: int, col: int) -> bytes: + from hwpx.table_patch import direct_table_cells + + cell = next(c for c in direct_table_cells(table) if (c.row, c.col) == (row, col)) + return table[cell.start:cell.end] + + +def test_a_cell_holding_a_table_changes_only_its_own_paragraphs(): + source = _nested_source() + res = apply_table_ops(source, [{"op": "set_cell_line_spacing", "table_index": 0, "cells": [[1, 1]], "line_spacing": 130}]) + assert res.ok and not res.skipped, [s.reason for s in res.skipped] + before, after = _tables(source), _tables(res.data) + assert after[1] == before[1] # the inner table, line caches included + holder = _cell(after[0], 1, 1) + old_ids = {re.search(rb'paraPrIDRef="(\d+)"', p).group(1) for p in _own_paragraphs(_cell(before[0], 1, 1))} + for paragraph in _own_paragraphs(holder): + open_tag = paragraph[: paragraph.index(b">") + 1] + assert re.search(rb'paraPrIDRef="(\d+)"', open_tag).group(1) not in old_ids + assert not paragraph.endswith(_LINESEG + b"") # own line cache dropped + assert holder.count(_LINESEG) == before[1].count(_LINESEG) # only the inner table's caches remain + + +def test_a_row_never_reaches_into_a_table_held_by_another_cell(): + source = _nested_source() + res = apply_table_ops(source, [{"op": "set_cell_line_spacing", "table_index": 0, "rows": [0], "line_spacing": 130}]) + entry = next(t for t in res.transcript if t["op"] == "set_cell_line_spacing") + assert entry["cellsTouched"] == 3 + before, after = _tables(source), _tables(res.data) + assert after[1] == before[1] + for row in (1, 2): + for col in range(3): + assert _cell(after[0], row, col) == _cell(before[0], row, col) + + +def test_the_inner_table_is_addressed_by_its_own_index(): + source = _nested_source() + res = apply_table_ops(source, [{"op": "set_cell_line_spacing", "table_index": 1, "cells": [[0, 1]], "line_spacing": 130}]) + assert res.ok and not res.skipped + before, after = _tables(source), _tables(res.data) + assert _cell(after[1], 0, 1) != _cell(before[1], 0, 1) + assert [_cell(after[1], r, c) for r, c in ((0, 0), (1, 0), (1, 1))] == [_cell(before[1], r, c) for r, c in ((0, 0), (1, 0), (1, 1))] diff --git a/tests/test_nested_table_structure_edits.py b/tests/test_nested_table_structure_edits.py index 0cae25bd..f3643f40 100644 --- a/tests/test_nested_table_structure_edits.py +++ b/tests/test_nested_table_structure_edits.py @@ -190,3 +190,70 @@ def test_split_and_merge_table_carry_nested_tables(): assert len(document.tables) == 1 and document.tables[0].row_count == 3 assert document.tables[0].cell(1, 1).tables[0].cell(1, 1).text == "inner:1:1" assert nested in _tables(merged.data) + + +def _numbers(data): + section = section_parts(data)["Contents/section0.xml"].decode("utf-8") + return (re.findall(r']*\bid="(\d+)"', section), + [v for v in re.findall(r']*\bid="(\d+)"', section) if v != "0"], + [int(v) for v in re.findall(r']*\bzOrder="(-?\d+)"', section)]) + + +@pytest.mark.parametrize("ops, tables", [ + ([{"op": "insert_row_by_clone", "ref_row": 1}, {"op": "insert_column_by_clone", "ref_col": 1}], 5), + ([{"op": "insert_column_by_clone", "ref_col": 1}, {"op": "insert_row_by_clone", "ref_row": 1}], 5), + ([{"op": "insert_block_by_clone", "ref_rows": [1, 1]}, {"op": "insert_block_by_clone", "ref_rows": [1, 2]}], 5), +]) +def test_copying_copies_again_gives_every_table_and_paragraph_its_own_number(ops, tables): + source = _source() + result = _apply(source, *ops) + assert result.ok, [s.reason for s in result.skipped] + table_ids, paragraph_ids, _ = _numbers(result.data) + assert len(table_ids) == len(set(table_ids)) == tables + assert len(paragraph_ids) == len(set(paragraph_ids)) + assert "hwpx-clone:" not in section_parts(result.data)["Contents/section0.xml"].decode("utf-8") + + +def test_a_cloned_table_is_numbered_as_hancom_numbers_a_pasted_table(): + source = _source(depth=3) + result = _apply(source, {"op": "clone_table", "count": 2}) + assert result.ok, [s.reason for s in result.skipped] + before_ids, _, before_z = _numbers(source) + table_ids, paragraph_ids, z_orders = _numbers(result.data) + assert len(table_ids) == len(set(table_ids)) == 3 * len(before_ids) + assert table_ids[:len(before_ids)] == before_ids # the original keeps its numbers + assert len(paragraph_ids) == len(set(paragraph_ids)) + assert min(z_orders[len(before_ids):]) > max(before_z) # the copies on top + with HwpxDocument.open(result.data) as document: + assert [t.cell(1, 1).tables[0].cell(1, 1).tables[0].cell(0, 0).text for t in document.tables] == ["deepest"] * 3 + + +def test_a_vertical_split_leaves_the_new_parts_empty(): + document = HwpxDocument.new() + outer = document.add_table(3, 2) + document.tables.merge_cells(outer, "A1:A3") + outer.cell(0, 0).text = "merged" + outer.cell(0, 0).paragraphs[0].add_rectangle(width=1000, height=500) + outer.cell(0, 0).add_table(1, 1).cell(0, 0).text = "inside" + source = document.to_bytes() + result = _apply(source, {"op": "split_cell_vertical", "row": 0, "col": 0, "sizes": [1, 2]}) + assert result.ok, [s.reason for s in result.skipped] + section = section_parts(result.data)["Contents/section0.xml"].decode("utf-8") + assert section.count(" Date: Mon, 2 Nov 2026 16:12:48 +0900 Subject: [PATCH 2/3] docs(changelog): move #647 and #651 out of the released 6.11.0 section Both merged after the 6.11.0 tag (a4058578), so they belong under [Unreleased]. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb0fabe..ce5ea020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ### 고침 +- 쪽 수 추정과 FormFit이 줄 바꿈 뒤 빈 run으로 끝나는 문단의 빈 마지막 줄을 한/글처럼 그 run의 크기로 잡는다(글자보다 작아도). + 전에는 작은 run은 무시하고 큰 run은 줄 바꿈 앞 줄까지 키웠다. 끝 run이 더 크면 그 문단의 줄 캐시는 쓰지 않는다. +- 쪽 수 추정이 첫 줄에 붙은 어울림 개체의 세로 위치를 한/글처럼 위 간격 위 문단 맨 위에서 잰다. 전에는 첫 줄 맨 위에서 + 재서, 위 간격이 있는 문단에서 개체 곁에 좁아지는 줄을 위 간격만큼 아래로 잡았다. - 셀 줄간격(`set_cell_line_spacing`)이 표를 품은 칸도 바꾼다. 칸 자신의 문단(표를 담은 문단 포함)만 줄간격을 바꾸고 그 문단들의 줄 캐시만 지우며, 안쪽 표와 글상자는 바이트 그대로 둔다. 전에는 그런 칸을 거부했고, 다른 칸에 표가 든 표에서 행을 고르면 안쪽 표의 같은 주소 칸까지 바꿨다. @@ -52,14 +56,10 @@ ### 고침 -- 쪽 수 추정과 FormFit이 줄 바꿈 뒤 빈 run으로 끝나는 문단의 빈 마지막 줄을 한/글처럼 그 run의 크기로 잡는다(글자보다 작아도). - 전에는 작은 run은 무시하고 큰 run은 줄 바꿈 앞 줄까지 키웠다. 끝 run이 더 크면 그 문단의 줄 캐시는 쓰지 않는다. - 쪽 수 추정이 아래로 놓인 위아래 표의 띠가 오기 전에 쪽 나눔이 있어도 문서를 추정한다. 한/글처럼 표는 제자리에 두고, 쪽 나눔 문단은 표가 끝나는 쪽의 다음 쪽에서 시작한다. 전에는 그런 문서를 추정하지 않았다. - 쪽 수 추정이 한 문단에 종이 기준으로 놓인 개체가 여럿이어도 문서를 추정한다. 한/글처럼 개체마다 따로 띠로 보고 본문 줄을 그 띠 밖에 둔다. 전에는 그런 문서를 추정하지 않았다. -- 쪽 수 추정이 첫 줄에 붙은 어울림 개체의 세로 위치를 한/글처럼 위 간격 위 문단 맨 위에서 잰다. 전에는 첫 줄 맨 위에서 - 재서, 위 간격이 있는 문단에서 개체 곁에 좁아지는 줄을 위 간격만큼 아래로 잡았다. - 쪽 수 추정이 글자 크기·모양이 섞인 표 칸 문단에 위아래 개체가 있어도 문서를 추정한다. 개체가 붙은 줄까지는 위에, 나머지 줄은 그 아래에 둔다. 전에는 그런 문서를 추정하지 않았다. - 쪽 수 추정이 표 칸 안 어울림 개체 곁의 문단에 왼쪽·오른쪽 여백이나 들여쓰기가 있어도 문서를 추정한다. 한/글처럼 개체가 From f8a592700e29b83632ca5fc06c4d75b8a3779ca3 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:12:20 +0900 Subject: [PATCH 3/3] docs(changelog): separate the merged sections Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d4ed6bb..91fde561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ 복사해서, 그 칸의 그림·도형이 같은 번호로 겹쳤다. - 객체 모델 `set_column_widths`·`equalize_column_widths`가 칸을 안쪽 표보다 좁게 만들면 바이트 편집처럼 거부하고 표를 그대로 둔다(`TableStructureError`). + ### 수정 - 쪽 수 추정이 글 없이 어울림 개체와 글자처럼 취급한 개체 하나만 있는 문단도 추정한다. 한/글처럼 그 개체를 곁 줄에 둔다(곁보다