python-hwpx 5.2.0__py3-none-any.whl → 5.4.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/shapes.py CHANGED
@@ -282,3 +282,78 @@ def add_equation(
282
282
  raise RuntimeError(
283
283
  "created equation was not recognized by the standard section scan"
284
284
  )
285
+
286
+
287
+ def add_chart(
288
+ doc: "HwpxDocument",
289
+ chart_xml: bytes | str,
290
+ *,
291
+ paragraph: HwpxOxmlParagraph | None = None,
292
+ section: HwpxOxmlSection | None = None,
293
+ section_index: int | None = None,
294
+ size: tuple[int, int] | None = None,
295
+ treat_as_char: bool = False,
296
+ char_pr_id_ref: str | int | None = None,
297
+ ) -> HwpxOxmlInlineObject:
298
+ """Add a chartML part and its ``<hp:chart>`` anchor, then prove recognition.
299
+
300
+ The part is stored under ``Chart/chartN.xml`` and addressed directly by
301
+ the anchor's ``chartIDRef`` — real Hancom registers chart parts in no
302
+ manifest and draws the chart from the ECMA-376 chartML alone
303
+ (specs/055-chart-authoring/evidence/p0/chart-contract.md). The chartML is
304
+ validated to parse and to carry the ``c:chartSpace`` root before any part
305
+ is written; after insertion the anchor is re-read through the standard
306
+ section scan — creation fails loudly if it did not land (no
307
+ special-casing by design).
308
+ """
309
+ from lxml import etree as _etree # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing
310
+
311
+ from ..oxml.namespaces import HP
312
+
313
+ _CHART_SPACE = "{http://schemas.openxmlformats.org/drawingml/2006/chart}chartSpace"
314
+
315
+ data = chart_xml.encode("utf-8") if isinstance(chart_xml, str) else bytes(chart_xml)
316
+ if not data.strip():
317
+ raise ValueError("chart_xml must be non-empty chartML")
318
+ try:
319
+ root = _etree.fromstring(data)
320
+ except _etree.XMLSyntaxError as exc:
321
+ raise ValueError(f"chart_xml is not well-formed XML: {exc}") from exc
322
+ if root.tag != _CHART_SPACE:
323
+ raise ValueError(
324
+ "chart_xml root must be the ECMA-376 c:chartSpace element, "
325
+ f"got {root.tag!r}"
326
+ )
327
+
328
+ existing = {name for name in doc._package.part_names() if name.startswith("Chart/")}
329
+ n = 1
330
+ while f"Chart/chart{n}.xml" in existing:
331
+ n += 1
332
+ part_path = f"Chart/chart{n}.xml"
333
+
334
+ if paragraph is None:
335
+ paragraph = doc.add_paragraph(
336
+ "", section=section, section_index=section_index,
337
+ include_run=False,
338
+ )
339
+ doc._package.write(part_path, data)
340
+ inline_object = paragraph.add_chart(
341
+ part_path,
342
+ size=size,
343
+ treat_as_char=treat_as_char,
344
+ char_pr_id_ref=char_pr_id_ref,
345
+ )
346
+
347
+ created_id = inline_object.element.get("id", "")
348
+ for owning_section in doc.sections:
349
+ for candidate in owning_section.element.iter(f"{HP}chart"):
350
+ if candidate.get("id") != created_id:
351
+ continue
352
+ if candidate.get("chartIDRef") != part_path:
353
+ raise RuntimeError(
354
+ "created chart anchor does not reference its part"
355
+ )
356
+ return inline_object
357
+ raise RuntimeError(
358
+ "created chart was not recognized by the standard section scan"
359
+ )
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(
@@ -1431,6 +1463,48 @@ class HwpxDocument:
1431
1463
  )
1432
1464
 
1433
1465
 
1466
+ def add_chart(
1467
+ self,
1468
+ chart_xml: bytes | str,
1469
+ *,
1470
+ paragraph: HwpxOxmlParagraph | None = None,
1471
+ section: HwpxOxmlSection | None = None,
1472
+ section_index: int | None = None,
1473
+ size: tuple[int, int] | None = None,
1474
+ treat_as_char: bool = False,
1475
+ char_pr_id_ref: str | int | None = None,
1476
+ ) -> HwpxOxmlInlineObject:
1477
+ """Insert a native chart from ECMA-376 chartML. **Experimental contract.**
1478
+
1479
+ Stores *chart_xml* as a ``Chart/chartN.xml`` package part and emits the
1480
+ real-Hancom ``<hp:chart>`` anchor referencing it via ``chartIDRef``
1481
+ (contract: ``specs/055-chart-authoring/evidence/p0/chart-contract.md``).
1482
+ Hancom draws the chart from the chartML alone — no OLE fallback or
1483
+ pre-rendered image is written. The chartML must parse and carry the
1484
+ ``c:chartSpace`` root (typed rejection otherwise), and the created
1485
+ anchor is re-read through the standard section scan — creation fails
1486
+ loudly if the standard consumer would not see it.
1487
+
1488
+ Args:
1489
+ chart_xml: ECMA-376 chartML document (``c:chartSpace``).
1490
+ paragraph: Target paragraph (e.g. inside a table cell). When
1491
+ omitted a new paragraph is appended to *section*.
1492
+ size: Optional ``(width, height)`` HWPUNIT pair for the anchor.
1493
+ treat_as_char: ``True`` places the chart inline in the text flow;
1494
+ default mirrors the render-verified gold float placement.
1495
+ """
1496
+
1497
+ return _shapes.add_chart(
1498
+ self,
1499
+ chart_xml,
1500
+ paragraph=paragraph,
1501
+ section=section,
1502
+ section_index=section_index,
1503
+ size=size,
1504
+ treat_as_char=treat_as_char,
1505
+ char_pr_id_ref=char_pr_id_ref,
1506
+ )
1507
+
1434
1508
  def add_equation(
1435
1509
  self,
1436
1510
  script: str,
@@ -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/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
 
@@ -405,6 +405,59 @@ class HwpxOxmlParagraph:
405
405
  tables.append(HwpxOxmlTable(child, self))
406
406
  return tables
407
407
 
408
+ def _context_table_width(self) -> int | None:
409
+ """Hancom-like default table width for this paragraph's context.
410
+
411
+ Inside a table cell the parent cell's usable width bounds the new
412
+ (nested) table — the fidelity audit reproduced silent content loss
413
+ when the fixed per-cell default exceeded it. At body level Hancom
414
+ fits a new table to the text width (page width minus margins).
415
+ Returns ``None`` when neither context is measurable.
416
+ """
417
+
418
+ # stdlib ET has no getparent; lxml-backed elements do. Guarded getattr
419
+ # keeps both backends working (19 suite failures without the guard).
420
+ get_parent = getattr(self.element, "getparent", None)
421
+ ancestor = cast(
422
+ "ET.Element | None", get_parent() if callable(get_parent) else None
423
+ )
424
+ while ancestor is not None:
425
+ if tag_local_name(ancestor.tag) == "tc":
426
+ cell_sz = ancestor.find(f"{_HP}cellSz")
427
+ margin = ancestor.find(f"{_HP}cellMargin")
428
+ if cell_sz is not None:
429
+ try:
430
+ width = int(cell_sz.get("width", "0"))
431
+ except ValueError:
432
+ return None
433
+ pad = 0
434
+ if margin is not None:
435
+ try:
436
+ pad = int(margin.get("left", "0")) + int(
437
+ margin.get("right", "0")
438
+ )
439
+ except ValueError:
440
+ pad = 0
441
+ usable = width - pad
442
+ return usable if usable > 0 else None
443
+ return None
444
+ next_parent = getattr(ancestor, "getparent", None)
445
+ ancestor = cast(
446
+ "ET.Element | None",
447
+ next_parent() if callable(next_parent) else None,
448
+ )
449
+
450
+ try:
451
+ properties = self.section.properties
452
+ page = properties.page_size
453
+ margins = properties.page_margins
454
+ except Exception:
455
+ return None
456
+ if page.width <= 0:
457
+ return None
458
+ usable = page.width - margins.left - margins.right - margins.gutter
459
+ return usable if usable > 0 else None
460
+
408
461
  def add_table(
409
462
  self,
410
463
  rows: int,
@@ -416,6 +469,8 @@ class HwpxOxmlParagraph:
416
469
  run_attributes: dict[str, str] | None = None,
417
470
  char_pr_id_ref: str | int | None = None,
418
471
  ) -> HwpxOxmlTable:
472
+ if width is None:
473
+ width = self._context_table_width()
419
474
  if border_fill_id_ref is None:
420
475
  document = self.section.document
421
476
  if document is not None:
@@ -809,8 +864,10 @@ class HwpxOxmlParagraph:
809
864
  """
810
865
  field_id = _object_id()
811
866
 
812
- # Run 1: fieldBegin
813
- run1 = self._create_run_for_object(char_pr_id_ref=char_pr_id_ref)
867
+ # 실한컴 gold 관례: 링크 서식(charPr)은 표시 텍스트 런에만 싣고
868
+ # fieldBegin/fieldEnd 런은 주변 서식을 유지한다 — 뒤에 추가되는
869
+ # 문단이 링크 서식을 상속(파랑 전염)하지 않게 하는 조건이기도 하다.
870
+ run1 = self._create_run_for_object()
814
871
  ctrl1 = _append_child(run1, f"{_HP}ctrl", {})
815
872
  fb_attrs: dict[str, str] = {
816
873
  "id": field_id,
@@ -827,13 +884,85 @@ class HwpxOxmlParagraph:
827
884
  t.text = _sanitize_text(display_text)
828
885
 
829
886
  # Run 3: fieldEnd
830
- run3 = self._create_run_for_object(char_pr_id_ref=char_pr_id_ref)
887
+ run3 = self._create_run_for_object()
831
888
  ctrl3 = _append_child(run3, f"{_HP}ctrl", {})
832
889
  _append_child(ctrl3, f"{_HP}fieldEnd", {"beginIDRef": field_id})
833
890
 
834
891
  self.section.mark_dirty()
835
892
  return HwpxOxmlInlineObject(ctrl1, self)
836
893
 
894
+ def add_chart(
895
+ self,
896
+ chart_id_ref: str,
897
+ *,
898
+ size: tuple[int, int] | None = None,
899
+ treat_as_char: bool = False,
900
+ char_pr_id_ref: str | int | None = None,
901
+ run_attributes: dict[str, str] | None = None,
902
+ ) -> HwpxOxmlInlineObject:
903
+ """Insert a native ``<hp:chart>`` anchor referencing a chartML part.
904
+
905
+ Emits the real-Hancom chart anchor (contract reverse-engineered in
906
+ specs/055-chart-authoring/evidence/p0/chart-contract.md): the chart
907
+ content lives in an ECMA-376 chartML part addressed directly by
908
+ ``chartIDRef`` (real Hancom registers the part in no manifest), and
909
+ Hancom lays the chart out from the chartML alone — no OLE fallback or
910
+ pre-rendered image is required.
911
+
912
+ Args:
913
+ chart_id_ref: Package path of the chartML part
914
+ (e.g. ``Chart/chart1.xml``).
915
+ size: Optional ``(width, height)`` HWPUNIT pair for ``<hp:sz>``;
916
+ defaults to the gold document's 32250x18750.
917
+ treat_as_char: ``True`` places the chart inline in the text flow;
918
+ the default mirrors the gold float placement.
919
+ """
920
+ reference = chart_id_ref.strip()
921
+ if not reference:
922
+ raise ValueError("chart_id_ref must be a non-empty package path")
923
+ width, height = size if size is not None else (32250, 18750)
924
+ run = self._create_run_for_object(
925
+ run_attributes, char_pr_id_ref=char_pr_id_ref
926
+ )
927
+ chart = _append_child(run, f"{_HP}chart", {
928
+ "id": _object_id(),
929
+ "zOrder": "0",
930
+ "numberingType": "PICTURE",
931
+ "textWrap": "TOP_AND_BOTTOM" if treat_as_char else "SQUARE",
932
+ "textFlow": "BOTH_SIDES",
933
+ "lock": "0",
934
+ "dropcapstyle": "None",
935
+ "chartIDRef": reference,
936
+ })
937
+ _append_child(chart, f"{_HP}sz", {
938
+ "width": str(width),
939
+ "widthRelTo": "ABSOLUTE",
940
+ "height": str(height),
941
+ "heightRelTo": "ABSOLUTE",
942
+ "protect": "0",
943
+ })
944
+ _append_child(chart, f"{_HP}pos", {
945
+ "treatAsChar": "1" if treat_as_char else "0",
946
+ "affectLSpacing": "0",
947
+ "flowWithText": "1",
948
+ "allowOverlap": "0",
949
+ "holdAnchorAndSO": "0",
950
+ "vertRelTo": "PARA",
951
+ "horzRelTo": "PARA" if treat_as_char else "COLUMN",
952
+ "vertAlign": "TOP",
953
+ "horzAlign": "LEFT",
954
+ "vertOffset": "0",
955
+ "horzOffset": "0",
956
+ })
957
+ _append_child(chart, f"{_HP}outMargin", {
958
+ "left": "0",
959
+ "right": "0",
960
+ "top": "0",
961
+ "bottom": "0",
962
+ })
963
+ self.section.mark_dirty()
964
+ return HwpxOxmlInlineObject(chart, self)
965
+
837
966
  def add_equation(
838
967
  self,
839
968
  script: str,
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-hwpx
3
- Version: 5.2.0
3
+ Version: 5.4.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=3N90Ra8krX4Skj5WUTDl4ezHf1q0yaBQCKqficnR1p8,64169
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,11 +12,11 @@ hwpx/templates.py,sha256=28bYqeJVeDb1Cq8G9NZG9Mhnu4K2GamAKC4QhxvUZyA,1187
12
12
  hwpx/_document/__init__.py,sha256=REiNqMbuk_TS4NVC27FIo1rUt3Z8nhJrTBGOgNtxBRg,90
13
13
  hwpx/_document/_units.py,sha256=qyC8YtnvV2VlSGltJntDaIMF_sHy_XJZCwwe-g080TQ,386
14
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
19
- hwpx/_document/shapes.py,sha256=XoCp41A6AmemOv9tGSMXRyu7sE7SMVBSzXSZfkelKWA,8825
19
+ hwpx/_document/shapes.py,sha256=NC3eaA4H2cXDdTi3REMaSZifXqwpChAhS4LPGCSDUc0,11702
20
20
  hwpx/_document/tracked.py,sha256=1sQ9Q3-_wOAH2SQPbJQAeCG9E9HjV3y1gCORzrPUEPo,5010
21
21
  hwpx/data/Skeleton.hwpx,sha256=yR-3epkzo-VkWiV3ji0rmemBN-2ZVypdHdKe8MD9TJg,7490
22
22
  hwpx/equation/__init__.py,sha256=0wks0_pS_tBokCVMeWUJGE6tbW35Ptc0I5Z_36FY-Jo,1328
@@ -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
54
+ hwpx/oxml/header_part.py,sha256=JBOw3Ljkt0PCYYKP7IlFpxA1ZRbrBHqDo8hjvE-yMsk,51409
55
55
  hwpx/oxml/memo.py,sha256=QlmuELEzywnmywgjm94S3vzJB8wPpoFOmI8KVuzoNPw,9484
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=U-bGkgMs3wiEBE1TXW6jSJj0SYwzN9xtWeOyOhLLJt0,44238
59
+ hwpx/oxml/paragraph.py,sha256=eZHoEnwiVhk9HvvhCZ77WMo1iulOD6BtLU_RvVHoDNY,49635
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
@@ -104,10 +104,10 @@ hwpx/tools/toc_fidelity.py,sha256=rvoKH8QJ65WNVrxS0d-i2AtODvBy-4O8NRudfQ244v4,19
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.2.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
108
- python_hwpx-5.2.0.dist-info/licenses/NOTICE,sha256=auRgKYGdrOgWrj4kZfAuXQnqgDnx5myI9TI17IztIzQ,3466
109
- python_hwpx-5.2.0.dist-info/METADATA,sha256=dTDpJNzdORyUZnVj8fANpARJ74j3QOekvJ9Mu_O5mYc,13162
110
- python_hwpx-5.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
111
- python_hwpx-5.2.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
112
- python_hwpx-5.2.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
113
- python_hwpx-5.2.0.dist-info/RECORD,,
107
+ python_hwpx-5.4.0.dist-info/licenses/LICENSE,sha256=_ubz4wv-BkkT3l3gu-QuH7JGeVjuRYGZoZK95eNsCHU,9688
108
+ python_hwpx-5.4.0.dist-info/licenses/NOTICE,sha256=auRgKYGdrOgWrj4kZfAuXQnqgDnx5myI9TI17IztIzQ,3466
109
+ python_hwpx-5.4.0.dist-info/METADATA,sha256=hd6N3Fnhn5arZdkRj-BfKR4or0Zceejz27w-lyHaQS8,13162
110
+ python_hwpx-5.4.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
111
+ python_hwpx-5.4.0.dist-info/entry_points.txt,sha256=JUKRxbly9UaeHV7YzOea23y8IiqSTcrhUlooP3fS_Zc,405
112
+ python_hwpx-5.4.0.dist-info/top_level.txt,sha256=R1iToqDh80Nf2oQhRjTN0rbN2X6kyDUizIocZjkhuxc,5
113
+ python_hwpx-5.4.0.dist-info/RECORD,,