Skip to content

feat(table): insert and delete rows and columns and split cells on the table object - #433

Open
airmang wants to merge 14 commits into
mainfrom
feat/table-object-structure-edits
Open

airmang wants to merge 14 commits into
mainfrom
feat/table-object-structure-edits

Conversation

@airmang

@airmang airmang commented Oct 6, 2026

Copy link
Copy Markdown
Owner

바뀐 점

표 객체(hwpx.model.Table, 곧 HwpxOxmlTable)에는 칸 합치기(merge_cells)와 합친 칸 풀기(split_merged_cell)만 있었다. 행·열을 넣거나 지우는 것과 칸 나누기는 바이트 경로(apply_table_ops)에만 있었다. 이제 표 객체에도 다섯 메서드가 있다.

table = doc.tables.all[0]
table.insert_rows(1, 2)                         # 1행 아래에 2행, 1행을 복제
table.insert_rows(0, side="above", blank=True)  # 0행 위에 빈 행
table.insert_columns(2, side="left")            # 2열 왼쪽에 열(표가 넓어짐)
table.delete_rows([3, 4])
table.delete_columns(1)                         # 그 폭은 남은 열에 나눔
table.split_cell(1, 1, rows=2, cols=3)          # 한/글의 셀 나누기
doc.tables.insert_rows(table, 1)                # doc.tables에도 같은 이름(첫 인자로 표)
  • 규칙은 apply_table_ops의 같은 편집과 같다(insert_row_by_clone, insert_column_by_clone, delete_row, delete_column, split_cell). 한/글의 줄/칸 추가하기·지우기, 셀 나누기를 따른다.
  • 새 칸은 기준 칸의 서식과 글을 받는다. blank=True면 한/글처럼 그 서식의 빈 문단 하나만 둔다.
  • 칸 영역(hp:cellzone)도 함께 옮긴다.
  • 새 문단은 문서 어디에도 없는 id를 받는다.
  • 편집하면 표를 담은 문단의 줄 캐시를 지운다.
  • 편집 전에 얻은 셀·행 객체는 표에 속하지 않으니 다시 얻는다.
  • 거부되는 편집(표 안의 표, 모든 행·열 삭제, 칸이 시작하지 않는 주소, 잘못된 인자)은 TableStructureError를 내고 표를 그대로 둔다.

코드

  • oxml/table_structure.py(새 모듈)
    • TableStructureEdits: HwpxOxmlTable의 믹스인.
    • 다섯 메서드는 표 XML에 apply_table_ops의 편집을 적용하고, 결과를 표 요소에 제자리로 돌려놓는다.
    • table.py가 줄 수 상한에 닿아 새 모듈로 뺐다.
    • docs/architecture/module-ownership.json에 등록하고, 형식 검사 범위(pyproject.toml)에 넣었다.
  • oxml/table.py: class HwpxOxmlTable(TableStructureEdits)(+1줄).
  • table_patch.py: _restructure_table(표 하나에 구조 편집 하나를 적용하고 격자를 검사). apply_table_ops도 이것을 쓴다.
  • _document/ns/tables.py: doc.tables의 다섯 위임 메서드.
  • capabilities.py: 표 구조 영역의 진입점에 hwpx.model:Table.
  • docs/api_reference.md, 지원 표(두 벌), 변경 로그.
  • tests/test_product_boundary.py(분류 모듈 수 181), tests/test_namespace_hygiene.py(doc.tables 동사).
  • tests/data/model_surface.json: 다섯 메서드를 Table의 안정(stable) 멤버로 넣었다. docs/stable-api.md의 멤버 수는 198이 되고, 설명 한 단락을 더했다.
  • tests/data/import_breadth.json: import hwpx가 읽는 hwpx 모듈 상한을 97 → 98로 올렸다.
    • 표 객체가 이 믹스인을 상속하므로, 새 모듈은 표 모듈과 함께 읽힐 수밖에 없다.
    • 모듈은 메서드 정의뿐이라 가볍고, 편집 코드(table_patch)는 처음 편집할 때 읽는다.
    • table.py에 둘 수 없는 이유는 줄 수 상한이다.

테스트

tests/test_table_structure_edits.py를 더한다.

  • 한/글 저장본과의 대조(15판)
    • 바이트 경로 시험의 한/글 저장본(열 넣기 5, 행 넣기 5, 칸 나누기 5)을 표 객체로 다시 연다.
    • 같은 편집을 하고 칸마다 한/글 결과와 맞춘다.
  • apply_table_ops와의 대조(7판): 합친 칸이 있는 표에서 두 경로의 결과 칸이 같은지 본다.
  • 편집 뒤 표 객체가 새 모양을 읽는지, doc.tables 메서드, 다시 연 문서.
  • 두 구역 문서에서 새 문단 id가 겹치지 않는지, 표를 담은 문단의 줄 캐시.
  • 거부 7가지에서 표가 그대로인지, 표 안의 표.

이 PR은 #432 위에 쌓았다(#430 → #431 → #432). 앞 PR이 병합되면 그 커밋들은 이 PR의 diff에서 빠진다.

🤖 Generated with Claude Code

airmang and others added 14 commits October 6, 2026 17:03
apply_table_ops could insert rows (insert_row_by_clone) but no columns.
insert_column_by_clone inserts count columns right of grid column
ref_col (left of it with side "left"), cloning it:

