python-hwpx 3.3.0__py3-none-any.whl → 3.4.1__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/_document/__init__.py +2 -0
- hwpx/_document/_units.py +16 -0
- hwpx/_document/fields.py +570 -0
- hwpx/_document/layout.py +682 -0
- hwpx/_document/media.py +347 -0
- hwpx/_document/memos.py +225 -0
- hwpx/_document/persistence.py +267 -0
- hwpx/_document/shapes.py +218 -0
- hwpx/_document/tracked.py +126 -0
- hwpx/agent/blueprint/model.py +103 -7
- hwpx/agent/commands.py +121 -89
- hwpx/authoring.py +30 -1
- hwpx/builder/core.py +87 -6
- hwpx/document.py +317 -1501
- hwpx/guidance_scan.py +136 -65
- hwpx/oxml/document_parts.py +149 -114
- hwpx/oxml/run.py +150 -137
- hwpx/tools/markdown_export.py +108 -87
- hwpx/tools/package_validator.py +325 -226
- hwpx/tools/toc_author.py +81 -0
- {python_hwpx-3.3.0.dist-info → python_hwpx-3.4.1.dist-info}/METADATA +18 -2
- {python_hwpx-3.3.0.dist-info → python_hwpx-3.4.1.dist-info}/RECORD +27 -18
- {python_hwpx-3.3.0.dist-info → python_hwpx-3.4.1.dist-info}/WHEEL +0 -0
- {python_hwpx-3.3.0.dist-info → python_hwpx-3.4.1.dist-info}/entry_points.txt +0 -0
- {python_hwpx-3.3.0.dist-info → python_hwpx-3.4.1.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-3.3.0.dist-info → python_hwpx-3.4.1.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-3.3.0.dist-info → python_hwpx-3.4.1.dist-info}/top_level.txt +0 -0
hwpx/_document/_units.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Shared HWPUNIT conversion helpers for the HwpxDocument owner modules (S-084)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_HWP_UNITS_PER_MM = 7200 / 25.4
|
|
8
|
+
_HWP_UNITS_PER_PT = 100
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _mm_to_hwp_units(value: float) -> int:
|
|
12
|
+
return round(value * _HWP_UNITS_PER_MM)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _pt_to_hwp_units(value: float) -> int:
|
|
16
|
+
return round(value * _HWP_UNITS_PER_PT)
|
hwpx/_document/fields.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Form-field domain owner behind the :class:`HwpxDocument` facade (S-084)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Iterator, Mapping, Sequence, cast
|
|
8
|
+
|
|
9
|
+
from ..oxml import HwpxOxmlParagraph
|
|
10
|
+
from ..oxml.namespaces import HP
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from hwpx.document import HwpxDocument
|
|
14
|
+
from ..form_fit.policy import FitPolicy
|
|
15
|
+
from ..form_fit.report import FitResult
|
|
16
|
+
from ..tools.table_navigation import TableFillResult
|
|
17
|
+
|
|
18
|
+
_HP = HP
|
|
19
|
+
|
|
20
|
+
_FORM_FIELD_EXCLUDED_TYPES = {"HYPERLINK", "MEMO"}
|
|
21
|
+
_FORM_FIELD_TYPES = {"FORM", "CLICKHERE", "CLICK_HERE", "CLICK-HERE", "NURUMTUL", "누름틀"}
|
|
22
|
+
_FORM_FIELD_NAME_ATTRS = ("fieldName", "fieldname", "name", "title", "id", "fieldid")
|
|
23
|
+
_FORM_FIELD_PROMPT_ATTRS = ("prompt", "instruction", "description", "desc", "help", "memo")
|
|
24
|
+
_FORM_FIELD_PARAM_NAMES = {
|
|
25
|
+
"fieldname",
|
|
26
|
+
"field_name",
|
|
27
|
+
"name",
|
|
28
|
+
"title",
|
|
29
|
+
"prompt",
|
|
30
|
+
"instruction",
|
|
31
|
+
"description",
|
|
32
|
+
"desc",
|
|
33
|
+
"help",
|
|
34
|
+
"memo",
|
|
35
|
+
"guide",
|
|
36
|
+
}
|
|
37
|
+
_TEXT_ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _local_name(node_or_tag: Any) -> str:
|
|
41
|
+
tag = getattr(node_or_tag, "tag", node_or_tag)
|
|
42
|
+
if not isinstance(tag, str):
|
|
43
|
+
return ""
|
|
44
|
+
if "}" in tag:
|
|
45
|
+
return tag.rsplit("}", 1)[1]
|
|
46
|
+
return tag
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sanitize_field_text(value: str) -> str:
|
|
50
|
+
return _TEXT_ILLEGAL.sub("", value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _field_type_tokens(*values: str | None) -> set[str]:
|
|
54
|
+
tokens: set[str] = set()
|
|
55
|
+
for value in values:
|
|
56
|
+
if not value:
|
|
57
|
+
continue
|
|
58
|
+
raw = str(value).strip()
|
|
59
|
+
if not raw:
|
|
60
|
+
continue
|
|
61
|
+
tokens.add(raw.upper())
|
|
62
|
+
tokens.add(raw.replace("_", "").replace("-", "").upper())
|
|
63
|
+
return tokens
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_form_field_begin(ctrl: Any, field_begin: Any) -> bool:
|
|
67
|
+
tokens = _field_type_tokens(
|
|
68
|
+
ctrl.get("type"),
|
|
69
|
+
field_begin.get("type"),
|
|
70
|
+
field_begin.get("name"),
|
|
71
|
+
field_begin.get("fieldName"),
|
|
72
|
+
field_begin.get("fieldname"),
|
|
73
|
+
)
|
|
74
|
+
if tokens & _FORM_FIELD_EXCLUDED_TYPES:
|
|
75
|
+
return False
|
|
76
|
+
if tokens & _FORM_FIELD_TYPES:
|
|
77
|
+
return True
|
|
78
|
+
return (ctrl.get("type") or "").strip().upper() == "FORM"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _field_identifier(field_begin: Any) -> str:
|
|
82
|
+
for attr in ("id", "fieldid", "name", "fieldName", "fieldname"):
|
|
83
|
+
value = (field_begin.get(attr) or "").strip()
|
|
84
|
+
if value:
|
|
85
|
+
return value
|
|
86
|
+
return ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _field_end_matches(field_begin: Any, field_end: Any) -> bool:
|
|
90
|
+
begin_keys = {
|
|
91
|
+
value
|
|
92
|
+
for value in (
|
|
93
|
+
field_begin.get("id"),
|
|
94
|
+
field_begin.get("fieldid"),
|
|
95
|
+
field_begin.get("name"),
|
|
96
|
+
)
|
|
97
|
+
if value
|
|
98
|
+
}
|
|
99
|
+
end_keys = {
|
|
100
|
+
value
|
|
101
|
+
for value in (
|
|
102
|
+
field_end.get("beginIDRef"),
|
|
103
|
+
field_end.get("fieldid"),
|
|
104
|
+
field_end.get("id"),
|
|
105
|
+
)
|
|
106
|
+
if value
|
|
107
|
+
}
|
|
108
|
+
if begin_keys and end_keys:
|
|
109
|
+
return bool(begin_keys & end_keys)
|
|
110
|
+
return not begin_keys
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _field_parameters(field_begin: Any) -> list[dict[str, str]]:
|
|
114
|
+
parameters: list[dict[str, str]] = []
|
|
115
|
+
for node in field_begin.iter():
|
|
116
|
+
if not _local_name(node).endswith("Param"):
|
|
117
|
+
continue
|
|
118
|
+
name = (node.get("name") or "").strip()
|
|
119
|
+
value = "".join(node.itertext()).strip()
|
|
120
|
+
if name or value:
|
|
121
|
+
parameters.append({"name": name, "value": value})
|
|
122
|
+
return parameters
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _first_attr(element: Any, names: Sequence[str]) -> str:
|
|
126
|
+
for name in names:
|
|
127
|
+
value = (element.get(name) or "").strip()
|
|
128
|
+
if value:
|
|
129
|
+
return value
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _field_parameter_value(parameters: Sequence[dict[str, str]], *names: str) -> str:
|
|
134
|
+
wanted = {name.casefold() for name in names}
|
|
135
|
+
for item in parameters:
|
|
136
|
+
name = item.get("name", "").casefold()
|
|
137
|
+
value = item.get("value", "").strip()
|
|
138
|
+
if name in wanted and value:
|
|
139
|
+
return value
|
|
140
|
+
return ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _clear_form_field_layout_cache(paragraph: Any) -> int:
|
|
144
|
+
removed = 0
|
|
145
|
+
for child in list(paragraph):
|
|
146
|
+
if _local_name(child).lower() == "linesegarray":
|
|
147
|
+
paragraph.remove(child)
|
|
148
|
+
removed += 1
|
|
149
|
+
return removed
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _find_field_end_position(
|
|
153
|
+
doc: "HwpxDocument",
|
|
154
|
+
runs: Sequence[Any],
|
|
155
|
+
*,
|
|
156
|
+
begin_run_index: int,
|
|
157
|
+
begin_child_index: int,
|
|
158
|
+
field_begin: Any,
|
|
159
|
+
) -> tuple[int, int, Any] | None:
|
|
160
|
+
for run_index in range(begin_run_index, len(runs)):
|
|
161
|
+
children = list(runs[run_index])
|
|
162
|
+
start = begin_child_index + 1 if run_index == begin_run_index else 0
|
|
163
|
+
for child_index in range(start, len(children)):
|
|
164
|
+
child = children[child_index]
|
|
165
|
+
if _local_name(child) != "ctrl":
|
|
166
|
+
continue
|
|
167
|
+
for field_end in child.findall(f"{_HP}fieldEnd"):
|
|
168
|
+
if _field_end_matches(field_begin, field_end):
|
|
169
|
+
return run_index, child_index, field_end
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _field_text_nodes(
|
|
174
|
+
doc: "HwpxDocument",
|
|
175
|
+
runs: Sequence[Any],
|
|
176
|
+
*,
|
|
177
|
+
begin_run_index: int,
|
|
178
|
+
begin_child_index: int,
|
|
179
|
+
end_run_index: int | None,
|
|
180
|
+
end_child_index: int | None,
|
|
181
|
+
) -> list[Any]:
|
|
182
|
+
nodes: list[Any] = []
|
|
183
|
+
last_run = end_run_index if end_run_index is not None else begin_run_index
|
|
184
|
+
for run_index in range(begin_run_index, last_run + 1):
|
|
185
|
+
children = list(runs[run_index])
|
|
186
|
+
start = begin_child_index + 1 if run_index == begin_run_index else 0
|
|
187
|
+
stop = end_child_index if end_run_index == run_index and end_child_index is not None else len(children)
|
|
188
|
+
for child in children[start:stop]:
|
|
189
|
+
if _local_name(child) == "t":
|
|
190
|
+
nodes.append(child)
|
|
191
|
+
return nodes
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _form_field_payload(
|
|
195
|
+
doc: "HwpxDocument",
|
|
196
|
+
*,
|
|
197
|
+
index: int,
|
|
198
|
+
section_index: int,
|
|
199
|
+
paragraph_index: int,
|
|
200
|
+
paragraph_index_in_section: int,
|
|
201
|
+
run_index: int,
|
|
202
|
+
child_index: int,
|
|
203
|
+
ctrl: Any,
|
|
204
|
+
field_begin: Any,
|
|
205
|
+
current_value: str,
|
|
206
|
+
has_end: bool,
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
parameters = _field_parameters(field_begin)
|
|
209
|
+
name = _first_attr(field_begin, _FORM_FIELD_NAME_ATTRS)
|
|
210
|
+
if not name:
|
|
211
|
+
name = _field_parameter_value(parameters, "fieldName", "fieldname", "field_name", "name", "title")
|
|
212
|
+
prompt = _first_attr(field_begin, _FORM_FIELD_PROMPT_ATTRS)
|
|
213
|
+
if not prompt:
|
|
214
|
+
prompt = _field_parameter_value(parameters, *_FORM_FIELD_PARAM_NAMES)
|
|
215
|
+
instruction = _field_parameter_value(parameters, "instruction", "guide", "help", "description", "desc")
|
|
216
|
+
if not instruction:
|
|
217
|
+
instruction = prompt
|
|
218
|
+
return {
|
|
219
|
+
"index": index,
|
|
220
|
+
"field_id": _field_identifier(field_begin),
|
|
221
|
+
"id": field_begin.get("id", ""),
|
|
222
|
+
"fieldid": field_begin.get("fieldid", ""),
|
|
223
|
+
"name": name,
|
|
224
|
+
"prompt": prompt,
|
|
225
|
+
"instruction": instruction,
|
|
226
|
+
"current_value": current_value,
|
|
227
|
+
"field_type": field_begin.get("type", ""),
|
|
228
|
+
"control_type": ctrl.get("type", ""),
|
|
229
|
+
"section_index": section_index,
|
|
230
|
+
"paragraph_index": paragraph_index,
|
|
231
|
+
"paragraph_index_in_section": paragraph_index_in_section,
|
|
232
|
+
"run_index": run_index,
|
|
233
|
+
"child_index": child_index,
|
|
234
|
+
"has_end": has_end,
|
|
235
|
+
"parameters": parameters,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _iter_form_field_matches(doc: "HwpxDocument") -> list[dict[str, Any]]:
|
|
240
|
+
matches: list[dict[str, Any]] = []
|
|
241
|
+
paragraph_index = 0
|
|
242
|
+
for section_index, section in enumerate(doc.sections):
|
|
243
|
+
direct_indexes = {
|
|
244
|
+
paragraph.element: index
|
|
245
|
+
for index, paragraph in enumerate(section.paragraphs)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def iter_content_paragraphs(element: Any) -> Iterator[Any]:
|
|
249
|
+
for child in element:
|
|
250
|
+
local = _local_name(child)
|
|
251
|
+
if local == "memogroup":
|
|
252
|
+
continue
|
|
253
|
+
if local == "p":
|
|
254
|
+
yield child
|
|
255
|
+
yield from iter_content_paragraphs(child)
|
|
256
|
+
|
|
257
|
+
for paragraph_element in iter_content_paragraphs(section.element):
|
|
258
|
+
paragraph = HwpxOxmlParagraph(paragraph_element, section)
|
|
259
|
+
paragraph_index_in_section = direct_indexes.get(paragraph_element, -1)
|
|
260
|
+
runs = [child for child in paragraph.element if _local_name(child) == "run"]
|
|
261
|
+
for run_index, run in enumerate(runs):
|
|
262
|
+
children = list(run)
|
|
263
|
+
for child_index, child in enumerate(children):
|
|
264
|
+
if _local_name(child) != "ctrl":
|
|
265
|
+
continue
|
|
266
|
+
for field_begin in child.findall(f"{_HP}fieldBegin"):
|
|
267
|
+
if not _is_form_field_begin(child, field_begin):
|
|
268
|
+
continue
|
|
269
|
+
end_position = _find_field_end_position(
|
|
270
|
+
doc,
|
|
271
|
+
runs,
|
|
272
|
+
begin_run_index=run_index,
|
|
273
|
+
begin_child_index=child_index,
|
|
274
|
+
field_begin=field_begin,
|
|
275
|
+
)
|
|
276
|
+
end_run_index: int | None = None
|
|
277
|
+
end_child_index: int | None = None
|
|
278
|
+
if end_position is not None:
|
|
279
|
+
end_run_index, end_child_index, _field_end = end_position
|
|
280
|
+
text_nodes = _field_text_nodes(
|
|
281
|
+
doc,
|
|
282
|
+
runs,
|
|
283
|
+
begin_run_index=run_index,
|
|
284
|
+
begin_child_index=child_index,
|
|
285
|
+
end_run_index=end_run_index,
|
|
286
|
+
end_child_index=end_child_index,
|
|
287
|
+
)
|
|
288
|
+
current_value = "".join("".join(node.itertext()) for node in text_nodes)
|
|
289
|
+
payload = _form_field_payload(
|
|
290
|
+
doc,
|
|
291
|
+
index=len(matches),
|
|
292
|
+
section_index=section_index,
|
|
293
|
+
paragraph_index=paragraph_index,
|
|
294
|
+
paragraph_index_in_section=paragraph_index_in_section,
|
|
295
|
+
run_index=run_index,
|
|
296
|
+
child_index=child_index,
|
|
297
|
+
ctrl=child,
|
|
298
|
+
field_begin=field_begin,
|
|
299
|
+
current_value=current_value,
|
|
300
|
+
has_end=end_position is not None,
|
|
301
|
+
)
|
|
302
|
+
payload["_paragraph"] = paragraph
|
|
303
|
+
payload["_runs"] = runs
|
|
304
|
+
payload["_begin_run_index"] = run_index
|
|
305
|
+
payload["_begin_child_index"] = child_index
|
|
306
|
+
payload["_end_run_index"] = end_run_index
|
|
307
|
+
payload["_end_child_index"] = end_child_index
|
|
308
|
+
payload["_text_nodes"] = text_nodes
|
|
309
|
+
matches.append(payload)
|
|
310
|
+
paragraph_index += 1
|
|
311
|
+
return matches
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def list_form_fields(doc: "HwpxDocument") -> list[dict[str, Any]]:
|
|
315
|
+
"""Return native form/click-here fields in document order."""
|
|
316
|
+
|
|
317
|
+
return [
|
|
318
|
+
{key: value for key, value in match.items() if not key.startswith("_")}
|
|
319
|
+
for match in _iter_form_field_matches(doc)
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _select_form_field(
|
|
324
|
+
doc: "HwpxDocument",
|
|
325
|
+
matches: Sequence[dict[str, Any]],
|
|
326
|
+
*,
|
|
327
|
+
field_index: int | None,
|
|
328
|
+
field_id: str | None,
|
|
329
|
+
name: str | None,
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
selectors = [field_index is not None, bool(field_id), bool(name)]
|
|
332
|
+
if selectors.count(True) != 1:
|
|
333
|
+
raise ValueError("provide exactly one of field_index, field_id, or name")
|
|
334
|
+
if field_index is not None:
|
|
335
|
+
for match in matches:
|
|
336
|
+
if match["index"] == field_index:
|
|
337
|
+
return match
|
|
338
|
+
raise ValueError(f"form field index not found: {field_index}")
|
|
339
|
+
if field_id:
|
|
340
|
+
wanted = field_id.strip()
|
|
341
|
+
candidates = [
|
|
342
|
+
match
|
|
343
|
+
for match in matches
|
|
344
|
+
if wanted in {match.get("field_id"), match.get("id"), match.get("fieldid")}
|
|
345
|
+
]
|
|
346
|
+
else:
|
|
347
|
+
wanted_name = (name or "").strip().casefold()
|
|
348
|
+
candidates = [
|
|
349
|
+
match
|
|
350
|
+
for match in matches
|
|
351
|
+
if wanted_name
|
|
352
|
+
and wanted_name
|
|
353
|
+
in {
|
|
354
|
+
str(match.get("name", "")).strip().casefold(),
|
|
355
|
+
str(match.get("prompt", "")).strip().casefold(),
|
|
356
|
+
str(match.get("instruction", "")).strip().casefold(),
|
|
357
|
+
}
|
|
358
|
+
]
|
|
359
|
+
if not candidates:
|
|
360
|
+
selector = f"field_id={field_id!r}" if field_id else f"name={name!r}"
|
|
361
|
+
raise ValueError(f"form field not found for {selector}")
|
|
362
|
+
if len(candidates) > 1:
|
|
363
|
+
labels = [candidate.get("name") or candidate.get("field_id") for candidate in candidates]
|
|
364
|
+
raise ValueError(f"form field selector is ambiguous: {labels}")
|
|
365
|
+
return candidates[0]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _field_run_style_snapshot(
|
|
369
|
+
doc: "HwpxDocument",
|
|
370
|
+
runs: Sequence[Any],
|
|
371
|
+
*,
|
|
372
|
+
begin_run_index: int,
|
|
373
|
+
end_run_index: int | None,
|
|
374
|
+
) -> list[str | None]:
|
|
375
|
+
last_run = end_run_index if end_run_index is not None else begin_run_index
|
|
376
|
+
return [runs[index].get("charPrIDRef") for index in range(begin_run_index, last_run + 1)]
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _insert_form_field_text_run(
|
|
380
|
+
doc: "HwpxDocument",
|
|
381
|
+
match: dict[str, Any],
|
|
382
|
+
value: str,
|
|
383
|
+
) -> None:
|
|
384
|
+
paragraph = match["_paragraph"]
|
|
385
|
+
runs: list[Any] = match["_runs"]
|
|
386
|
+
begin_run_index = int(match["_begin_run_index"])
|
|
387
|
+
end_run_index = match.get("_end_run_index")
|
|
388
|
+
begin_run = runs[begin_run_index]
|
|
389
|
+
char_ref = begin_run.get("charPrIDRef") or paragraph.char_pr_id_ref or "0"
|
|
390
|
+
run = paragraph.element.makeelement(f"{_HP}run", {"charPrIDRef": str(char_ref)})
|
|
391
|
+
text_node = run.makeelement(f"{_HP}t", {})
|
|
392
|
+
text_node.text = _sanitize_field_text(value)
|
|
393
|
+
run.append(text_node)
|
|
394
|
+
if end_run_index is None:
|
|
395
|
+
paragraph.element.insert(begin_run_index + 1, run)
|
|
396
|
+
else:
|
|
397
|
+
paragraph.element.insert(int(end_run_index), run)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def fill_form_field(
|
|
401
|
+
doc: "HwpxDocument",
|
|
402
|
+
value: str,
|
|
403
|
+
*,
|
|
404
|
+
field_index: int | None = None,
|
|
405
|
+
field_id: str | None = None,
|
|
406
|
+
name: str | None = None,
|
|
407
|
+
fit_policy: "FitPolicy | None" = None,
|
|
408
|
+
box_width: int | None = None,
|
|
409
|
+
font_pt: float | None = None,
|
|
410
|
+
) -> dict[str, Any]:
|
|
411
|
+
"""Fill a native form/click-here field while preserving surrounding runs."""
|
|
412
|
+
|
|
413
|
+
matches = _iter_form_field_matches(doc)
|
|
414
|
+
match = _select_form_field(
|
|
415
|
+
doc,
|
|
416
|
+
matches,
|
|
417
|
+
field_index=field_index,
|
|
418
|
+
field_id=field_id,
|
|
419
|
+
name=name,
|
|
420
|
+
)
|
|
421
|
+
paragraph = match["_paragraph"]
|
|
422
|
+
runs = match["_runs"]
|
|
423
|
+
before_value = str(match.get("current_value", ""))
|
|
424
|
+
before_style = _field_run_style_snapshot(
|
|
425
|
+
doc,
|
|
426
|
+
runs,
|
|
427
|
+
begin_run_index=int(match["_begin_run_index"]),
|
|
428
|
+
end_run_index=match.get("_end_run_index"),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
fit_result = None
|
|
432
|
+
write_value = str(value)
|
|
433
|
+
if fit_policy is not None:
|
|
434
|
+
fit_result = _measure_form_field_fit(
|
|
435
|
+
doc, str(value), match, fit_policy, box_width, font_pt
|
|
436
|
+
)
|
|
437
|
+
write_value = fit_result.applied_value
|
|
438
|
+
|
|
439
|
+
text_nodes: list[Any] = match.get("_text_nodes", [])
|
|
440
|
+
sanitized = _sanitize_field_text(write_value)
|
|
441
|
+
if text_nodes:
|
|
442
|
+
primary = text_nodes[0]
|
|
443
|
+
primary.text = sanitized
|
|
444
|
+
for child in list(primary):
|
|
445
|
+
child.tail = ""
|
|
446
|
+
for node in text_nodes[1:]:
|
|
447
|
+
node.text = ""
|
|
448
|
+
for child in list(node):
|
|
449
|
+
child.tail = ""
|
|
450
|
+
else:
|
|
451
|
+
_insert_form_field_text_run(doc, match, sanitized)
|
|
452
|
+
|
|
453
|
+
if fit_result is not None:
|
|
454
|
+
_apply_form_field_fit_style(doc, match, fit_result)
|
|
455
|
+
|
|
456
|
+
_clear_form_field_layout_cache(paragraph.element)
|
|
457
|
+
paragraph.section.mark_dirty()
|
|
458
|
+
updated = _iter_form_field_matches(doc)[int(match["index"])]
|
|
459
|
+
after_style = _field_run_style_snapshot(
|
|
460
|
+
doc,
|
|
461
|
+
updated["_runs"],
|
|
462
|
+
begin_run_index=int(updated["_begin_run_index"]),
|
|
463
|
+
end_run_index=updated.get("_end_run_index"),
|
|
464
|
+
)
|
|
465
|
+
field = {key: value for key, value in updated.items() if not key.startswith("_")}
|
|
466
|
+
response = {
|
|
467
|
+
"ok": True if fit_result is None else fit_result.ok,
|
|
468
|
+
"field": field,
|
|
469
|
+
"before_value": before_value,
|
|
470
|
+
"after_value": str(field.get("current_value", "")),
|
|
471
|
+
"style_before": before_style,
|
|
472
|
+
"style_after": after_style,
|
|
473
|
+
"style_preserved": before_style == after_style[: len(before_style)],
|
|
474
|
+
}
|
|
475
|
+
if fit_result is not None:
|
|
476
|
+
response["fit"] = fit_result.to_dict()
|
|
477
|
+
if not fit_result.ok:
|
|
478
|
+
response["suggestedRetry"] = fit_result.suggested_retry()
|
|
479
|
+
return response
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _measure_form_field_fit(
|
|
483
|
+
doc: "HwpxDocument",
|
|
484
|
+
value: str,
|
|
485
|
+
match: Mapping[str, Any],
|
|
486
|
+
fit_policy: "FitPolicy",
|
|
487
|
+
box_width: int | None,
|
|
488
|
+
font_pt: float | None,
|
|
489
|
+
) -> "FitResult":
|
|
490
|
+
"""Run the FormFit engine for a native field (plan §2 C)."""
|
|
491
|
+
|
|
492
|
+
from hwpx.form_fit import DEFAULT_SAFETY, FitEngine, FitResult, SlotMetrics
|
|
493
|
+
|
|
494
|
+
runs = match["_runs"]
|
|
495
|
+
begin_index = int(match["_begin_run_index"])
|
|
496
|
+
begin_ref = None
|
|
497
|
+
if 0 <= begin_index < len(runs):
|
|
498
|
+
begin_ref = runs[begin_index].get("charPrIDRef")
|
|
499
|
+
if begin_ref is None:
|
|
500
|
+
begin_ref = match["_paragraph"].char_pr_id_ref or "0"
|
|
501
|
+
resolved_pt = font_pt if font_pt is not None else _font_pt_for_ref(doc, begin_ref)
|
|
502
|
+
field_id = str(match.get("name") or match.get("field_id") or match.get("index"))
|
|
503
|
+
|
|
504
|
+
if not box_width:
|
|
505
|
+
# No reliable geometry: measure-free, low-confidence, never a hard fail.
|
|
506
|
+
return FitResult(
|
|
507
|
+
ok=True,
|
|
508
|
+
value=value,
|
|
509
|
+
applied_value=value,
|
|
510
|
+
font_pt=resolved_pt,
|
|
511
|
+
confidence="low",
|
|
512
|
+
warnings=[
|
|
513
|
+
"native field has no box_width; fit is unverified — supply "
|
|
514
|
+
"box_width or rely on the render oracle"
|
|
515
|
+
],
|
|
516
|
+
field_id=field_id,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
slot = SlotMetrics(
|
|
520
|
+
available_width=float(box_width) * DEFAULT_SAFETY,
|
|
521
|
+
font_pt=resolved_pt,
|
|
522
|
+
max_lines=fit_policy.effective_max_lines,
|
|
523
|
+
)
|
|
524
|
+
return FitEngine().fit(value, slot, fit_policy, field_id=field_id)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _font_pt_for_ref(doc: "HwpxDocument", char_pr_id_ref: object) -> float:
|
|
528
|
+
style = doc.char_property(cast(Any, char_pr_id_ref))
|
|
529
|
+
if style is not None:
|
|
530
|
+
height = style.attributes.get("height")
|
|
531
|
+
if height:
|
|
532
|
+
try:
|
|
533
|
+
return int(height) / 100.0
|
|
534
|
+
except (TypeError, ValueError): # pragma: no cover - defensive
|
|
535
|
+
pass
|
|
536
|
+
return 10.0
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _apply_form_field_fit_style(
|
|
540
|
+
doc: "HwpxDocument", match: Mapping[str, Any], fit_result: "FitResult"
|
|
541
|
+
) -> None:
|
|
542
|
+
"""Materialise a font shrink on the field's primary run (real change)."""
|
|
543
|
+
|
|
544
|
+
new_pt = fit_result.applied_style_changes.get("font_pt")
|
|
545
|
+
if not new_pt:
|
|
546
|
+
return
|
|
547
|
+
text_nodes: list[Any] = match.get("_text_nodes", [])
|
|
548
|
+
run = None
|
|
549
|
+
if text_nodes and hasattr(text_nodes[0], "getparent"):
|
|
550
|
+
run = text_nodes[0].getparent()
|
|
551
|
+
if run is None:
|
|
552
|
+
return
|
|
553
|
+
base_ref = run.get("charPrIDRef")
|
|
554
|
+
try:
|
|
555
|
+
new_ref = doc.ensure_run_style(size=float(new_pt), base_char_pr_id=base_ref)
|
|
556
|
+
except Exception: # pragma: no cover - defensive: never break the fill
|
|
557
|
+
fit_result.warnings.append("font shrink could not be materialised")
|
|
558
|
+
return
|
|
559
|
+
run.set("charPrIDRef", str(new_ref))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def fill_by_path(
|
|
563
|
+
doc: "HwpxDocument",
|
|
564
|
+
mappings: Mapping[str, str],
|
|
565
|
+
) -> TableFillResult:
|
|
566
|
+
"""Fill table cells using ``label > direction > ...`` navigation paths."""
|
|
567
|
+
|
|
568
|
+
from ..tools.table_navigation import fill_by_path
|
|
569
|
+
|
|
570
|
+
return fill_by_path(doc, mappings)
|