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.
- hwpx/__init__.py +14 -0
- hwpx/_document/_legacy.py +5 -3
- hwpx/_document/fields.py +337 -25
- hwpx/_document/layout.py +351 -68
- hwpx/_document/media.py +210 -38
- hwpx/_document/memos.py +85 -20
- hwpx/_document/ns/fields.py +64 -3
- hwpx/_document/ns/media.py +37 -4
- hwpx/_document/ns/notes.py +2 -2
- hwpx/_document/ns/page.py +27 -4
- hwpx/_document/ns/parts.py +102 -2
- hwpx/_document/ns/shapes.py +102 -10
- hwpx/_document/ns/styles.py +175 -8
- hwpx/_document/ns/text.py +157 -20
- hwpx/_document/ns/tracking.py +5 -1
- hwpx/_document/persistence.py +78 -17
- hwpx/_document/shapes.py +177 -8
- hwpx/_document/tracked.py +49 -1
- hwpx/_moved_modules.py +118 -0
- hwpx/body_patch.py +6 -2
- hwpx/capabilities.py +1 -1
- hwpx/data/contract_docs/known-traps.md +61 -2
- hwpx/data/contract_docs/mutation-semantics.md +57 -6
- hwpx/data/contract_docs/support-matrix.md +12 -13
- hwpx/document.py +107 -10
- hwpx/equation/authoring.py +30 -25
- hwpx/equation/eqedit.py +61 -4
- hwpx/equation/measure.py +422 -0
- hwpx/equation/tokens.py +14 -1
- hwpx/errors.py +62 -3
- hwpx/experimental.py +8 -0
- hwpx/form_fit/__init__.py +4 -0
- hwpx/form_fit/apply.py +8 -2
- hwpx/form_fit/engine.py +25 -77
- hwpx/form_fit/measure.py +1044 -94
- hwpx/form_fit/policy.py +7 -1
- hwpx/hwp5/__init__.py +7 -0
- hwpx/hwp5/binary.py +124 -0
- hwpx/hwp5/bodytext.py +201 -0
- hwpx/hwp5/cfb.py +601 -0
- hwpx/hwp5/controls.py +1175 -0
- hwpx/hwp5/docinfo.py +881 -0
- hwpx/hwp5/docinfo_writer.py +647 -0
- hwpx/hwp5/errors.py +75 -0
- hwpx/hwp5/fileheader.py +87 -0
- hwpx/hwp5/header_xml.py +703 -0
- hwpx/hwp5/owpml.py +390 -0
- hwpx/hwp5/package.py +331 -0
- hwpx/hwp5/reader.py +149 -0
- hwpx/hwp5/records.py +214 -0
- hwpx/hwp5/section_common.py +149 -0
- hwpx/hwp5/section_writer.py +1826 -0
- hwpx/hwp5/section_xml.py +1514 -0
- hwpx/hwp5/shape_xml.py +709 -0
- hwpx/hwp5/shapes.py +842 -0
- hwpx/hwp5/summary.py +204 -0
- hwpx/hwp5/writer.py +325 -0
- hwpx/ingest/hwpx_converter.py +1 -1
- hwpx/layout/__init__.py +2 -0
- hwpx/layout/lint.py +349 -3
- hwpx/layout/pages.py +2245 -0
- hwpx/model.py +2 -0
- hwpx/objects/__init__.py +13 -1
- hwpx/objects/form_field.py +90 -3
- hwpx/objects/results.py +131 -0
- hwpx/opc/package.py +148 -16
- hwpx/opc/relationships.py +88 -4
- hwpx/opc/xml_utils.py +44 -15
- hwpx/oxml/_document_primitives.py +76 -56
- hwpx/oxml/_paragraph_text_edit.py +104 -2
- hwpx/oxml/color.py +36 -1
- hwpx/oxml/curves.py +231 -0
- hwpx/oxml/document_parts.py +131 -160
- hwpx/oxml/drop_cap.py +65 -15
- hwpx/oxml/dutmal_compose.py +19 -10
- hwpx/oxml/field_marks.py +38 -7
- hwpx/oxml/header.py +45 -35
- hwpx/oxml/header_fonts.py +322 -0
- hwpx/oxml/header_part.py +43 -42
- hwpx/oxml/hyperlink_form.py +159 -0
- hwpx/oxml/master_page_authoring.py +130 -6
- hwpx/oxml/memo.py +185 -11
- hwpx/oxml/note_authoring.py +3 -4
- hwpx/oxml/numbering_kinds.py +82 -2
- hwpx/oxml/objects.py +112 -66
- hwpx/oxml/paragraph.py +80 -90
- hwpx/oxml/paragraph_heading.py +70 -0
- hwpx/oxml/run.py +209 -44
- hwpx/oxml/section.py +305 -7
- hwpx/oxml/section_format.py +218 -13
- hwpx/oxml/section_layout.py +87 -0
- hwpx/oxml/section_story.py +266 -39
- hwpx/oxml/shape_position.py +282 -0
- hwpx/oxml/table.py +222 -87
- hwpx/oxml/table_merge.py +82 -0
- hwpx/oxml/table_sizes.py +420 -0
- hwpx/oxml/utils.py +121 -1
- hwpx/patch.py +16 -3
- hwpx/quality/save_pipeline.py +4 -1
- hwpx/table_patch.py +265 -81
- hwpx/tools/_schemas/owpml-body.xsd +18 -0
- hwpx/tools/_schemas/owpml-core.xsd +905 -0
- hwpx/tools/_schemas/owpml-header.xsd +1869 -0
- hwpx/tools/_schemas/owpml-paralist.xsd +2813 -0
- hwpx/tools/_schemas/owpml-xml.xsd +15 -0
- hwpx/tools/document_merge.py +96 -16
- hwpx/tools/exporter.py +622 -59
- hwpx/tools/id_integrity.py +22 -35
- hwpx/tools/layout_preview.py +49 -4
- hwpx/tools/mail_merge.py +19 -13
- hwpx/tools/markdown_export.py +37 -16
- hwpx/tools/package_validator.py +349 -26
- hwpx/tools/read_fidelity.py +4 -4
- hwpx/tools/redline.py +4 -4
- hwpx/tools/repair.py +38 -2
- hwpx/tools/table_navigation.py +3 -2
- hwpx/tools/template_analyzer.py +14 -7
- hwpx/tools/text_extractor.py +54 -24
- hwpx/tools/toc_author.py +118 -15
- hwpx/tools/validator.py +280 -1
- {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/METADATA +12 -25
- python_hwpx-6.7.0.dist-info/RECORD +199 -0
- python_hwpx-6.5.0.dist-info/RECORD +0 -163
- {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/WHEEL +0 -0
- {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-6.5.0.dist-info → python_hwpx-6.7.0.dist-info}/licenses/NOTICE +0 -0
- {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 =
|
|
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*."""
|
|
@@ -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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|