python-hwpx 6.6.0__py3-none-any.whl → 6.8.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.
- hwpx/__init__.py +95 -34
- hwpx/_document/_legacy.py +2 -2
- hwpx/_document/fields.py +288 -24
- hwpx/_document/layout.py +64 -8
- hwpx/_document/media.py +38 -45
- hwpx/_document/memos.py +32 -12
- hwpx/_document/ns/fields.py +39 -3
- hwpx/_document/ns/media.py +16 -2
- hwpx/_document/ns/notes.py +2 -2
- hwpx/_document/ns/parts.py +31 -0
- hwpx/_document/ns/shapes.py +96 -9
- hwpx/_document/ns/styles.py +23 -5
- hwpx/_document/persistence.py +22 -69
- hwpx/_document/shapes.py +96 -7
- hwpx/_moved_modules.py +118 -0
- hwpx/data/contract_docs/known-traps.md +25 -1
- hwpx/data/contract_docs/mutation-semantics.md +7 -4
- hwpx/data/contract_docs/support-matrix.md +5 -5
- hwpx/document.py +46 -7
- hwpx/equation/authoring.py +30 -20
- hwpx/equation/eqedit.py +66 -4
- hwpx/equation/measure.py +229 -82
- hwpx/equation/tokens.py +14 -1
- hwpx/errors.py +41 -1
- hwpx/experimental.py +8 -0
- hwpx/form_fit/_glyph_table.py +3765 -0
- hwpx/form_fit/apply.py +7 -1
- hwpx/form_fit/engine.py +15 -66
- hwpx/form_fit/measure.py +1384 -127
- hwpx/form_fit/policy.py +7 -1
- hwpx/hwp5/errors.py +12 -22
- hwpx/ingest/hwpx_converter.py +2 -1
- hwpx/layout/lint.py +228 -27
- hwpx/layout/pages.py +3523 -0
- hwpx/objects/__init__.py +2 -1
- hwpx/objects/form_field.py +51 -38
- hwpx/opc/relationships.py +72 -1
- hwpx/oxml/_document_primitives.py +19 -24
- hwpx/oxml/_paragraph_text_edit.py +27 -0
- hwpx/oxml/curves.py +231 -0
- hwpx/oxml/document_parts.py +6 -3
- hwpx/oxml/drop_cap.py +22 -17
- hwpx/oxml/dutmal_compose.py +19 -10
- hwpx/oxml/field_marks.py +38 -7
- hwpx/oxml/header.py +39 -33
- hwpx/oxml/header_fonts.py +37 -6
- hwpx/oxml/header_part.py +21 -25
- hwpx/oxml/master_page_authoring.py +67 -5
- hwpx/oxml/memo.py +181 -3
- hwpx/oxml/note_authoring.py +1 -1
- hwpx/oxml/numbering_kinds.py +35 -1
- hwpx/oxml/objects.py +44 -27
- hwpx/oxml/paragraph.py +20 -13
- hwpx/oxml/paragraph_heading.py +70 -0
- hwpx/oxml/section.py +23 -5
- hwpx/oxml/section_format.py +25 -6
- hwpx/oxml/section_story.py +13 -1
- hwpx/oxml/shape_position.py +157 -3
- hwpx/oxml/table.py +36 -28
- hwpx/oxml/table_sizes.py +126 -11
- hwpx/oxml/utils.py +18 -1
- hwpx/quality/save_pipeline.py +4 -1
- hwpx/table_patch.py +32 -8
- hwpx/tools/__init__.py +220 -74
- hwpx/tools/document_merge.py +4 -1
- hwpx/tools/exporter.py +136 -17
- hwpx/tools/id_integrity.py +22 -35
- hwpx/tools/layout_preview.py +5 -2
- hwpx/tools/package_validator.py +334 -24
- hwpx/tools/repair.py +38 -2
- hwpx/tools/template_analyzer.py +9 -6
- hwpx/tools/toc_author.py +8 -6
- hwpx/tools/validator.py +53 -0
- {python_hwpx-6.6.0.dist-info → python_hwpx-6.8.0.dist-info}/METADATA +7 -22
- {python_hwpx-6.6.0.dist-info → python_hwpx-6.8.0.dist-info}/RECORD +80 -75
- {python_hwpx-6.6.0.dist-info → python_hwpx-6.8.0.dist-info}/WHEEL +0 -0
- {python_hwpx-6.6.0.dist-info → python_hwpx-6.8.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-6.6.0.dist-info → python_hwpx-6.8.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-6.6.0.dist-info → python_hwpx-6.8.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-6.6.0.dist-info → python_hwpx-6.8.0.dist-info}/top_level.txt +0 -0
hwpx/__init__.py
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import importlib
|
|
15
15
|
import warnings
|
|
16
16
|
from dataclasses import dataclass
|
|
17
|
-
from typing import Literal
|
|
17
|
+
from typing import TYPE_CHECKING, Literal
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _resolve_version() -> str:
|
|
@@ -28,9 +28,9 @@ def _resolve_version() -> str:
|
|
|
28
28
|
|
|
29
29
|
# --- experimental / deprecated 최상위 표면 (지연 접근, 접근 시 경고) ---------------
|
|
30
30
|
#
|
|
31
|
-
# stable 이름은 아래에서 eager import
|
|
32
|
-
#
|
|
33
|
-
# 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다.
|
|
31
|
+
# stable 이름은 경고 없이 해석됩니다. 대부분 아래에서 eager import 되고, ``hwpx.tools``의
|
|
32
|
+
# 이름은 ``_TOOLS_STABLE_EXPORTS``가 지연 해석합니다. 여기 등록된 이름만 지연 해석되어
|
|
33
|
+
# 경고를 냅니다. 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다.
|
|
34
34
|
|
|
35
35
|
_EXPERIMENTAL_EXPORTS = {
|
|
36
36
|
# 문서 ingestion 프레임워크(임의 포맷 -> HWPX). 계약 유동.
|
|
@@ -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",
|
|
@@ -289,8 +293,8 @@ class RetiredSurface(ImportError):
|
|
|
289
293
|
def __getattr__(name: str) -> object:
|
|
290
294
|
"""Resolve dynamic module attributes.
|
|
291
295
|
|
|
292
|
-
``__version__
|
|
293
|
-
해석 시 ``DeprecationWarning``을 냅니다.
|
|
296
|
+
``__version__``과 ``hwpx.tools``의 stable 이름은 경고 없이 지연 해석하고,
|
|
297
|
+
experimental/deprecated 이름은 해석 시 ``DeprecationWarning``을 냅니다.
|
|
294
298
|
"""
|
|
295
299
|
|
|
296
300
|
if name == "__version__":
|
|
@@ -314,6 +318,17 @@ def __getattr__(name: str) -> object:
|
|
|
314
318
|
warnings.warn(_deprecated_message(name), DeprecationWarning, stacklevel=2)
|
|
315
319
|
return getattr(importlib.import_module(module_name), name)
|
|
316
320
|
|
|
321
|
+
if name in _TOOLS_STABLE_EXPORTS or name == "tools":
|
|
322
|
+
# Not ``from . import tools``: that asks this module for ``tools`` first and
|
|
323
|
+
# re-enters this function.
|
|
324
|
+
import hwpx.tools as tools
|
|
325
|
+
|
|
326
|
+
if name == "tools":
|
|
327
|
+
return tools
|
|
328
|
+
value = getattr(tools, name)
|
|
329
|
+
globals()[name] = value
|
|
330
|
+
return value
|
|
331
|
+
|
|
317
332
|
moved = _MOVED_TO_COMPANION.get(name)
|
|
318
333
|
if moved is not None:
|
|
319
334
|
statement = moved.import_statement(name)
|
|
@@ -342,41 +357,87 @@ def __dir__() -> list[str]:
|
|
|
342
357
|
return sorted(
|
|
343
358
|
set(globals())
|
|
344
359
|
| set(__all__)
|
|
360
|
+
| {"tools"}
|
|
345
361
|
| set(_EXPERIMENTAL_EXPORTS)
|
|
346
362
|
| set(_DEPRECATED_EXPORTS)
|
|
347
363
|
)
|
|
348
364
|
|
|
349
365
|
|
|
350
|
-
# --- stable 최상위 표면 (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
366
|
+
# --- 지연 stable 최상위 표면 (첫 접근 때 해석, 경고 없음) ---------------------------
|
|
367
|
+
#
|
|
368
|
+
# ``hwpx.tools``에 사는 stable 이름은 첫 접근 때 ``__getattr__``이 그 모듈을 읽어
|
|
369
|
+
# 같은 객체를 돌려주고 모듈 전역에 둡니다(경고 없음). ``import hwpx``가 도구 모듈
|
|
370
|
+
# 전부(내보내기·비교·검증 등)를 읽지 않게 하려는 것입니다. ``__all__``·``dir()``·
|
|
371
|
+
# ``from hwpx import *``에는 그대로 있습니다.
|
|
372
|
+
|
|
373
|
+
#: ``hwpx.tools``의 하위 모듈이 정의하고 ``hwpx.tools``가 다시 내보내는 stable 이름(텍스트 추출·개체
|
|
374
|
+
#: 찾기·문서 비교·메일 머지·패키지 검증). 정적 import로 해석하므로 동적 import 지점은
|
|
375
|
+
#: 늘지 않는다.
|
|
376
|
+
_TOOLS_STABLE_EXPORTS = frozenset(
|
|
377
|
+
{
|
|
378
|
+
"DEFAULT_NAMESPACES",
|
|
379
|
+
"ParagraphInfo",
|
|
380
|
+
"SectionInfo",
|
|
381
|
+
"TextExtractor",
|
|
382
|
+
"FoundElement",
|
|
383
|
+
"ObjectFinder",
|
|
384
|
+
"DOC_DIFF_REPORT_VERSION",
|
|
385
|
+
"REFERENCE_CONSISTENCY_REPORT_VERSION",
|
|
386
|
+
"diff_paragraphs",
|
|
387
|
+
"doc_diff",
|
|
388
|
+
"inspect_reference_consistency",
|
|
389
|
+
"MAIL_MERGE_REPORT_VERSION",
|
|
390
|
+
"inspect_mail_merge_placeholders",
|
|
391
|
+
"load_mail_merge_rows",
|
|
392
|
+
"merge_template_rows",
|
|
393
|
+
"EditorOpenSafetyReport",
|
|
394
|
+
"PackageValidationReport",
|
|
395
|
+
"validate_editor_open_safety",
|
|
396
|
+
"validate_package",
|
|
397
|
+
}
|
|
376
398
|
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if TYPE_CHECKING: # type checkers see the real objects; at runtime they load lazily
|
|
402
|
+
from .tools.doc_diff import (
|
|
403
|
+
DOC_DIFF_REPORT_VERSION,
|
|
404
|
+
REFERENCE_CONSISTENCY_REPORT_VERSION,
|
|
405
|
+
diff_paragraphs,
|
|
406
|
+
doc_diff,
|
|
407
|
+
inspect_reference_consistency,
|
|
408
|
+
)
|
|
409
|
+
from .tools.mail_merge import (
|
|
410
|
+
MAIL_MERGE_REPORT_VERSION,
|
|
411
|
+
inspect_mail_merge_placeholders,
|
|
412
|
+
load_mail_merge_rows,
|
|
413
|
+
merge_template_rows,
|
|
414
|
+
)
|
|
415
|
+
from .tools.object_finder import FoundElement, ObjectFinder
|
|
416
|
+
from .tools.package_validator import (
|
|
417
|
+
EditorOpenSafetyReport,
|
|
418
|
+
PackageValidationReport,
|
|
419
|
+
validate_editor_open_safety,
|
|
420
|
+
validate_package,
|
|
421
|
+
)
|
|
422
|
+
from .tools.text_extractor import (
|
|
423
|
+
DEFAULT_NAMESPACES,
|
|
424
|
+
ParagraphInfo,
|
|
425
|
+
SectionInfo,
|
|
426
|
+
TextExtractor,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ``import hwpx.builder`` goes through the import system, not ``__getattr__``,
|
|
431
|
+
# so the moved module names get their destination from a finder instead.
|
|
432
|
+
from . import _moved_modules
|
|
433
|
+
|
|
434
|
+
_moved_modules.install(_MOVED_TO_COMPANION)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# --- stable 최상위 표면 (eager import) ------------------------------------------
|
|
438
|
+
# ``hwpx.tools``의 stable 이름은 위 ``_TOOLS_STABLE_EXPORTS``가 지연 해석한다.
|
|
377
439
|
from .ingest import HwpxMarkdownConverter
|
|
378
|
-
from .errors import HwpxError
|
|
379
|
-
from .hwp5.errors import Hwp5ConversionWarning, Hwp5Error
|
|
440
|
+
from .errors import Hwp5ConversionWarning, Hwp5Error, HwpxError
|
|
380
441
|
from .mutation_report import (
|
|
381
442
|
MutationReport,
|
|
382
443
|
PreservationDowngradeError,
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
|