- each new column is as wide as ref_col, and the table grows by it;
- each new cell takes the format of the cell of the same row in
  ref_col, and its text unless blank is set, which leaves one empty
  paragraph as Hangul does;
- a cell merged across the new columns grows its colSpan and width,
  next to a merged cell ending (on the left, starting) at ref_col each
  new column gets an empty cell of its format and rows, and a cell
  merged down ref_col is cloned with its rows;
- cell zones move with their columns.

Eighteen Hangul-saved files: 3x3 tables made by Hangul and the same
tables after Hangul inserted columns (right, left, three, right of a
wider column, merged cells across, ending at and down the column,
formats per row, a table as wide as the text). With blank the inserted
tables match Hangul's cell for cell, but for the stored width of a cell
merged across the new column, which Hangul leaves as it was and draws
by the grid.

The row insertion shares its paragraph id helper (_fresh_ids).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tables written by other programs can hold hp:tc elements with their
paragraphs straight inside, without hp:subList, hp:cellAddr, hp:cellSpan
or hp:cellSz. Deleting a row or a column, inserting rows or columns,
setting or fitting column widths and splitting the table stopped on an
AssertionError there, and inserting a row or reordering rows refused it
for an unrelated reason. _guard_flat, which every one of these edits
calls first, now refuses such a table as it refuses one holding a table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
delete_column on the only column of a one-column table, or on every
column, stopped on a ZeroDivisionError sharing out the freed width
among no columns left. delete_row on every row left a table without
rows and refused it only for the grid it left. Hangul does not delete
the last row or column either; both now refuse and point to
delete_table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
insert_row_by_clone inserted rows below ref_row only, keeping the text
of the cells it clones. It now takes side "above" to insert before the
row, and blank to leave each new cell one empty paragraph of its format
as Hangul does:

- each new row is as high as ref_row, and each new cell takes the
  format of the cell of the same column in ref_row, a cell merged
  across columns there included;
- above the row, a cell merged across the new rows grows its rowSpan
  over them, and over a merged cell starting at ref_row each new row
  gets an empty cell of its format, as below the row for one ending
  there;
- cell zones now move with their rows, below the row too.

Twenty Hangul-saved files: 3x3 tables made by Hangul and the same
tables after Hangul inserted rows (below, above, three, next to a
taller row, merged cells across, ending at and starting at the row,
a cell merged across columns, formats per column). With blank the
inserted tables match Hangul's cell for cell; Hangul also stores the
table's laid-out height anew, which python-hwpx leaves as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
delete_row and delete_column left the table's hp:cellzone entries where
they were, so a zone drew its border fill on the cells that moved into
its rows or columns. A zone now keeps the lines it covers that stay,
moved back over the deleted ones before it, and goes when none stays;
the zone list goes with its last zone, as OWPML wants one in it. A row
the column deletion leaves without cells folds its zones into its
neighbours, as a cell merge folds such a line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
apply_table_ops could split a cell merged down several rows back into
stacked cells (split_cell_vertical), but not split a cell into columns
or a single row into several. split_cell takes the cell's row and col
and rows and/or cols (2..63):

- into columns the cell's width is shared out evenly, the last part
  taking what is left over; new grid lines go where the parts end, and
  every other cell keeps its width over the grid columns it now covers;
- into rows the parts take the rows the cell covers when they divide
  evenly, else one row each and as many new rows after its last as it
  lacks, which the cells beside that row grow over; the parts share
  out the cell's stored height evenly;
- the content stays in the first part, and the others hold one empty
  paragraph of the cell's format; cell zones keep the area they cover.

Nineteen Hangul-saved files: 3x3 tables made by Hangul and the same
tables after Hangul split a cell (into two and three columns and rows,
both, in a wider column and a taller row, merged across columns and
down rows, a formatted cell). Column splits and the even split of a
taller row match Hangul's cell for cell. For rows lower than their
content Hangul stores heights from the content (0 or less for the last
part) and lays the cells out to their content either way.

_drop_line_caches is split out of _blank_cell_text for the first part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e table object

The table object had merge_cells and split_merged_cell but no row or
column insertion or deletion and no cell split; those were only on the
byte path (apply_table_ops). HwpxOxmlTable (hwpx.model.Table) now has
insert_rows (below or above), insert_columns (right or left),
delete_rows, delete_columns and split_cell, and doc.tables the same
names taking the table first.

Each runs the matching edit of apply_table_ops on the table's XML
(_restructure_table, which apply_table_ops now uses as well) and puts
the result back into the table element, so both paths follow one set
of rules: those of Hangul's own row and column insertion and deletion
and cell split. New paragraphs take ids unused anywhere in the
document. The paragraph holding the table loses its line cache, and
cells taken from the table before an edit no longer belong to it.

The methods live in oxml/table_structure.py (TableStructureEdits, a
mixin of HwpxOxmlTable), as table.py sits at its line cap; the module
is registered in module-ownership.json and born in the type gate.

The model surface lock lists the five as stable members of Table
(docs/stable-api.md counts them), and the import breadth bound rises
to 98: HwpxOxmlTable inherits the mixin, so its module loads with the
table module, while the edits themselves (table_patch) load on first
use.

Tests run the edits through the object model on the Hangul-saved
tables of the byte-path tests and against apply_table_ops itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant