python-hwpx 5.3.0__py3-none-any.whl → 5.5.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/_document/layout.py +12 -1
- hwpx/document.py +34 -2
- hwpx/oxml/_document_primitives.py +17 -2
- hwpx/oxml/document_parts.py +188 -4
- hwpx/oxml/header_part.py +16 -0
- hwpx/oxml/memo.py +26 -6
- hwpx/oxml/paragraph.py +162 -14
- hwpx/oxml/section.py +53 -10
- hwpx/oxml/table.py +15 -2
- hwpx/tools/markdown_export.py +6 -2
- hwpx/tools/text_extractor.py +14 -0
- {python_hwpx-5.3.0.dist-info → python_hwpx-5.5.0.dist-info}/METADATA +1 -1
- {python_hwpx-5.3.0.dist-info → python_hwpx-5.5.0.dist-info}/RECORD +18 -18
- {python_hwpx-5.3.0.dist-info → python_hwpx-5.5.0.dist-info}/WHEEL +0 -0
- {python_hwpx-5.3.0.dist-info → python_hwpx-5.5.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-5.3.0.dist-info → python_hwpx-5.5.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-5.3.0.dist-info → python_hwpx-5.5.0.dist-info}/licenses/NOTICE +0 -0
- {python_hwpx-5.3.0.dist-info → python_hwpx-5.5.0.dist-info}/top_level.txt +0 -0
hwpx/_document/layout.py
CHANGED
|
@@ -425,17 +425,28 @@ def add_hyperlink(
|
|
|
425
425
|
paragraph: HwpxOxmlParagraph | None = None,
|
|
426
426
|
section: HwpxOxmlSection | None = None,
|
|
427
427
|
section_index: int | None = None,
|
|
428
|
+
char_pr_id_ref: str | int | None = None,
|
|
428
429
|
) -> HwpxOxmlInlineObject:
|
|
429
430
|
"""Insert a hyperlink (fieldBegin + text + fieldEnd).
|
|
430
431
|
|
|
432
|
+
The display text follows the Hancom convention (blue ``#0000FF`` text
|
|
433
|
+
with a blue bottom underline — dominant styling across real-corpus
|
|
434
|
+
hyperlinks) unless ``char_pr_id_ref`` overrides it.
|
|
435
|
+
|
|
431
436
|
Returns the ``<hp:ctrl>`` wrapper containing the ``<hp:fieldBegin>``.
|
|
432
437
|
"""
|
|
438
|
+
if char_pr_id_ref is None:
|
|
439
|
+
char_pr_id_ref = doc.ensure_run_style(
|
|
440
|
+
underline=True,
|
|
441
|
+
color="#0000FF",
|
|
442
|
+
underline_color="#0000FF",
|
|
443
|
+
)
|
|
433
444
|
if paragraph is None:
|
|
434
445
|
paragraph = doc.add_paragraph(
|
|
435
446
|
"", section=section, section_index=section_index,
|
|
436
447
|
include_run=False,
|
|
437
448
|
)
|
|
438
|
-
return paragraph.add_hyperlink(url, display_text)
|
|
449
|
+
return paragraph.add_hyperlink(url, display_text, char_pr_id_ref=char_pr_id_ref)
|
|
439
450
|
|
|
440
451
|
|
|
441
452
|
def _resolve_section(
|
hwpx/document.py
CHANGED
|
@@ -264,14 +264,21 @@ class HwpxDocument:
|
|
|
264
264
|
border_width: str = "0.12 mm",
|
|
265
265
|
fill_color: str | None = None,
|
|
266
266
|
active_borders: Sequence[str] | None = None,
|
|
267
|
+
border_type: str = "SOLID",
|
|
267
268
|
) -> str:
|
|
268
|
-
"""Return a borderFill id matching the requested border/fill attributes.
|
|
269
|
+
"""Return a borderFill id matching the requested border/fill attributes.
|
|
270
|
+
|
|
271
|
+
``border_type`` selects the OWPML line style (``SOLID``, ``DASH``,
|
|
272
|
+
``DOT``, ``DOUBLE_SLIM``, ``WAVE``, …); values outside the OWPML
|
|
273
|
+
vocabulary are rejected.
|
|
274
|
+
"""
|
|
269
275
|
|
|
270
276
|
return self._root.ensure_border_fill(
|
|
271
277
|
border_color=border_color,
|
|
272
278
|
border_width=border_width,
|
|
273
279
|
fill_color=fill_color,
|
|
274
280
|
active_borders=active_borders,
|
|
281
|
+
border_type=border_type,
|
|
275
282
|
)
|
|
276
283
|
|
|
277
284
|
@property
|
|
@@ -594,9 +601,22 @@ class HwpxDocument:
|
|
|
594
601
|
size: int | float | None = None,
|
|
595
602
|
highlight: str | None = None,
|
|
596
603
|
strike: bool | None = None,
|
|
604
|
+
underline_shape: str | None = None,
|
|
605
|
+
underline_color: str | None = None,
|
|
606
|
+
strike_shape: str | None = None,
|
|
607
|
+
ratio: int | None = None,
|
|
608
|
+
letter_spacing: int | None = None,
|
|
609
|
+
shadow: str | None = None,
|
|
610
|
+
script: str | None = None,
|
|
597
611
|
base_char_pr_id: str | int | None = None,
|
|
598
612
|
) -> str:
|
|
599
|
-
"""Return a ``charPr`` identifier matching the requested flags.
|
|
613
|
+
"""Return a ``charPr`` identifier matching the requested flags.
|
|
614
|
+
|
|
615
|
+
5.4.0 additions (render-verified vocabulary; invalid values are
|
|
616
|
+
rejected): ``underline_shape``/``underline_color``, ``strike_shape``,
|
|
617
|
+
``ratio`` (장평 %), ``letter_spacing`` (자간 %), ``shadow`` (drop
|
|
618
|
+
shadow colour), ``script`` (``"sup"``/``"sub"``).
|
|
619
|
+
"""
|
|
600
620
|
|
|
601
621
|
return self._root.ensure_run_style(
|
|
602
622
|
bold=bold,
|
|
@@ -607,6 +627,13 @@ class HwpxDocument:
|
|
|
607
627
|
size=size,
|
|
608
628
|
highlight=highlight,
|
|
609
629
|
strike=strike,
|
|
630
|
+
underline_shape=underline_shape,
|
|
631
|
+
underline_color=underline_color,
|
|
632
|
+
strike_shape=strike_shape,
|
|
633
|
+
ratio=ratio,
|
|
634
|
+
letter_spacing=letter_spacing,
|
|
635
|
+
shadow=shadow,
|
|
636
|
+
script=script,
|
|
610
637
|
base_char_pr_id=base_char_pr_id,
|
|
611
638
|
)
|
|
612
639
|
|
|
@@ -1373,9 +1400,13 @@ class HwpxDocument:
|
|
|
1373
1400
|
paragraph: HwpxOxmlParagraph | None = None,
|
|
1374
1401
|
section: HwpxOxmlSection | None = None,
|
|
1375
1402
|
section_index: int | None = None,
|
|
1403
|
+
char_pr_id_ref: str | int | None = None,
|
|
1376
1404
|
) -> HwpxOxmlInlineObject:
|
|
1377
1405
|
"""Insert a hyperlink (fieldBegin + text + fieldEnd).
|
|
1378
1406
|
|
|
1407
|
+
The display text follows the Hancom convention (blue underlined)
|
|
1408
|
+
unless ``char_pr_id_ref`` overrides it.
|
|
1409
|
+
|
|
1379
1410
|
Returns the ``<hp:ctrl>`` wrapper containing the ``<hp:fieldBegin>``.
|
|
1380
1411
|
"""
|
|
1381
1412
|
|
|
@@ -1386,6 +1417,7 @@ class HwpxDocument:
|
|
|
1386
1417
|
paragraph=paragraph,
|
|
1387
1418
|
section=section,
|
|
1388
1419
|
section_index=section_index,
|
|
1420
|
+
char_pr_id_ref=char_pr_id_ref,
|
|
1389
1421
|
)
|
|
1390
1422
|
|
|
1391
1423
|
def add_form_field(
|
|
@@ -422,9 +422,10 @@ def _border_fill_child_attrs(
|
|
|
422
422
|
active: bool,
|
|
423
423
|
color: str,
|
|
424
424
|
width: str,
|
|
425
|
+
border_type: str = "SOLID",
|
|
425
426
|
) -> dict[str, str]:
|
|
426
427
|
return {
|
|
427
|
-
"type":
|
|
428
|
+
"type": border_type if active else "NONE",
|
|
428
429
|
"width": width,
|
|
429
430
|
"color": color,
|
|
430
431
|
}
|
|
@@ -465,6 +466,7 @@ def _border_fill_matches(
|
|
|
465
466
|
border_width: str,
|
|
466
467
|
fill_color: str | None,
|
|
467
468
|
active_borders: set[str],
|
|
469
|
+
border_type: str = "SOLID",
|
|
468
470
|
) -> bool:
|
|
469
471
|
if _element_local_name(element) != "borderFill":
|
|
470
472
|
return False
|
|
@@ -484,7 +486,7 @@ def _border_fill_matches(
|
|
|
484
486
|
child = element.find(f"{_HH}{child_name}")
|
|
485
487
|
if child is None:
|
|
486
488
|
return False
|
|
487
|
-
expected_type =
|
|
489
|
+
expected_type = border_type if side in active_borders else "NONE"
|
|
488
490
|
if (child.get("type") or "").upper() != expected_type:
|
|
489
491
|
return False
|
|
490
492
|
if _normalize_length(child.get("width")) != _normalize_length(border_width):
|
|
@@ -509,6 +511,7 @@ def _create_border_fill_element(
|
|
|
509
511
|
border_width: str,
|
|
510
512
|
fill_color: str | None,
|
|
511
513
|
active_borders: set[str],
|
|
514
|
+
border_type: str = "SOLID",
|
|
512
515
|
) -> ET.Element:
|
|
513
516
|
element = ET.Element(f"{_HH}borderFill", {"id": border_id, **_BASIC_BORDER_FILL_ATTRIBUTES})
|
|
514
517
|
ET.SubElement(element, f"{_HH}slash", {"type": "NONE", "Crooked": "0", "isCounter": "0"})
|
|
@@ -521,6 +524,7 @@ def _create_border_fill_element(
|
|
|
521
524
|
active=side in active_borders,
|
|
522
525
|
color=border_color,
|
|
523
526
|
width=border_width,
|
|
527
|
+
border_type=border_type,
|
|
524
528
|
),
|
|
525
529
|
)
|
|
526
530
|
ET.SubElement(
|
|
@@ -579,6 +583,17 @@ def _default_cell_margin_attributes() -> dict[str, str]:
|
|
|
579
583
|
return {"left": "0", "right": "0", "top": "0", "bottom": "0"}
|
|
580
584
|
|
|
581
585
|
|
|
586
|
+
def _default_cell_inner_margin_attributes() -> dict[str, str]:
|
|
587
|
+
"""Hancom's new-table cell padding: 1.8 mm left/right, 0.5 mm top/bottom.
|
|
588
|
+
|
|
589
|
+
Measured from real Hancom-authored gold documents
|
|
590
|
+
(specs/056-authoring-fidelity-audit — the audit found our former 0
|
|
591
|
+
padding makes text hug the borders, visibly unlike a Hancom table).
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
return {"left": "510", "right": "510", "top": "141", "bottom": "141"}
|
|
595
|
+
|
|
596
|
+
|
|
582
597
|
def _get_int_attr(element: ET.Element, name: str, default: int = 0) -> int:
|
|
583
598
|
"""Return *name* attribute of *element* as an integer."""
|
|
584
599
|
|
hwpx/oxml/document_parts.py
CHANGED
|
@@ -59,6 +59,13 @@ class _RunStyleSpec:
|
|
|
59
59
|
height: str | None
|
|
60
60
|
strike: bool | None
|
|
61
61
|
font_ref: dict[str, str] | None
|
|
62
|
+
underline_shape: str | None = None
|
|
63
|
+
underline_color: str | None = None
|
|
64
|
+
strike_shape: str | None = None
|
|
65
|
+
ratio: int | None = None
|
|
66
|
+
letter_spacing: int | None = None
|
|
67
|
+
shadow_color: str | None = None
|
|
68
|
+
script: str | None = None
|
|
62
69
|
|
|
63
70
|
|
|
64
71
|
def _run_style_element_flags(element: ET.Element) -> tuple[bool, bool, bool]:
|
|
@@ -78,6 +85,81 @@ def _run_style_element_strike(element: ET.Element) -> bool:
|
|
|
78
85
|
return strike_element.get("shape", "").upper() != "NONE"
|
|
79
86
|
|
|
80
87
|
|
|
88
|
+
def _validated_line_shape(
|
|
89
|
+
value: str | None, vocabulary: frozenset[str], label: str
|
|
90
|
+
) -> str | None:
|
|
91
|
+
if value is None:
|
|
92
|
+
return None
|
|
93
|
+
normalized = str(value).upper()
|
|
94
|
+
if normalized not in vocabulary:
|
|
95
|
+
raise ValueError(f"unsupported {label} {value!r}")
|
|
96
|
+
return normalized
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _run_style_underline_matches(element: ET.Element, spec: _RunStyleSpec) -> bool:
|
|
100
|
+
if spec.underline_shape is None and spec.underline_color is None:
|
|
101
|
+
return True
|
|
102
|
+
underline = element.find(f"{_HH}underline")
|
|
103
|
+
if underline is None:
|
|
104
|
+
return False
|
|
105
|
+
if spec.underline_shape is not None and (
|
|
106
|
+
underline.get("shape", "").upper() != spec.underline_shape
|
|
107
|
+
):
|
|
108
|
+
return False
|
|
109
|
+
if spec.underline_color is not None and (
|
|
110
|
+
(underline.get("color") or "").upper() != spec.underline_color.upper()
|
|
111
|
+
):
|
|
112
|
+
return False
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_style_lang_value_matches(
|
|
117
|
+
element: ET.Element, tag: str, value: int | None
|
|
118
|
+
) -> bool:
|
|
119
|
+
if value is None:
|
|
120
|
+
return True
|
|
121
|
+
node = element.find(f"{_HH}{tag}")
|
|
122
|
+
return node is not None and node.get("hangul") == str(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_style_extensions_match(element: ET.Element, spec: _RunStyleSpec) -> bool:
|
|
126
|
+
if not _run_style_underline_matches(element, spec):
|
|
127
|
+
return False
|
|
128
|
+
if spec.strike_shape is not None:
|
|
129
|
+
strike_el = element.find(f"{_HH}strikeout")
|
|
130
|
+
if strike_el is None or strike_el.get("shape", "").upper() != spec.strike_shape:
|
|
131
|
+
return False
|
|
132
|
+
if not _run_style_lang_value_matches(element, "ratio", spec.ratio):
|
|
133
|
+
return False
|
|
134
|
+
if not _run_style_lang_value_matches(element, "spacing", spec.letter_spacing):
|
|
135
|
+
return False
|
|
136
|
+
if not _run_style_shadow_matches(element, spec.shadow_color):
|
|
137
|
+
return False
|
|
138
|
+
if not _run_style_script_matches(element, spec.script):
|
|
139
|
+
return False
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _run_style_shadow_matches(element: ET.Element, shadow_color: str | None) -> bool:
|
|
144
|
+
if shadow_color is None:
|
|
145
|
+
return True
|
|
146
|
+
shadow_el = element.find(f"{_HH}shadow")
|
|
147
|
+
if shadow_el is None or shadow_el.get("type", "").upper() == "NONE":
|
|
148
|
+
return False
|
|
149
|
+
return (shadow_el.get("color") or "").upper() == shadow_color.upper()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _run_style_script_matches(element: ET.Element, script: str | None) -> bool:
|
|
153
|
+
if script is None:
|
|
154
|
+
return True
|
|
155
|
+
if not _run_style_lang_value_matches(element, "relSz", 67):
|
|
156
|
+
return False
|
|
157
|
+
off_el = element.find(f"{_HH}offset")
|
|
158
|
+
# 실한컴 렌더 실측: offset 음수=위로(위첨자), 양수=아래로(아래첨자).
|
|
159
|
+
wanted_offset = "-30" if script == "sup" else "30"
|
|
160
|
+
return off_el is not None and off_el.get("hangul") == wanted_offset
|
|
161
|
+
|
|
162
|
+
|
|
81
163
|
def _run_style_predicate(element: ET.Element, spec: _RunStyleSpec) -> bool:
|
|
82
164
|
if _run_style_element_flags(element) != spec.flags:
|
|
83
165
|
return False
|
|
@@ -92,6 +174,8 @@ def _run_style_predicate(element: ET.Element, spec: _RunStyleSpec) -> bool:
|
|
|
92
174
|
return False
|
|
93
175
|
if spec.strike is not None and _run_style_element_strike(element) != bool(spec.strike):
|
|
94
176
|
return False
|
|
177
|
+
if not _run_style_extensions_match(element, spec):
|
|
178
|
+
return False
|
|
95
179
|
if spec.font_ref is not None:
|
|
96
180
|
font_ref = element.find(f"{_HH}fontRef")
|
|
97
181
|
if font_ref is None:
|
|
@@ -127,9 +211,10 @@ def _run_style_apply_underline(
|
|
|
127
211
|
) -> None:
|
|
128
212
|
underline_attrs = dict(base_underline_attrs)
|
|
129
213
|
if want_underline:
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
214
|
+
# 실한컴 gold 관례: hh:underline type은 위치 어휘(BOTTOM/CENTER/TOP).
|
|
215
|
+
underline_attrs.setdefault("type", "BOTTOM")
|
|
216
|
+
if underline_attrs.get("type", "").upper() in ("NONE", "SOLID"):
|
|
217
|
+
underline_attrs["type"] = "BOTTOM"
|
|
133
218
|
underline_attrs.setdefault(
|
|
134
219
|
"shape", base_underline_attrs.get("shape", "SOLID")
|
|
135
220
|
)
|
|
@@ -159,6 +244,56 @@ def _run_style_apply_strikeout(
|
|
|
159
244
|
_append_child(element, f"{_HH}strikeout", strike_attrs)
|
|
160
245
|
|
|
161
246
|
|
|
247
|
+
def _run_style_set_lang_values(element: ET.Element, tag: str, value: int) -> None:
|
|
248
|
+
node = element.find(f"{_HH}{tag}")
|
|
249
|
+
if node is None:
|
|
250
|
+
node = _append_child(element, f"{_HH}{tag}")
|
|
251
|
+
for lang in ("hangul", "latin", "hanja", "japanese", "other", "symbol", "user"):
|
|
252
|
+
node.set(lang, str(value))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _run_style_apply_underline_extension(
|
|
256
|
+
element: ET.Element, spec: _RunStyleSpec
|
|
257
|
+
) -> None:
|
|
258
|
+
if spec.underline_shape is None and spec.underline_color is None:
|
|
259
|
+
return
|
|
260
|
+
underline = element.find(f"{_HH}underline")
|
|
261
|
+
if underline is None:
|
|
262
|
+
return
|
|
263
|
+
if underline.get("type", "").upper() == "NONE":
|
|
264
|
+
underline.set("type", "BOTTOM")
|
|
265
|
+
if spec.underline_shape is not None:
|
|
266
|
+
underline.set("shape", spec.underline_shape)
|
|
267
|
+
if spec.underline_color is not None:
|
|
268
|
+
underline.set("color", spec.underline_color)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _run_style_apply_extensions(element: ET.Element, spec: _RunStyleSpec) -> None:
|
|
272
|
+
_run_style_apply_underline_extension(element, spec)
|
|
273
|
+
if spec.strike_shape is not None:
|
|
274
|
+
strike_el = element.find(f"{_HH}strikeout")
|
|
275
|
+
if strike_el is None:
|
|
276
|
+
strike_el = _append_child(element, f"{_HH}strikeout")
|
|
277
|
+
strike_el.set("shape", spec.strike_shape)
|
|
278
|
+
if not strike_el.get("color"):
|
|
279
|
+
strike_el.set("color", "#000000")
|
|
280
|
+
if spec.ratio is not None:
|
|
281
|
+
_run_style_set_lang_values(element, "ratio", spec.ratio)
|
|
282
|
+
if spec.letter_spacing is not None:
|
|
283
|
+
_run_style_set_lang_values(element, "spacing", spec.letter_spacing)
|
|
284
|
+
if spec.shadow_color is not None:
|
|
285
|
+
shadow_el = element.find(f"{_HH}shadow")
|
|
286
|
+
if shadow_el is None:
|
|
287
|
+
shadow_el = _append_child(element, f"{_HH}shadow")
|
|
288
|
+
shadow_el.set("type", "DROP")
|
|
289
|
+
shadow_el.set("color", spec.shadow_color)
|
|
290
|
+
shadow_el.set("offsetX", "12")
|
|
291
|
+
shadow_el.set("offsetY", "12")
|
|
292
|
+
if spec.script is not None:
|
|
293
|
+
_run_style_set_lang_values(element, "relSz", 67)
|
|
294
|
+
_run_style_set_lang_values(element, "offset", -30 if spec.script == "sup" else 30)
|
|
295
|
+
|
|
296
|
+
|
|
162
297
|
def _run_style_modifier(element: ET.Element, spec: _RunStyleSpec) -> None:
|
|
163
298
|
underline_nodes = list(element.findall(f"{_HH}underline"))
|
|
164
299
|
base_underline_attrs = (
|
|
@@ -186,6 +321,8 @@ def _run_style_modifier(element: ET.Element, spec: _RunStyleSpec) -> None:
|
|
|
186
321
|
_run_style_apply_underline(element, base_underline_attrs, spec.flags[2])
|
|
187
322
|
_run_style_apply_strikeout(element, base_strike_attrs, spec.strike)
|
|
188
323
|
|
|
324
|
+
_run_style_apply_extensions(element, spec)
|
|
325
|
+
|
|
189
326
|
|
|
190
327
|
class HwpxOxmlDocument:
|
|
191
328
|
"""Aggregates the XML parts that make up an HWPX document."""
|
|
@@ -357,6 +494,12 @@ class HwpxOxmlDocument:
|
|
|
357
494
|
return None
|
|
358
495
|
return cache.get(normalized)
|
|
359
496
|
|
|
497
|
+
_UNDERLINE_SHAPES = frozenset({
|
|
498
|
+
"SOLID", "DASH", "DOT", "DASH_DOT", "DASH_DOT_DOT", "LONG_DASH",
|
|
499
|
+
"CIRCLE", "DOUBLE_SLIM", "SLIM_THICK", "THICK_SLIM",
|
|
500
|
+
"SLIM_THICK_SLIM", "WAVE", "DOUBLEWAVE",
|
|
501
|
+
})
|
|
502
|
+
|
|
360
503
|
def ensure_run_style(
|
|
361
504
|
self,
|
|
362
505
|
*,
|
|
@@ -368,13 +511,45 @@ class HwpxOxmlDocument:
|
|
|
368
511
|
size: int | float | None = None,
|
|
369
512
|
highlight: str | None = None,
|
|
370
513
|
strike: bool | None = None,
|
|
514
|
+
underline_shape: str | None = None,
|
|
515
|
+
underline_color: str | None = None,
|
|
516
|
+
strike_shape: str | None = None,
|
|
517
|
+
ratio: int | None = None,
|
|
518
|
+
letter_spacing: int | None = None,
|
|
519
|
+
shadow: str | None = None,
|
|
520
|
+
script: str | None = None,
|
|
371
521
|
base_char_pr_id: str | int | None = None,
|
|
372
522
|
) -> str:
|
|
373
|
-
"""Return a char property identifier matching the requested flags.
|
|
523
|
+
"""Return a char property identifier matching the requested flags.
|
|
524
|
+
|
|
525
|
+
The 5.4.0 additions mirror what the fidelity audit render-verified on
|
|
526
|
+
real Hancom: ``underline_shape``/``underline_color`` (implies an
|
|
527
|
+
underline), ``strike_shape`` (implies a strikeout), ``ratio`` (장평 %),
|
|
528
|
+
``letter_spacing`` (자간 %), ``shadow`` (drop-shadow colour), and
|
|
529
|
+
``script`` (``"sup"``/``"sub"``). Values outside the OWPML vocabulary
|
|
530
|
+
are rejected — no silent approximation.
|
|
531
|
+
"""
|
|
374
532
|
|
|
375
533
|
if not self._headers:
|
|
376
534
|
raise ValueError("document does not contain any headers")
|
|
377
535
|
|
|
536
|
+
normalized_underline_shape = _validated_line_shape(
|
|
537
|
+
underline_shape, self._UNDERLINE_SHAPES, "underline_shape"
|
|
538
|
+
)
|
|
539
|
+
if normalized_underline_shape is not None or underline_color is not None:
|
|
540
|
+
underline = True
|
|
541
|
+
normalized_strike_shape = _validated_line_shape(
|
|
542
|
+
strike_shape, self._UNDERLINE_SHAPES, "strike_shape"
|
|
543
|
+
)
|
|
544
|
+
if normalized_strike_shape is not None:
|
|
545
|
+
strike = True
|
|
546
|
+
if ratio is not None and not 10 <= int(ratio) <= 400:
|
|
547
|
+
raise ValueError("ratio must be a percentage between 10 and 400")
|
|
548
|
+
if letter_spacing is not None and not -50 <= int(letter_spacing) <= 100:
|
|
549
|
+
raise ValueError("letter_spacing must be between -50 and 100")
|
|
550
|
+
if script is not None and script not in ("sup", "sub"):
|
|
551
|
+
raise ValueError('script must be "sup" or "sub"')
|
|
552
|
+
|
|
378
553
|
header = self._headers[0]
|
|
379
554
|
spec = _RunStyleSpec(
|
|
380
555
|
flags=(bool(bold), bool(italic), bool(underline)),
|
|
@@ -383,6 +558,13 @@ class HwpxOxmlDocument:
|
|
|
383
558
|
height=_char_height_from_points(size),
|
|
384
559
|
strike=strike,
|
|
385
560
|
font_ref=header.font_ref_for_face(font) if font is not None else None,
|
|
561
|
+
underline_shape=normalized_underline_shape,
|
|
562
|
+
underline_color=_normalize_color(underline_color),
|
|
563
|
+
strike_shape=normalized_strike_shape,
|
|
564
|
+
ratio=int(ratio) if ratio is not None else None,
|
|
565
|
+
letter_spacing=int(letter_spacing) if letter_spacing is not None else None,
|
|
566
|
+
shadow_color=_normalize_color(shadow),
|
|
567
|
+
script=script,
|
|
386
568
|
)
|
|
387
569
|
element = header.ensure_char_property(
|
|
388
570
|
predicate=lambda el: _run_style_predicate(el, spec),
|
|
@@ -425,6 +607,7 @@ class HwpxOxmlDocument:
|
|
|
425
607
|
border_width: str = "0.12 mm",
|
|
426
608
|
fill_color: str | None = None,
|
|
427
609
|
active_borders: Iterable[str] | None = None,
|
|
610
|
+
border_type: str = "SOLID",
|
|
428
611
|
) -> str:
|
|
429
612
|
if not self._headers:
|
|
430
613
|
return "0"
|
|
@@ -433,6 +616,7 @@ class HwpxOxmlDocument:
|
|
|
433
616
|
border_width=border_width,
|
|
434
617
|
fill_color=fill_color,
|
|
435
618
|
active_borders=active_borders,
|
|
619
|
+
border_type=border_type,
|
|
436
620
|
)
|
|
437
621
|
|
|
438
622
|
def ensure_shading_border_fill(
|
hwpx/oxml/header_part.py
CHANGED
|
@@ -898,6 +898,13 @@ class HwpxOxmlHeader:
|
|
|
898
898
|
self.mark_dirty()
|
|
899
899
|
return new_id
|
|
900
900
|
|
|
901
|
+
_BORDER_LINE_TYPES = frozenset({
|
|
902
|
+
"SOLID", "DASH", "DOT", "DASH_DOT", "DASH_DOT_DOT", "LONG_DASH",
|
|
903
|
+
"CIRCLE", "DOUBLE_SLIM", "SLIM_THICK", "THICK_SLIM", "SLIM_THICK_SLIM",
|
|
904
|
+
"WAVE", "DOUBLEWAVE", "THICK_3D", "THICK_3D_REVERSE_LIGHTING",
|
|
905
|
+
"SLIM_3D", "SLIM_3D_REVERSE_LIGHTING",
|
|
906
|
+
})
|
|
907
|
+
|
|
901
908
|
def ensure_border_fill(
|
|
902
909
|
self,
|
|
903
910
|
*,
|
|
@@ -905,11 +912,18 @@ class HwpxOxmlHeader:
|
|
|
905
912
|
border_width: str = "0.12 mm",
|
|
906
913
|
fill_color: str | None = None,
|
|
907
914
|
active_borders: Iterable[str] | None = None,
|
|
915
|
+
border_type: str = "SOLID",
|
|
908
916
|
) -> str:
|
|
909
917
|
element = self._border_fills_element(create=True)
|
|
910
918
|
if element is None: # pragma: no cover - defensive branch
|
|
911
919
|
raise RuntimeError("failed to create <borderFills> element")
|
|
912
920
|
|
|
921
|
+
normalized_border_type = str(border_type or "SOLID").upper()
|
|
922
|
+
if normalized_border_type not in self._BORDER_LINE_TYPES:
|
|
923
|
+
raise ValueError(
|
|
924
|
+
f"unsupported border_type {border_type!r}; expected one of "
|
|
925
|
+
+ ", ".join(sorted(self._BORDER_LINE_TYPES))
|
|
926
|
+
)
|
|
913
927
|
normalized_border_color = _normalize_color(border_color) or "#BFBFBF"
|
|
914
928
|
normalized_border_width = str(border_width or "0.12 mm")
|
|
915
929
|
normalized_active_borders = _normalize_border_side_names(active_borders)
|
|
@@ -922,6 +936,7 @@ class HwpxOxmlHeader:
|
|
|
922
936
|
border_width=normalized_border_width,
|
|
923
937
|
fill_color=normalized_fill_color,
|
|
924
938
|
active_borders=normalized_active_borders,
|
|
939
|
+
border_type=normalized_border_type,
|
|
925
940
|
):
|
|
926
941
|
border_id = border_fill.get("id")
|
|
927
942
|
if border_id:
|
|
@@ -934,6 +949,7 @@ class HwpxOxmlHeader:
|
|
|
934
949
|
border_width=normalized_border_width,
|
|
935
950
|
fill_color=normalized_fill_color,
|
|
936
951
|
active_borders=normalized_active_borders,
|
|
952
|
+
border_type=normalized_border_type,
|
|
937
953
|
)
|
|
938
954
|
if isinstance(element, LET._Element):
|
|
939
955
|
new_border_fill = LET.fromstring(ET.tostring(new_border_fill, encoding="utf-8"))
|
hwpx/oxml/memo.py
CHANGED
|
@@ -14,7 +14,6 @@ from ._document_primitives import (
|
|
|
14
14
|
_default_sublist_attributes,
|
|
15
15
|
_element_local_name,
|
|
16
16
|
_memo_id,
|
|
17
|
-
_paragraph_id,
|
|
18
17
|
_sanitize_text,
|
|
19
18
|
)
|
|
20
19
|
|
|
@@ -213,14 +212,35 @@ class HwpxOxmlNote:
|
|
|
213
212
|
|
|
214
213
|
@text.setter
|
|
215
214
|
def text(self, value: str) -> None:
|
|
216
|
-
"""Replace the note body text.
|
|
215
|
+
"""Replace the note body text.
|
|
216
|
+
|
|
217
|
+
The gold-shaped body (각주/미주 style paragraph with the leading
|
|
218
|
+
``<hp:autoNum>`` control real Hancom requires to render the note) is
|
|
219
|
+
preserved: only text content is replaced.
|
|
220
|
+
"""
|
|
217
221
|
sublist = self.element.find(f"{_HP}subList")
|
|
218
222
|
if sublist is None:
|
|
219
|
-
|
|
220
|
-
|
|
223
|
+
attrs = _default_sublist_attributes()
|
|
224
|
+
attrs["vertAlign"] = "TOP"
|
|
225
|
+
sublist = _append_child(self.element, f"{_HP}subList", attrs)
|
|
226
|
+
paragraphs = sublist.findall(f"{_HP}p")
|
|
227
|
+
template = paragraphs[0] if paragraphs else None
|
|
228
|
+
p_attrs = (
|
|
229
|
+
dict(template.attrib)
|
|
230
|
+
if template is not None
|
|
231
|
+
else {"id": "0", **_DEFAULT_PARAGRAPH_ATTRS}
|
|
232
|
+
)
|
|
233
|
+
first_run = template.find(f"{_HP}run") if template is not None else None
|
|
234
|
+
run_attrs = dict(first_run.attrib) if first_run is not None else {"charPrIDRef": "0"}
|
|
235
|
+
auto_num = (
|
|
236
|
+
first_run.find(f"{_HP}ctrl") if first_run is not None else None
|
|
237
|
+
)
|
|
238
|
+
for p in paragraphs:
|
|
221
239
|
sublist.remove(p)
|
|
222
|
-
paragraph = _append_child(sublist, f"{_HP}p",
|
|
223
|
-
run = _append_child(paragraph, f"{_HP}run",
|
|
240
|
+
paragraph = _append_child(sublist, f"{_HP}p", p_attrs)
|
|
241
|
+
run = _append_child(paragraph, f"{_HP}run", run_attrs)
|
|
242
|
+
if auto_num is not None:
|
|
243
|
+
run.append(auto_num)
|
|
224
244
|
t = _append_child(run, f"{_HP}t", {})
|
|
225
245
|
t.text = _sanitize_text(value)
|
|
226
246
|
self.paragraph.section.mark_dirty()
|
hwpx/oxml/paragraph.py
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
-
from typing import TYPE_CHECKING, Sequence
|
|
7
|
+
from typing import TYPE_CHECKING, Sequence, cast
|
|
8
8
|
import warnings
|
|
9
9
|
import xml.etree.ElementTree as ET
|
|
10
10
|
|
|
@@ -23,7 +23,6 @@ from ._document_primitives import (
|
|
|
23
23
|
_default_sublist_attributes,
|
|
24
24
|
_is_tab_control_element,
|
|
25
25
|
_object_id,
|
|
26
|
-
_paragraph_id,
|
|
27
26
|
_sanitize_text,
|
|
28
27
|
)
|
|
29
28
|
from .memo import HwpxOxmlNote
|
|
@@ -405,6 +404,59 @@ class HwpxOxmlParagraph:
|
|
|
405
404
|
tables.append(HwpxOxmlTable(child, self))
|
|
406
405
|
return tables
|
|
407
406
|
|
|
407
|
+
def _context_table_width(self) -> int | None:
|
|
408
|
+
"""Hancom-like default table width for this paragraph's context.
|
|
409
|
+
|
|
410
|
+
Inside a table cell the parent cell's usable width bounds the new
|
|
411
|
+
(nested) table — the fidelity audit reproduced silent content loss
|
|
412
|
+
when the fixed per-cell default exceeded it. At body level Hancom
|
|
413
|
+
fits a new table to the text width (page width minus margins).
|
|
414
|
+
Returns ``None`` when neither context is measurable.
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
# stdlib ET has no getparent; lxml-backed elements do. Guarded getattr
|
|
418
|
+
# keeps both backends working (19 suite failures without the guard).
|
|
419
|
+
get_parent = getattr(self.element, "getparent", None)
|
|
420
|
+
ancestor = cast(
|
|
421
|
+
"ET.Element | None", get_parent() if callable(get_parent) else None
|
|
422
|
+
)
|
|
423
|
+
while ancestor is not None:
|
|
424
|
+
if tag_local_name(ancestor.tag) == "tc":
|
|
425
|
+
cell_sz = ancestor.find(f"{_HP}cellSz")
|
|
426
|
+
margin = ancestor.find(f"{_HP}cellMargin")
|
|
427
|
+
if cell_sz is not None:
|
|
428
|
+
try:
|
|
429
|
+
width = int(cell_sz.get("width", "0"))
|
|
430
|
+
except ValueError:
|
|
431
|
+
return None
|
|
432
|
+
pad = 0
|
|
433
|
+
if margin is not None:
|
|
434
|
+
try:
|
|
435
|
+
pad = int(margin.get("left", "0")) + int(
|
|
436
|
+
margin.get("right", "0")
|
|
437
|
+
)
|
|
438
|
+
except ValueError:
|
|
439
|
+
pad = 0
|
|
440
|
+
usable = width - pad
|
|
441
|
+
return usable if usable > 0 else None
|
|
442
|
+
return None
|
|
443
|
+
next_parent = getattr(ancestor, "getparent", None)
|
|
444
|
+
ancestor = cast(
|
|
445
|
+
"ET.Element | None",
|
|
446
|
+
next_parent() if callable(next_parent) else None,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
properties = self.section.properties
|
|
451
|
+
page = properties.page_size
|
|
452
|
+
margins = properties.page_margins
|
|
453
|
+
except Exception:
|
|
454
|
+
return None
|
|
455
|
+
if page.width <= 0:
|
|
456
|
+
return None
|
|
457
|
+
usable = page.width - margins.left - margins.right - margins.gutter
|
|
458
|
+
return usable if usable > 0 else None
|
|
459
|
+
|
|
408
460
|
def add_table(
|
|
409
461
|
self,
|
|
410
462
|
rows: int,
|
|
@@ -416,6 +468,8 @@ class HwpxOxmlParagraph:
|
|
|
416
468
|
run_attributes: dict[str, str] | None = None,
|
|
417
469
|
char_pr_id_ref: str | int | None = None,
|
|
418
470
|
) -> HwpxOxmlTable:
|
|
471
|
+
if width is None:
|
|
472
|
+
width = self._context_table_width()
|
|
419
473
|
if border_fill_id_ref is None:
|
|
420
474
|
document = self.section.document
|
|
421
475
|
if document is not None:
|
|
@@ -809,8 +863,10 @@ class HwpxOxmlParagraph:
|
|
|
809
863
|
"""
|
|
810
864
|
field_id = _object_id()
|
|
811
865
|
|
|
812
|
-
#
|
|
813
|
-
|
|
866
|
+
# 실한컴 gold 관례: 링크 서식(charPr)은 표시 텍스트 런에만 싣고
|
|
867
|
+
# fieldBegin/fieldEnd 런은 주변 서식을 유지한다 — 뒤에 추가되는
|
|
868
|
+
# 문단이 링크 서식을 상속(파랑 전염)하지 않게 하는 조건이기도 하다.
|
|
869
|
+
run1 = self._create_run_for_object()
|
|
814
870
|
ctrl1 = _append_child(run1, f"{_HP}ctrl", {})
|
|
815
871
|
fb_attrs: dict[str, str] = {
|
|
816
872
|
"id": field_id,
|
|
@@ -827,7 +883,7 @@ class HwpxOxmlParagraph:
|
|
|
827
883
|
t.text = _sanitize_text(display_text)
|
|
828
884
|
|
|
829
885
|
# Run 3: fieldEnd
|
|
830
|
-
run3 = self._create_run_for_object(
|
|
886
|
+
run3 = self._create_run_for_object()
|
|
831
887
|
ctrl3 = _append_child(run3, f"{_HP}ctrl", {})
|
|
832
888
|
_append_child(ctrl3, f"{_HP}fieldEnd", {"beginIDRef": field_id})
|
|
833
889
|
|
|
@@ -1126,6 +1182,61 @@ class HwpxOxmlParagraph:
|
|
|
1126
1182
|
# Footnote / Endnote helpers
|
|
1127
1183
|
# ------------------------------------------------------------------
|
|
1128
1184
|
|
|
1185
|
+
_NOTE_STYLE_NAMES = {"footNote": ("각주", "Footnote"), "endNote": ("미주", "Endnote")}
|
|
1186
|
+
_NOTE_STYLE_FALLBACK = {"footNote": ("15", "10", "3"), "endNote": ("16", "10", "3")}
|
|
1187
|
+
|
|
1188
|
+
def _note_style_refs(self, tag: str) -> tuple[str, str, str]:
|
|
1189
|
+
"""(styleIDRef, paraPrIDRef, charPrIDRef) for the note body paragraph.
|
|
1190
|
+
|
|
1191
|
+
Real Hancom puts note bodies on the 각주/미주 paragraph styles. Resolve
|
|
1192
|
+
by style name from the header so documents with re-numbered styles stay
|
|
1193
|
+
correct; fall back to the fixed template coordinates.
|
|
1194
|
+
"""
|
|
1195
|
+
|
|
1196
|
+
korean, english = self._NOTE_STYLE_NAMES[tag]
|
|
1197
|
+
document = self.section.document
|
|
1198
|
+
headers = cast(
|
|
1199
|
+
"Sequence[object]",
|
|
1200
|
+
getattr(document, "_headers", []) if document is not None else [],
|
|
1201
|
+
)
|
|
1202
|
+
for header in headers:
|
|
1203
|
+
styles = getattr(header, "_styles_element", None)
|
|
1204
|
+
container = cast(
|
|
1205
|
+
"ET.Element | None", styles() if callable(styles) else None
|
|
1206
|
+
)
|
|
1207
|
+
if container is None:
|
|
1208
|
+
continue
|
|
1209
|
+
for style in container:
|
|
1210
|
+
if style.get("name") == korean or style.get("engName") == english:
|
|
1211
|
+
return (
|
|
1212
|
+
style.get("id") or self._NOTE_STYLE_FALLBACK[tag][0],
|
|
1213
|
+
style.get("paraPrIDRef") or self._NOTE_STYLE_FALLBACK[tag][1],
|
|
1214
|
+
style.get("charPrIDRef") or self._NOTE_STYLE_FALLBACK[tag][2],
|
|
1215
|
+
)
|
|
1216
|
+
return self._NOTE_STYLE_FALLBACK[tag]
|
|
1217
|
+
|
|
1218
|
+
def _note_suffix_char(self, tag: str) -> str:
|
|
1219
|
+
"""The note suffix character from secPr, defaulting to ")"."""
|
|
1220
|
+
|
|
1221
|
+
properties = self.section._section_properties_element()
|
|
1222
|
+
if properties is not None:
|
|
1223
|
+
pr = properties.find(f"{_HP}{tag}Pr")
|
|
1224
|
+
if pr is not None:
|
|
1225
|
+
fmt = pr.find(f"{_HP}autoNumFormat")
|
|
1226
|
+
if fmt is not None and fmt.get("suffixChar"):
|
|
1227
|
+
return fmt.get("suffixChar", ")")
|
|
1228
|
+
return ")"
|
|
1229
|
+
|
|
1230
|
+
def _next_note_number(self, tag: str) -> int:
|
|
1231
|
+
"""Document-continuous note number, counted per note type."""
|
|
1232
|
+
|
|
1233
|
+
document = self.section.document
|
|
1234
|
+
sections = document.sections if document is not None else [self.section]
|
|
1235
|
+
count = 0
|
|
1236
|
+
for section in sections:
|
|
1237
|
+
count += sum(1 for _ in section.element.iter(f"{_HP}{tag}"))
|
|
1238
|
+
return count + 1
|
|
1239
|
+
|
|
1129
1240
|
def _add_note(
|
|
1130
1241
|
self,
|
|
1131
1242
|
tag: str,
|
|
@@ -1134,17 +1245,54 @@ class HwpxOxmlParagraph:
|
|
|
1134
1245
|
run_attributes: dict[str, str] | None = None,
|
|
1135
1246
|
char_pr_id_ref: str | int | None = None,
|
|
1136
1247
|
) -> HwpxOxmlNote:
|
|
1137
|
-
"""Insert a ``<hp:footNote>`` or ``<hp:endNote>`` element.
|
|
1248
|
+
"""Insert a ``<hp:footNote>`` or ``<hp:endNote>`` element.
|
|
1249
|
+
|
|
1250
|
+
Emits the real-Hancom shape (gold-reversed): the note element is
|
|
1251
|
+
wrapped in ``<hp:ctrl>`` inside a body run, carries ``number`` and
|
|
1252
|
+
``suffixChar``, and its body paragraph uses the 각주/미주 style with a
|
|
1253
|
+
leading ``<hp:autoNum>`` control — without these real Hancom does not
|
|
1254
|
+
render the note at all.
|
|
1255
|
+
"""
|
|
1138
1256
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1257
|
+
number = self._next_note_number(tag)
|
|
1258
|
+
suffix = self._note_suffix_char(tag)
|
|
1259
|
+
style_ref, para_pr_ref, note_char_ref = self._note_style_refs(tag)
|
|
1260
|
+
if char_pr_id_ref is not None:
|
|
1261
|
+
note_char_ref = str(char_pr_id_ref)
|
|
1262
|
+
|
|
1263
|
+
runs = self._run_elements()
|
|
1264
|
+
if run_attributes is None and runs:
|
|
1265
|
+
run = runs[-1]
|
|
1266
|
+
else:
|
|
1267
|
+
run = self._create_run_for_object(run_attributes, char_pr_id_ref=char_pr_id_ref)
|
|
1268
|
+
ctrl = _append_child(run, f"{_HP}ctrl", {})
|
|
1269
|
+
note_element = _append_child(
|
|
1270
|
+
ctrl,
|
|
1271
|
+
f"{_HP}{tag}",
|
|
1272
|
+
{
|
|
1273
|
+
"number": str(number),
|
|
1274
|
+
"suffixChar": str(ord(suffix[0])) if suffix else "41",
|
|
1275
|
+
"instId": _object_id(),
|
|
1276
|
+
},
|
|
1277
|
+
)
|
|
1278
|
+
sublist_attrs = _default_sublist_attributes()
|
|
1279
|
+
sublist_attrs["vertAlign"] = "TOP"
|
|
1280
|
+
sublist = _append_child(note_element, f"{_HP}subList", sublist_attrs)
|
|
1281
|
+
p_attrs = dict(_DEFAULT_PARAGRAPH_ATTRS)
|
|
1282
|
+
p_attrs.update({"id": "0", "paraPrIDRef": para_pr_ref, "styleIDRef": style_ref})
|
|
1143
1283
|
paragraph = _append_child(sublist, f"{_HP}p", p_attrs)
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1284
|
+
note_run = _append_child(paragraph, f"{_HP}run", {"charPrIDRef": note_char_ref})
|
|
1285
|
+
num_ctrl = _append_child(note_run, f"{_HP}ctrl", {})
|
|
1286
|
+
auto_num = _append_child(
|
|
1287
|
+
num_ctrl,
|
|
1288
|
+
f"{_HP}autoNum",
|
|
1289
|
+
{"num": str(number), "numType": "FOOTNOTE" if tag == "footNote" else "ENDNOTE"},
|
|
1290
|
+
)
|
|
1291
|
+
_append_child(
|
|
1292
|
+
auto_num,
|
|
1293
|
+
f"{_HP}autoNumFormat",
|
|
1294
|
+
{"type": "DIGIT", "userChar": "", "prefixChar": "", "suffixChar": suffix, "supscript": "0"},
|
|
1295
|
+
)
|
|
1148
1296
|
t = _append_child(note_run, f"{_HP}t", {})
|
|
1149
1297
|
t.text = _sanitize_text(text)
|
|
1150
1298
|
self.section.mark_dirty()
|
hwpx/oxml/section.py
CHANGED
|
@@ -25,6 +25,7 @@ from ._document_primitives import (
|
|
|
25
25
|
from .memo import HwpxOxmlMemo, HwpxOxmlMemoGroup
|
|
26
26
|
from .paragraph import HwpxOxmlParagraph
|
|
27
27
|
from .section_format import HwpxOxmlSectionProperties
|
|
28
|
+
from .namespaces import tag_local_name
|
|
28
29
|
from .section_story import HwpxOxmlSectionHeaderFooter
|
|
29
30
|
|
|
30
31
|
if TYPE_CHECKING:
|
|
@@ -176,6 +177,55 @@ class HwpxOxmlSection:
|
|
|
176
177
|
paragraph = paras[paragraph]
|
|
177
178
|
paragraph.remove()
|
|
178
179
|
|
|
180
|
+
def _inherited_paragraph_refs(
|
|
181
|
+
self,
|
|
182
|
+
) -> tuple[str | None, str | None, str | None]:
|
|
183
|
+
"""Style refs the next appended paragraph inherits from the last one.
|
|
184
|
+
|
|
185
|
+
A list paragraph (bullet/numbered paraPr) must not leak into the next
|
|
186
|
+
paragraph — in Hancom, Enter continues the list but programmatic
|
|
187
|
+
appends of ordinary text are body text. The fidelity audit reproduced
|
|
188
|
+
every following paragraph turning into a list item without this guard.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
existing = self.paragraphs
|
|
192
|
+
if not existing:
|
|
193
|
+
return None, None, None
|
|
194
|
+
last = existing[-1]
|
|
195
|
+
prev_para_ref = last.para_pr_id_ref
|
|
196
|
+
prev_style_ref = last.style_id_ref
|
|
197
|
+
prev_char_ref = last.char_pr_id_ref
|
|
198
|
+
if prev_para_ref is not None and self._para_pr_is_list(prev_para_ref):
|
|
199
|
+
prev_para_ref = None
|
|
200
|
+
prev_style_ref = None
|
|
201
|
+
return prev_para_ref, prev_style_ref, prev_char_ref
|
|
202
|
+
|
|
203
|
+
def _para_pr_is_list(self, para_pr_id_ref: str | int) -> bool:
|
|
204
|
+
"""True when the paraPr carries a bullet/numbered heading."""
|
|
205
|
+
|
|
206
|
+
document = self.document
|
|
207
|
+
if document is None:
|
|
208
|
+
return False
|
|
209
|
+
wanted = str(para_pr_id_ref)
|
|
210
|
+
for header in getattr(document, "_headers", []):
|
|
211
|
+
para_properties = getattr(header, "_para_properties_element", None)
|
|
212
|
+
if para_properties is None:
|
|
213
|
+
continue
|
|
214
|
+
container = para_properties()
|
|
215
|
+
if container is None:
|
|
216
|
+
continue
|
|
217
|
+
for para_pr in container:
|
|
218
|
+
if para_pr.get("id") != wanted:
|
|
219
|
+
continue
|
|
220
|
+
for child in para_pr:
|
|
221
|
+
if tag_local_name(child.tag) == "heading":
|
|
222
|
+
return str(child.get("type", "")).upper() in {
|
|
223
|
+
"BULLET",
|
|
224
|
+
"NUMBER",
|
|
225
|
+
}
|
|
226
|
+
return False
|
|
227
|
+
return False
|
|
228
|
+
|
|
179
229
|
def add_paragraph(
|
|
180
230
|
self,
|
|
181
231
|
text: str = "",
|
|
@@ -202,16 +252,9 @@ class HwpxOxmlSection:
|
|
|
202
252
|
"""
|
|
203
253
|
|
|
204
254
|
# Collect style refs from the last paragraph for inheritance.
|
|
205
|
-
prev_para_ref
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if inherit_style:
|
|
209
|
-
existing = self.paragraphs
|
|
210
|
-
if existing:
|
|
211
|
-
last = existing[-1]
|
|
212
|
-
prev_para_ref = last.para_pr_id_ref
|
|
213
|
-
prev_style_ref = last.style_id_ref
|
|
214
|
-
prev_char_ref = last.char_pr_id_ref
|
|
255
|
+
prev_para_ref, prev_style_ref, prev_char_ref = (
|
|
256
|
+
self._inherited_paragraph_refs() if inherit_style else (None, None, None)
|
|
257
|
+
)
|
|
215
258
|
|
|
216
259
|
attrs = {"id": _paragraph_id(), **_DEFAULT_PARAGRAPH_ATTRS}
|
|
217
260
|
attrs.update(extra_attrs)
|
hwpx/oxml/table.py
CHANGED
|
@@ -12,6 +12,7 @@ import xml.etree.ElementTree as ET
|
|
|
12
12
|
from ._document_primitives import (
|
|
13
13
|
_DEFAULT_CELL_HEIGHT,
|
|
14
14
|
_DEFAULT_CELL_WIDTH,
|
|
15
|
+
_default_cell_inner_margin_attributes,
|
|
15
16
|
_DEFAULT_PARAGRAPH_ATTRS,
|
|
16
17
|
_HP,
|
|
17
18
|
_append_child,
|
|
@@ -459,7 +460,7 @@ class HwpxOxmlTable:
|
|
|
459
460
|
},
|
|
460
461
|
)
|
|
461
462
|
ET.SubElement(table, f"{_HP}outMargin", _default_cell_margin_attributes())
|
|
462
|
-
ET.SubElement(table, f"{_HP}inMargin",
|
|
463
|
+
ET.SubElement(table, f"{_HP}inMargin", _default_cell_inner_margin_attributes())
|
|
463
464
|
|
|
464
465
|
column_widths = _distribute_size(max(table_width, 0), cols)
|
|
465
466
|
row_heights = _distribute_size(max(table_height, 0), rows)
|
|
@@ -486,7 +487,7 @@ class HwpxOxmlTable:
|
|
|
486
487
|
"height": str(row_heights[row_index] if row_heights else 0),
|
|
487
488
|
},
|
|
488
489
|
)
|
|
489
|
-
ET.SubElement(cell, f"{_HP}cellMargin",
|
|
490
|
+
ET.SubElement(cell, f"{_HP}cellMargin", _default_cell_inner_margin_attributes())
|
|
490
491
|
return table
|
|
491
492
|
|
|
492
493
|
def mark_dirty(self) -> None:
|
|
@@ -647,6 +648,18 @@ class HwpxOxmlTable:
|
|
|
647
648
|
entry = self._grid_entry(row_index, col_index)
|
|
648
649
|
return entry.cell
|
|
649
650
|
|
|
651
|
+
def set_cell_border_fill(
|
|
652
|
+
self, row_index: int, col_index: int, border_fill_id_ref: str | int
|
|
653
|
+
) -> None:
|
|
654
|
+
"""Point one cell at a header ``borderFill`` definition.
|
|
655
|
+
|
|
656
|
+
Pairs with :meth:`set_cell_shading`; obtain ids from
|
|
657
|
+
``document.ensure_border_fill`` (line style/width/color/fill).
|
|
658
|
+
"""
|
|
659
|
+
cell = self.cell(row_index, col_index)
|
|
660
|
+
cell.element.set("borderFillIDRef", str(border_fill_id_ref))
|
|
661
|
+
self.mark_dirty()
|
|
662
|
+
|
|
650
663
|
def set_cell_shading(self, row_index: int, col_index: int, color: str) -> None:
|
|
651
664
|
cell = self.cell(row_index, col_index)
|
|
652
665
|
document = self.paragraph.section.document
|
hwpx/tools/markdown_export.py
CHANGED
|
@@ -228,7 +228,7 @@ def _md_push_text(state: _MdParagraphState, cpr, text) -> None:
|
|
|
228
228
|
state.items.append((cpr, text))
|
|
229
229
|
|
|
230
230
|
|
|
231
|
-
def _md_handle_ctrl_child(child, state: _MdParagraphState, base_cp, chars) -> None:
|
|
231
|
+
def _md_handle_ctrl_child(child, state: _MdParagraphState, base_cp, chars, doc=None, notes_out=None) -> None:
|
|
232
232
|
for gc in child:
|
|
233
233
|
gctag = _local_name(gc)
|
|
234
234
|
if gctag == "fieldBegin" and gc.get("type") == "HYPERLINK":
|
|
@@ -236,6 +236,9 @@ def _md_handle_ctrl_child(child, state: _MdParagraphState, base_cp, chars) -> No
|
|
|
236
236
|
state.link_url = gc.get("name", "")
|
|
237
237
|
elif gctag == "fieldEnd":
|
|
238
238
|
_md_flush_link(state, base_cp, chars)
|
|
239
|
+
elif gctag in ("footNote", "endNote"):
|
|
240
|
+
# 실한컴 계약: 각주/미주는 run 안 <hp:ctrl>에 래핑되어 있다.
|
|
241
|
+
_md_handle_note_child(gc, gctag, state, base_cp, chars, doc, notes_out)
|
|
239
242
|
|
|
240
243
|
|
|
241
244
|
def _md_handle_note_child(
|
|
@@ -271,8 +274,9 @@ def _p_element_to_md(p_el, doc, notes_out: list | None = None) -> str:
|
|
|
271
274
|
if child.text:
|
|
272
275
|
_md_push_text(state, cpr, child.text)
|
|
273
276
|
elif tag == "ctrl":
|
|
274
|
-
_md_handle_ctrl_child(child, state, base_cp, chars)
|
|
277
|
+
_md_handle_ctrl_child(child, state, base_cp, chars, doc, notes_out)
|
|
275
278
|
elif tag in ("footNote", "endNote"):
|
|
279
|
+
# 구식(5.4 이전 자사 방출) 호환: run 직속 각주도 계속 읽는다.
|
|
276
280
|
_md_handle_note_child(child, tag, state, base_cp, chars, doc, notes_out)
|
|
277
281
|
|
|
278
282
|
_md_flush_items(state, base_cp, chars)
|
hwpx/tools/text_extractor.py
CHANGED
|
@@ -483,6 +483,20 @@ class TextExtractor:
|
|
|
483
483
|
annotations: Optional[AnnotationOptions],
|
|
484
484
|
preserve_breaks: bool,
|
|
485
485
|
) -> None:
|
|
486
|
+
# 실한컴 계약: 각주/미주는 <hp:ctrl> 래핑으로 나타난다 — annotations
|
|
487
|
+
# 유무와 무관하게 노트 분기로 위임한다(구식 run 직속도 별도 분기 유지).
|
|
488
|
+
for note_tag in ("footNote", "endNote"):
|
|
489
|
+
note = _first_child_by_local(element, note_tag)
|
|
490
|
+
if note is not None:
|
|
491
|
+
self._handle_note(
|
|
492
|
+
note,
|
|
493
|
+
fragments,
|
|
494
|
+
note_tag,
|
|
495
|
+
annotations=annotations,
|
|
496
|
+
preserve_breaks=preserve_breaks,
|
|
497
|
+
)
|
|
498
|
+
return
|
|
499
|
+
|
|
486
500
|
if annotations is None:
|
|
487
501
|
return
|
|
488
502
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
hwpx/__init__.py,sha256=WmlWHk6vfjr2HQa6Dzp6rmIXIUJXKWKDZJZ-LBpL3Nc,15936
|
|
2
2
|
hwpx/body_patch.py,sha256=XfwrTMThS9vPLA62PDnEleCGuvYv61URxelbD_LHe8w,25730
|
|
3
|
-
hwpx/document.py,sha256=
|
|
3
|
+
hwpx/document.py,sha256=XV8T-ucd0F-Uup6XBUe_ZLliSNuImq7MgGhefIwMQD0,67298
|
|
4
4
|
hwpx/errors.py,sha256=l3QK2Izwkpm25ar7QnNQvyg0yosoLfQt7kW5wn5MCG0,3033
|
|
5
5
|
hwpx/experimental.py,sha256=BfD3fvXDgiEEm8G66TuUe_OPETitGwFU0VurUXUuV9Q,1693
|
|
6
6
|
hwpx/mutation_report.py,sha256=6hurhDdgGiONeLIeWZJT8lwLtWJZ1VHp1l6G0zy0mQo,19409
|
|
@@ -12,7 +12,7 @@ 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
14
|
hwpx/_document/fields.py,sha256=6VB7FV69cWcHLONj_Bn_DUGH5k9IMn94SRhRCia6wvI,22935
|
|
15
|
-
hwpx/_document/layout.py,sha256=
|
|
15
|
+
hwpx/_document/layout.py,sha256=_fNHVqI2zFUn6L1-JsdyO8Z4UShmIi9G29Y5YJtQtKw,23553
|
|
16
16
|
hwpx/_document/media.py,sha256=kamsFJZ4K9aoKhjyztMHC64MVFR_ZQ50rDVgouZqxFA,11592
|
|
17
17
|
hwpx/_document/memos.py,sha256=_tscEhNfPe9-iAkcEtJGjwHTN42_lx4stFoPrgJxk3o,7481
|
|
18
18
|
hwpx/_document/persistence.py,sha256=NgWeFmm6jWV11194kdx32lwRQ6dyqiQYnc1ZBzJcIbE,16003
|
|
@@ -43,28 +43,28 @@ hwpx/opc/security.py,sha256=hsA73sZxUoGIgy2zue9EnV7ChHMfasnplmqzxFs3Mp4,4419
|
|
|
43
43
|
hwpx/opc/xml_utils.py,sha256=L_fHY1-D5I_TfdRkDQV-bn55EnXc6AqEDWItfMpawVs,3840
|
|
44
44
|
hwpx/oxml/__init__.py,sha256=pqHqOK2snS74U_mkmkFcgyCUCLiGmbS84TKFBrwxF5I,5507
|
|
45
45
|
hwpx/oxml/_document_impl.py,sha256=41cxktJqXHER37fjA5HIFR8kw7dHMoISTxEv53PRBYo,5367
|
|
46
|
-
hwpx/oxml/_document_primitives.py,sha256=
|
|
46
|
+
hwpx/oxml/_document_primitives.py,sha256=4nMOkaDmNbpg_kKRG0_Nr0k5TqmDGRWR_f9jhCykdZY,19207
|
|
47
47
|
hwpx/oxml/body.py,sha256=qd_t8bPdK4QaW_y7hEIOsqM_qiB1UAKQXzvGI4xriGA,32163
|
|
48
48
|
hwpx/oxml/canonical_defaults.py,sha256=WHAK7u_W-PDv3P3N1-1-aWAoiUgBh4x-UybFL4NqW-w,3791
|
|
49
49
|
hwpx/oxml/color.py,sha256=K2lbU_JdqRxFkQelGD6BbBkH1IspJSQyGlKHNz1Nmf4,2393
|
|
50
50
|
hwpx/oxml/common.py,sha256=TJkafzg7x4T3J29tZchRZk57ZTsrM9PEiqGT3rX3w5o,1044
|
|
51
51
|
hwpx/oxml/document.py,sha256=0Ml-t2fwa4f2xMfu5jbkOoh_KDKCCf3gNpaYICRV-KY,1172
|
|
52
|
-
hwpx/oxml/document_parts.py,sha256=
|
|
52
|
+
hwpx/oxml/document_parts.py,sha256=NSlCmXKfItIaTEYruu6zUh-XHZRRDI9XZ2U-5rD2UUU,49240
|
|
53
53
|
hwpx/oxml/header.py,sha256=4LtTDPe7YW0o_4jPv3Rd-vyv5suRfebecxtTWbF65Uw,50011
|
|
54
|
-
hwpx/oxml/header_part.py,sha256=
|
|
55
|
-
hwpx/oxml/memo.py,sha256=
|
|
54
|
+
hwpx/oxml/header_part.py,sha256=JBOw3Ljkt0PCYYKP7IlFpxA1ZRbrBHqDo8hjvE-yMsk,51409
|
|
55
|
+
hwpx/oxml/memo.py,sha256=jTtZIrR7LwWn_vvFN6Fr9yX2eSJfMPh8WnNW1DhD2Rs,10283
|
|
56
56
|
hwpx/oxml/namespaces.py,sha256=c7JfdOdJbzrhyHvjbxoeeeloRE3xPuoB7v3YI8SmKDk,6524
|
|
57
57
|
hwpx/oxml/numbering.py,sha256=9a0ARGW1DkKsTF7mEEXM4FA5loQmzIBuqE4APA8UFIQ,669
|
|
58
58
|
hwpx/oxml/objects.py,sha256=8ErDXWvkjLm3FTsx0Z_IovUXbiv2uc-j0B5Y2-OtihA,20864
|
|
59
|
-
hwpx/oxml/paragraph.py,sha256=
|
|
59
|
+
hwpx/oxml/paragraph.py,sha256=btJlX6WScB9gjKRu2uAs1qAdEQYar2BuKdSwNfdRm9U,53408
|
|
60
60
|
hwpx/oxml/parser.py,sha256=pIfyNdW3WdFkCcE8JFY7hn1fobRbMngzfhVZICvWeVs,2937
|
|
61
61
|
hwpx/oxml/run.py,sha256=F939J1W6zQjr-JQ1Z8-nKh2s7NQ_0Okov4QJAEyw_5M,15503
|
|
62
62
|
hwpx/oxml/schema.py,sha256=ElR3_IIhhPPZEqJtNKMNCHA-VFVLdhL454W_4spuvlc,1284
|
|
63
|
-
hwpx/oxml/section.py,sha256=
|
|
63
|
+
hwpx/oxml/section.py,sha256=W91Si7-kaUvl2y_VLV5kpsXPGtT5fUwOyIRh6qQLGyM,13395
|
|
64
64
|
hwpx/oxml/section_format.py,sha256=EKqod989mzcaPz-lDKM1Y7atdpFX9x_SdQ8zfCL3BpU,18320
|
|
65
65
|
hwpx/oxml/section_story.py,sha256=zvUV7O4dtogWcM9-zudRAszCHRwfzTBMW2GGI1Q24kg,22410
|
|
66
66
|
hwpx/oxml/simple_parts.py,sha256=dOVKWlUCQV8GePH0WRWPUmRylvH5O8bu0B6jj_4lejk,1864
|
|
67
|
-
hwpx/oxml/table.py,sha256=
|
|
67
|
+
hwpx/oxml/table.py,sha256=sImxS7SMgfhgvaAVUg-zILeMqlrRY3BD2GmmLtTbREQ,42727
|
|
68
68
|
hwpx/oxml/utils.py,sha256=2L9au3SD_MFkFt2DXx3udXBT5caYuYO623lhGhgldVo,2972
|
|
69
69
|
hwpx/quality/__init__.py,sha256=zvOvfOYh0nlKH5ywpj9ZQHov0sdRIouicv9DamKmHLU,1478
|
|
70
70
|
hwpx/quality/ledger.py,sha256=MvrUxd6LlJPD_dWoD5yH9ceFPNcjBR6tr6STqQqAdk0,3534
|
|
@@ -83,7 +83,7 @@ hwpx/tools/idempotence.py,sha256=kRZBWsPJFVprYRJkkxn3DHjrT6qX2nK7KvhmdgJ4JpA,501
|
|
|
83
83
|
hwpx/tools/ir_equality.py,sha256=NIjq33-4q7iTo_asv7WUvh_-EfB1JZMTwbLfYIw2c5w,4765
|
|
84
84
|
hwpx/tools/layout_preview.py,sha256=cHlBUEdMn_ac-hNFDUgF5r2PMM0cTw65l7sqq7Li8NM,22702
|
|
85
85
|
hwpx/tools/mail_merge.py,sha256=8kGlSk2GQL7cFxZgg6P_bKisj1zVUWy8qh8AXIZLK_w,18611
|
|
86
|
-
hwpx/tools/markdown_export.py,sha256=
|
|
86
|
+
hwpx/tools/markdown_export.py,sha256=_qhOqnYg2tFQa4-EuXyFdJvkc_-9x6ln9lx-MeqtNeY,20495
|
|
87
87
|
hwpx/tools/object_finder.py,sha256=ovXlbuPiQFdGzNSU08SuEzNFoeYcIb7GfF6vB9MRwBE,13800
|
|
88
88
|
hwpx/tools/package_reconcile.py,sha256=y1Hl7hbPh4YaV59LTdDLzQwgn4g1qEnFmSjmajnrEbA,2416
|
|
89
89
|
hwpx/tools/package_validator.py,sha256=fYkJevV_soQuM8LEUQMkwYaTy2k2HV4VWBwfIXFt29Y,33306
|
|
@@ -98,16 +98,16 @@ hwpx/tools/table_cleanup.py,sha256=0_f6NnvNp3QD4owKd_bRX6FZbeUmoQC7a4_VGzF2SCE,1
|
|
|
98
98
|
hwpx/tools/table_navigation.py,sha256=rtbrWFKpJhqC3LD0ZXImyHgjmDR2hjHCFy3_S-qNBwA,16479
|
|
99
99
|
hwpx/tools/template_analyzer.py,sha256=iH7egGRYNb8UesDB1TJ4tvW41-mlmv5Ap3VacqJFSlE,21355
|
|
100
100
|
hwpx/tools/text_extract_cli.py,sha256=BmsDAwNXpDPhEayb9ez2ORtGNzPd_Xxduy4_cLXhnUw,2188
|
|
101
|
-
hwpx/tools/text_extractor.py,sha256=
|
|
101
|
+
hwpx/tools/text_extractor.py,sha256=6Wtk3av69z848ZJAEh-fV4TU2BkAwk3qKJpIyUkYT_4,26122
|
|
102
102
|
hwpx/tools/toc_author.py,sha256=eS1Zm5OBoonuD_H92JxcN2o276d5XmcJDqpfscd4wSg,15445
|
|
103
103
|
hwpx/tools/toc_fidelity.py,sha256=rvoKH8QJ65WNVrxS0d-i2AtODvBy-4O8NRudfQ244v4,19890
|
|
104
104
|
hwpx/tools/validator.py,sha256=U856izL9NcJZOiKDYoCpwOSaFNQ2Un8Jn4pzL20A96Q,7100
|
|
105
105
|
hwpx/tools/_schemas/header.xsd,sha256=mJXuFMuHGT1JnFFaluUpYUglwjMCNlfbFCRVM26eHXE,664
|
|
106
106
|
hwpx/tools/_schemas/section.xsd,sha256=MgvavVHG05RDfUnVPxVU10H4FQOja5ON04_m9Uk_m7E,522
|
|
107
|
-
python_hwpx-5.
|
|
108
|
-
python_hwpx-5.
|
|
109
|
-
python_hwpx-5.
|
|
110
|
-
python_hwpx-5.
|
|
111
|
-
python_hwpx-5.
|
|
112
|
-
python_hwpx-5.
|
|
113
|
-
python_hwpx-5.
|
|
107
|
+
python_hwpx-5.5.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
|
|
108
|
+
python_hwpx-5.5.0.dist-info/licenses/NOTICE,sha256=auRgKYGdrOgWrj4kZfAuXQnqgDnx5myI9TI17IztIzQ,3466
|
|
109
|
+
python_hwpx-5.5.0.dist-info/METADATA,sha256=8nE4IAXunnxWpqeKPO01ZzKLHA1mXKqZUb034C9gB9k,13162
|
|
110
|
+
python_hwpx-5.5.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
111
|
+
python_hwpx-5.5.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
|
|
112
|
+
python_hwpx-5.5.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
|
|
113
|
+
python_hwpx-5.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|