python-hwpx 5.0.2__py3-none-any.whl → 5.1.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/fields.py CHANGED
@@ -211,10 +211,16 @@ def _form_field_payload(
211
211
  name = _field_parameter_value(parameters, "fieldName", "fieldname", "field_name", "name", "title")
212
212
  prompt = _first_attr(field_begin, _FORM_FIELD_PROMPT_ATTRS)
213
213
  if not prompt:
214
- prompt = _field_parameter_value(parameters, *_FORM_FIELD_PARAM_NAMES)
214
+ # "Direction" is the real-Hancom 안내문 parameter (P0 gold contract).
215
+ prompt = _field_parameter_value(parameters, "Direction", *_FORM_FIELD_PARAM_NAMES)
215
216
  instruction = _field_parameter_value(parameters, "instruction", "guide", "help", "description", "desc")
216
217
  if not instruction:
217
218
  instruction = prompt
219
+ memo = _field_parameter_value(parameters, "HelpState", "memo")
220
+ dirty = (field_begin.get("dirty") or "").strip()
221
+ # Contract: while dirty != "1" the content between begin/end is the prompt
222
+ # placeholder (screen-only, not printed), not a user value.
223
+ is_placeholder = dirty != "1" and bool(prompt) and current_value == prompt
218
224
  return {
219
225
  "index": index,
220
226
  "field_id": _field_identifier(field_begin),
@@ -223,9 +229,13 @@ def _form_field_payload(
223
229
  "name": name,
224
230
  "prompt": prompt,
225
231
  "instruction": instruction,
232
+ "memo": memo,
233
+ "dirty": dirty,
234
+ "is_placeholder": is_placeholder,
226
235
  "current_value": current_value,
227
236
  "field_type": field_begin.get("type", ""),
228
237
  "control_type": ctrl.get("type", ""),
238
+ "_field_begin": field_begin,
229
239
  "section_index": section_index,
230
240
  "paragraph_index": paragraph_index,
231
241
  "paragraph_index_in_section": paragraph_index_in_section,
@@ -320,6 +330,62 @@ def list_form_fields(doc: "HwpxDocument") -> list[dict[str, Any]]:
320
330
  ]
321
331
 
322
332
 
333
+ _PROMPT_TEXT_COLOR = "#FF0000"
334
+
335
+
336
+ def add_form_field(
337
+ doc: "HwpxDocument",
338
+ name: str,
339
+ *,
340
+ prompt: str = "",
341
+ memo: str = "",
342
+ editable: bool = True,
343
+ paragraph: Any | None = None,
344
+ section: Any | None = None,
345
+ section_index: int | None = None,
346
+ ) -> dict[str, Any]:
347
+ """Create a click-here (누름틀) form field and return its field payload.
348
+
349
+ The emitted XML follows the real-Hancom CLICKHERE contract
350
+ (reverse-engineered from Hancom Office 12.0.0.3288 gold documents). The
351
+ prompt (안내문) is materialized as a screen-only red-italic run, exactly as
352
+ Hancom authors it. The created field is immediately re-read through the
353
+ standard ``list_form_fields`` matcher — creation fails loudly if the
354
+ standard consumer would not recognize it (no special-casing by design).
355
+ """
356
+
357
+ if not str(name).strip():
358
+ raise ValueError("form field name must be a non-empty string")
359
+ if paragraph is None:
360
+ paragraph = doc.add_paragraph(
361
+ "", section=section, section_index=section_index, include_run=False,
362
+ )
363
+
364
+ prompt_char_pr: str | None = None
365
+ if _sanitize_field_text(prompt):
366
+ prompt_char_pr = doc.ensure_run_style(italic=True, color=_PROMPT_TEXT_COLOR)
367
+
368
+ control = paragraph.add_form_field(
369
+ name,
370
+ prompt=prompt,
371
+ memo=memo,
372
+ editable=editable,
373
+ prompt_char_pr_id_ref=prompt_char_pr,
374
+ )
375
+ _clear_form_field_layout_cache(paragraph.element)
376
+
377
+ field_begin = control.element.find(f"{_HP}fieldBegin")
378
+ created_id = field_begin.get("id", "") if field_begin is not None else ""
379
+ for match in _iter_form_field_matches(doc):
380
+ if match.get("id") == created_id:
381
+ return {
382
+ key: value for key, value in match.items() if not key.startswith("_")
383
+ }
384
+ raise RuntimeError(
385
+ "created form field was not recognized by the standard form-field matcher"
386
+ )
387
+
388
+
323
389
  def _select_form_field(
324
390
  doc: "HwpxDocument",
325
391
  matches: Sequence[dict[str, Any]],
@@ -447,9 +513,23 @@ def fill_form_field(
447
513
  node.text = ""
448
514
  for child in list(node):
449
515
  child.tail = ""
516
+ if match.get("is_placeholder"):
517
+ # Contract (P0 gold): Hancom swaps the screen-only prompt style for
518
+ # the surrounding style when a value replaces the placeholder.
519
+ begin_run = runs[int(match["_begin_run_index"])]
520
+ begin_ref = begin_run.get("charPrIDRef")
521
+ primary_run = primary.getparent()
522
+ if begin_ref is not None and primary_run is not None:
523
+ primary_run.set("charPrIDRef", begin_ref)
450
524
  else:
451
525
  _insert_form_field_text_run(doc, match, sanitized)
452
526
 
527
+ # Contract (P0 gold): a field that went through fill machinery carries
528
+ # dirty="1"; while dirty != "1" readers treat the content as the prompt.
529
+ field_begin = match.get("_field_begin")
530
+ if field_begin is not None:
531
+ field_begin.set("dirty", "1")
532
+
453
533
  if fit_result is not None:
454
534
  _apply_form_field_fit_style(doc, match, fit_result)
455
535
 
hwpx/document.py CHANGED
@@ -1388,6 +1388,48 @@ class HwpxDocument:
1388
1388
  section_index=section_index,
1389
1389
  )
1390
1390
 
1391
+ def add_form_field(
1392
+ self,
1393
+ name: str,
1394
+ *,
1395
+ prompt: str = "",
1396
+ memo: str = "",
1397
+ editable: bool = True,
1398
+ paragraph: HwpxOxmlParagraph | None = None,
1399
+ section: HwpxOxmlSection | None = None,
1400
+ section_index: int | None = None,
1401
+ ) -> dict[str, Any]:
1402
+ """Create a click-here (누름틀) form field. **Experimental contract.**
1403
+
1404
+ Emits the real-Hancom CLICKHERE shape (안내문 placeholder run included)
1405
+ so the created field is indistinguishable from a Hancom-authored one:
1406
+ ``list_form_fields``/``fill_form_field`` recognize it with no
1407
+ special-casing, and real Hancom Office enumerates and fills it.
1408
+
1409
+ Args:
1410
+ name: Field name (non-empty).
1411
+ prompt: 안내문 shown while the field is empty. Screen-only —
1412
+ Hancom does not print it.
1413
+ memo: Help text (``HelpState``).
1414
+ paragraph: Target paragraph (e.g. inside a table cell). When
1415
+ omitted a new paragraph is appended to *section*.
1416
+
1417
+ Returns:
1418
+ The created field's payload, same shape as a ``list_form_fields``
1419
+ entry.
1420
+ """
1421
+
1422
+ return _fields.add_form_field(
1423
+ self,
1424
+ name,
1425
+ prompt=prompt,
1426
+ memo=memo,
1427
+ editable=editable,
1428
+ paragraph=paragraph,
1429
+ section=section,
1430
+ section_index=section_index,
1431
+ )
1432
+
1391
1433
 
1392
1434
  def set_page_size(
1393
1435
  self,
hwpx/oxml/paragraph.py CHANGED
@@ -27,7 +27,7 @@ from ._document_primitives import (
27
27
  _sanitize_text,
28
28
  )
29
29
  from .memo import HwpxOxmlNote
30
- from .namespaces import tag_local_name
30
+ from .namespaces import XML_NS, tag_local_name
31
31
  from .objects import (
32
32
  HwpxOxmlInlineObject,
33
33
  HwpxOxmlShape,
@@ -834,6 +834,105 @@ class HwpxOxmlParagraph:
834
834
  self.section.mark_dirty()
835
835
  return HwpxOxmlInlineObject(ctrl1, self)
836
836
 
837
+ def add_form_field(
838
+ self,
839
+ name: str,
840
+ *,
841
+ prompt: str = "",
842
+ memo: str = "",
843
+ editable: bool = True,
844
+ prompt_char_pr_id_ref: str | int | None = None,
845
+ char_pr_id_ref: str | int | None = None,
846
+ ) -> HwpxOxmlInlineObject:
847
+ """Insert a click-here (누름틀) form field at the end of this paragraph.
848
+
849
+ Emits the real-Hancom CLICKHERE shape (reverse-engineered from Hancom
850
+ Office 12.0.0.3288 gold documents):
851
+ a ``fieldBegin`` ctrl run carrying the ``Prop``/``Command``/``Direction``/
852
+ ``HelpState`` parameters, an optional prompt run showing *prompt* (the
853
+ 안내문, screen-only — Hancom does not print it), and a ``fieldEnd`` ctrl
854
+ run. ``Command`` lengths count UTF-16 characters, so values may contain
855
+ spaces. ``id``/``fieldid`` values are semantically free — Hancom reissues
856
+ its own on save.
857
+
858
+ Args:
859
+ name: Field name used by ``list_form_fields``/``fill_form_field``.
860
+ prompt: 안내문 text shown while the field is empty (``Direction``).
861
+ memo: Help text (``HelpState``).
862
+ prompt_char_pr_id_ref: charPr for the prompt run (callers normally
863
+ pass a red-italic style; Hancom uses one).
864
+
865
+ Returns:
866
+ The ``<hp:ctrl>`` element wrapping the ``<hp:fieldBegin>``.
867
+ """
868
+ field_id = _object_id()
869
+ field_instance_id = _object_id()
870
+ direction = _sanitize_text(prompt)
871
+ help_state = _sanitize_text(memo)
872
+
873
+ # Run 1: fieldBegin with the CLICKHERE parameter block
874
+ run1 = self._create_run_for_object(char_pr_id_ref=char_pr_id_ref)
875
+ ctrl1 = _append_child(run1, f"{_HP}ctrl", {})
876
+ field_begin = _append_child(ctrl1, f"{_HP}fieldBegin", {
877
+ "id": field_id,
878
+ "type": "CLICK_HERE",
879
+ "name": _sanitize_text(name),
880
+ "editable": "1" if editable else "0",
881
+ "dirty": "0",
882
+ "zorder": "-1",
883
+ "fieldid": field_instance_id,
884
+ "metaTag": "",
885
+ })
886
+ payload = (
887
+ f"Direction:wstring:{len(direction)}:{direction} "
888
+ f"HelpState:wstring:{len(help_state)}:{help_state} "
889
+ )
890
+ param_count = 2 + (1 if direction else 0) + (1 if help_state else 0)
891
+ parameters = _append_child(
892
+ field_begin, f"{_HP}parameters", {"cnt": str(param_count), "name": ""}
893
+ )
894
+ prop = _append_child(parameters, f"{_HP}integerParam", {"name": "Prop"})
895
+ prop.text = "9"
896
+ command = _append_child(parameters, f"{_HP}stringParam", {
897
+ "name": "Command",
898
+ f"{{{XML_NS}}}space": "preserve",
899
+ })
900
+ command.text = f"Clickhere:set:{len(payload)}:{payload} "
901
+ if direction:
902
+ direction_param = _append_child(
903
+ parameters, f"{_HP}stringParam", {"name": "Direction"}
904
+ )
905
+ direction_param.text = direction
906
+ if help_state:
907
+ help_param = _append_child(
908
+ parameters, f"{_HP}stringParam", {"name": "HelpState"}
909
+ )
910
+ help_param.text = help_state
911
+
912
+ # Run 2: the prompt placeholder (only while a prompt exists)
913
+ if direction:
914
+ run2 = self._create_run_for_object(
915
+ char_pr_id_ref=(
916
+ prompt_char_pr_id_ref
917
+ if prompt_char_pr_id_ref is not None
918
+ else char_pr_id_ref
919
+ ),
920
+ )
921
+ prompt_text = _append_child(run2, f"{_HP}t", {})
922
+ prompt_text.text = direction
923
+
924
+ # Run 3: fieldEnd + trailing empty text node (gold shape)
925
+ run3 = self._create_run_for_object(char_pr_id_ref=char_pr_id_ref)
926
+ ctrl3 = _append_child(run3, f"{_HP}ctrl", {})
927
+ _append_child(ctrl3, f"{_HP}fieldEnd", {
928
+ "beginIDRef": field_id,
929
+ "fieldid": field_instance_id,
930
+ })
931
+ _append_child(run3, f"{_HP}t", {})
932
+
933
+ self.section.mark_dirty()
934
+ return HwpxOxmlInlineObject(ctrl1, self)
935
+
837
936
  @property
838
937
  def bookmarks(self) -> list[str]:
839
938
  """Return the names of all bookmarks in this paragraph."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 5.0.2
3
+ Version: 5.1.1
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  hwpx/__init__.py,sha256=C63jP28R_TnVNTQifvWgOXwKg273JUpLwAa-8Z0B4dQ,15706
2
2
  hwpx/body_patch.py,sha256=XfwrTMThS9vPLA62PDnEleCGuvYv61URxelbD_LHe8w,25730
3
- hwpx/document.py,sha256=sDbRQjIoUGw3VzKPAIuQi9oemcb4PqKHczBDrYZ9wTU,60776
3
+ hwpx/document.py,sha256=fjFEO12_bBnDLHmAwD-CfHNYWbnvK0jHdWWthhtpEl4,62237
4
4
  hwpx/errors.py,sha256=l3QK2Izwkpm25ar7QnNQvyg0yosoLfQt7kW5wn5MCG0,3033
5
5
  hwpx/experimental.py,sha256=H8fBrmGf0BzoDS6dqwBpUChUestUgOpO7XjMEBXsssg,1499
6
6
  hwpx/mutation_report.py,sha256=6hurhDdgGiONeLIeWZJT8lwLtWJZ1VHp1l6G0zy0mQo,19409
@@ -11,7 +11,7 @@ hwpx/table_patch.py,sha256=nl7UT-Qf5-KV25Wdxm4ve9ZxX6FfijibymPXZcJJIjQ,83729
11
11
  hwpx/templates.py,sha256=28bYqeJVeDb1Cq8G9NZG9Mhnu4K2GamAKC4QhxvUZyA,1187
12
12
  hwpx/_document/__init__.py,sha256=REiNqMbuk_TS4NVC27FIo1rUt3Z8nhJrTBGOgNtxBRg,90
13
13
  hwpx/_document/_units.py,sha256=qyC8YtnvV2VlSGltJntDaIMF_sHy_XJZCwwe-g080TQ,386
14
- hwpx/_document/fields.py,sha256=-R3tXMaTvX1DtSNouARSvcBepW1JYUk2caOYTz4sVh0,19683
14
+ hwpx/_document/fields.py,sha256=6VB7FV69cWcHLONj_Bn_DUGH5k9IMn94SRhRCia6wvI,22935
15
15
  hwpx/_document/layout.py,sha256=TicBhdxXW2lQkLyG5bg6PHW-OP-X4oWdP6XAgAXw8mg,23089
16
16
  hwpx/_document/media.py,sha256=kamsFJZ4K9aoKhjyztMHC64MVFR_ZQ50rDVgouZqxFA,11592
17
17
  hwpx/_document/memos.py,sha256=_tscEhNfPe9-iAkcEtJGjwHTN42_lx4stFoPrgJxk3o,7481
@@ -55,7 +55,7 @@ hwpx/oxml/memo.py,sha256=QlmuELEzywnmywgjm94S3vzJB8wPpoFOmI8KVuzoNPw,9484
55
55
  hwpx/oxml/namespaces.py,sha256=c7JfdOdJbzrhyHvjbxoeeeloRE3xPuoB7v3YI8SmKDk,6524
56
56
  hwpx/oxml/numbering.py,sha256=9a0ARGW1DkKsTF7mEEXM4FA5loQmzIBuqE4APA8UFIQ,669
57
57
  hwpx/oxml/objects.py,sha256=8ErDXWvkjLm3FTsx0Z_IovUXbiv2uc-j0B5Y2-OtihA,20864
58
- hwpx/oxml/paragraph.py,sha256=0Gb8FBgzirE32_ALJQIz6TYpBeQiLX3Jh14JOKyS-SA,36779
58
+ hwpx/oxml/paragraph.py,sha256=tjsPttiYAu0d92C3O78pt-7h9gJa1cPRs9qP7iY-6wQ,40865
59
59
  hwpx/oxml/parser.py,sha256=pIfyNdW3WdFkCcE8JFY7hn1fobRbMngzfhVZICvWeVs,2937
60
60
  hwpx/oxml/run.py,sha256=F939J1W6zQjr-JQ1Z8-nKh2s7NQ_0Okov4QJAEyw_5M,15503
61
61
  hwpx/oxml/schema.py,sha256=ElR3_IIhhPPZEqJtNKMNCHA-VFVLdhL454W_4spuvlc,1284
@@ -103,10 +103,10 @@ hwpx/tools/toc_fidelity.py,sha256=rvoKH8QJ65WNVrxS0d-i2AtODvBy-4O8NRudfQ244v4,19
103
103
  hwpx/tools/validator.py,sha256=U856izL9NcJZOiKDYoCpwOSaFNQ2Un8Jn4pzL20A96Q,7100
104
104
  hwpx/tools/_schemas/header.xsd,sha256=mJXuFMuHGT1JnFFaluUpYUglwjMCNlfbFCRVM26eHXE,664
105
105
  hwpx/tools/_schemas/section.xsd,sha256=MgvavVHG05RDfUnVPxVU10H4FQOja5ON04_m9Uk_m7E,522
106
- python_hwpx-5.0.2.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
107
- python_hwpx-5.0.2.dist-info/licenses/NOTICE,sha256=KJgtwIIzrXoA0j3PUhwp78ZtnucocEoB7jiuwoL4i7o,3060
108
- python_hwpx-5.0.2.dist-info/METADATA,sha256=nLrp-MhviX2B4sd8FIUBc8rLKQxTX_itwHnLeyDExSg,13162
109
- python_hwpx-5.0.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
- python_hwpx-5.0.2.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
111
- python_hwpx-5.0.2.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
112
- python_hwpx-5.0.2.dist-info/RECORD,,
106
+ python_hwpx-5.1.1.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
107
+ python_hwpx-5.1.1.dist-info/licenses/NOTICE,sha256=KJgtwIIzrXoA0j3PUhwp78ZtnucocEoB7jiuwoL4i7o,3060
108
+ python_hwpx-5.1.1.dist-info/METADATA,sha256=K_jKvQjMhwByWju_P3C9_TIyGyqJQVTvGfns4Y2viPI,13162
109
+ python_hwpx-5.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
110
+ python_hwpx-5.1.1.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
111
+ python_hwpx-5.1.1.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
112
+ python_hwpx-5.1.1.dist-info/RECORD,,