python-hwpx 6.5.0__py3-none-any.whl → 6.7.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.
Files changed (128) hide show
  1. hwpx/__init__.py +14 -0
  2. hwpx/_document/_legacy.py +5 -3
  3. hwpx/_document/fields.py +337 -25
  4. hwpx/_document/layout.py +351 -68
  5. hwpx/_document/media.py +210 -38
  6. hwpx/_document/memos.py +85 -20
  7. hwpx/_document/ns/fields.py +64 -3
  8. hwpx/_document/ns/media.py +37 -4
  9. hwpx/_document/ns/notes.py +2 -2
  10. hwpx/_document/ns/page.py +27 -4
  11. hwpx/_document/ns/parts.py +102 -2
  12. hwpx/_document/ns/shapes.py +102 -10
  13. hwpx/_document/ns/styles.py +175 -8
  14. hwpx/_document/ns/text.py +157 -20
  15. hwpx/_document/ns/tracking.py +5 -1
  16. hwpx/_document/persistence.py +78 -17
  17. hwpx/_document/shapes.py +177 -8
  18. hwpx/_document/tracked.py +49 -1
  19. hwpx/_moved_modules.py +118 -0
  20. hwpx/body_patch.py +6 -2
  21. hwpx/capabilities.py +1 -1
  22. hwpx/data/contract_docs/known-traps.md +61 -2
  23. hwpx/data/contract_docs/mutation-semantics.md +57 -6
  24. hwpx/data/contract_docs/support-matrix.md +12 -13
  25. hwpx/document.py +107 -10
  26. hwpx/equation/authoring.py +30 -25
  27. hwpx/equation/eqedit.py +61 -4
  28. hwpx/equation/measure.py +422 -0
  29. hwpx/equation/tokens.py +14 -1
  30. hwpx/errors.py +62 -3
  31. hwpx/experimental.py +8 -0
  32. hwpx/form_fit/__init__.py +4 -0
  33. hwpx/form_fit/apply.py +8 -2
  34. hwpx/form_fit/engine.py +25 -77
  35. hwpx/form_fit/measure.py +1044 -94
  36. hwpx/form_fit/policy.py +7 -1
  37. hwpx/hwp5/__init__.py +7 -0
  38. hwpx/hwp5/binary.py +124 -0
  39. hwpx/hwp5/bodytext.py +201 -0
  40. hwpx/hwp5/cfb.py +601 -0
  41. hwpx/hwp5/controls.py +1175 -0
  42. hwpx/hwp5/docinfo.py +881 -0
  43. hwpx/hwp5/docinfo_writer.py +647 -0
  44. hwpx/hwp5/errors.py +75 -0
  45. hwpx/hwp5/fileheader.py +87 -0
  46. hwpx/hwp5/header_xml.py +703 -0
  47. hwpx/hwp5/owpml.py +390 -0
  48. hwpx/hwp5/package.py +331 -0
  49. hwpx/hwp5/reader.py +149 -0
  50. hwpx/hwp5/records.py +214 -0
  51. hwpx/hwp5/section_common.py +149 -0
  52. hwpx/hwp5/section_writer.py +1826 -0
  53. hwpx/hwp5/section_xml.py +1514 -0
  54. hwpx/hwp5/shape_xml.py +709 -0
  55. hwpx/hwp5/shapes.py +842 -0
  56. hwpx/hwp5/summary.py +204 -0
  57. hwpx/hwp5/writer.py +325 -0
  58. hwpx/ingest/hwpx_converter.py +1 -1
  59. hwpx/layout/__init__.py +2 -0
  60. hwpx/layout/lint.py +349 -3
  61. hwpx/layout/pages.py +2245 -0
  62. hwpx/model.py +2 -0
  63. hwpx/objects/__init__.py +13 -1
  64. hwpx/objects/form_field.py +90 -3
  65. hwpx/objects/results.py +131 -0
  66. hwpx/opc/package.py +148 -16
  67. hwpx/opc/relationships.py +88 -4
  68. hwpx/opc/xml_utils.py +44 -15
  69. hwpx/oxml/_document_primitives.py +76 -56
  70. hwpx/oxml/_paragraph_text_edit.py +104 -2
  71. hwpx/oxml/color.py +36 -1
  72. hwpx/oxml/curves.py +231 -0
  73. hwpx/oxml/document_parts.py +131 -160
  74. hwpx/oxml/drop_cap.py +65 -15
  75. hwpx/oxml/dutmal_compose.py +19 -10
  76. hwpx/oxml/field_marks.py +38 -7
  77. hwpx/oxml/header.py +45 -35
  78. hwpx/oxml/header_fonts.py +322 -0
  79. hwpx/oxml/header_part.py +43 -42
  80. hwpx/oxml/hyperlink_form.py +159 -0
  81. hwpx/oxml/master_page_authoring.py +130 -6
  82. hwpx/oxml/memo.py +185 -11
  83. hwpx/oxml/note_authoring.py +3 -4
  84. hwpx/oxml/numbering_kinds.py +82 -2
  85. hwpx/oxml/objects.py +112 -66
  86. hwpx/oxml/paragraph.py +80 -90
  87. hwpx/oxml/paragraph_heading.py +70 -0
  88. hwpx/oxml/run.py +209 -44
  89. hwpx/oxml/section.py +305 -7
  90. hwpx/oxml/section_format.py +218 -13
  91. hwpx/oxml/section_layout.py +87 -0
  92. hwpx/oxml/section_story.py +266 -39
  93. hwpx/oxml/shape_position.py +282 -0
  94. hwpx/oxml/table.py +222 -87
  95. hwpx/oxml/table_merge.py +82 -0
  96. hwpx/oxml/table_sizes.py +420 -0
  97. hwpx/oxml/utils.py +121 -1
  98. hwpx/patch.py +16 -3
  99. hwpx/quality/save_pipeline.py +4 -1
  100. hwpx/table_patch.py +265 -81
  101. hwpx/tools/_schemas/owpml-body.xsd +18 -0
  102. hwpx/tools/_schemas/owpml-core.xsd +905 -0
  103. hwpx/tools/_schemas/owpml-header.xsd +1869 -0
  104. hwpx/tools/_schemas/owpml-paralist.xsd +2813 -0
  105. hwpx/tools/_schemas/owpml-xml.xsd +15 -0
  106. hwpx/tools/document_merge.py +96 -16
  107. hwpx/tools/exporter.py +622 -59
  108. hwpx/tools/id_integrity.py +22 -35
  109. hwpx/tools/layout_preview.py +49 -4
  110. hwpx/tools/mail_merge.py +19 -13
  111. hwpx/tools/markdown_export.py +37 -16
  112. hwpx/tools/package_validator.py +349 -26
  113. hwpx/tools/read_fidelity.py +4 -4
  114. hwpx/tools/redline.py +4 -4
  115. hwpx/tools/repair.py +38 -2
  116. hwpx/tools/table_navigation.py +3 -2
  117. hwpx/tools/template_analyzer.py +14 -7
  118. hwpx/tools/text_extractor.py +54 -24
  119. hwpx/tools/toc_author.py +118 -15
  120. hwpx/tools/validator.py +280 -1
  121. {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/METADATA +12 -25
  122. python_hwpx-6.7.0.dist-info/RECORD +199 -0
  123. python_hwpx-6.5.0.dist-info/RECORD +0 -163
  124. {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/WHEEL +0 -0
  125. {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/entry_points.txt +0 -0
  126. {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/licenses/LICENSE +0 -0
  127. {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/licenses/NOTICE +0 -0
  128. {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/top_level.txt +0 -0
hwpx/__init__.py CHANGED
@@ -45,6 +45,10 @@ _EXPERIMENTAL_EXPORTS = {
45
45
  "LayoutPreview": "hwpx.tools.layout_preview",
46
46
  "PreviewPage": "hwpx.tools.layout_preview",
47
47
  "render_layout_preview": "hwpx.tools.layout_preview",
48
+ # 쪽 수 추정(한/글 없이, 줄마다 쪽·단·세로 위치). 계약 유동.
49
+ "EstimatedLine": "hwpx.layout.pages",
50
+ "PageEstimate": "hwpx.layout.pages",
51
+ "estimate_pages": "hwpx.layout.pages",
48
52
  # 문서 프리뷰 뷰어(3.8.0 신규). 계약 유동.
49
53
  "DocumentViewer": "hwpx.tools.document_viewer",
50
54
  "render_document_viewer": "hwpx.tools.document_viewer",
@@ -347,6 +351,13 @@ def __dir__() -> list[str]:
347
351
  )
348
352
 
349
353
 
354
+ # ``import hwpx.builder`` goes through the import system, not ``__getattr__``,
355
+ # so the moved module names get their destination from a finder instead.
356
+ from . import _moved_modules
357
+
358
+ _moved_modules.install(_MOVED_TO_COMPANION)
359
+
360
+
350
361
  # --- stable 최상위 표면 (eager import) ------------------------------------------
351
362
  from .tools.text_extractor import (
352
363
  DEFAULT_NAMESPACES,
@@ -376,6 +387,7 @@ from .tools.package_validator import (
376
387
  )
377
388
  from .ingest import HwpxMarkdownConverter
378
389
  from .errors import HwpxError
390
+ from .hwp5.errors import Hwp5ConversionWarning, Hwp5Error
379
391
  from .mutation_report import (
380
392
  MutationReport,
381
393
  PreservationDowngradeError,
@@ -415,6 +427,8 @@ __all__ = [
415
427
  "PackageValidationReport",
416
428
  "BytePreservingPatchResult",
417
429
  "HwpxError",
430
+ "Hwp5Error",
431
+ "Hwp5ConversionWarning",
418
432
  "MutationReport",
419
433
  "PreservationDowngradeError",
420
434
  "ParagraphTextPatch",
hwpx/_document/_legacy.py CHANGED
@@ -615,7 +615,7 @@ class _LegacyFacade:
615
615
  field_id: str | None = None,
616
616
  author: str | None = None,
617
617
  created: datetime | str | None = None,
618
- number: int = 1,
618
+ number: int | None = None,
619
619
  anchor_char_pr_id_ref: str | int | None = None,
620
620
  ) -> tuple[HwpxOxmlMemo, HwpxOxmlParagraph, str]:
621
621
  """Create a memo and ensure it is visible by anchoring a MEMO field."""
@@ -801,7 +801,7 @@ class _LegacyFacade:
801
801
  field_id: str | None = None,
802
802
  author: str | None = None,
803
803
  created: datetime | str | None = None,
804
- number: int = 1,
804
+ number: int | None = None,
805
805
  char_pr_id_ref: str | int | None = None,
806
806
  ) -> str:
807
807
  """Attach a MEMO field control to *paragraph* so Hangul shows *memo*."""
@@ -1241,7 +1241,9 @@ class _LegacyFacade:
1241
1241
  """Remove an embedded image by its manifest item id.
1242
1242
 
1243
1243
  This removes the binary data from the ZIP, the manifest entry, and
1244
- the header binItem entry.
1244
+ the header binItem entry. An item the document still points at is
1245
+ refused with ``HwpxValueError`` (code ``media-item-in-use``);
1246
+ ``doc.media.remove_image(item_id, force=True)`` removes it anyway.
1245
1247
 
1246
1248
  Returns:
1247
1249
  ``True`` if any component was removed.
hwpx/_document/fields.py CHANGED
@@ -8,10 +8,11 @@ from typing import TYPE_CHECKING, Any, Iterator, Mapping, Sequence, cast
8
8
 
9
9
  from ..errors import HwpxStateError, HwpxValueError
10
10
  from ..objects.checkbox import CheckBox
11
- from ..objects.form_field import FieldLocation, FieldParameter, FormField
11
+ from ..objects.form_field import CellField, FieldLocation, FieldParameter, FormField, TextBoxField
12
12
  from ..objects.results import FieldFillResult
13
13
  from ..oxml import HwpxOxmlParagraph
14
14
  from ..oxml.namespaces import HP
15
+ from ..oxml.section_story import control_twins
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from hwpx.document import HwpxDocument
@@ -87,6 +88,41 @@ def _sanitize_field_text(value: str) -> str:
87
88
  return _TEXT_ILLEGAL.sub("", value)
88
89
 
89
90
 
91
+ # Characters an hp:t holds as child elements; they belong to the text they sit in.
92
+ _TEXT_CHARACTER_NAMES = {"tab": "\t", "lineBreak": "\n", "hyphen": "", "nbSpace": "\u00a0", "fwSpace": "\u3000"}
93
+
94
+
95
+ def _text_node_value(node: Any) -> str:
96
+ """The text of an ``hp:t``, with ``hp:lineBreak`` as a newline and ``hp:tab`` as a tab."""
97
+
98
+ parts = [node.text or ""]
99
+ for child in node:
100
+ parts.append(_TEXT_CHARACTER_NAMES.get(_local_name(child), ""))
101
+ parts.append(child.tail or "")
102
+ return "".join(parts)
103
+
104
+
105
+ def _write_text_node(node: Any, value: str) -> None:
106
+ """Put *value* in the ``hp:t`` *node* in place of its text.
107
+
108
+ A line break (CRLF, LF or CR) becomes ``hp:lineBreak``, the element Hancom
109
+ turns such a character into when it opens the file. The characters of the
110
+ old text (tabs, line breaks) go; other marks stay, emptied.
111
+ """
112
+
113
+ for child in list(node):
114
+ if _local_name(child) in _TEXT_CHARACTER_NAMES:
115
+ node.remove(child)
116
+ else:
117
+ child.tail = ""
118
+ lines = value.replace("\r\n", "\n").replace("\r", "\n").split("\n")
119
+ node.text = lines[0]
120
+ for line in lines[1:]:
121
+ line_break = node.makeelement(f"{_HP}lineBreak", {})
122
+ node.append(line_break)
123
+ line_break.tail = line
124
+
125
+
90
126
  def _field_type_tokens(*values: str | None) -> set[str]:
91
127
  tokens: set[str] = set()
92
128
  for value in values:
@@ -124,6 +160,11 @@ def _field_identifier(field_begin: Any) -> str:
124
160
 
125
161
 
126
162
  def _field_end_matches(field_begin: Any, field_end: Any) -> bool:
163
+ # An end that names its begin (beginIDRef) is that begin's end only: a fieldid
164
+ # is shared by every field of a type (Hancom's control id), so it pairs only
165
+ # ends that name no begin.
166
+ if field_begin.get("id") and field_end.get("beginIDRef"):
167
+ return field_begin.get("id") == field_end.get("beginIDRef")
127
168
  begin_keys = {
128
169
  value
129
170
  for value in (
@@ -159,6 +200,18 @@ def _field_parameters(field_begin: Any) -> list[dict[str, str]]:
159
200
  return parameters
160
201
 
161
202
 
203
+ def _field_name(field_begin: Any, parameters: Sequence[dict[str, str]]) -> str:
204
+ """The field's name. A ``name`` attribute is the name even when empty: Hancom
205
+ treats such a field as unnamed (it lists and fills fields by name only)."""
206
+
207
+ if field_begin.get("name") is not None:
208
+ return field_begin.get("name", "").strip()
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
+ return name
213
+
214
+
162
215
  def _first_attr(element: Any, names: Sequence[str]) -> str:
163
216
  for name in names:
164
217
  value = (element.get(name) or "").strip()
@@ -177,6 +230,21 @@ def _field_parameter_value(parameters: Sequence[dict[str, str]], *names: str) ->
177
230
  return ""
178
231
 
179
232
 
233
+ _COMMAND_DIRECTION = re.compile(r"(?:^|[\s:])Direction:wstring:(\d+):")
234
+
235
+
236
+ def _command_direction(parameters: Sequence[dict[str, str]]) -> str:
237
+ """The prompt a field keeps only in its ``Command`` string
238
+ (``Clickhere:set:N:Direction:wstring:<length>:<prompt> HelpState:...``)."""
239
+
240
+ command = _field_parameter_value(parameters, "Command")
241
+ found = _COMMAND_DIRECTION.search(command)
242
+ if found is None:
243
+ return ""
244
+ start = found.end()
245
+ return command[start : start + int(found.group(1))]
246
+
247
+
180
248
  def _clear_form_field_layout_cache(paragraph: Any) -> int:
181
249
  removed = 0
182
250
  for child in list(paragraph):
@@ -207,6 +275,40 @@ def _find_field_end_position(
207
275
  return None
208
276
 
209
277
 
278
+ def _find_field_end_in_following_paragraphs(
279
+ paragraph_element: Any, field_begin: Any
280
+ ) -> tuple[Any, list[Any], int, int, list[Any]] | None:
281
+ """The end of a field whose content runs past its paragraph: the later
282
+ paragraph beside it that holds the fieldEnd naming this field's id, that
283
+ paragraph's runs, the end's run and child index, and the paragraphs in
284
+ between. Only an end that names the begin's id counts here."""
285
+
286
+ ids = {value for value in (field_begin.get("id"), field_begin.get("fieldid")) if value}
287
+ if not ids:
288
+ return None
289
+ middle: list[Any] = []
290
+ sibling = paragraph_element.getnext()
291
+ while sibling is not None:
292
+ if _local_name(sibling) == "p":
293
+ runs = [child for child in sibling if _local_name(child) == "run"]
294
+ for run_index, run in enumerate(runs):
295
+ for child_index, child in enumerate(run):
296
+ if _local_name(child) != "ctrl":
297
+ continue
298
+ for field_end in child.findall(f"{_HP}fieldEnd"):
299
+ if _field_end_matches(field_begin, field_end) and (
300
+ ids & {field_end.get("beginIDRef"), field_end.get("fieldid")}
301
+ ):
302
+ return sibling, runs, run_index, child_index, middle
303
+ middle.append(sibling)
304
+ sibling = sibling.getnext()
305
+ return None
306
+
307
+
308
+ def _run_texts(runs: Sequence[Any]) -> str:
309
+ return "".join(_text_node_value(node) for run in runs for node in run if _local_name(node) == "t")
310
+
311
+
210
312
  def _field_text_nodes(
211
313
  doc: "HwpxDocument",
212
314
  runs: Sequence[Any],
@@ -243,13 +345,14 @@ def _form_field_payload(
243
345
  has_end: bool,
244
346
  ) -> dict[str, Any]:
245
347
  parameters = _field_parameters(field_begin)
246
- name = _first_attr(field_begin, _FORM_FIELD_NAME_ATTRS)
247
- if not name:
248
- name = _field_parameter_value(parameters, "fieldName", "fieldname", "field_name", "name", "title")
348
+ name = _field_name(field_begin, parameters)
249
349
  prompt = _first_attr(field_begin, _FORM_FIELD_PROMPT_ATTRS)
250
350
  if not prompt:
251
351
  # "Direction" is the real-Hancom 안내문 parameter (P0 gold contract).
252
352
  prompt = _field_parameter_value(parameters, "Direction", *_FORM_FIELD_PARAM_NAMES)
353
+ if not prompt:
354
+ # Some fields keep it only inside their Command string.
355
+ prompt = _command_direction(parameters)
253
356
  instruction = _field_parameter_value(parameters, "instruction", "guide", "help", "description", "desc")
254
357
  if not instruction:
255
358
  instruction = prompt
@@ -291,11 +394,13 @@ def _iter_form_field_matches(doc: "HwpxDocument") -> list[dict[str, Any]]:
291
394
  paragraph.element: index
292
395
  for index, paragraph in enumerate(section.paragraphs)
293
396
  }
397
+ # a header/footer python-hwpx keeps twice counts once: its hp:secPr copy
398
+ twins = control_twins(section.element)
294
399
 
295
400
  def iter_content_paragraphs(element: Any) -> Iterator[Any]:
296
401
  for child in element:
297
402
  local = _local_name(child)
298
- if local == "memogroup":
403
+ if local == "memogroup" or child in twins:
299
404
  continue
300
405
  if local == "p":
301
406
  yield child
@@ -322,17 +427,38 @@ def _iter_form_field_matches(doc: "HwpxDocument") -> list[dict[str, Any]]:
322
427
  )
323
428
  end_run_index: int | None = None
324
429
  end_child_index: int | None = None
430
+ span = None
325
431
  if end_position is not None:
326
432
  end_run_index, end_child_index, _field_end = end_position
433
+ else:
434
+ span = _find_field_end_in_following_paragraphs(paragraph_element, field_begin)
327
435
  text_nodes = _field_text_nodes(
328
436
  doc,
329
437
  runs,
330
438
  begin_run_index=run_index,
331
439
  begin_child_index=child_index,
332
- end_run_index=end_run_index,
440
+ end_run_index=end_run_index if span is None else len(runs) - 1,
333
441
  end_child_index=end_child_index,
334
442
  )
335
- current_value = "".join("".join(node.itertext()) for node in text_nodes)
443
+ current_value = "".join(_text_node_value(node) for node in text_nodes)
444
+ if span is not None:
445
+ # the content goes on over paragraphs: their texts, one per line
446
+ end_paragraph, end_runs, span_run, span_child, middle = span
447
+ tail = _field_text_nodes(
448
+ doc,
449
+ end_runs,
450
+ begin_run_index=0,
451
+ begin_child_index=-1,
452
+ end_run_index=span_run,
453
+ end_child_index=span_child,
454
+ )
455
+ current_value = "\n".join(
456
+ [
457
+ current_value,
458
+ *(_run_texts([c for c in p if _local_name(c) == "run"]) for p in middle),
459
+ "".join(_text_node_value(node) for node in tail),
460
+ ]
461
+ )
336
462
  payload = _form_field_payload(
337
463
  doc,
338
464
  index=len(matches),
@@ -344,7 +470,7 @@ def _iter_form_field_matches(doc: "HwpxDocument") -> list[dict[str, Any]]:
344
470
  ctrl=child,
345
471
  field_begin=field_begin,
346
472
  current_value=current_value,
347
- has_end=end_position is not None,
473
+ has_end=end_position is not None or span is not None,
348
474
  )
349
475
  payload["_paragraph"] = paragraph
350
476
  payload["_runs"] = runs
@@ -353,6 +479,7 @@ def _iter_form_field_matches(doc: "HwpxDocument") -> list[dict[str, Any]]:
353
479
  payload["_end_run_index"] = end_run_index
354
480
  payload["_end_child_index"] = end_child_index
355
481
  payload["_text_nodes"] = text_nodes
482
+ payload["_span"] = span
356
483
  matches.append(payload)
357
484
  paragraph_index += 1
358
485
  return matches
@@ -398,11 +525,127 @@ def _form_field_from_match(doc: "HwpxDocument", match: Mapping[str, Any]) -> For
398
525
 
399
526
 
400
527
  def list_form_fields(doc: "HwpxDocument") -> tuple[FormField, ...]:
401
- """Return native form/click-here fields in document order."""
528
+ """Return native form/click-here fields in document order.
529
+
530
+ Every field in the document is listed. An unnamed field (an empty ``name``) is
531
+ listed with an empty name and told apart by its id: :func:`fill_form_field`
532
+ does not find it by name, as Hancom does not, but ``field_index`` and
533
+ ``field_id`` reach it.
534
+ """
402
535
 
403
536
  return tuple(_form_field_from_match(doc, match) for match in _iter_form_field_matches(doc))
404
537
 
405
538
 
539
+ def _named_cells(paragraphs: Any) -> Iterator[Any]:
540
+ for paragraph in paragraphs:
541
+ for table in paragraph.tables:
542
+ for row in table.rows:
543
+ for cell in row.cells:
544
+ if cell.field_name:
545
+ yield cell
546
+ yield from _named_cells(cell.paragraphs)
547
+
548
+
549
+ def list_cell_fields(doc: "HwpxDocument") -> tuple[CellField, ...]:
550
+ """Named table cells (Hancom's cell fields) in document order: body tables and the tables in their cells."""
551
+
552
+ return tuple(CellField(cell) for section in doc.sections for cell in _named_cells(section.paragraphs))
553
+
554
+
555
+ def fill_cell_fields(doc: "HwpxDocument", value: str, *, name: str, index: int | None = None) -> tuple[CellField, ...]:
556
+ """Set the text of every cell field called *name*, or of the *index*-th of them only."""
557
+
558
+ wanted = (name or "").strip()
559
+ fields = [field for field in list_cell_fields(doc) if wanted and field.name == wanted]
560
+ if index is not None:
561
+ fields = fields[index : index + 1] if index >= 0 else []
562
+ if not fields:
563
+ where = f"{wanted!r}" if index is None else f"{wanted!r} at index {index}"
564
+ raise HwpxValueError(
565
+ f"no cell field named {where}",
566
+ code="field-cell-not-found",
567
+ context={"name": wanted, "index": index},
568
+ suggestion="List doc.fields.cells to see the cell field names.",
569
+ )
570
+ for field in fields:
571
+ field.text = value
572
+ return tuple(fields)
573
+
574
+
575
+ def list_text_box_fields(doc: "HwpxDocument") -> tuple[TextBoxField, ...]:
576
+ """Named text boxes in document order: Hancom lists a text box whose ``hp:drawText``
577
+ has a name among its fields (and leaves an unnamed one out)."""
578
+
579
+ return tuple(
580
+ TextBoxField(draw_text, section)
581
+ for section in doc.sections
582
+ for draw_text in section.element.iter(f"{_HP}drawText")
583
+ if (draw_text.get("name") or "").strip()
584
+ )
585
+
586
+
587
+ def fill_text_box_fields(
588
+ doc: "HwpxDocument", value: str, *, name: str, index: int | None = None
589
+ ) -> tuple[TextBoxField, ...]:
590
+ """Set the text of every text box called *name*, or of the *index*-th of them only."""
591
+
592
+ wanted = (name or "").strip()
593
+ fields = [field for field in list_text_box_fields(doc) if wanted and field.name == wanted]
594
+ if index is not None:
595
+ fields = fields[index : index + 1] if index >= 0 else []
596
+ if not fields:
597
+ where = f"{wanted!r}" if index is None else f"{wanted!r} at index {index}"
598
+ raise HwpxValueError(
599
+ f"no text box named {where}",
600
+ code="field-text-box-not-found",
601
+ context={"name": wanted, "index": index},
602
+ suggestion="List doc.fields.text_boxes to see the text box names.",
603
+ )
604
+ for field in fields:
605
+ field.text = value
606
+ return tuple(fields)
607
+
608
+
609
+ def text_box_text(draw_text: Any) -> str:
610
+ """The text of a text box, a line per paragraph."""
611
+
612
+ return "\n".join(
613
+ "".join(_text_node_value(node) for node in paragraph.iter(f"{_HP}t"))
614
+ for paragraph in draw_text.findall(f"{_HP}subList/{_HP}p")
615
+ )
616
+
617
+
618
+ def set_text_box_text(draw_text: Any, section: Any, value: str) -> None:
619
+ """Put *value* in a text box as Hancom fills a text box field: the box keeps its
620
+ first paragraph and that paragraph's first run, which hold the value alone."""
621
+
622
+ sub_list = draw_text.find(f"{_HP}subList")
623
+ if sub_list is None:
624
+ sub_list = draw_text.makeelement(f"{_HP}subList", {})
625
+ draw_text.insert(0, sub_list)
626
+ paragraphs = sub_list.findall(f"{_HP}p")
627
+ first = paragraphs[0] if paragraphs else sub_list.makeelement(
628
+ f"{_HP}p", {"id": "0", "paraPrIDRef": "0", "styleIDRef": "0", "pageBreak": "0", "columnBreak": "0", "merged": "0"}
629
+ )
630
+ if not paragraphs:
631
+ sub_list.append(first)
632
+ for extra in paragraphs[1:]:
633
+ sub_list.remove(extra)
634
+ runs = first.findall(f"{_HP}run")
635
+ run = runs[0] if runs else first.makeelement(f"{_HP}run", {"charPrIDRef": "0"})
636
+ if not runs:
637
+ first.insert(0, run)
638
+ for extra in runs[1:]:
639
+ first.remove(extra)
640
+ for child in list(run):
641
+ run.remove(child)
642
+ text = run.makeelement(f"{_HP}t", {})
643
+ run.append(text)
644
+ _write_text_node(text, value)
645
+ _clear_form_field_layout_cache(first)
646
+ section.mark_dirty()
647
+
648
+
406
649
  _PROMPT_TEXT_COLOR = "#FF0000"
407
650
 
408
651
 
@@ -647,6 +890,7 @@ def _select_form_field(
647
890
  match
648
891
  for match in matches
649
892
  if wanted_name
893
+ and match.get("name")
650
894
  and wanted_name
651
895
  in {
652
896
  str(match.get("name", "")).strip().casefold(),
@@ -696,10 +940,62 @@ def _insert_form_field_text_run(
696
940
  runs: list[Any] = match["_runs"]
697
941
  begin_run = runs[int(match["_begin_run_index"])]
698
942
  text_node = begin_run.makeelement(f"{_HP}t", {})
699
- text_node.text = _sanitize_field_text(value)
943
+ _write_text_node(text_node, _sanitize_field_text(value))
700
944
  begin_run.insert(int(match["_begin_child_index"]) + 1, text_node)
701
945
 
702
946
 
947
+ def _drop_nested_fields(match: Mapping[str, Any]) -> None:
948
+ """Hancom's way with a field that holds other fields: the value replaces
949
+ them with the rest of the content, so the begin and end of every field
950
+ lying wholly between this field's begin and end go."""
951
+
952
+ runs: list[Any] = match["_runs"]
953
+ end_run_index = match.get("_end_run_index")
954
+ if end_run_index is None:
955
+ return
956
+ begin_run_index = int(match["_begin_run_index"])
957
+ inside: list[tuple[Any, Any]] = []
958
+ for run_index in range(begin_run_index, int(end_run_index) + 1):
959
+ children = list(runs[run_index])
960
+ start = int(match["_begin_child_index"]) + 1 if run_index == begin_run_index else 0
961
+ stop = int(match["_end_child_index"]) if run_index == end_run_index else len(children)
962
+ inside.extend((runs[run_index], child) for child in children[start:stop] if _local_name(child) == "ctrl")
963
+ begun = {mark.get("id") for _, ctrl in inside for mark in ctrl.findall(f"{_HP}fieldBegin")}
964
+ ended = {mark.get("beginIDRef") for _, ctrl in inside for mark in ctrl.findall(f"{_HP}fieldEnd")}
965
+ nested = (begun & ended) - {None, ""}
966
+ for run, ctrl in inside:
967
+ marks = [(mark.get("id") if _local_name(mark) == "fieldBegin" else mark.get("beginIDRef"))
968
+ for mark in ctrl if _local_name(mark) in ("fieldBegin", "fieldEnd")]
969
+ if marks and all(mark in nested for mark in marks):
970
+ run.remove(ctrl)
971
+
972
+
973
+ def _collapse_field_span(match: Mapping[str, Any]) -> None:
974
+ """Hancom's way with a field whose content runs over paragraphs: the
975
+ content between the begin and the end goes, paragraphs and all, and the
976
+ end with the rest of its paragraph joins the begin's paragraph."""
977
+
978
+ end_paragraph, end_runs, end_run_index, end_child_index, middle = match["_span"]
979
+ paragraph_element = match["_paragraph"].element
980
+ runs: list[Any] = match["_runs"]
981
+ begin_run_index = int(match["_begin_run_index"])
982
+ begin_run = runs[begin_run_index]
983
+ for child in list(begin_run)[int(match["_begin_child_index"]) + 1 :]:
984
+ begin_run.remove(child)
985
+ for run in runs[begin_run_index + 1 :]:
986
+ paragraph_element.remove(run)
987
+ for element in middle:
988
+ element.getparent().remove(element)
989
+ end_run = end_runs[end_run_index]
990
+ moved = end_run.makeelement(end_run.tag, dict(end_run.attrib))
991
+ for child in list(end_run)[end_child_index:]:
992
+ moved.append(child)
993
+ position = paragraph_element.index(begin_run) + 1
994
+ for offset, run in enumerate([moved, *end_runs[end_run_index + 1 :]]):
995
+ paragraph_element.insert(position + offset, run)
996
+ end_paragraph.getparent().remove(end_paragraph)
997
+
998
+
703
999
  def fill_form_field(
704
1000
  doc: "HwpxDocument",
705
1001
  value: str,
@@ -727,6 +1023,13 @@ def fill_form_field(
727
1023
  field_id=field_id,
728
1024
  name=name,
729
1025
  )
1026
+ if not match.get("has_end"):
1027
+ raise HwpxValueError(
1028
+ f"the field {match.get('name') or match.get('index')!r} has no end, so it has no content to replace",
1029
+ code="field-end-missing",
1030
+ context={"name": match.get("name"), "index": match.get("index")},
1031
+ suggestion="Hancom leaves such a field unchanged too; give it an end or remove it.",
1032
+ )
730
1033
  paragraph = match["_paragraph"]
731
1034
  runs = match["_runs"]
732
1035
  before_value = str(match.get("current_value", ""))
@@ -763,23 +1066,23 @@ def fill_form_field(
763
1066
 
764
1067
  text_nodes: list[Any] = match.get("_text_nodes", [])
765
1068
  sanitized = _sanitize_field_text(write_value)
766
- if text_nodes:
1069
+ if match.get("_span") is None:
1070
+ _drop_nested_fields(match)
1071
+ if match.get("_span") is not None:
1072
+ _collapse_field_span(match)
1073
+ _insert_form_field_text_run(doc, match, sanitized)
1074
+ elif text_nodes:
767
1075
  primary = text_nodes[0]
768
- primary.text = sanitized
769
- for child in list(primary):
770
- child.tail = ""
1076
+ _write_text_node(primary, sanitized)
771
1077
  for node in text_nodes[1:]:
772
- node.text = ""
773
- for child in list(node):
774
- child.tail = ""
775
- if match.get("is_placeholder"):
776
- # Contract (P0 gold): Hancom swaps the screen-only prompt style for
777
- # the surrounding style when a value replaces the placeholder.
778
- begin_run = runs[int(match["_begin_run_index"])]
779
- begin_ref = begin_run.get("charPrIDRef")
780
- primary_run = primary.getparent()
781
- if begin_ref is not None and primary_run is not None:
782
- primary_run.set("charPrIDRef", begin_ref)
1078
+ _write_text_node(node, "")
1079
+ # Hancom writes the value in the shape of the run holding the field's
1080
+ # begin, whatever shape the old value or the screen-only prompt had.
1081
+ begin_run = runs[int(match["_begin_run_index"])]
1082
+ begin_ref = begin_run.get("charPrIDRef")
1083
+ primary_run = primary.getparent()
1084
+ if begin_ref is not None and primary_run is not None:
1085
+ primary_run.set("charPrIDRef", begin_ref)
783
1086
  else:
784
1087
  _insert_form_field_text_run(doc, match, sanitized)
785
1088
 
@@ -823,7 +1126,10 @@ def _measure_form_field_fit(
823
1126
  ) -> "FitResult":
824
1127
  """Run the FormFit engine for a native field (plan §2 C)."""
825
1128
 
1129
+ from dataclasses import replace
1130
+
826
1131
  from hwpx.form_fit import DEFAULT_SAFETY, FitEngine, FitResult, SlotMetrics
1132
+ from hwpx.form_fit.measure import text_style_from_refs
827
1133
 
828
1134
  runs = match["_runs"]
829
1135
  begin_index = int(match["_begin_run_index"])
@@ -850,10 +1156,16 @@ def _measure_form_field_fit(
850
1156
  field_id=field_id,
851
1157
  )
852
1158
 
1159
+ # The field sits inside its paragraph, so the paragraph's indent does not
1160
+ # apply to the box.
1161
+ text_style = text_style_from_refs(
1162
+ doc._root, match["_paragraph"].para_pr_id_ref, [begin_ref]
1163
+ )
853
1164
  slot = SlotMetrics(
854
1165
  available_width=float(box_width) * DEFAULT_SAFETY,
855
1166
  font_pt=resolved_pt,
856
1167
  max_lines=fit_policy.effective_max_lines,
1168
+ text_style=replace(text_style, indent=0),
857
1169
  )
858
1170
  return FitEngine().fit(value, slot, fit_policy, field_id=field_id)
859
1171