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 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": "SOLID" if active else "NONE",
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 = "SOLID" if side in active_borders else "NONE"
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
 
@@ -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
- underline_attrs.setdefault("type", "SOLID")
131
- if underline_attrs.get("type", "").upper() == "NONE":
132
- underline_attrs["type"] = "SOLID"
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
- sublist = _append_child(self.element, f"{_HP}subList", _default_sublist_attributes())
220
- for p in sublist.findall(f"{_HP}p"):
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", {"id": _paragraph_id(), **_DEFAULT_PARAGRAPH_ATTRS})
223
- run = _append_child(paragraph, f"{_HP}run", {"charPrIDRef": "0"})
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
- # Run 1: fieldBegin
813
- run1 = self._create_run_for_object(char_pr_id_ref=char_pr_id_ref)
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(char_pr_id_ref=char_pr_id_ref)
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
- run = self._create_run_for_object(run_attributes, char_pr_id_ref=char_pr_id_ref)
1140
- note_element = _append_child(run, f"{_HP}{tag}", {"instId": _object_id()})
1141
- sublist = _append_child(note_element, f"{_HP}subList", _default_sublist_attributes())
1142
- p_attrs = {"id": _paragraph_id(), **_DEFAULT_PARAGRAPH_ATTRS}
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
- # 본문 run의 charPrIDRef도 인자를 따라가도록 적용 (host run과 동일 스타일).
1145
- # None이면 "0"(default).
1146
- body_cpr = "0" if char_pr_id_ref is None else str(char_pr_id_ref)
1147
- note_run = _append_child(paragraph, f"{_HP}run", {"charPrIDRef": body_cpr})
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: str | None = None
206
- prev_style_ref: str | None = None
207
- prev_char_ref: str | None = None
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", _default_cell_margin_attributes())
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", _default_cell_margin_attributes())
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
@@ -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)
@@ -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
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 5.3.0
3
+ Version: 5.5.0
4
4
  Summary: 한글 없이 HWPX 문서를 열고, 편집하고, 생성하고, 검증하는 Python 문서 라이브러리
5
5
  Author: python-hwpx Maintainers
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  hwpx/__init__.py,sha256=WmlWHk6vfjr2HQa6Dzp6rmIXIUJXKWKDZJZ-LBpL3Nc,15936
2
2
  hwpx/body_patch.py,sha256=XfwrTMThS9vPLA62PDnEleCGuvYv61URxelbD_LHe8w,25730
3
- hwpx/document.py,sha256=ly12zmjR96_4itGwJw9xljDI3sowmrSsZZfHHn4Znio,65995
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=TicBhdxXW2lQkLyG5bg6PHW-OP-X4oWdP6XAgAXw8mg,23089
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=IrruWTqHevJZpfnpZNmedeYErpyNIlRSYlkWyEUOA8k,18636
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=fbj_MA8imnKhz6XN2ufNAMPYNI5xi0dCNjEv0nGESwo,41692
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=BIwdzDlwJSxcVIErZ8jQuT2vQ4Tndr4Xa87beYbpSFU,50640
55
- hwpx/oxml/memo.py,sha256=QlmuELEzywnmywgjm94S3vzJB8wPpoFOmI8KVuzoNPw,9484
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=p9M22WLPt3_mjmQZGrpSS9agRDKAXwSvnKJGnCUgc48,47075
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=i3t1SKIMneA1qWAajDlmZ-yrWJF9VvqT-aoq3mRXtxE,11638
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=DhvbAlD9ZX0t2dhsR-XfrGbL3Iw-Xtq8KBJDXWtyxLA,42198
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=6g0G30Zk0psN9KeZWVMK_fnSzd6Ij9w7t-ZSOsHxW3c,20133
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=m9OSBd4FjZrzlMP8b4uS2Lnmjp-u-t7EBKFDF3P1bTM,25514
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.3.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
108
- python_hwpx-5.3.0.dist-info/licenses/NOTICE,sha256=auRgKYGdrOgWrj4kZfAuXQnqgDnx5myI9TI17IztIzQ,3466
109
- python_hwpx-5.3.0.dist-info/METADATA,sha256=ve8a6QxqNSMJ6H-PbMfQOS6OpFREBe8Xfe1jEWER33U,13162
110
- python_hwpx-5.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
111
- python_hwpx-5.3.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
112
- python_hwpx-5.3.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
113
- python_hwpx-5.3.0.dist-info/RECORD,,
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,,