python-hwpx 3.3.1__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.
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Domain owners behind the HwpxDocument facade (S-084)."""
@@ -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)
@@ -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)