python-hwpx 6.6.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 (75) hide show
  1. hwpx/__init__.py +11 -0
  2. hwpx/_document/_legacy.py +2 -2
  3. hwpx/_document/fields.py +288 -24
  4. hwpx/_document/layout.py +64 -8
  5. hwpx/_document/media.py +38 -45
  6. hwpx/_document/memos.py +32 -12
  7. hwpx/_document/ns/fields.py +39 -3
  8. hwpx/_document/ns/media.py +16 -2
  9. hwpx/_document/ns/notes.py +2 -2
  10. hwpx/_document/ns/parts.py +31 -0
  11. hwpx/_document/ns/shapes.py +96 -9
  12. hwpx/_document/ns/styles.py +23 -5
  13. hwpx/_document/persistence.py +8 -67
  14. hwpx/_document/shapes.py +96 -7
  15. hwpx/_moved_modules.py +118 -0
  16. hwpx/data/contract_docs/known-traps.md +25 -1
  17. hwpx/data/contract_docs/mutation-semantics.md +7 -4
  18. hwpx/data/contract_docs/support-matrix.md +5 -5
  19. hwpx/document.py +42 -5
  20. hwpx/equation/authoring.py +25 -18
  21. hwpx/equation/eqedit.py +61 -4
  22. hwpx/equation/measure.py +89 -38
  23. hwpx/equation/tokens.py +14 -1
  24. hwpx/errors.py +13 -1
  25. hwpx/experimental.py +8 -0
  26. hwpx/form_fit/apply.py +7 -1
  27. hwpx/form_fit/engine.py +15 -66
  28. hwpx/form_fit/measure.py +818 -126
  29. hwpx/form_fit/policy.py +7 -1
  30. hwpx/layout/lint.py +228 -27
  31. hwpx/layout/pages.py +2245 -0
  32. hwpx/objects/__init__.py +2 -1
  33. hwpx/objects/form_field.py +51 -38
  34. hwpx/opc/relationships.py +72 -1
  35. hwpx/oxml/_document_primitives.py +17 -22
  36. hwpx/oxml/_paragraph_text_edit.py +27 -0
  37. hwpx/oxml/curves.py +231 -0
  38. hwpx/oxml/document_parts.py +6 -3
  39. hwpx/oxml/drop_cap.py +22 -17
  40. hwpx/oxml/dutmal_compose.py +19 -10
  41. hwpx/oxml/field_marks.py +38 -7
  42. hwpx/oxml/header.py +39 -33
  43. hwpx/oxml/header_fonts.py +37 -6
  44. hwpx/oxml/header_part.py +20 -24
  45. hwpx/oxml/master_page_authoring.py +67 -5
  46. hwpx/oxml/memo.py +181 -3
  47. hwpx/oxml/note_authoring.py +1 -1
  48. hwpx/oxml/numbering_kinds.py +35 -1
  49. hwpx/oxml/objects.py +44 -27
  50. hwpx/oxml/paragraph.py +20 -13
  51. hwpx/oxml/paragraph_heading.py +70 -0
  52. hwpx/oxml/section.py +23 -5
  53. hwpx/oxml/section_format.py +25 -6
  54. hwpx/oxml/section_story.py +13 -1
  55. hwpx/oxml/shape_position.py +157 -3
  56. hwpx/oxml/table.py +36 -28
  57. hwpx/oxml/table_sizes.py +97 -11
  58. hwpx/quality/save_pipeline.py +4 -1
  59. hwpx/table_patch.py +32 -8
  60. hwpx/tools/document_merge.py +4 -1
  61. hwpx/tools/exporter.py +136 -17
  62. hwpx/tools/id_integrity.py +22 -35
  63. hwpx/tools/layout_preview.py +5 -2
  64. hwpx/tools/package_validator.py +332 -22
  65. hwpx/tools/repair.py +38 -2
  66. hwpx/tools/template_analyzer.py +9 -6
  67. hwpx/tools/toc_author.py +8 -6
  68. hwpx/tools/validator.py +53 -0
  69. {python_hwpx-6.6.0.dist-info → python_hwpx-6.7.0.dist-info}/METADATA +7 -22
  70. {python_hwpx-6.6.0.dist-info → python_hwpx-6.7.0.dist-info}/RECORD +75 -71
  71. {python_hwpx-6.6.0.dist-info → python_hwpx-6.7.0.dist-info}/WHEEL +0 -0
  72. {python_hwpx-6.6.0.dist-info → python_hwpx-6.7.0.dist-info}/entry_points.txt +0 -0
  73. {python_hwpx-6.6.0.dist-info → python_hwpx-6.7.0.dist-info}/licenses/LICENSE +0 -0
  74. {python_hwpx-6.6.0.dist-info → python_hwpx-6.7.0.dist-info}/licenses/NOTICE +0 -0
  75. {python_hwpx-6.6.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,
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*."""
hwpx/_document/fields.py CHANGED
@@ -8,7 +8,7 @@ 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 CellField, 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
@@ -88,6 +88,41 @@ def _sanitize_field_text(value: str) -> str:
88
88
  return _TEXT_ILLEGAL.sub("", value)
89
89
 
90
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
+
91
126
  def _field_type_tokens(*values: str | None) -> set[str]:
92
127
  tokens: set[str] = set()
93
128
  for value in values:
@@ -125,6 +160,11 @@ def _field_identifier(field_begin: Any) -> str:
125
160
 
126
161
 
127
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")
128
168
  begin_keys = {
129
169
  value
130
170
  for value in (
@@ -160,6 +200,18 @@ def _field_parameters(field_begin: Any) -> list[dict[str, str]]:
160
200
  return parameters
161
201
 
162
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
+
163
215
  def _first_attr(element: Any, names: Sequence[str]) -> str:
164
216
  for name in names:
165
217
  value = (element.get(name) or "").strip()
@@ -178,6 +230,21 @@ def _field_parameter_value(parameters: Sequence[dict[str, str]], *names: str) ->
178
230
  return ""
179
231
 
180
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
+
181
248
  def _clear_form_field_layout_cache(paragraph: Any) -> int:
182
249
  removed = 0
183
250
  for child in list(paragraph):
@@ -208,6 +275,40 @@ def _find_field_end_position(
208
275
  return None
209
276
 
210
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
+
211
312
  def _field_text_nodes(
212
313
  doc: "HwpxDocument",
213
314
  runs: Sequence[Any],
@@ -244,13 +345,14 @@ def _form_field_payload(
244
345
  has_end: bool,
245
346
  ) -> dict[str, Any]:
246
347
  parameters = _field_parameters(field_begin)
247
- name = _first_attr(field_begin, _FORM_FIELD_NAME_ATTRS)
248
- if not name:
249
- name = _field_parameter_value(parameters, "fieldName", "fieldname", "field_name", "name", "title")
348
+ name = _field_name(field_begin, parameters)
250
349
  prompt = _first_attr(field_begin, _FORM_FIELD_PROMPT_ATTRS)
251
350
  if not prompt:
252
351
  # "Direction" is the real-Hancom 안내문 parameter (P0 gold contract).
253
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)
254
356
  instruction = _field_parameter_value(parameters, "instruction", "guide", "help", "description", "desc")
255
357
  if not instruction:
256
358
  instruction = prompt
@@ -325,17 +427,38 @@ def _iter_form_field_matches(doc: "HwpxDocument") -> list[dict[str, Any]]:
325
427
  )
326
428
  end_run_index: int | None = None
327
429
  end_child_index: int | None = None
430
+ span = None
328
431
  if end_position is not None:
329
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)
330
435
  text_nodes = _field_text_nodes(
331
436
  doc,
332
437
  runs,
333
438
  begin_run_index=run_index,
334
439
  begin_child_index=child_index,
335
- end_run_index=end_run_index,
440
+ end_run_index=end_run_index if span is None else len(runs) - 1,
336
441
  end_child_index=end_child_index,
337
442
  )
338
- 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
+ )
339
462
  payload = _form_field_payload(
340
463
  doc,
341
464
  index=len(matches),
@@ -347,7 +470,7 @@ def _iter_form_field_matches(doc: "HwpxDocument") -> list[dict[str, Any]]:
347
470
  ctrl=child,
348
471
  field_begin=field_begin,
349
472
  current_value=current_value,
350
- has_end=end_position is not None,
473
+ has_end=end_position is not None or span is not None,
351
474
  )
352
475
  payload["_paragraph"] = paragraph
353
476
  payload["_runs"] = runs
@@ -356,6 +479,7 @@ def _iter_form_field_matches(doc: "HwpxDocument") -> list[dict[str, Any]]:
356
479
  payload["_end_run_index"] = end_run_index
357
480
  payload["_end_child_index"] = end_child_index
358
481
  payload["_text_nodes"] = text_nodes
482
+ payload["_span"] = span
359
483
  matches.append(payload)
360
484
  paragraph_index += 1
361
485
  return matches
@@ -401,7 +525,13 @@ def _form_field_from_match(doc: "HwpxDocument", match: Mapping[str, Any]) -> For
401
525
 
402
526
 
403
527
  def list_form_fields(doc: "HwpxDocument") -> tuple[FormField, ...]:
404
- """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
+ """
405
535
 
406
536
  return tuple(_form_field_from_match(doc, match) for match in _iter_form_field_matches(doc))
407
537
 
@@ -442,6 +572,80 @@ def fill_cell_fields(doc: "HwpxDocument", value: str, *, name: str, index: int |
442
572
  return tuple(fields)
443
573
 
444
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
+
445
649
  _PROMPT_TEXT_COLOR = "#FF0000"
446
650
 
447
651
 
@@ -686,6 +890,7 @@ def _select_form_field(
686
890
  match
687
891
  for match in matches
688
892
  if wanted_name
893
+ and match.get("name")
689
894
  and wanted_name
690
895
  in {
691
896
  str(match.get("name", "")).strip().casefold(),
@@ -735,10 +940,62 @@ def _insert_form_field_text_run(
735
940
  runs: list[Any] = match["_runs"]
736
941
  begin_run = runs[int(match["_begin_run_index"])]
737
942
  text_node = begin_run.makeelement(f"{_HP}t", {})
738
- text_node.text = _sanitize_field_text(value)
943
+ _write_text_node(text_node, _sanitize_field_text(value))
739
944
  begin_run.insert(int(match["_begin_child_index"]) + 1, text_node)
740
945
 
741
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
+
742
999
  def fill_form_field(
743
1000
  doc: "HwpxDocument",
744
1001
  value: str,
@@ -766,6 +1023,13 @@ def fill_form_field(
766
1023
  field_id=field_id,
767
1024
  name=name,
768
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
+ )
769
1033
  paragraph = match["_paragraph"]
770
1034
  runs = match["_runs"]
771
1035
  before_value = str(match.get("current_value", ""))
@@ -802,23 +1066,23 @@ def fill_form_field(
802
1066
 
803
1067
  text_nodes: list[Any] = match.get("_text_nodes", [])
804
1068
  sanitized = _sanitize_field_text(write_value)
805
- 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:
806
1075
  primary = text_nodes[0]
807
- primary.text = sanitized
808
- for child in list(primary):
809
- child.tail = ""
1076
+ _write_text_node(primary, sanitized)
810
1077
  for node in text_nodes[1:]:
811
- node.text = ""
812
- for child in list(node):
813
- child.tail = ""
814
- if match.get("is_placeholder"):
815
- # Contract (P0 gold): Hancom swaps the screen-only prompt style for
816
- # the surrounding style when a value replaces the placeholder.
817
- begin_run = runs[int(match["_begin_run_index"])]
818
- begin_ref = begin_run.get("charPrIDRef")
819
- primary_run = primary.getparent()
820
- if begin_ref is not None and primary_run is not None:
821
- 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)
822
1086
  else:
823
1087
  _insert_form_field_text_run(doc, match, sanitized)
824
1088
 
hwpx/_document/layout.py CHANGED
@@ -17,8 +17,10 @@ from ..objects.results import (
17
17
  )
18
18
  from ..oxml._document_primitives import NEW_NUM_KINDS
19
19
  from ..oxml.namespaces import HH, HP
20
+ from ..oxml.numbering_kinds import ensure_numbering_levels
20
21
  from ..oxml.objects import HwpxOxmlInlineObject
21
22
  from ..oxml.section_format import _PAGE_LANDSCAPE, _PAGE_PORTRAIT, _page_orientation_value
23
+ from ..oxml.table_sizes import cell_margins_of
22
24
  from ._units import _mm_to_hwp_units, _pt_to_hwp_units
23
25
 
24
26
  if TYPE_CHECKING:
@@ -448,6 +450,46 @@ def set_paragraph_format(
448
450
  )
449
451
 
450
452
 
453
+ #: Heading types Hancom counts: paragraphs that share a numbering definition number on.
454
+ _COUNTED_HEADING_TYPES = {"number": "NUMBER", "numbered": "NUMBER", "numbering": "NUMBER", "outline": "OUTLINE"}
455
+
456
+
457
+ def _previous_numbering_id(doc: "HwpxDocument", heading_type: str, before: int) -> str | None:
458
+ """The numbering definition of the last *heading_type* paragraph before paragraph *before*."""
459
+
460
+ header = doc._root.headers[0]
461
+ para_prs = {para_pr.get("id"): para_pr for para_pr in header.element.iter(f"{_HH}paraPr")}
462
+ for paragraph in reversed(doc.paragraphs[:before]):
463
+ para_pr = para_prs.get(str(paragraph.para_pr_id_ref))
464
+ heading = None if para_pr is None else para_pr.find(f"{_HH}heading")
465
+ if heading is not None and heading.get("type") == heading_type:
466
+ return heading.get("idRef")
467
+ return None
468
+
469
+
470
+ def _list_para_pr_id(
471
+ doc: "HwpxDocument",
472
+ *,
473
+ kind: str,
474
+ level: int,
475
+ level_specs: list[dict[str, str]],
476
+ continue_before: int | None,
477
+ ) -> str:
478
+ """The list paragraph shape of *level*: in the list before paragraph *continue_before*
479
+ when there is one to continue, else in a new numbering definition."""
480
+
481
+ heading_type = _COUNTED_HEADING_TYPES.get(kind.lower())
482
+ if continue_before is not None and heading_type is not None:
483
+ numbering_id = _previous_numbering_id(doc, heading_type, continue_before)
484
+ if numbering_id is not None:
485
+ header = doc._root.headers[0]
486
+ ensure_numbering_levels(header, numbering_id, level)
487
+ return header._ensure_para_property_heading(
488
+ heading_type=heading_type, id_ref=numbering_id, level=level - 1
489
+ )
490
+ return doc._root.ensure_numbering(kind=kind, levels=level_specs)[level - 1]
491
+
492
+
451
493
  def set_list_format(
452
494
  doc: "HwpxDocument",
453
495
  *,
@@ -458,9 +500,18 @@ def set_list_format(
458
500
  bullet_char: str | None = None,
459
501
  number_format: str | None = None,
460
502
  start: int | None = None,
503
+ continue_list: bool = False,
461
504
  ) -> ListFormatResult:
462
505
  """Apply bullet or numbered-list paragraph properties to paragraphs."""
463
506
 
507
+ if continue_list and (number_format or start is not None):
508
+ raise HwpxValueError(
509
+ "continue_list cannot be combined with number_format or start",
510
+ code="style-list-continue-conflict",
511
+ context={"numberFormat": number_format, "start": start},
512
+ suggestion="A continued list numbers on in the format of the list before it: "
513
+ "leave out number_format and start, or continue_list to start a new list.",
514
+ )
464
515
  if level < 1:
465
516
  raise HwpxValueError(
466
517
  "level must be 1 or greater",
@@ -483,8 +534,17 @@ def set_list_format(
483
534
  if start is not None:
484
535
  level_specs[level - 1]["start"] = str(max(1, int(start)))
485
536
 
486
- refs = doc._root.ensure_numbering(kind=kind, levels=level_specs)
487
- list_para_pr_id = refs[level - 1]
537
+ targets = _resolve_paragraph_targets(doc,
538
+ paragraph_index=paragraph_index,
539
+ paragraph_indexes=paragraph_indexes,
540
+ )
541
+ list_para_pr_id = _list_para_pr_id(
542
+ doc,
543
+ kind=kind,
544
+ level=level,
545
+ level_specs=level_specs,
546
+ continue_before=min(index for index, _ in targets) if continue_list else None,
547
+ )
488
548
  header = doc._root.headers[0]
489
549
  list_para_pr = header.element.find(f".//{_HH}paraPr[@id='{list_para_pr_id}']")
490
550
  heading_element = list_para_pr.find(f"{_HH}heading") if list_para_pr is not None else None
@@ -499,10 +559,6 @@ def set_list_format(
499
559
  "idRef": heading_element.get("idRef", "0"),
500
560
  "level": heading_element.get("level", str(level - 1)),
501
561
  }
502
- targets = _resolve_paragraph_targets(doc,
503
- paragraph_index=paragraph_index,
504
- paragraph_indexes=paragraph_indexes,
505
- )
506
562
 
507
563
  formatted: list[int] = []
508
564
  first_para_pr_id: str | None = None
@@ -1125,8 +1181,8 @@ def _table_min_height(doc: "HwpxDocument", table: Any) -> int:
1125
1181
  continue
1126
1182
  run = cell.find(f".//{HP}run")
1127
1183
  line = _char_height(doc, run.get("charPrIDRef") if run is not None else None)
1128
- margin = cell.find(f"{HP}cellMargin")
1129
- padding = _int_attr(margin, "top") + _int_attr(margin, "bottom")
1184
+ margins = cell_margins_of(cell, table)
1185
+ padding = margins.top + margins.bottom if margins is not None else 0
1130
1186
  tallest = max(tallest, _int_attr(cell.find(f"{HP}cellSz"), "height"), line + padding)
1131
1187
  total += tallest
1132
1188
  return total