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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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은 글꼴
Expand Down
2 changes: 1 addition & 1 deletion docs/editor-menu-reverse-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
150 changes: 132 additions & 18 deletions src/hwpx/table_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <hp:tbl> plus any nested ones; >1 open == nested
if len(re.findall(r"<hp:tbl\b", table)) > 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]:
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1247,6 +1262,22 @@ def _clone_row_template(rows: list[str], ref_row: int) -> str:
return (opening.group(0) if opening else "<hp:tr>") + "".join(tc for _, tc in sorted(cells, key=lambda c: c[0])) + "</hp:tr>"


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:
Expand Down Expand Up @@ -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))
Expand All @@ -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"<hp:cellzone\b[^>]*>", 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).
Expand Down Expand Up @@ -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))),
Expand All @@ -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 간격 프리미티브.

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading
Loading