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 +787 -0
- {python_hwpx-2.20.0.dist-info → python_hwpx-2.21.0.dist-info}/METADATA +1 -1
- {python_hwpx-2.20.0.dist-info → python_hwpx-2.21.0.dist-info}/RECORD +8 -7
- {python_hwpx-2.20.0.dist-info → python_hwpx-2.21.0.dist-info}/WHEEL +0 -0
- {python_hwpx-2.20.0.dist-info → python_hwpx-2.21.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-2.20.0.dist-info → python_hwpx-2.21.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-2.20.0.dist-info → python_hwpx-2.21.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-2.20.0.dist-info → python_hwpx-2.21.0.dist-info}/top_level.txt +0 -0
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
|
+
]
|
|
@@ -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.
|
|
156
|
-
python_hwpx-2.
|
|
157
|
-
python_hwpx-2.
|
|
158
|
-
python_hwpx-2.
|
|
159
|
-
python_hwpx-2.
|
|
160
|
-
python_hwpx-2.
|
|
161
|
-
python_hwpx-2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|