python-hwpx 2.20.0__py3-none-any.whl → 2.21.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hwpx/table_patch.py ADDED
@@ -0,0 +1,787 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Byte-preserving form-fill: address a table cell and splice only its text.
3
+
4
+ S-064 / M10 (spec ``specs/011-byte-preserving-formfill``). The 2026-07-03 case
5
+ study failed because the only byte-preserving entry point
6
+ (:func:`hwpx.patch.paragraph_patch`) addresses a flat paragraph index that is
7
+ ambiguous inside tables, so an agent could not say "fill cell (r,c) of table T"
8
+ and fell back to rebuilding tables (destroying formatting). This module adds
9
+ cell addressing on top of the same byte machinery:
10
+
11
+ * locate the ``N``-th ``<hp:tbl>`` (document order) in a section,
12
+ * resolve a logical ``(row, col)`` -- merge aware -- to the covering ``<hp:tc>``,
13
+ * splice **only** that cell's first paragraph text (reusing
14
+ :func:`hwpx.patch._text_edit_for_paragraph`, which handles empty / self-closing
15
+ ``<hp:run/>`` / ``<hp:t/>`` cells -- the kordoc #4/#30 edge the P0 spike hit),
16
+ * strip that paragraph's stale ``<hp:linesegarray>`` so Hancom relayouts,
17
+ * rewrite only the changed section parts through the ZIP partial-patch writer, so
18
+ every untouched byte of the document round-trips identical (Constitution VII).
19
+
20
+ Table structure primitives (delete/insert row/column/table) build on the same
21
+ parsing helpers and live alongside this in a follow-up; this file is the cell
22
+ fill (FR-001/FR-002) plus the grid validator (FR-004) they share.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any, Iterable, Mapping, Sequence
30
+
31
+ from .patch import (
32
+ _apply_edits,
33
+ _patch_zip_entries,
34
+ _read_source_bytes,
35
+ _rewrite_zip_entries,
36
+ _strip_paragraph_layout_cache,
37
+ _text_edit_for_paragraph,
38
+ _finalize,
39
+ )
40
+
41
+ # --- byte-level table / row / cell parsing (namespace-prefix agnostic) ---------
42
+
43
+ def _tag(name: str) -> bytes:
44
+ # match <hp:name / <hh:name / <name (any or no prefix)
45
+ return rb"<(?:[A-Za-z_][\w.-]*:)?" + name.encode() + rb"\b"
46
+
47
+ def _top_spans(xml: bytes, name: str) -> list[tuple[int, int]]:
48
+ """Top-level (depth-0) ``<name ...>...</name>`` spans within *xml*.
49
+
50
+ Handles same-name nesting by depth counting. Used to find nested tables so
51
+ they can be masked out before scanning a table's *direct* rows/cells.
52
+ """
53
+ open_re = re.compile(_tag(name))
54
+ close_re = re.compile(rb"</(?:[A-Za-z_][\w.-]*:)?" + name.encode() + rb">")
55
+ events = sorted(
56
+ [(m.start(), 1, m.end()) for m in open_re.finditer(xml)]
57
+ + [(m.start(), -1, m.end()) for m in close_re.finditer(xml)]
58
+ )
59
+ spans: list[tuple[int, int]] = []
60
+ depth = 0
61
+ start = 0
62
+ for pos, delta, end in events:
63
+ if delta == 1:
64
+ if depth == 0:
65
+ start = pos
66
+ depth += 1
67
+ else:
68
+ depth -= 1
69
+ if depth == 0:
70
+ spans.append((start, end))
71
+ return spans
72
+
73
+ def _mask_nested_tables(xml: bytes) -> bytes:
74
+ """Blank nested ``<hp:tbl>...</hp:tbl>`` with same-length spaces (offset-
75
+ preserving), so direct-child row/cell scans never descend into them."""
76
+ out = bytearray(xml)
77
+ for s, e in _top_spans(xml, "tbl"):
78
+ out[s:e] = b" " * (e - s)
79
+ return bytes(out)
80
+
81
+ _TBL_EVENT_RE = re.compile(_tag("tbl") + rb"|</(?:[A-Za-z_][\w.-]*:)?tbl>")
82
+
83
+ def _iter_table_spans(section: bytes) -> list[tuple[int, int]]:
84
+ """Byte spans of every ``<hp:tbl>...</hp:tbl>`` in document order (of open
85
+ tags), balanced for nesting -- matching the ``table_index`` convention."""
86
+ spans: list[tuple[int, int]] = []
87
+ for m in re.finditer(_tag("tbl"), section):
88
+ start = m.start()
89
+ depth = 0
90
+ end = None
91
+ for t in _TBL_EVENT_RE.finditer(section, start):
92
+ depth += -1 if t.group().startswith(b"</") else 1
93
+ if depth == 0:
94
+ end = t.end()
95
+ break
96
+ if end is not None:
97
+ spans.append((start, end))
98
+ return spans
99
+
100
+ def _open_tag_end(table: bytes) -> int:
101
+ return table.index(b">") + 1
102
+
103
+ def _iattr(chunk: bytes, name: str, attr: str) -> int | None:
104
+ m = re.search(_tag(name) + rb'[^>]*\b' + attr.encode() + rb'="(-?\d+)"', chunk)
105
+ return int(m.group(1)) if m else None
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class _Cell:
110
+ start: int # byte offset within the table
111
+ end: int
112
+ row: int
113
+ col: int
114
+ row_span: int
115
+ col_span: int
116
+
117
+
118
+ def _direct_cells(table: bytes) -> list[_Cell]:
119
+ """Direct ``<hp:tc>`` of *table* (skips nested-table cells), with byte spans
120
+ relative to the table and their cellAddr/cellSpan."""
121
+ open_end = _open_tag_end(table)
122
+ body_start = open_end
123
+ body = table[body_start:]
124
+ masked = _mask_nested_tables(body)
125
+ cells: list[_Cell] = []
126
+ for m in re.finditer(rb"<(?:[A-Za-z_][\w.-]*:)?tc\b.*?</(?:[A-Za-z_][\w.-]*:)?tc>", masked, re.DOTALL):
127
+ real = table[body_start + m.start(): body_start + m.end()]
128
+ col = _iattr(real, "cellAddr", "colAddr")
129
+ row = _iattr(real, "cellAddr", "rowAddr")
130
+ cspan = _iattr(real, "cellSpan", "colSpan") or 1
131
+ rspan = _iattr(real, "cellSpan", "rowSpan") or 1
132
+ if row is None or col is None:
133
+ continue
134
+ cells.append(_Cell(body_start + m.start(), body_start + m.end(), row, col, rspan, cspan))
135
+ return cells
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class GridReport:
140
+ ok: bool
141
+ row_count: int
142
+ col_count: int
143
+ issues: tuple[str, ...]
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ return {"ok": self.ok, "rowCount": self.row_count, "colCount": self.col_count, "issues": list(self.issues)}
147
+
148
+
149
+ def build_grid(table: bytes) -> tuple[dict[tuple[int, int], _Cell], GridReport]:
150
+ """Expand merged cells into a logical grid and check it (FR-004).
151
+
152
+ Returns ``{(row, col): cell}`` covering every logical position, plus a report
153
+ flagging overlaps, holes, and out-of-bounds spans against the table's declared
154
+ ``rowCnt``/``colCnt``.
155
+ """
156
+ cells = _direct_cells(table)
157
+ declared_rows = _iattr(table, "tbl", "rowCnt")
158
+ declared_cols = _iattr(table, "tbl", "colCnt")
159
+ grid: dict[tuple[int, int], _Cell] = {}
160
+ issues: list[str] = []
161
+ max_r = max_c = 0
162
+ for c in cells:
163
+ for dr in range(c.row_span):
164
+ for dc in range(c.col_span):
165
+ key = (c.row + dr, c.col + dc)
166
+ if key in grid:
167
+ issues.append(f"overlap at {key}")
168
+ grid[key] = c
169
+ max_r = max(max_r, key[0])
170
+ max_c = max(max_c, key[1])
171
+ n_rows = max_r + 1
172
+ n_cols = max_c + 1
173
+ for r in range(n_rows):
174
+ for col in range(n_cols):
175
+ if (r, col) not in grid:
176
+ issues.append(f"hole at {(r, col)}")
177
+ if declared_cols is not None and n_cols != declared_cols:
178
+ issues.append(f"colCnt {declared_cols} != observed {n_cols}")
179
+ if declared_rows is not None and n_rows != declared_rows:
180
+ issues.append(f"rowCnt {declared_rows} != observed {n_rows}")
181
+ return grid, GridReport(not issues, n_rows, n_cols, tuple(issues))
182
+
183
+
184
+ _P_SPAN_RE = re.compile(rb"<(?:[A-Za-z_][\w.-]*:)?p\b.*?</(?:[A-Za-z_][\w.-]*:)?p>", re.DOTALL)
185
+
186
+ def _first_paragraph_span(cell: bytes) -> tuple[int, int] | None:
187
+ """Byte span of the cell's first ``<hp:p>...</hp:p>`` (its own subList's
188
+ first paragraph), skipping any nested table content."""
189
+ m = _P_SPAN_RE.search(_mask_nested_tables(cell))
190
+ return (m.start(), m.end()) if m else None
191
+
192
+ def _all_paragraph_spans(cell: bytes) -> list[tuple[int, int]]:
193
+ """Byte spans of every direct ``<hp:p>`` of the cell (its own paragraphs,
194
+ not nested-table ones)."""
195
+ masked = _mask_nested_tables(cell)
196
+ return [(m.start(), m.end()) for m in _P_SPAN_RE.finditer(masked)]
197
+
198
+
199
+ # --- public API ---------------------------------------------------------------
200
+
201
+ @dataclass(frozen=True)
202
+ class CellApplied:
203
+ section_path: str
204
+ table_index: int
205
+ row: int
206
+ col: int
207
+ original_text: str
208
+ replacement_text: str
209
+
210
+ def to_dict(self) -> dict[str, Any]:
211
+ return {
212
+ "sectionPath": self.section_path, "tableIndex": self.table_index,
213
+ "row": self.row, "col": self.col,
214
+ "originalText": self.original_text, "replacementText": self.replacement_text,
215
+ }
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class CellSkipped:
220
+ section_path: str
221
+ table_index: int
222
+ row: int
223
+ col: int
224
+ reason: str
225
+
226
+ def to_dict(self) -> dict[str, Any]:
227
+ return {
228
+ "sectionPath": self.section_path, "tableIndex": self.table_index,
229
+ "row": self.row, "col": self.col, "reason": self.reason,
230
+ }
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class CellFillResult:
235
+ data: bytes
236
+ applied: tuple[CellApplied, ...]
237
+ skipped: tuple[CellSkipped, ...]
238
+ changed_parts: tuple[str, ...]
239
+ byte_identical: bool
240
+ zip_method: str
241
+ open_safety: dict[str, Any]
242
+
243
+ @property
244
+ def ok(self) -> bool:
245
+ return bool(self.open_safety.get("ok")) and not self.skipped
246
+
247
+ def to_dict(self) -> dict[str, Any]:
248
+ return {
249
+ "ok": self.ok,
250
+ "applied": [a.to_dict() for a in self.applied],
251
+ "skipped": [s.to_dict() for s in self.skipped],
252
+ "changedParts": list(self.changed_parts),
253
+ "byteIdentical": self.byte_identical,
254
+ "zipMethod": self.zip_method,
255
+ "openSafety": self.open_safety,
256
+ }
257
+
258
+
259
+ def _normalize(cell: Mapping[str, Any] | Any) -> tuple[str, int, int, int, str]:
260
+ get = cell.get if isinstance(cell, Mapping) else (lambda k, d=None: getattr(cell, k, d))
261
+ section = str(get("section_path") or get("sectionPath") or "Contents/section0.xml")
262
+ table_index = get("table_index", get("tableIndex"))
263
+ row = get("row")
264
+ col = get("col")
265
+ text = get("text")
266
+ if table_index is None or row is None or col is None or text is None:
267
+ raise ValueError("cell fill requires table_index, row, col, text")
268
+ return section, int(table_index), int(row), int(col), str(text)
269
+
270
+
271
+ def fill_cells(
272
+ source: str | Path | bytes,
273
+ cells: Sequence[Mapping[str, Any] | Any],
274
+ *,
275
+ output_path: str | Path | None = None,
276
+ ) -> CellFillResult:
277
+ """Byte-preserving fill of table cells by ``(table_index, row, col)`` address.
278
+
279
+ Only the addressed cells' first-paragraph text is spliced; the stale
280
+ ``<hp:linesegarray>`` of each edited paragraph is stripped. Untouched section
281
+ parts and untouched tables round-trip byte-identical. Empty / self-closing
282
+ cells are filled (text inserted), never silently reported as done. An
283
+ unresolvable address is reported in ``skipped`` and mutates nothing.
284
+ """
285
+ source_bytes = _read_source_bytes(source)
286
+ specs = [_normalize(c) for c in cells]
287
+ if not specs:
288
+ open_safety, _ = _finalize(source_bytes, output_path, source=source)
289
+ return CellFillResult(source_bytes, (), (), (), True, "none", open_safety)
290
+
291
+ import io, zipfile
292
+ with zipfile.ZipFile(io.BytesIO(source_bytes), "r") as zf:
293
+ parts = {i.filename: zf.read(i.filename) for i in zf.infolist() if not i.is_dir()}
294
+
295
+ applied: list[CellApplied] = []
296
+ skipped: list[CellSkipped] = []
297
+ by_section: dict[str, list[tuple[int, int, int, str]]] = {}
298
+ for section, ti, row, col, text in specs:
299
+ by_section.setdefault(section, []).append((ti, row, col, text))
300
+
301
+ changed_parts: dict[str, bytes] = {}
302
+ for section_path, section_specs in by_section.items():
303
+ section_xml = parts.get(section_path)
304
+ if section_xml is None:
305
+ skipped.extend(CellSkipped(section_path, ti, r, c, "section part not found") for ti, r, c, _ in section_specs)
306
+ continue
307
+ table_spans = _iter_table_spans(section_xml)
308
+ # accumulate byte edits over the whole section (table region splices)
309
+ section_edits: list[tuple[int, int, bytes]] = []
310
+ occupied: list[tuple[int, int]] = [] # filled paragraph spans (merge-collision guard)
311
+ for ti, row, col, text in section_specs:
312
+ if ti < 0 or ti >= len(table_spans):
313
+ skipped.append(CellSkipped(section_path, ti, row, col, "table_index out of range"))
314
+ continue
315
+ ts, te = table_spans[ti]
316
+ table = section_xml[ts:te]
317
+ grid, report = build_grid(table)
318
+ cell = grid.get((row, col))
319
+ if cell is None:
320
+ skipped.append(CellSkipped(section_path, ti, row, col, "cell address out of range"))
321
+ continue
322
+ cell_bytes = table[cell.start:cell.end]
323
+ p_spans = _all_paragraph_spans(cell_bytes)
324
+ if not p_spans:
325
+ skipped.append(CellSkipped(section_path, ti, row, col, "cell has no paragraph"))
326
+ continue
327
+ # Replace the WHOLE cell's visible text with `text`: line k -> paragraph k,
328
+ # trailing paragraphs are emptied (so stale multi-line template content —
329
+ # e.g. stacked 성취기준 codes — does not survive). newline-separated text
330
+ # fills successive paragraphs.
331
+ lines = text.split("\n")
332
+ cell_edits: list[tuple[int, int, bytes]] = []
333
+ first_orig: str | None = None
334
+ collided = False
335
+ for idx, (ps, pe) in enumerate(p_spans):
336
+ para = cell_bytes[ps:pe]
337
+ line = lines[idx] if idx < len(lines) else ""
338
+ edit = _text_edit_for_paragraph(para, line)
339
+ if edit is None:
340
+ continue
341
+ _s, _e, replacement, original_text = edit
342
+ if idx == 0:
343
+ first_orig = original_text
344
+ if original_text == line:
345
+ continue
346
+ a0, a1 = ts + cell.start + ps, ts + cell.start + pe
347
+ if any(a0 < oe and os < a1 for os, oe in occupied):
348
+ collided = True
349
+ break
350
+ new_para = _strip_paragraph_layout_cache(replacement if (_s, _e) == (0, len(para)) else _apply_edits(para, [(_s, _e, replacement)]))
351
+ occupied.append((a0, a1))
352
+ cell_edits.append((a0, a1, new_para))
353
+ if collided:
354
+ skipped.append(CellSkipped(section_path, ti, row, col, "cell shares a merged region already filled by an earlier address"))
355
+ continue
356
+ if not cell_edits:
357
+ continue
358
+ section_edits.extend(cell_edits)
359
+ applied.append(CellApplied(section_path, ti, row, col, first_orig or "", text))
360
+ if section_edits:
361
+ new_section = _apply_edits(section_xml, section_edits)
362
+ if new_section != section_xml:
363
+ changed_parts[section_path] = new_section
364
+
365
+ if not changed_parts:
366
+ open_safety, _ = _finalize(source_bytes, output_path, source=source)
367
+ return CellFillResult(source_bytes, tuple(applied), tuple(skipped), (), True, "none", open_safety)
368
+
369
+ try:
370
+ output = _patch_zip_entries(source_bytes, changed_parts)
371
+ zip_method = "partial-local-record-copy"
372
+ except ValueError:
373
+ output = _rewrite_zip_entries(source_bytes, changed_parts)
374
+ zip_method = "zipfile-rewrite-fallback"
375
+ open_safety, _ = _finalize(output, output_path, source=source)
376
+ return CellFillResult(
377
+ output, tuple(applied), tuple(skipped), tuple(changed_parts),
378
+ output == source_bytes, zip_method, open_safety,
379
+ )
380
+
381
+
382
+ # --- table structure primitives (FR-003/FR-004) ------------------------------
383
+ #
384
+ # A structure edit is performed on the isolated table's *decoded* XML (str), then
385
+ # the produced table replaces ONLY that table's byte span in the section, so every
386
+ # other byte round-trips identical (Constitution VII). The edited table itself is
387
+ # re-serialised (its byte identity is intentionally forfeit -- it is the thing you
388
+ # changed). Every op is grid-validated afterwards and refuses (raises) on an
389
+ # invalid result (fail-closed, Constitution VI). Nested tables are out of scope:
390
+ # a table containing a nested <hp:tbl> refuses structure edits.
391
+
392
+ _S_TR = re.compile(r"<hp:tr\b.*?</hp:tr>", re.DOTALL)
393
+ _S_TC = re.compile(r"<hp:tc\b.*?</hp:tc>", re.DOTALL)
394
+
395
+
396
+ class TableStructureError(ValueError):
397
+ """A structure edit was refused (fail-closed) or is unsupported."""
398
+
399
+
400
+ def _si(chunk: str, tag: str, attr: str) -> int | None:
401
+ m = re.search(rf'<hp:{tag}\b[^>]*\b{attr}="(-?\d+)"', chunk)
402
+ return int(m.group(1)) if m else None
403
+
404
+
405
+ def _ss(chunk: str, tag: str, attr: str, val: int) -> str:
406
+ return re.sub(rf'(<hp:{tag}\b[^>]*\b{attr}=")-?\d+(")', rf"\g<1>{val}\g<2>", chunk, count=1)
407
+
408
+
409
+ def _guard_flat(table: str) -> None:
410
+ # the table's own <hp:tbl> plus any nested ones; >1 open == nested
411
+ if len(re.findall(r"<hp:tbl\b", table)) > 1:
412
+ raise TableStructureError("nested tables are unsupported for structure edits")
413
+
414
+
415
+ def _parse_table(table: str) -> tuple[str, list[str], str]:
416
+ first = table.index("<hp:tr")
417
+ last = table.rindex("</hp:tr>") + len("</hp:tr>")
418
+ return table[:first], _S_TR.findall(table[first:last]), table[last:]
419
+
420
+
421
+ def _rebuild(prefix: str, rows: list[str], suffix: str, *, rowcnt: int | None = None, colcnt: int | None = None) -> str:
422
+ out = prefix + "".join(rows) + suffix
423
+ if rowcnt is not None:
424
+ out = re.sub(r'(<hp:tbl\b[^>]*\browCnt=")\d+(")', rf"\g<1>{rowcnt}\g<2>", out, count=1)
425
+ if colcnt is not None:
426
+ out = re.sub(r'(<hp:tbl\b[^>]*\bcolCnt=")\d+(")', rf"\g<1>{colcnt}\g<2>", out, count=1)
427
+ return out
428
+
429
+
430
+ def _map_cells(row: str, fn) -> str:
431
+ parts, last = [], 0
432
+ for m in _S_TC.finditer(row):
433
+ parts.append(row[last:m.start()])
434
+ r = fn(m.group(0))
435
+ if r is not None:
436
+ parts.append(r)
437
+ last = m.end()
438
+ parts.append(row[last:])
439
+ return "".join(parts)
440
+
441
+
442
+ def _uniform_col_widths(rows: list[str]) -> dict[int, int] | None:
443
+ for row in rows:
444
+ w, ok = {}, True
445
+ for tc in _S_TC.findall(row):
446
+ if (_si(tc, "cellSpan", "colSpan") or 1) != 1:
447
+ ok = False
448
+ break
449
+ w[_si(tc, "cellAddr", "colAddr")] = _si(tc, "cellSz", "width")
450
+ if ok and w and max(w) + 1 == len(w):
451
+ return w
452
+ return None
453
+
454
+
455
+ def _delete_columns(table: str, del_cols: Iterable[int]) -> str:
456
+ _guard_flat(table)
457
+ del_cols = sorted(set(del_cols))
458
+ if not del_cols:
459
+ return table
460
+ dmax = del_cols[-1]
461
+ prefix, rows, suffix = _parse_table(table)
462
+ widths = _uniform_col_widths(rows)
463
+ if widths is None:
464
+ raise TableStructureError("no uniform (all colSpan=1) row to derive column widths")
465
+ ncol = max(widths) + 1
466
+ freed = sum(widths[c] for c in del_cols)
467
+ survivors = [c for c in range(ncol) if c not in del_cols]
468
+ targets = [c for c in survivors if c > dmax and c != survivors[-1]] or survivors
469
+ add, rem = divmod(freed, len(targets))
470
+ nw = {c: widths[c] for c in survivors}
471
+ for i, c in enumerate(targets):
472
+ nw[c] += add + (1 if i < rem else 0)
473
+ newidx = {c: i for i, c in enumerate(survivors)}
474
+
475
+ def fix(tc: str):
476
+ ca, cs = _si(tc, "cellAddr", "colAddr"), _si(tc, "cellSpan", "colSpan") or 1
477
+ surv = [c for c in range(ca, ca + cs) if c not in del_cols]
478
+ if not surv:
479
+ return None
480
+ tc = _ss(tc, "cellAddr", "colAddr", newidx[surv[0]])
481
+ tc = _ss(tc, "cellSpan", "colSpan", len(surv))
482
+ tc = _ss(tc, "cellSz", "width", sum(nw[c] for c in surv))
483
+ return tc
484
+
485
+ rows = [_map_cells(r, fix) for r in rows]
486
+ return _rebuild(prefix, rows, suffix, colcnt=len(survivors))
487
+
488
+
489
+ def _collapse_empty_rows(table: str) -> str:
490
+ """Cascade: drop physical rows left with 0 cells (e.g. a row that existed only
491
+ for a now-deleted column block), collapsing rowSpans that crossed them."""
492
+ prefix, rows, suffix = _parse_table(table)
493
+ while True:
494
+ empty = next((i for i, r in enumerate(rows) if not _S_TC.findall(r)), None)
495
+ if empty is None:
496
+ break
497
+ heights = []
498
+ for r in rows:
499
+ for tc in _S_TC.findall(r):
500
+ ra, rs = _si(tc, "cellAddr", "rowAddr"), _si(tc, "cellSpan", "rowSpan") or 1
501
+ if ra < empty < ra + rs:
502
+ h = _si(tc, "cellSz", "height")
503
+ if h:
504
+ heights.append(h // rs)
505
+ drop_h = min(heights) if heights else 0
506
+
507
+ def fix(tc: str):
508
+ ra, rs = _si(tc, "cellAddr", "rowAddr"), _si(tc, "cellSpan", "rowSpan") or 1
509
+ if ra < empty < ra + rs:
510
+ tc = _ss(tc, "cellSpan", "rowSpan", rs - 1)
511
+ h = _si(tc, "cellSz", "height")
512
+ if h and drop_h:
513
+ tc = _ss(tc, "cellSz", "height", max(1, h - drop_h))
514
+ elif ra > empty:
515
+ tc = _ss(tc, "cellAddr", "rowAddr", ra - 1)
516
+ return tc
517
+
518
+ rows = [_map_cells(r, fix) for i, r in enumerate(rows) if i != empty]
519
+ rowcnt = len(rows)
520
+ return _rebuild(prefix, rows, suffix, rowcnt=rowcnt)
521
+
522
+
523
+ def _delete_rows(table: str, del_rows: Iterable[int]) -> str:
524
+ """Delete physical rows by index, reconciling rowAddr/rowSpan and covering
525
+ cells' height."""
526
+ _guard_flat(table)
527
+ del_rows = sorted(set(del_rows), reverse=True)
528
+ prefix, rows, suffix = _parse_table(table)
529
+ for empty in del_rows:
530
+ if empty >= len(rows):
531
+ raise TableStructureError(f"row index {empty} out of range")
532
+ heights = []
533
+ for r in rows:
534
+ for tc in _S_TC.findall(r):
535
+ ra, rs = _si(tc, "cellAddr", "rowAddr"), _si(tc, "cellSpan", "rowSpan") or 1
536
+ if ra <= empty < ra + rs and rs > 1:
537
+ h = _si(tc, "cellSz", "height")
538
+ if h:
539
+ heights.append(h // rs)
540
+ drop_h = min(heights) if heights else 0
541
+
542
+ def fix(tc: str):
543
+ ra, rs = _si(tc, "cellAddr", "rowAddr"), _si(tc, "cellSpan", "rowSpan") or 1
544
+ if ra <= empty < ra + rs:
545
+ if rs > 1:
546
+ tc = _ss(tc, "cellSpan", "rowSpan", rs - 1)
547
+ h = _si(tc, "cellSz", "height")
548
+ if h and drop_h:
549
+ tc = _ss(tc, "cellSz", "height", max(1, h - drop_h))
550
+ return tc
551
+ return None # single-row cell in the deleted row -> drop
552
+ if ra > empty:
553
+ tc = _ss(tc, "cellAddr", "rowAddr", ra - 1)
554
+ return tc
555
+
556
+ rows = [_map_cells(r, fix) for i, r in enumerate(rows)]
557
+ rows = [r for i, r in enumerate(rows) if i != empty]
558
+ return _rebuild(prefix, rows, suffix, rowcnt=len(rows))
559
+
560
+
561
+ _PARA_ID_RE = re.compile(r'(<hp:p\b[^>]*\bid=")(\d+)(")')
562
+
563
+
564
+ def _refresh_ids(row: str, bump: int) -> str:
565
+ return _PARA_ID_RE.sub(lambda m: m.group(1) + str((int(m.group(2)) + bump) & 0x7FFFFFFF) + m.group(3), row)
566
+
567
+
568
+ def _insert_row_by_clone(table: str, ref_row: int, count: int = 1) -> str:
569
+ """Insert *count* rows after physical row *ref_row* by cloning it (formatting
570
+ preserved, paragraph ids refreshed). Rows below shift; cells spanning across
571
+ the insertion grow their rowSpan."""
572
+ _guard_flat(table)
573
+ if count < 1:
574
+ return table
575
+ prefix, rows, suffix = _parse_table(table)
576
+ if ref_row >= len(rows):
577
+ raise TableStructureError(f"ref row {ref_row} out of range")
578
+
579
+ def shift(tc: str):
580
+ ra, rs = _si(tc, "cellAddr", "rowAddr"), _si(tc, "cellSpan", "rowSpan") or 1
581
+ if ra > ref_row:
582
+ return _ss(tc, "cellAddr", "rowAddr", ra + count)
583
+ if ra <= ref_row < ra + rs and ra + rs - 1 > ref_row:
584
+ # cell spans across the insertion point -> extend
585
+ return _ss(tc, "cellSpan", "rowSpan", rs + count)
586
+ return tc
587
+
588
+ shifted = [_map_cells(r, shift) for r in rows]
589
+ # build the clones from the ORIGINAL ref row (single-row cells only; a ref row
590
+ # whose cells are all rowSpan==1 is the safe clone source)
591
+ ref = rows[ref_row]
592
+ if any((_si(tc, "cellSpan", "rowSpan") or 1) != 1 for tc in _S_TC.findall(ref)):
593
+ raise TableStructureError("clone source row must have rowSpan==1 cells")
594
+ clones = []
595
+ for k in range(1, count + 1):
596
+ clone = _map_cells(ref, lambda tc: _ss(tc, "cellAddr", "rowAddr", ref_row + k))
597
+ clone = _refresh_ids(clone, 1000 + k)
598
+ clones.append(clone)
599
+ new_rows = shifted[: ref_row + 1] + clones + shifted[ref_row + 1:]
600
+ return _rebuild(prefix, new_rows, suffix, rowcnt=len(new_rows))
601
+
602
+
603
+ def _validate_or_raise(table: str) -> None:
604
+ _grid, rep = build_grid(table.encode("utf-8"))
605
+ if not rep.ok:
606
+ raise TableStructureError(f"invalid table grid after edit: {rep.issues}")
607
+
608
+
609
+ # --- section-level application (byte-region splice back) ----------------------
610
+
611
+ _STRUCT_OPS = {
612
+ "delete_column": lambda t, o: _collapse_empty_rows(_delete_columns(t, o["cols"] if "cols" in o else [o["col"]])),
613
+ "delete_row": lambda t, o: _delete_rows(t, o["rows"] if "rows" in o else [o["row"]]),
614
+ "insert_row_by_clone": lambda t, o: _insert_row_by_clone(t, o["ref_row"], int(o.get("count", 1))),
615
+ }
616
+
617
+
618
+ def _p_wrapper_span(section: bytes, table_start: int) -> tuple[int, int]:
619
+ """Byte span of the <hp:p> paragraph that wraps the table starting at
620
+ *table_start* (used by delete_table)."""
621
+ p_open = section.rfind(b"<hp:p", 0, table_start)
622
+ if p_open < 0:
623
+ raise TableStructureError("could not find wrapping <hp:p> for table")
624
+ # balanced close from p_open
625
+ depth = 0
626
+ for t in re.finditer(rb"<(?:[A-Za-z_][\w.-]*:)?p\b|</(?:[A-Za-z_][\w.-]*:)?p>", section[p_open:]):
627
+ depth += -1 if t.group().startswith(b"</") else 1
628
+ if depth == 0:
629
+ return p_open, p_open + t.end()
630
+ raise TableStructureError("unbalanced wrapping paragraph")
631
+
632
+
633
+ def _sections(data: bytes) -> dict[str, bytes]:
634
+ import io, zipfile
635
+ with zipfile.ZipFile(io.BytesIO(data)) as z:
636
+ return {n: z.read(n) for n in z.namelist() if re.search(r"section\d+\.xml$", n)}
637
+
638
+
639
+ def apply_table_ops(
640
+ source: str | Path | bytes,
641
+ ops: Sequence[Mapping[str, Any]],
642
+ *,
643
+ output_path: str | Path | None = None,
644
+ ) -> CellFillResult:
645
+ """Apply structure ops (then cell fills) to a document, byte-region splicing
646
+ each changed table back so untouched bytes stay identical.
647
+
648
+ Op dicts: ``{op: 'delete_column'|'delete_row'|'delete_table'|
649
+ 'insert_row_by_clone'|'fill_cell', section_path?, table_index, ...}``.
650
+ Structure ops run first, in order; ``delete_table`` shifts later table indices,
651
+ so sequence table deletes in reverse index order (as the recipe does). Every
652
+ structure edit is grid-validated and refuses on an invalid result
653
+ (fail-closed). Cell fills are then applied on the restructured document via
654
+ :func:`fill_cells`.
655
+ """
656
+ source_bytes = _read_source_bytes(source)
657
+ struct_ops = [o for o in ops if o.get("op") != "fill_cell"]
658
+ fill_ops = [{**o, "section_path": o.get("section_path") or o.get("sectionPath") or "Contents/section0.xml"}
659
+ for o in ops if o.get("op") == "fill_cell"]
660
+
661
+ sections = _sections(source_bytes)
662
+ skipped: list[CellSkipped] = []
663
+ changed: set[str] = set()
664
+
665
+ for op in struct_ops:
666
+ name = op.get("op")
667
+ sp = str(op.get("section_path") or op.get("sectionPath") or "Contents/section0.xml")
668
+ section = sections.get(sp)
669
+ if section is None:
670
+ skipped.append(CellSkipped(sp, -1, -1, -1, "section part not found"))
671
+ continue
672
+ try:
673
+ ti = int(op.get("table_index", op.get("tableIndex")))
674
+ except (TypeError, ValueError):
675
+ skipped.append(CellSkipped(sp, -1, -1, -1, f"{name}: table_index required"))
676
+ continue
677
+ spans = _iter_table_spans(section)
678
+ if ti < 0 or ti >= len(spans):
679
+ skipped.append(CellSkipped(sp, ti, -1, -1, "table_index out of range"))
680
+ continue
681
+ ts, te = spans[ti]
682
+ try:
683
+ if name == "delete_table":
684
+ ps, pe = _p_wrapper_span(section, ts)
685
+ new_section = section[:ps] + section[pe:]
686
+ elif name in _STRUCT_OPS:
687
+ new_table = _STRUCT_OPS[name](section[ts:te].decode("utf-8"), op)
688
+ _validate_or_raise(new_table)
689
+ new_section = section[:ts] + new_table.encode("utf-8") + section[te:]
690
+ else:
691
+ skipped.append(CellSkipped(sp, ti, -1, -1, f"unknown op {name!r}"))
692
+ continue
693
+ except TableStructureError as exc:
694
+ skipped.append(CellSkipped(sp, ti, -1, -1, f"{name}: {exc}"))
695
+ continue
696
+ sections[sp] = new_section
697
+ changed.add(sp)
698
+
699
+ intermediate = source_bytes
700
+ if changed:
701
+ try:
702
+ intermediate = _patch_zip_entries(source_bytes, {sp: sections[sp] for sp in changed})
703
+ except ValueError:
704
+ intermediate = _rewrite_zip_entries(source_bytes, {sp: sections[sp] for sp in changed})
705
+
706
+ if fill_ops:
707
+ fres = fill_cells(intermediate, fill_ops, output_path=output_path)
708
+ return CellFillResult(
709
+ fres.data, fres.applied, tuple(skipped) + fres.skipped,
710
+ tuple(sorted(changed | set(fres.changed_parts))),
711
+ fres.data == source_bytes,
712
+ "partial-local-record-copy" if (changed or fres.changed_parts) else "none",
713
+ fres.open_safety,
714
+ )
715
+
716
+ open_safety, _ = _finalize(intermediate, output_path, source=source)
717
+ return CellFillResult(
718
+ intermediate, (), tuple(skipped), tuple(sorted(changed)),
719
+ intermediate == source_bytes,
720
+ "partial-local-record-copy" if changed else "none", open_safety,
721
+ )
722
+
723
+
724
+ # --- P3: real-Hancom oracle gate for form-fill (FR-005) -----------------------
725
+
726
+ class RenderCheckRequired(RuntimeError):
727
+ """``verify_fill(require=True)`` but no real Hancom oracle rendered."""
728
+
729
+
730
+ def verify_fill(
731
+ before: str | Path | bytes | None,
732
+ after: str | Path | bytes,
733
+ *,
734
+ oracle: Any = None,
735
+ require: bool = False,
736
+ edit_mask: Any = None,
737
+ work_dir: str | None = None,
738
+ ):
739
+ """Render *before*/*after* in **real Hancom** and judge the fill.
740
+
741
+ Wires the real oracle (:func:`hwpx.visual.oracle.resolve_oracle` /
742
+ :func:`~hwpx.visual.oracle.visual_check`) into the form-fill verdict so a
743
+ caller never mistakes structural validity (open-safety) or a lenient HTML
744
+ preview for Hancom acceptance -- the exact 2026-07-03 overclaim. Returns a
745
+ ``VisualReport`` carrying ``render_checked`` plus ``overflow_detected`` /
746
+ ``overlap_detected`` (글자겹침) / ``page_count_changed``.
747
+
748
+ Honest degrade (Constitution V): with no reachable Hancom / imaging stack the
749
+ report is ``render_checked=False, ok=True`` and nothing raises -- **unless**
750
+ ``require=True``, which fails closed with :class:`RenderCheckRequired`.
751
+ """
752
+ import os
753
+ import shutil
754
+ import tempfile
755
+
756
+ from .visual.oracle import resolve_oracle, visual_check
757
+
758
+ after_bytes = _read_source_bytes(after)
759
+ before_bytes = _read_source_bytes(before) if before is not None else None
760
+ if oracle is None:
761
+ oracle = resolve_oracle()
762
+
763
+ tmp = tempfile.mkdtemp(prefix="hwpx-verify-")
764
+ try:
765
+ after_path = os.path.join(tmp, "after.hwpx")
766
+ Path(after_path).write_bytes(after_bytes)
767
+ before_path = None
768
+ if before_bytes is not None:
769
+ before_path = os.path.join(tmp, "before.hwpx")
770
+ Path(before_path).write_bytes(before_bytes)
771
+ report = visual_check(before_path, after_path, oracle=oracle, edit_mask=edit_mask, work_dir=work_dir)
772
+ finally:
773
+ if work_dir is None:
774
+ shutil.rmtree(tmp, ignore_errors=True)
775
+
776
+ if require and not report.render_checked:
777
+ raise RenderCheckRequired(
778
+ "render_check='required' but no Hancom oracle rendered: "
779
+ + "; ".join(list(report.warnings) + list(report.errors))
780
+ )
781
+ return report
782
+
783
+
784
+ __all__ = [
785
+ "fill_cells", "build_grid", "GridReport", "CellFillResult", "CellApplied", "CellSkipped",
786
+ "apply_table_ops", "TableStructureError", "verify_fill", "RenderCheckRequired",
787
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 2.20.0
3
+ Version: 2.21.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 자동화 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -5,6 +5,7 @@ hwpx/form_fill.py,sha256=VUIU53Qa9Ho2aP72biDvJwnDW7ngdAzu3PSd5A7d1JM,9908
5
5
  hwpx/package.py,sha256=0rKjGCJbPQvrVBIy07Jpjsu3fI7HhbqFCGWTiTDsJpo,1141
6
6
  hwpx/patch.py,sha256=8G1wgGVgCp0KyhQb-y0ZI2FJEatCwLt_JpepHJmYhYQ,23799
7
7
  hwpx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ hwpx/table_patch.py,sha256=Z5uM1jlyfSZLToGrd0dUo1-Ex_AAVcX8yY9MBXrMXHU,32064
8
9
  hwpx/template_formfit.py,sha256=dlxf98FatT5Nl4hN15TuMXqj5NlDTGSnLaqXVo_PoBo,23424
9
10
  hwpx/templates.py,sha256=28bYqeJVeDb1Cq8G9NZG9Mhnu4K2GamAKC4QhxvUZyA,1187
10
11
  hwpx/builder/__init__.py,sha256=8tcxClbeuNle_R_2nDAauyy518OCegG8qVPjwIKRUZA,849
@@ -152,10 +153,10 @@ hwpx/visual/diff.py,sha256=0X5T9IgwRZU3td-7vnPrlowovtGud7P_ymq0KVehlKk,5677
152
153
  hwpx/visual/masks.py,sha256=oXhgynAb4uKjJtZ2BGHHdAjyvWGqSFlZFQ-iJxzHiuo,1832
153
154
  hwpx/visual/oracle.py,sha256=VNO-tAB-lzTdW5XtSELxg_Aau369qRFBsJvNryHF_D8,31319
154
155
  hwpx/visual/report.py,sha256=2RhXN1KBYOZTim9FNpeUhaaDHR7oFxI6Z2DLUkDIiwE,1717
155
- python_hwpx-2.20.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
156
- python_hwpx-2.20.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
157
- python_hwpx-2.20.0.dist-info/METADATA,sha256=ocHd1cXv2SP9a-kS7cxsutbef5hTE-3Ox8IpK2ii8FA,19982
158
- python_hwpx-2.20.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
159
- python_hwpx-2.20.0.dist-info/entry_points.txt,sha256=4U6WXYWHxEiWp2VRHo97fvOYNh7ebu6roonk7chxKcY,453
160
- python_hwpx-2.20.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
161
- python_hwpx-2.20.0.dist-info/RECORD,,
156
+ python_hwpx-2.21.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
157
+ python_hwpx-2.21.0.dist-info/licenses/NOTICE,sha256=k48h6EaGQE8Y1c0dS9sIOOcz4YqkbcImWClF7pBOgsg,2473
158
+ python_hwpx-2.21.0.dist-info/METADATA,sha256=K-bzEFVTuF3yVB7Fr3kJ0EGnRDmiy6_1hGrQIQRGq_k,19982
159
+ python_hwpx-2.21.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
160
+ python_hwpx-2.21.0.dist-info/entry_points.txt,sha256=4U6WXYWHxEiWp2VRHo97fvOYNh7ebu6roonk7chxKcY,453
161
+ python_hwpx-2.21.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
162
+ python_hwpx-2.21.0.dist-info/RECORD,,