python-hwpx 6.8.0__py3-none-any.whl → 6.9.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.
Files changed (59) hide show
  1. hwpx/_document/fields.py +1 -1
  2. hwpx/_document/layout.py +46 -25
  3. hwpx/_document/media.py +45 -8
  4. hwpx/_document/memos.py +3 -1
  5. hwpx/_document/ns/shapes.py +2 -2
  6. hwpx/_document/ns/tables.py +49 -3
  7. hwpx/_document/shapes.py +15 -7
  8. hwpx/body_patch.py +27 -12
  9. hwpx/capabilities.py +1 -1
  10. hwpx/data/contract_docs/support-matrix.md +4 -4
  11. hwpx/document.py +35 -1
  12. hwpx/errors.py +20 -1
  13. hwpx/form_fit/_glyph_table.py +3328 -142
  14. hwpx/form_fit/measure.py +836 -94
  15. hwpx/hwp5/docinfo_writer.py +4 -3
  16. hwpx/hwp5/header_xml.py +4 -3
  17. hwpx/hwp5/package.py +70 -3
  18. hwpx/hwp5/records.py +2 -0
  19. hwpx/hwp5/section_writer.py +6 -5
  20. hwpx/hwp5/section_xml.py +69 -6
  21. hwpx/hwp5/shape_xml.py +6 -4
  22. hwpx/hwp5/shapes.py +22 -16
  23. hwpx/layout/line_cache.py +636 -0
  24. hwpx/layout/lint.py +4 -3
  25. hwpx/layout/pages.py +1114 -225
  26. hwpx/oxml/__init__.py +4 -0
  27. hwpx/oxml/_document_primitives.py +30 -19
  28. hwpx/oxml/_paragraph_text_edit.py +5 -11
  29. hwpx/oxml/body.py +3 -3
  30. hwpx/oxml/container.py +377 -0
  31. hwpx/oxml/curves.py +10 -3
  32. hwpx/oxml/document_parts.py +38 -5
  33. hwpx/oxml/dutmal_compose.py +1 -1
  34. hwpx/oxml/header.py +4 -0
  35. hwpx/oxml/header_compat.py +6 -0
  36. hwpx/oxml/header_fonts.py +75 -4
  37. hwpx/oxml/memo.py +4 -1
  38. hwpx/oxml/note_authoring.py +2 -0
  39. hwpx/oxml/objects.py +145 -215
  40. hwpx/oxml/paragraph.py +54 -81
  41. hwpx/oxml/run.py +1 -0
  42. hwpx/oxml/section.py +69 -15
  43. hwpx/oxml/section_format.py +185 -54
  44. hwpx/oxml/shape_position.py +320 -2
  45. hwpx/oxml/table.py +6 -4
  46. hwpx/oxml/table_sizes.py +53 -1
  47. hwpx/oxml/table_structure.py +166 -0
  48. hwpx/oxml/utils.py +28 -0
  49. hwpx/patch.py +11 -8
  50. hwpx/table_patch.py +428 -56
  51. hwpx/tools/package_validator.py +27 -4
  52. hwpx/tools/toc_author.py +1 -1
  53. {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/METADATA +9 -4
  54. {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/RECORD +59 -56
  55. {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/WHEEL +0 -0
  56. {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/entry_points.txt +0 -0
  57. {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/licenses/LICENSE +0 -0
  58. {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/licenses/NOTICE +0 -0
  59. {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/top_level.txt +0 -0
hwpx/_document/fields.py CHANGED
@@ -663,7 +663,7 @@ def add_form_field(
663
663
  """Create a click-here (누름틀) form field and return it as a :class:`FormField`.
664
664
 
665
665
  The emitted XML follows the real-Hancom CLICKHERE contract
666
- (reverse-engineered from Hancom Office 12.0.0.3288 gold documents). The
666
+ (inferred from Hancom Office 12.0.0.3288 gold documents). The
667
667
  prompt (안내문) is materialized as a screen-only red-italic run, exactly as
668
668
  Hancom authors it. The created field is immediately re-read through the
669
669
  standard ``list_form_fields`` matcher — creation fails loudly if the
hwpx/_document/layout.py CHANGED
@@ -19,7 +19,9 @@ from ..oxml._document_primitives import NEW_NUM_KINDS
19
19
  from ..oxml.namespaces import HH, HP
20
20
  from ..oxml.numbering_kinds import ensure_numbering_levels
21
21
  from ..oxml.objects import HwpxOxmlInlineObject
22
- from ..oxml.section_format import _PAGE_LANDSCAPE, _PAGE_PORTRAIT, _page_orientation_value
22
+ from ..oxml.section_format import (
23
+ _PAGE_LANDSCAPE, _PAGE_PORTRAIT, _checked, _page_orientation_value, column_shares, validate_column_gap,
24
+ )
23
25
  from ..oxml.table_sizes import cell_margins_of
24
26
  from ._units import _mm_to_hwp_units, _pt_to_hwp_units
25
27
 
@@ -512,12 +514,12 @@ def set_list_format(
512
514
  suggestion="A continued list numbers on in the format of the list before it: "
513
515
  "leave out number_format and start, or continue_list to start a new list.",
514
516
  )
515
- if level < 1:
517
+ if isinstance(level, bool) or not isinstance(level, int) or not 1 <= level <= 10:
516
518
  raise HwpxValueError(
517
- "level must be 1 or greater",
519
+ f"level must be an int from 1 to 10; got {level!r}",
518
520
  code="style-list-level-invalid",
519
- context={"requested": level},
520
- suggestion="Levels start at 1.",
521
+ context={"requested": repr(level)},
522
+ suggestion="Hancom lists have ten levels, 1 to 10; it cannot save a document using a deeper one.",
521
523
  )
522
524
  if not doc._root.headers:
523
525
  raise HwpxValueError(
@@ -525,6 +527,13 @@ def set_list_format(
525
527
  code="document-header-missing",
526
528
  suggestion="Check that this is an intact HWPX package.",
527
529
  )
530
+ if start is not None and (isinstance(start, bool) or not isinstance(start, int) or not 0 <= start <= 0xFFFF):
531
+ raise HwpxValueError( # Hancom keeps a list's start as 16 bits unsigned (0 drawn as 1)
532
+ f"start must be an int from 0 to 65535; got {start!r}",
533
+ code="style-list-start-value",
534
+ context={"requested": repr(start)},
535
+ suggestion="Pass the number the list starts at, from 0 to 65535.",
536
+ )
528
537
 
529
538
  level_specs: list[dict[str, str]] = [{} for _ in range(level)]
530
539
  if bullet_char:
@@ -532,7 +541,7 @@ def set_list_format(
532
541
  if number_format:
533
542
  level_specs[level - 1]["format"] = str(number_format).upper()
534
543
  if start is not None:
535
- level_specs[level - 1]["start"] = str(max(1, int(start)))
544
+ level_specs[level - 1]["start"] = str(start)
536
545
 
537
546
  targets = _resolve_paragraph_targets(doc,
538
547
  paragraph_index=paragraph_index,
@@ -609,6 +618,8 @@ def set_page_setup(
609
618
  """
610
619
 
611
620
  normalized_orientation = _normalize_page_orientation(orientation)
621
+ if columns is not None: # the gap checked before the page or its margins change
622
+ validate_column_gap(_mm_to_hwp_units(float(column_gap_mm or 0)))
612
623
  target_width_mm = width_mm
613
624
  target_height_mm = height_mm
614
625
  if paper_size:
@@ -633,6 +644,24 @@ def set_page_setup(
633
644
 
634
645
  width = _mm_to_hwp_units(float(target_width_mm)) if target_width_mm is not None else None
635
646
  height = _mm_to_hwp_units(float(target_height_mm)) if target_height_mm is not None else None
647
+ margin_source = dict(margins_mm or {})
648
+ margin_values = {
649
+ "left": margin_left_mm if margin_left_mm is not None else margin_source.get("left"),
650
+ "right": margin_right_mm if margin_right_mm is not None else margin_source.get("right"),
651
+ "top": margin_top_mm if margin_top_mm is not None else margin_source.get("top"),
652
+ "bottom": margin_bottom_mm if margin_bottom_mm is not None else margin_source.get("bottom"),
653
+ "header": header_margin_mm if header_margin_mm is not None else margin_source.get("header"),
654
+ "footer": footer_margin_mm if footer_margin_mm is not None else margin_source.get("footer"),
655
+ "gutter": gutter_mm if gutter_mm is not None else margin_source.get("gutter"),
656
+ }
657
+ hwp_margins = {
658
+ name: _mm_to_hwp_units(float(value))
659
+ for name, value in margin_values.items()
660
+ if value is not None
661
+ }
662
+ for name, value in (("width", width), ("height", height), *hwp_margins.items()):
663
+ if value is not None: # checked before the page or its margins change
664
+ _checked(name, value, 2**31 - 1, "page-size-value")
636
665
  if width is not None or height is not None or normalized_orientation is not None:
637
666
  # Call the local primitives directly rather than `doc.set_page_size`/
638
667
  # `doc.set_page_margins`/`doc.set_columns` below — all three names
@@ -649,21 +678,6 @@ def set_page_setup(
649
678
  section_index=section_index,
650
679
  )
651
680
 
652
- margin_source = dict(margins_mm or {})
653
- margin_values = {
654
- "left": margin_left_mm if margin_left_mm is not None else margin_source.get("left"),
655
- "right": margin_right_mm if margin_right_mm is not None else margin_source.get("right"),
656
- "top": margin_top_mm if margin_top_mm is not None else margin_source.get("top"),
657
- "bottom": margin_bottom_mm if margin_bottom_mm is not None else margin_source.get("bottom"),
658
- "header": header_margin_mm if header_margin_mm is not None else margin_source.get("header"),
659
- "footer": footer_margin_mm if footer_margin_mm is not None else margin_source.get("footer"),
660
- "gutter": gutter_mm if gutter_mm is not None else margin_source.get("gutter"),
661
- }
662
- hwp_margins = {
663
- name: _mm_to_hwp_units(float(value))
664
- for name, value in margin_values.items()
665
- if value is not None
666
- }
667
681
  if hwp_margins:
668
682
  set_page_margins(
669
683
  doc,
@@ -723,15 +737,18 @@ def set_columns(
723
737
  ) -> HwpxOxmlInlineObject:
724
738
  """Set the columns of a section, or start new columns at a paragraph.
725
739
 
726
- Without ``paragraph`` this rewrites the section's own column layout (the
727
- ``hp:colPr`` next to ``hp:secPr``) in place, so the whole section is laid
728
- out in ``col_count`` columns. With ``paragraph`` it adds a column
729
- definition control there, and the text from that paragraph on uses it.
740
+ Without ``paragraph``, or with a section's first one, this rewrites the
741
+ section's own column layout (the ``hp:colPr`` next to ``hp:secPr``) in
742
+ place, so the whole section is laid out in ``col_count`` columns. With a
743
+ later ``paragraph`` it adds a column definition there, used from it on.
730
744
 
731
745
  Args:
732
746
  col_count: Number of columns (1–255).
733
747
  col_type: ``NEWSPAPER``, ``BALANCED_NEWSPAPER``, or ``PARALLEL``.
734
748
  same_gap: Gap in HWPUNIT (7200 = 1 inch).
749
+ column_widths: With ``same_size=False``, a ``(width, gap)`` pair per
750
+ column, in HWPUNIT adding up to the text width or in plain
751
+ proportions; Hancom keeps them as shares of 32768 of the text width.
735
752
  separator_type: Optional column separator line type (e.g. ``SOLID``).
736
753
  """
737
754
  if not 1 <= col_count <= 255:
@@ -741,6 +758,10 @@ def set_columns(
741
758
  context={"requested": col_count},
742
759
  suggestion="Use columns=1 to remove columns.",
743
760
  )
761
+ if same_size:
762
+ validate_column_gap(same_gap) # before a paragraph is added
763
+ elif column_widths:
764
+ column_shares(column_widths) # checked before a paragraph is added too
744
765
  if paragraph is None:
745
766
  target_section = _resolve_section(doc, section=section, section_index=section_index)
746
767
  ctrl = target_section.properties.set_columns(
hwpx/_document/media.py CHANGED
@@ -19,6 +19,11 @@ from ..opc.relationships import (
19
19
  )
20
20
  from ..oxml import HwpxOxmlInlineObject, HwpxOxmlParagraph
21
21
  from ..oxml.namespaces import HC, HP
22
+ from ..oxml.color import normalize_color
23
+ from ..oxml.shape_position import PictureGlow, PictureReflection, PictureShadow, validate_picture_effects
24
+ from ..oxml.shape_position import (
25
+ validate_picture_align, validate_picture_border, validate_picture_crop, validate_picture_image, validate_shape_size,
26
+ )
22
27
  from ._units import _mm_to_hwp_units
23
28
 
24
29
  if TYPE_CHECKING:
@@ -106,23 +111,35 @@ def add_picture(
106
111
  width_mm: float | None = None,
107
112
  height_mm: float | None = None,
108
113
  align: str | None = None,
114
+ brightness: int = 0,
115
+ contrast: int = 0,
116
+ effect: str = "REAL_PIC",
117
+ alpha: int = 0,
118
+ line_color: str | None = None,
119
+ line_width: int = 33,
120
+ crop: tuple[int, int, int, int] | None = None,
121
+ shadow: PictureShadow | None = None,
122
+ glow: PictureGlow | None = None,
123
+ soft_edge: int | None = None,
124
+ reflection: PictureReflection | None = None,
109
125
  para_pr_id_ref: str | int | None = None,
110
126
  style_id_ref: str | int | None = None,
111
127
  char_pr_id_ref: str | int | None = None,
112
128
  run_attributes: dict[str, str] | None = None,
113
129
  **extra_attrs: str,
114
130
  ) -> HwpxOxmlInlineObject:
115
- """Embed image data and place a picture object in a new paragraph."""
116
-
117
- # Call the local primitive directly rather than `doc.add_image` — that
118
- # facade name moved in 6.0 (design table row 33), and going through it
119
- # would fire a DeprecationWarning on every `add_picture` call even though
120
- # `add_picture` itself is a kept (unmoved) root method.
121
- binary_item_id_ref = str(add_image(doc, image_data, image_format))
122
-
131
+ """Embed image data and place a picture object in a new paragraph, the image adjusted and bordered as
132
+ :meth:`HwpxOxmlParagraph.add_picture` takes it."""
133
+
134
+ validate_picture_align(align) # before anything is stored: Hancom reads an unknown one as LEFT
135
+ validate_picture_image(brightness, contrast, effect, alpha) # ... and draws no other adjustment
136
+ validate_picture_border(line_width)
137
+ if line_color is not None:
138
+ normalize_color(line_color)
123
139
  resolved_width = width
124
140
  if resolved_width is None:
125
141
  resolved_width = _mm_to_hwp_units(width_mm) if width_mm is not None else 14400
142
+ validate_shape_size(resolved_width, 0) # validate before aspect-ratio arithmetic too
126
143
 
127
144
  resolved_height = height
128
145
  if resolved_height is None:
@@ -135,6 +152,15 @@ def add_picture(
135
152
  resolved_height = round(resolved_width * source_height / source_width)
136
153
  else:
137
154
  resolved_height = resolved_width
155
+ validate_shape_size(resolved_width, resolved_height) # before anything is stored too
156
+ validate_picture_crop(crop, resolved_width, resolved_height)
157
+ validate_picture_effects(shadow, glow, soft_edge, reflection)
158
+
159
+ # Call the local primitive directly rather than `doc.add_image` — that
160
+ # facade name moved in 6.0 (design table row 33), and going through it
161
+ # would fire a DeprecationWarning on every `add_picture` call even though
162
+ # `add_picture` itself is a kept (unmoved) root method.
163
+ binary_item_id_ref = str(add_image(doc, image_data, image_format))
138
164
 
139
165
  paragraph = doc.add_paragraph(
140
166
  "",
@@ -158,6 +184,17 @@ def add_picture(
158
184
  width=resolved_width,
159
185
  height=resolved_height,
160
186
  align=align,
187
+ brightness=brightness,
188
+ contrast=contrast,
189
+ effect=effect,
190
+ alpha=alpha,
191
+ line_color=line_color,
192
+ line_width=line_width,
193
+ crop=crop,
194
+ shadow=shadow,
195
+ glow=glow,
196
+ soft_edge=soft_edge,
197
+ reflection=reflection,
161
198
  run_attributes=run_attributes,
162
199
  char_pr_id_ref=char_pr_id_ref,
163
200
  )
hwpx/_document/memos.py CHANGED
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from ..errors import HwpxValueError
10
10
 
11
- from ..oxml._document_primitives import _object_id
11
+ from ..oxml._document_primitives import _clear_paragraph_layout_cache, _object_id
12
12
  from ..oxml.namespaces import HP
13
13
 
14
14
  if TYPE_CHECKING:
@@ -189,6 +189,7 @@ def attach_memo_field(
189
189
  "dirty": "0",
190
190
  "zorder": order,
191
191
  "fieldid": MEMO_FIELD_ID,
192
+ "metaTag": "",
192
193
  },
193
194
  )
194
195
 
@@ -247,6 +248,7 @@ def attach_memo_field(
247
248
 
248
249
  paragraph.element.insert(0, run_begin)
249
250
  paragraph.element.append(run_end)
251
+ _clear_paragraph_layout_cache(paragraph.element) # the field's controls move its text: laid out again
250
252
  paragraph.section.mark_dirty()
251
253
 
252
254
  return memo
@@ -345,8 +345,8 @@ class ShapesNamespace(_Namespace):
345
345
  section: "int | Section | None" = None,
346
346
  section_index: int | None = None,
347
347
  ) -> "Shape":
348
- """도형을 그룹으로 묶는다(`ContainerMember.rect`/`.ellipse`/`.polygon`으로
349
- 각 부재를 그룹 로컬 좌표로 만들어 넘긴다)."""
348
+ """도형을 그룹으로 묶는다(`ContainerMember.rect`/`.ellipse`/`.polygon`/`.line`/
349
+ `.text_box`/`.picture`/`.group`으로 각 부재를 그룹 로컬 좌표로 만들어 넘긴다)."""
350
350
 
351
351
  from .. import shapes as _shapes
352
352
 
@@ -1,5 +1,5 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
- """`doc.tables` — 표 탐색·매핑·병합·경로 채움.
2
+ """`doc.tables` — 표 탐색·매핑·병합·구조 편집·경로 채움.
3
3
 
4
4
  표를 **만드는** 것은 루트에 남았다(`doc.add_table` — python-docx 대응이자
5
5
  automation 최다 사용 저작 동사 중 하나). 만든 다음에 하는 일들이 여기 모인다.
@@ -11,7 +11,7 @@ automation 최다 사용 저작 동사 중 하나). 만든 다음에 하는 일
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from typing import TYPE_CHECKING, Mapping
14
+ from typing import TYPE_CHECKING, Iterable, Mapping, overload
15
15
 
16
16
  from ._base import _Namespace
17
17
 
@@ -28,7 +28,7 @@ __all__ = ["TablesNamespace"]
28
28
 
29
29
 
30
30
  class TablesNamespace(_Namespace):
31
- """표 탐색·매핑·병합·경로 채움."""
31
+ """표 탐색·매핑·병합·구조 편집·경로 채움."""
32
32
 
33
33
  __slots__ = ()
34
34
  _path = "doc.tables"
@@ -48,6 +48,20 @@ class TablesNamespace(_Namespace):
48
48
  def __iter__(self):
49
49
  return iter(self.all)
50
50
 
51
+ @overload
52
+ def __getitem__(self, index: int) -> "Table": ...
53
+
54
+ @overload
55
+ def __getitem__(self, index: slice) -> list["Table"]: ...
56
+
57
+ def __getitem__(self, index: int | slice) -> "Table | list[Table]":
58
+ """문서 순서의 ``index`` 번째 표(``doc.tables[0]``, ``doc.tables[-1]``, ``doc.tables[1:3]``).
59
+
60
+ ``doc.tables.all[index]``와 같다. 범위 밖이면 ``IndexError``.
61
+ """
62
+
63
+ return self.all[index]
64
+
51
65
  def map(self) -> "TableMapResult":
52
66
  """모든 표의 위치·모양·헤더 미리보기를 담은 간결 지도."""
53
67
 
@@ -78,3 +92,35 @@ class TablesNamespace(_Namespace):
78
92
  """
79
93
 
80
94
  return table.merge_cells(cell_range)
95
+
96
+ def insert_rows(
97
+ self, table: "Table", ref_row: int, count: int = 1, *, side: str = "below", blank: bool = False
98
+ ) -> None:
99
+ """``ref_row`` 행을 복제해 그 아래(``side="above"``면 위)에 ``count`` 행을 넣는다.
100
+
101
+ 새 칸은 같은 열 기준 칸의 서식과 글을 받고, ``blank=True``면 한/글처럼 빈 문단 하나만 둔다.
102
+ """
103
+
104
+ table.insert_rows(ref_row, count, side=side, blank=blank)
105
+
106
+ def insert_columns(
107
+ self, table: "Table", ref_col: int, count: int = 1, *, side: str = "right", blank: bool = False
108
+ ) -> None:
109
+ """``ref_col`` 열을 복제해 그 오른쪽(``side="left"``면 왼쪽)에 ``count`` 열을 넣는다(표가 넓어진다)."""
110
+
111
+ table.insert_columns(ref_col, count, side=side, blank=blank)
112
+
113
+ def delete_rows(self, table: "Table", rows: int | Iterable[int]) -> None:
114
+ """행을 지운다. 모든 행은 지울 수 없다(표 삭제를 쓴다)."""
115
+
116
+ table.delete_rows(rows)
117
+
118
+ def delete_columns(self, table: "Table", cols: int | Iterable[int]) -> None:
119
+ """열을 지우고 그 폭을 남은 열에 나눈다. 모든 열은 지울 수 없다."""
120
+
121
+ table.delete_columns(cols)
122
+
123
+ def split_cell(self, table: "Table", row: int, col: int, *, rows: int = 1, cols: int = 1) -> None:
124
+ """(``row``, ``col``) 칸을 ``rows`` 줄 × ``cols`` 칸으로 나눈다(한/글의 셀 나누기)."""
125
+
126
+ table.split_cell(row, col, rows=rows, cols=cols)
hwpx/_document/shapes.py CHANGED
@@ -8,7 +8,9 @@ from typing import TYPE_CHECKING, Any, Sequence, cast
8
8
  from ._units import _mm_to_hwp_units
9
9
  from ..errors import HwpxStateError, HwpxValueError
10
10
  from ..oxml.objects import _closed_points
11
- from ..oxml.shape_position import validate_original_size
11
+ from ..oxml.shape_position import (
12
+ validate_equation_base_unit, validate_original_size, validate_rect_ratio, validate_shape_size,
13
+ )
12
14
 
13
15
  if TYPE_CHECKING:
14
16
  from hwpx.document import HwpxDocument
@@ -265,6 +267,8 @@ def add_rectangle(
265
267
  Dimensions are in HWPUNIT. *ratio* controls corner roundness
266
268
  (0 = sharp, 50 = semicircle).
267
269
  """
270
+ validate_shape_size(width, height)
271
+ validate_rect_ratio(ratio)
268
272
  validate_original_size(original_size)
269
273
  if paragraph is None:
270
274
  paragraph = doc.add_paragraph(
@@ -297,6 +301,7 @@ def add_ellipse(
297
301
 
298
302
  Dimensions are in HWPUNIT.
299
303
  """
304
+ validate_shape_size(width, height)
300
305
  validate_original_size(original_size)
301
306
  if paragraph is None:
302
307
  paragraph = doc.add_paragraph(
@@ -334,6 +339,7 @@ def add_arc(
334
339
  mechanism every other shape here already uses. *arc_type* is the
335
340
  schema's own ``NORMAL``/``PIE``/``CHORD`` passthrough.
336
341
  """
342
+ validate_shape_size(width, height)
337
343
  if paragraph is None:
338
344
  paragraph = doc.add_paragraph(
339
345
  "", section=section, section_index=section_index,
@@ -461,7 +467,8 @@ def add_container(
461
467
  """Insert a group (``<hp:container>``) wrapping *members*.
462
468
 
463
469
  Each member is built with :class:`hwpx.oxml.ContainerMember`'s
464
- ``rect``/``ellipse``/``polygon`` classmethods, which take the member's
470
+ ``rect``/``ellipse``/``polygon``/``line``/``text_box``/``picture``/``group``
471
+ classmethods, which take the member's
465
472
  position in the group's own local coordinate space (HWPUNIT, top-left
466
473
  anchored) alongside its usual shape parameters::
467
474
 
@@ -522,15 +529,16 @@ def add_equation(
522
529
  context={"length": len(text), "limit": MAX_SOURCE_LENGTH},
523
530
  suggestion="Split the equation.",
524
531
  )
532
+ validate_equation_base_unit(base_unit)
533
+ width, height = size if size is not None else estimate_equation_size(text, base_unit=base_unit)
534
+ validate_shape_size(width, height) # one measured at a huge base_unit too
525
535
  if paragraph is None:
526
536
  paragraph = doc.add_paragraph(
527
537
  "", section=section, section_index=section_index,
528
538
  include_run=False,
529
539
  )
530
- if size is None:
531
- size = estimate_equation_size(text, base_unit=base_unit)
532
540
  inline_object = paragraph.add_equation(
533
- text, base_unit=base_unit, size=size, char_pr_id_ref=char_pr_id_ref,
541
+ text, base_unit=base_unit, size=(width, height), char_pr_id_ref=char_pr_id_ref,
534
542
  )
535
543
 
536
544
  created_id = inline_object.element.get("id", "")
@@ -756,10 +764,10 @@ def add_drop_cap(
756
764
  char_pr_id_ref: str | int | None = None,
757
765
  para_pr_id_ref: str | int | None = None,
758
766
  ) -> HwpxOxmlInlineObject:
759
- """Insert a drop cap (문단 첫 글자 장식), reverse-engineered from the one
767
+ """Insert a drop cap (문단 첫 글자 장식), inferred from the one
760
768
  real-corpus example that carries a non-default ``dropcapstyle``
761
769
  (``error__20230809__test.hwpx`` -- see ``oxml.drop_cap``'s own
762
- docstring for the full structural reverse engineering and why it
770
+ docstring for the full structural format inference and why it
763
771
  supports ``style="TripleLine"`` and ``"DoubleLine"`` only).
764
772
 
765
773
  Element construction (element-building validation included) happens in
hwpx/body_patch.py CHANGED
@@ -38,6 +38,7 @@ from typing import Any, Mapping, Sequence
38
38
  from .opc.security import guard_zip_file, read_member
39
39
  from .mutation_report import MutationReport, project_byte_splice
40
40
  from .patch import (
41
+ _escaped_text,
41
42
  _finalize,
42
43
  _patch_zip_entries,
43
44
  _read_source_bytes,
@@ -185,9 +186,9 @@ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, An
185
186
  replace = str(op.get("replace", ""))
186
187
  expected = int(op.get("count", 1))
187
188
  esc_find = html.escape(find, quote=False)
188
- # A tab goes in as an hp:tab element: Hancom never finishes laying out a
189
- # raw tab character inside hp:t.
190
- esc_replace = "<hp:tab/>".join(html.escape(part, quote=False) for part in replace.split("\t"))
189
+ # A tab goes in as an hp:tab element (Hancom never finishes laying out a
190
+ # raw tab character inside hp:t), a line break as an hp:lineBreak.
191
+ esc_replace = _escaped_text(replace, b"hp:").decode("utf-8")
191
192
  hits: list[tuple[int, int]] = []
192
193
  for t in _T_CONTENT_RE.finditer(xml):
193
194
  a, b = t.start(1), t.end(1)
@@ -215,14 +216,6 @@ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, An
215
216
  raise ValueError("replace_text: match outside any <hp:p> paragraph")
216
217
  by_para.setdefault(span, []).append(hit)
217
218
 
218
- edits: list[tuple[int, int, str]] = []
219
- for (pa, pb), phits in by_para.items():
220
- block = xml[pa:pb]
221
- for a, b in sorted(phits, reverse=True):
222
- block = block[: a - pa] + esc_replace + block[b - pa:]
223
- block = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8")
224
- edits.append((pa, pb, block))
225
-
226
219
  # 모듈 취지는 표-밖 본문이지만(위 docstring), <hp:t> 검색은 구조적으로
227
220
  # 표 셀 내부까지 닿는다 — 편집된 문단이 셀(subList) 안이면 그 셀의
228
221
  # lineWrap=SQUEEZE도 함께 BREAK로 바꾼다. 안 바꾸면 실측대로 압축 자간이
@@ -232,6 +225,7 @@ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, An
232
225
  # 문단이 여럿이어도 셀 편집은 한 번만.
233
226
  sublist_spans = _all_sublist_spans(xml)
234
227
  fixed_sublists: set[tuple[int, int]] = set()
228
+ wrap_edits: list[tuple[int, int, str]] = []
235
229
  for pa, _pb in by_para:
236
230
  sublist_span = _innermost_span(pa, sublist_spans)
237
231
  if sublist_span is None or sublist_span in fixed_sublists:
@@ -239,7 +233,28 @@ def _op_replace_text(xml: str, op: Mapping[str, Any]) -> tuple[str, dict[str, An
239
233
  fixed_sublists.add(sublist_span)
240
234
  wrap_edit = _squeeze_wrap_edit_for_sublist(xml, sublist_span)
241
235
  if wrap_edit is not None:
242
- edits.append(wrap_edit)
236
+ wrap_edits.append(wrap_edit)
237
+
238
+ # 표를 담은 문단과 그 표의 셀 문단이 둘 다 편집되면 두 문단 구간이 겹친다.
239
+ # 바깥 문단 하나로 묶어 그 안의 치환·셀 SQUEEZE 편집을 한 번에 적용한 뒤
240
+ # 캐시를 지운다(바깥 문단의 캐시 제거가 안쪽 문단의 캐시도 지운다).
241
+ outer = [
242
+ span for span in by_para
243
+ if not any(other != span and other[0] <= span[0] and span[1] <= other[1] for other in by_para)
244
+ ]
245
+ edits: list[tuple[int, int, str]] = []
246
+ for pa, pb in outer:
247
+ local = [(a, b, esc_replace) for a, b in hits if pa <= a < pb]
248
+ local += [edit for edit in wrap_edits if pa <= edit[0] and edit[1] <= pb]
249
+ block = xml[pa:pb]
250
+ for a, b, replacement in sorted(local, key=lambda e: e[0], reverse=True):
251
+ block = block[: a - pa] + replacement + block[b - pa:]
252
+ block = _strip_paragraph_layout_cache(block.encode("utf-8")).decode("utf-8")
253
+ edits.append((pa, pb, block))
254
+ edits += [
255
+ edit for edit in wrap_edits
256
+ if not any(pa <= edit[0] and edit[1] <= pb for pa, pb in outer)
257
+ ]
243
258
 
244
259
  for a, b, replacement in sorted(edits, key=lambda e: e[0], reverse=True):
245
260
  xml = xml[:a] + replacement + xml[b:]
hwpx/capabilities.py CHANGED
@@ -81,7 +81,7 @@ _CAPABILITY_AREAS: tuple[dict[str, Any], ...] = (
81
81
  "area": "table-structure",
82
82
  "namespace": "doc.tables",
83
83
  "matrix_row": "표 구조 변경(행·열·표 삭제/삽입, 열 오토핏)",
84
- "entry_points": ("hwpx.table_patch:apply_table_ops",),
84
+ "entry_points": ("hwpx.table_patch:apply_table_ops", "hwpx.model:Table"),
85
85
  "authoring_methods": (),
86
86
  },
87
87
  {
@@ -21,16 +21,16 @@
21
21
 
22
22
  | 능력 영역 | 상태 | 증거 |
23
23
  |---|---|---|
24
- | 문단·표 저작/편집 | Parse·Preserve·Edit·Create·Render-verified | `Table.set_cell_borders`는 네 변 색/종류만 수정하며 기존 선 두께·배경·대각선과 공유 스타일을 보존한다. 전체 스타일 교체용 `set_cell_border_fill`과 구분한다. corpus-metrics「오픈 수용률」476/476, 「저작 품질 게이트」실저작 58/58, 「렌더 검증」416건. **6.5+**: `paragraph.add_run(text, expand_special_characters=True)`가 `hp:lineBreak`(줄바꿈)·`hp:nbSpace`(비분리공백)·`hp:fwSpace`(전각공백)를 저작한다 — 실코퍼스 3파일(`error__20230818__test.hwpx`/`error__20251107__test.hwpx`/`error__20250808__...hwpx`) 리버스로 셋 다 **단일 hp:t 안의 mixed content**임을 확인(`hp:tab`이 쓰는 "hp:run 형제" 형태와 다름 — 스키마는 둘 다 허용하나 실 산출물은 이 셋에 대해 전자만 씀). 같은 리버스 과정에서 텍스트 추출기(`TextExtractor`)가 이 세 원자(+`hp:hyphen`)를 `hp:t` 중첩 위치에서 못 읽던 결함도 함께 수리 — 수리 전엔 원자가 조용히 사라지며 앞뒤 텍스트가 구분자 없이 붙어 읽혔다(줄바꿈이 있었다는 사실 자체가 유실). 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-inlineatoms 스트라텀(3원자의 0공백 순서조합 15종 전량), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정). **6.12 트레인㊸ 갭④**: `doc.styles.apply_paragraph_format(column_break=...)`가 `hp:p`의 `columnBreak` 속성(문단 인스턴스별 강제 단 나눔)을 저작한다 — `page_break_before`가 다루는 `hh:breakSetting`(paraPr 공유 스타일)과는 다른 메커니즘이라 대상 paraPr을 새로 안 만들고 문단에 직접 적용된다. 실코퍼스 67파일 전수(14266문단) 측정: 자매 속성 `pageBreak`는 73건 실사용 확인(항상 "0"/"1" 리터럴, "true"/"false" 0건)되지만 `columnBreak`는 0건 — 그래도 같은 요소·같은 리터럴 규약을 재사용하는 것뿐이라 구조적 추측 위험은 없다. 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-column-break 스트라텀(5문단 문서에서 대상 문단 위치를 0~4로 로테이션, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정). 옛 5.x 표면(`doc.set_paragraph_format`)에는 의도적으로 안 얹었다 — 그 shim은 5.x 시그니처 그대로 얼려두는 게 계약이라(7.0 제거 예정), 신규 옵션은 `doc.styles.apply_paragraph_format`에만 산다 |
25
- | 표 구조 변경(행·열·표 삭제/삽입, 열 오토핏) | Preserve·Edit·Render-verified | `hwpx.table_patch.apply_table_ops`; corpus-metrics「바이트 보존」497/497(patch 경로). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-tablestructure 스트라텀(delete_column/delete_row/insert_row_by_clone/set_column_widths/autofit_columns 각 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장(coverage-ledger) 라우팅은 없음** — `apply_table_ops`가 건드리는 `hp:tbl`/`tr`/`tc`/`cellAddr`/`cellSpan`/`cellSz`/`cellMargin`은 전부 "문단·표 저작/편집"(대형 혼합 영역)에 이미 등록돼 있어, 여기 추가하면 이 스트라텀이 실제로 검증한 적 없는 이웃 요소까지 openrate 근거를 얻는 위양성이 생긴다(v8 polygon/arc 전례와 동일 판단) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만. **6.12 트레인㊸ 갭⑤**가 `split_table`(표 나누기)·`merge_table`(표 붙이기) 두 op를 추가했다 — 스키마에 표 나누기/붙이기 전용 어휘는 없다(`hp:tbl`/`TableType`은 `pageBreak`/`repeatHeader`/`rowCnt`/`colCnt`뿐, `ParaList XML schema.xml:2008`), 순수 구조 편집이라 `_insert_block_by_clone`과 같은 fail-closed 원칙(병합 셀이 경계를 걸치면 거부)을 그대로 따른다. `split_table`은 `split_row` 물리 행 인덱스에서 표를 둘로 나눠(윗칸은 원래 표 id·아랫칸은 새 래핑 문단+새 표 id) 병합 셀이 경계를 걸치면 거부한다(실코퍼스 `public_official_table.hwpx`로 clean·crossing 양쪽 검증). `merge_table`은 그 역연산 — 바로 다음 표와 합치되 `colCnt` 불일치나 두 표 사이 실텍스트 존재 시 거부(내용 무음 소실 방지). **"표 뒤집기"는 이번 트레인에서 의도적으로 보류**: 행/열 순서 반전은 병합 셀·중첩표에서 정의가 흐려지고(예: rowSpan 셀을 반전하면 어느 칸이 내용을 갖는가?) 실코퍼스에 근거로 삼을 예시가 전혀 없다(스키마조차 이런 연산의 존재를 암시하지 않는다) — curve·connectLine과 같은 원칙으로 근거 없는 알고리즘을 추측하지 않는다. 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-table-split-merge 스트라텀(분할 3종[3/4/5행 표를 각기 다른 위치에서 분할]·병합 1건[2+2행 표 결합]·분할 후 재병합 왕복 1건, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정, 기존 v14의 15/15은 split/merge를 겨냥하지 않았었다) |
24
+ | 문단·표 저작/편집 | Parse·Preserve·Edit·Create·Render-verified | `Table.set_cell_borders`는 네 변 색/종류만 수정하며 기존 선 두께·배경·대각선과 공유 스타일을 보존한다. 전체 스타일 교체용 `set_cell_border_fill`과 구분한다. corpus-metrics「오픈 수용률」476/476, 「저작 품질 게이트」실저작 58/58, 「렌더 검증」416건. **6.5+**: `paragraph.add_run(text, expand_special_characters=True)`가 `hp:lineBreak`(줄바꿈)·`hp:nbSpace`(비분리공백)·`hp:fwSpace`(전각공백)를 저작한다 — 실코퍼스 3파일(`error__20230818__test.hwpx`/`error__20251107__test.hwpx`/`error__20250808__...hwpx`) 리버스로 셋 다 **단일 hp:t 안의 mixed content**임을 확인(`hp:tab`이 쓰는 "hp:run 형제" 형태와 다름 — 스키마는 둘 다 허용하나 실 산출물은 이 셋에 대해 전자만 씀). 같은 리버스 과정에서 텍스트 추출기(`TextExtractor`)가 이 세 원자(+`hp:hyphen`)를 `hp:t` 중첩 위치에서 못 읽던 결함도 함께 수리 — 수리 전엔 원자가 조용히 사라지며 앞뒤 텍스트가 구분자 없이 붙어 읽혔다(줄바꿈이 있었다는 사실 자체가 유실). 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-inlineatoms 스트라텀(3원자의 0공백 순서조합 15종 전량), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정). **Unreleased**: 줄바꿈은 플래그 없이도 `hp:t` 안 `hp:lineBreak`로 쓴다(`add_paragraph`·`paragraph.text =`·`add_run`·`cell.text =`·`run.text =`·`paragraph_patch`·`apply_body_ops` `replace_text`). 플래그는 비분리공백·전각공백만 더 바꾼다. **6.12 트레인㊸ 갭④**: `doc.styles.apply_paragraph_format(column_break=...)`가 `hp:p`의 `columnBreak` 속성(문단 인스턴스별 강제 단 나눔)을 저작한다 — `page_break_before`가 다루는 `hh:breakSetting`(paraPr 공유 스타일)과는 다른 메커니즘이라 대상 paraPr을 새로 안 만들고 문단에 직접 적용된다. 실코퍼스 67파일 전수(14266문단) 측정: 자매 속성 `pageBreak`는 73건 실사용 확인(항상 "0"/"1" 리터럴, "true"/"false" 0건)되지만 `columnBreak`는 0건 — 그래도 같은 요소·같은 리터럴 규약을 재사용하는 것뿐이라 구조적 추측 위험은 없다. 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-column-break 스트라텀(5문단 문서에서 대상 문단 위치를 0~4로 로테이션, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정). 옛 5.x 표면(`doc.set_paragraph_format`)에는 의도적으로 안 얹었다 — 그 shim은 5.x 시그니처 그대로 얼려두는 게 계약이라(7.0 제거 예정), 신규 옵션은 `doc.styles.apply_paragraph_format`에만 산다 |
25
+ | 표 구조 변경(행·열·표 삭제/삽입, 열 오토핏) | Preserve·Edit·Render-verified | `hwpx.table_patch.apply_table_ops`; corpus-metrics「바이트 보존」497/497(patch 경로). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-tablestructure 스트라텀(delete_column/delete_row/insert_row_by_clone/set_column_widths/autofit_columns 각 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장(coverage-ledger) 라우팅은 없음** — `apply_table_ops`가 건드리는 `hp:tbl`/`tr`/`tc`/`cellAddr`/`cellSpan`/`cellSz`/`cellMargin`은 전부 "문단·표 저작/편집"(대형 혼합 영역)에 이미 등록돼 있어, 여기 추가하면 이 스트라텀이 실제로 검증한 적 없는 이웃 요소까지 openrate 근거를 얻는 위양성이 생긴다(v8 polygon/arc 전례와 동일 판단) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만. **6.12 트레인㊸ 갭⑤**가 `split_table`(표 나누기)·`merge_table`(표 붙이기) 두 op를 추가했다 — 스키마에 표 나누기/붙이기 전용 어휘는 없다(`hp:tbl`/`TableType`은 `pageBreak`/`repeatHeader`/`rowCnt`/`colCnt`뿐, `ParaList XML schema.xml:2008`), 순수 구조 편집이라 `_insert_block_by_clone`과 같은 fail-closed 원칙(병합 셀이 경계를 걸치면 거부)을 그대로 따른다. `split_table`은 `split_row` 물리 행 인덱스에서 표를 둘로 나눠(윗칸은 원래 표 id·아랫칸은 새 래핑 문단+새 표 id) 병합 셀이 경계를 걸치면 거부한다(실코퍼스 `public_official_table.hwpx`로 clean·crossing 양쪽 검증). `merge_table`은 그 역연산 — 바로 다음 표와 합치되 `colCnt` 불일치나 두 표 사이 실텍스트 존재 시 거부(내용 무음 소실 방지). **"표 뒤집기"는 이번 트레인에서 의도적으로 보류**: 행/열 순서 반전은 병합 셀·중첩표에서 정의가 흐려지고(예: rowSpan 셀을 반전하면 어느 칸이 내용을 갖는가?) 실코퍼스에 근거로 삼을 예시가 전혀 없다(스키마조차 이런 연산의 존재를 암시하지 않는다) — curve·connectLine과 같은 원칙으로 근거 없는 알고리즘을 추측하지 않는다. 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-table-split-merge 스트라텀(분할 3종[3/4/5행 표를 각기 다른 위치에서 분할]·병합 1건[2+2행 표 결합]·분할 후 재병합 왕복 1건, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정, 기존 v14의 15/15은 split/merge를 겨냥하지 않았었다) 행·열 삽입(`insert_row_by_clone` 위·아래, `insert_column_by_clone` 왼쪽·오른쪽, `blank`로 빈 칸)과 칸 나누기(`split_cell`)는 한/글의 줄/칸 추가하기·셀 나누기를 따르고, 근거는 한/글 저장본(`tests/fixtures/hancom_saved/table_insert_*`·`table_split_cell_*`)이다. 표 객체에도 같은 규칙의 `Table.insert_rows`·`insert_columns`·`delete_rows`·`delete_columns`·`split_cell`(`doc.tables`에도)이 있다. |
26
26
  | 표 생성(병합·중첩 포함) | Create·Render-verified | `add_table`·`merge_cells`(피복 셀 제거+cellSpan — 실한컴 병합 의미론과 동형, 3종 병합 렌더 픽셀 확인)·셀 문단 `add_table`(중첩). 기본값은 5.4.0에서 실한컴에 맞췄다: 셀 안여백 510/510/141/141, 기본 표 폭은 본문 폭, 중첩 표는 부모 셀 사용 폭. **남은 갭**: 선 종류는 raw 전용. **6.7+**: `table.label`/`.set_label()`/`.remove_label()`(`hp:label`, Avery형 라벨시트/명패 인쇄 레이아웃) — 사설 코퍼스 75건·436건 리버스(DEV-023, 스키마와 완전 일치), 항상 표의 마지막 자식으로 배치. **Render-verified(6.8, 트레인㉘)**: openrate v11 실한컴 배치(macOS GUI 오라클) 15/15 render-checked·음성대조 3/3 — 관측 2클러스터(2×9 소형시트·1×2 대형 정사각 명패)와 관측 밖 조합(3×6 미관측 그리드·landscape=NARROWLY·partial 속성) 전부 수용, 수용 경계가 관측 분포보다 넓다. **6.13 트레인㊻**: `equalize_column_widths()`/`equalize_row_heights()`(`HwpxOxmlTable`) — 편집기 메뉴 표면 역매핑(트레인㊷·㊺)이 찾은 부분 대응(균등화 "전용" 헬퍼 부재)을 닫는다. 신규 XML 어휘는 전혀 없음: 전자는 기존 `set_column_widths([1]*column_count)`를 전용 이름으로 노출할 뿐이고, 후자는 그 행 대응(같은 `iter_grid`/rowSpan 로직·`_distribute_size` 재사용, 병합 셀은 자신이 덮는 행/열들의 새 크기 합을 받는다 — `cellSz`만 갱신). 실한컴 렌더 검증: `docs/openrate/report-v17.json` authored-cell-equalize 스트라텀(열만/행만/양축/양축+병합 셀 1개 로테이션, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-11 측정) — 기존 `set_column_widths` 자체는 v14의 authored-tablestructure에서 1건 확인됐을 뿐 이 두 신규 편의 메서드 자체는 이번이 첫 독립 검증 |
27
27
  | 양식 채움(byte-splice) | Preserve·Edit·Render-verified | `hwpx.patch`·`table_patch`·`body_patch`; corpus-metrics「바이트 보존」497/497. wild 공개 양식의 서식 충실은 2차 실측 후 **산출분 pass 82.1%(23/28)·산출+fail 7.6%(5/66)** — 불가능 타깃은 typed 거부 35건(전수 감별 과잉거부 0). 잔존은 페이지 리플 5건(4건 typed 경고 동반·완전 무음 1건, 「구조결함 2차 실측」절), 표 shape 부류는 측정 인공물로 판명·판정기 교정. **6.11 사이클(트레인㊳ 판정, ㊵ 이후 정리 트레인 생성)**: 이 영역 자체의 render-verified 갭(v14 소탕 대상에서 빠뜨림으로 판정)을 v15로 메움 — `docs/openrate/report-v15.json` authored-formfill 스트라텀(합성 2열 양식 → `table_patch.fill_cells`로 빈 값 셀 byte-splice 채움, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — `fill_cells`가 건드리는 `hp:t`는 기존 요소 텍스트 치환일 뿐 새 요소를 안 만든다(find-replace·document-merge와 같은 이유) — 등급 갱신은 이 산문과 report-v15.json 직접 인용으로만 |
28
28
  | 편집 계획 실행(edit plan) | Preserve·Edit·Render-verified | `hwpx.plan.apply_edit_plan`(experimental, 5.6.0+): 바이트-스플라이스 op 7종을 계획 1파일로 합성 — 정적 선검증→전 체인 인메모리 실행→최종 open-safety 검증→단 1회 원자 쓰기. 중간 실패 시 output·source 바이트 불변(테스트가 바이트 비교로 증명), step별+원본→최종 `hwpx.mutation-report/v1` 실측 사영. 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-editplan 스트라텀(paragraph_patch/fill_cells/apply_table_ops(중첩)/recolor_runs_by_color/strip_runs_by_color 각 1건 — v3/v4의 옛 `edit-plan` 버킷이 이미 다룬 paragraph_patch+fill_cells 조합 이외의 3개 op가 이번 신규 실증분), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정). **원장 라우팅은 없음** — 이 모듈은 기존 op를 체이닝만 할 뿐 대응하는 단일 capabilityArea가 없다(formfield류 저수준 공유 요소와 같은 이유) — 등급 갱신은 이 산문과 report-v14.json 직접 인용으로만 |
29
29
  | 도형 저작(선·사각형·타원) | Parse·Preserve·Edit·Create·Render-verified | 전용 `add_line`·`add_rectangle`·`add_ellipse`. 기존 floating 도형은 `Shape.set_position`으로 오프셋만 편집 가능(글자처럼 취급·위치 요소 없음은 typed 거부; 아래 기존 렌더 실적이 새 이동 경로의 검증을 뜻하지 않음). 5.0.0에서 기하 네임스페이스(`hc:`)·선 bounding box·`resize()`의 기하 반영을 수정한 뒤 실한컴 12.30.0(build 6446)에서 **개봉 7/7**(선 수평·대각·수직, 사각형, 타원, resize 후 혼합, 그림). 렌더로 사각형 144×72pt+`#CCE5FF`, 타원 100×60pt+`#FFD9CC`, 대각선이 요청 박스를 실제 span, `resize(14400,7200)` 후 새 크기·DASH 반영까지 확인 |
30
30
  | 저수준 도형·컨트롤 탈출구 | Edit | `add_shape`·`add_control`은 건네받은 요소와 속성만 쓰고 OWPML 필수 하위 요소(`offset`·`orgSz`·`curSz`·`sz`·`pos`·유형별 기하)는 만들지 않는다. 그대로 저장한 문서는 실한컴 12.30.0이 **거부**한다(음성 대조로 확인). 생성 시점의 `UserWarning`은 여전히 하나뿐이지만(휘발성 — 호출 스택에만 남고 파일엔 안 남음), `validate_package`·`validate_editor_open_safety`는 이 위험을 **오류**로 보고하고(빈 `hp:ctrl`은 한컴이 멈춘다), 공개 저장 경로는 이런 문서를 쓰지 않는다(6.6 트레인㉒의 `warning`에서 격상). `hp:container` 부재는 오탐 안 됨(부재는 이 5종을 원래 안 가짐, DEV-016). 도형은 위의 전용 헬퍼를 쓸 것(전용 헬퍼는 경고하지 않는다) |
31
31
  | arc·polygon·curve·connectLine | Parse·Preserve·Create(experimental)·Render-verified(arc·polygon만) | **6.11 트레인㊳ 재발견**: `docs/openrate/report-v8.json`의 authored-polygon/authored-arc 스트라텀(각 15/15 render_checked·0 render_failed)이 애초부터 실재했고 원장(coverage-ledger)에도 `hp:polygon`/`hp:arc`로 이미 정확히 반영돼 있었다 — 하지만 이 행의 등급 문자열에 "Render-verified"가 한 번도 반영되지 않아 편집기 표면 인벤토리가 이 항목을 계속 "미실측"으로 잘못 보고해 왔다(순수 매트릭스 산문 갱신 누락, "차트" 행과 같은 종류의 결함, 이번 트레인에서 함께 바로잡음). curve·connectLine은 아래 서술대로 여전히 정직 보류(저작 API 자체가 없음, Render-verified 대상 아님). 전용 `add_polygon`(6.4+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimplePolygon.hwpx`) 리버스 — 꼭짓점은 `hc:pt`(core 네임스페이스, rect/ellipse와 같은 기하 네임스페이스 계약: 5.0.0 도형 네임스페이스 결함 수리와 동일 축), 자기 bbox 좌상단 원점 로컬 좌표계(정점 목록이 그 자기 `orgSz`와 정확히 일치, 실측). `resize()`는 이미 범용(`pt` 로컬명 스캔)이라 변경 없이 다각형에도 적용된다. 전용 `add_arc`(6.4+, `doc.shapes`): 실코퍼스(`SimpleArc.hwpx`) 리버스 — 스키마상 `hp:arc`는 좌표 3점(`center`/`ax1`/`ax2`)뿐 각도 필드가 아예 없다. 유일한 실 예시는 center가 자기 bbox 모서리에 앉고 ax1이 그 바로 아래, ax2가 그 바로 오른쪽인 사분원 하나뿐이라 그 한 배치만 점 단위로 실측 검증됐고, 나머지 세 모서리는 다른 모든 도형이 이미 쓰는 `hp:flip` 미러링으로 얻는다(새 점 계산 없음 — `corner` 인자, `TOP_LEFT`만 실측·나머지 3개는 유도). `arc_type`(NORMAL/PIE/CHORD)은 스키마 열거값 그대로 통과. **curve**: 전용 `add_curve`(`doc.shapes`) — 앵커를 지나는 곡선(`hp:seg type="CURVE"`)만 쓴다. 한/글은 곡선을 `sz`/`orgSz` 배율로 그리고 크기 상자는 스스로 다시 계산하지 않는다(점을 옮겨도 `orgSz`·`sz`가 그대로). 그래서 한/글 곡선이 갖는 상자 — 앵커를 지나는 Catmull-Rom 곡선(제어점은 이웃 점 차이의 1/6, 닫힌 곡선은 이웃을 돌려 쓰고 열린 곡선의 끝은 끝점 자신)을 구간마다 16단계 꺾은선으로 근사한 상자 — 를 쓰고, 앵커는 그 상자 좌상단 원점 로컬 좌표로 둔다(`SimpleCurve.hwpx`의 16636×21360과 정확히 같다). `LINE` 구간이 섞인 곡선과 묶음 안 곡선은 한/글 상자가 이 규칙과 맞지 않아 쓰지 않는다. **connectLine**: 전용 `add_connector`(`doc.shapes`) — 글자처럼 두지 않고 같은 기준(종이·쪽, 또는 연결선 문단의 단·문단)에서 왼쪽·위로 놓인 두 도형을 잇는 붙은 연결선(`subjectIDRef` = 도형 `instid`, `subjectIdx` 0 위·1 오른쪽·2 아래·3 왼쪽 가운데)을 직선(`STRAIGHT_NOARROW`)이나 꺾인 선(`STROKE_NOARROW`)으로 쓴다. 한/글은 붙은 연결선을 대상 도형의 현재 상자와 `subjectIdx`로 다시 그린다(저장 좌표는 그림에 쓰이지 않고, 다시 저장해도 고치지 않는다). 그래서 도형을 옮기거나 키워도 선이 따라가고, 저장 좌표는 다른 읽기 도구를 위해 대상 상자의 해당 변 가운데로 둔다(`SimpleConnectLine.hwpx`의 도형으로 다시 만든 연결선은 원본과 같은 자리·크기이고, 한/글이 그린 쪽도 원본과 같다). 호(`ARC`) 연결선은 제어점(`hp:controlPoints`)이 필요해 거부하고, 떼어 낸 연결선(`subjectIDRef="0"`, 한/글이 저장 좌표 그대로 그림)은 만들지 않는다. `hp:connectLine`의 `startPt`/`endPt`는 `hp:` 네임스페이스다(`hp:line`의 `hc:startPt`와 다름) |
32
- | 그룹 개체(컨테이너) | Parse·Create(experimental)·Render-verified | 전용 `add_container`(6.5+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimpleContainer.hwpx` 3부재 + 실문서 2종 71개 컨테이너, 총 74개 표본) 리버스 — 부재는 독립 도형과 완전히 같은 구조(offset/orgSz/curSz/flip/rotationInfo/renderingInfo + 도형별 기하)를 유지하되 `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment — 그룹만 가짐)는 없고 `groupLevel="1"`(그룹 자신은 `"0"`), `renderingInfo`의 `transMatrix` 이동성분이 자기 `offset`과 일치, `numberingType="PICTURE"` 전량 관측. `ContainerMember.rect`/`.ellipse`/`.polygon`으로 부재를 그룹 로컬 좌표에 배치 — **`pic`/`arc`/`line`/`connectLine`/중첩 `container` 부재와 이미 배치된 도형의 재그룹은 이번 사이클 범위 밖**(전자는 같은 패턴으로 자연 확장 가능, `pic`은 media 배선이 추가로 필요). `resize()`는 한/글이 크기를 바꾼 묶음을 저장하는 모양대로 쓴다: 한/글은 묶음을 `sz` 크기로 그리며, `orgSz`는 그대로, `sz`·`curSz`는 새 크기, 묶음과 모든 부재(묶음 안 묶음의 부재도)의 첫 `scaMatrix`에 배율(부재는 오프셋 × (배율 − 1)의 이동 포함), 부재의 `curSz`는 `orgSz` × 배율이다. 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-container 스트라텀(2~4부재·rect/ellipse/polygon 슬롯별 회전), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) |
33
- | 그림 삽입/치환 | Edit·Create·Render-verified | `add_picture`·`add_image`·`doc.media.replace_picture`. `doc.media.images`/`remove_image`는 header `binDataList`의 항목과 `content.hpf` 매니페스트에만 있는 이진 항목을 함께 다룬다(`remove_image`는 id·파트 경로·`BinaryItem`을 받고, 문서가 아직 가리키는 항목은 `force=True` 없이는 `media-item-in-use`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조). 효과 있는 복잡 개체 생성은 미지원. 그림을 포함한 그룹 생성은 위 "그룹 개체(컨테이너)" 참조(현재 그림 부재는 미지원). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-picture 스트라텀(크기 변주 4건 + `replace_picture` 자산 교체 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정) — `hp:pic`/`imgRect`/`imgDim`/`imgClip`/`effects`/`hc:img`는 `add_picture` 호출마다 무조건 방출되는 고정 스켈레톤이라(파라미터 유무 무관, 직접 확인) 이 영역째 원장 라우팅해도 위양성 없음 |
32
+ | 그룹 개체(컨테이너) | Parse·Create(experimental)·Render-verified | 전용 `add_container`(6.5+, `doc.shapes`): 실코퍼스(`hwpxlib_corpus/reader_writer__SimpleContainer.hwpx` 3부재 + 실문서 2종 71개 컨테이너, 총 74개 표본) 리버스 — 부재는 독립 도형과 완전히 같은 구조(offset/orgSz/curSz/flip/rotationInfo/renderingInfo + 도형별 기하)를 유지하되 `AbstractShapeObjectType` 꼬리(sz/pos/outMargin/shapeComment — 그룹만 가짐)는 없고 `groupLevel="1"`(그룹 자신은 `"0"`), `renderingInfo`의 `transMatrix` 이동성분이 자기 `offset`과 일치, `numberingType="PICTURE"` 전량 관측. `ContainerMember.rect`/`.ellipse`/`.polygon`/`.line`/`.text_box`/`.picture`/`.group`으로 부재를 그룹 로컬 좌표에 배치한다. 한/글이 저장한 묶음처럼 부재는 `id`·`zOrder` 0, `numberingType="NONE"`, `textWrap="TOP_AND_BOTTOM"`이고 `groupLevel`은 깊이(1·2·3…)다. 묶음 안 묶음은 꼬리 없는 부재이고, 모든 깊이의 도형은 가장 바깥 묶음의 좌표에 놓인다(한/글은 각 도형을 그 좌표의 자기 `transMatrix`에 그린다). 글상자 부재의 글은 묶음이 구역에 들어간 뒤 쓰여 문단 id가 구역에서 겹치지 않는다. **`arc`/`connectLine` 부재와 이미 배치된 도형의 재그룹은 범위 밖**. `resize()`는 한/글이 크기를 바꾼 묶음을 저장하는 모양대로 쓴다: 한/글은 묶음을 `sz` 크기로 그리며, `orgSz`는 그대로, `sz`·`curSz`는 새 크기, 묶음과 모든 부재(묶음 안 묶음의 부재도)의 첫 `scaMatrix`에 배율(부재는 오프셋 × (배율 − 1)의 이동 포함), 부재의 `curSz`는 `orgSz` × 배율이다. 실한컴 렌더 검증: `docs/openrate/report-v9.json` authored-container 스트라텀(2~4부재·rect/ellipse/polygon 슬롯별 회전), macOS Hancom GUI 오라클 15/15 render_checked·0 render_failed(2026-08-07 측정) |
33
+ | 그림 삽입/치환 | Edit·Create·Render-verified | `add_picture`·`add_image`·`doc.media.replace_picture`. `doc.media.images`/`remove_image`는 header `binDataList`의 항목과 `content.hpf` 매니페스트에만 있는 이진 항목을 함께 다룬다(`remove_image`는 id·파트 경로·`BinaryItem`을 받고, 문서가 아직 가리키는 항목은 `force=True` 없이는 `media-item-in-use`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조). `add_picture`의 `brightness`·`contrast`(-100~100)·`effect`(`REAL_PIC`/`GRAY_SCALE`/`BLACK_WHITE`)·`alpha`(투명도 0~255)와 `line_color`·`line_width`(실선 테두리)는 한/글이 그대로 저장하고 그린다(범위 밖은 `shape-picture-image-value`·`shape-picture-border-value`로 거부). `crop`(네 변에서 잘라 낼 HWPUNIT, 축척 유지)도 한/글이 그대로 그린다(`shape-picture-crop-value`). 그림자(`PictureShadow`)·빛남(`PictureGlow`)도 같다(`shape-picture-effect-value`; 한/글은 그림을 효과까지 합친 크기로 배치한다). 부드러운 가장자리(`soft_edge`)·반사(`PictureReflection`)도 같다. 뒤집기·회전은 미지원. 그림을 포함한 그룹 생성은 위 "그룹 개체(컨테이너)" 참조(`ContainerMember.picture`로 그림 부재를 넣는다). 실한컴 렌더 검증: `docs/openrate/report-v14.json` authored-picture 스트라텀(크기 변주 4건 + `replace_picture` 자산 교체 1건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-08 측정) — `hp:pic`/`imgRect`/`imgDim`/`imgClip`/`effects`/`hc:img`는 `add_picture` 호출마다 무조건 방출되는 고정 스켈레톤이라(파라미터 유무 무관, 직접 확인) 이 영역째 원장 라우팅해도 위양성 없음 |
34
34
  | 차트 | Create(experimental)·Preserve·Render-verified | `doc.shapes.add_chart`(5.3.0+): 데이터·계열·축 레이블에서 차트 개체를 생성한다. 기존 차트 part는 patch 저장 시 바이트 보존(497/497). 생성 어휘 밖의 차트 종류·서식은 미지원이며 기존 개체 보존으로만 다룬다. 실한컴 렌더 검증: `docs/openrate/report-v4.json` authored-chart 스트라텀(bar/line/pie, `hwpx_automation.office.charting.build_chart_ml`로 생성) 15/15 render_checked·0 render_failed — **이 v4 증거는 이미 존재했으나 "Render-verified" 등급이 이번(6.11 트레인㊳)까지 한 번도 이 행에 반영되지 않았다(순수 매트릭스 산문 갱신 누락, 재발견해서 바로잡음)**. `docs/openrate/report-v14.json` authored-chart 스트라텀(pie/bar/line 로테이션, core 저장소 자체 손빌드 최소 ChartML — automation 저장소 임포트 불가라 v4의 build_chart_ml을 재사용 못 함, 단방향 아키텍처) 5/5 render_checked·0 render_failed(2026-08-08 측정)로 두 번째 독립 배치 추가 — 같은 스트라텀명이라 원장이 두 report를 OR로 자동 누적. **축이 필요한 차트는 축 필수**: 한컴은 축(`c:axId`·`c:catAx`·`c:valAx`) 없는 막대 차트를 막대 없이 빈 차트로 그리고, 꺾은선(`report-v14`의 v14-chart-002)·영역·분산형·방사형·거품형은 렌더하거나 저장하다 멈춘다. 같은 차트에 축만 넣으면 정상이라 `add_chart`가 이런 입력을 `shape-chart-axes-missing`으로 거부한다. 축이 없는 원형·도넛은 받는다 |
35
35
  | 수식 | Parse·Create(experimental)·Render-verified | `add_equation`(EqEdit script 삽입, 5.2.0+)과 `hwpx.equation.latex_to_eqedit`(렌더 검증 토큰셋만 변환, 밖은 `UnsupportedLatexError`로 typed 거부). 저작 어휘 전 토큰을 실한컴 렌더 오라클 픽셀 실측(60수식 배터리)으로 확정했고, 기존 수식 개체는 파싱·patch 보존됨(미리보기 MathML 렌더는 뷰어/플러그인 계층) |
36
36
  | 문단 첫 글자 장식(드롭캡) | Create(experimental)·Render-verified | `doc.shapes.add_drop_cap`(6.12 트레인㊸, 갭②) — 편집기 메뉴 표면 역매핑(트레인㊷)이 찾은 신규 갭. **어휘가 문단 속성이 아니라 도형 공통속성이라는 게 첫 발견**: `dropcapstyle`(`hc:DropCapStyleType`, `None`/`DoubleLine`/`TripleLine`/`Margin`)은 `hh:paraPr`가 아니라 `AbstractShapeObjectType`(rect/picture/table/chart 등 모든 삽입 개체가 공유하는 공통 속성군, `ParaList XML schema.xml:2006`)에 산다 — 이 라이브러리의 기존 도형 빌더 전부가 이 속성을 `"None"`으로 고정 방출해 온 이유가 이거다. 실코퍼스 67파일 전수에서 `dropcapstyle="TripleLine"` 실사용은 `error__20230809__test.hwpx` 단 1건뿐(나머지 전부 `"None"`) — 그 1건을 역설계: 드롭캡은 문단 첫 글자를 잘라내는 게 아니라 **투명한 `hp:rect`(선 `style="NONE"`·채움/그림자 `alpha="0"`, `lock="1"`)를 run에 끼워 넣고, 그 안 `drawText/subList/p/run/t`에 키운 문자를 별도로 담는** 구조다. 배치는 일반 도형 빌더의 "흐르지 않는 개체" 기본값(`horzRelTo="COLUMN"`·`flowWithText="0"`)과 달리 `horzRelTo="PARA"`·`flowWithText="1"`, `curSz`는 `orgSz`와 다르게 `"0"`/`"0"`(리사이즈 안 됨 센티널로 추정), `outMargin right="850"`(다른 셋은 0). 정체불명 `hp:parameterset`(`name="539"`, 값 `"2"`)은 DEV-011 선례대로 의미 추정 없이 그대로 보존. **`TripleLine`과 `DoubleLine`을 쓴다**: 한/글은 같은 rect+drawText 구조에 parameterset 값 1인 `DoubleLine`(`TripleLine`은 2)을 읽고, 그리고, 그대로 저장하며, 상자 크기가 같으면 두 스타일의 화면이 같다(상자 옆으로 가는 줄 수는 스타일이 아니라 상자 높이가 정한다). `Margin`은 상자를 여백 쪽 어디에 두는지 근거가 없어 typed 거부로 남긴다(curve·connectLine과 같은 원칙). width/height도 실측된 폰트-크기→박스-크기 변환 공식이 없어 호출자가 직접 지정(자동 계산 시도 안 함). 원장 라우팅은 없음(`hp:rect`가 이미 "도형 저작" 영역에 등록돼 있어 재등록하면 그 카운트를 덮어씀, `hp:fieldBegin`류보다 더 확실한 공유 요소 사례). 실한컴 렌더 검증: `docs/openrate/report-v16.json` authored-drop-cap 스트라텀(문자 5종 로테이션 + width/height 변주 1건, 5건), macOS Hancom GUI 오라클 5/5 render_checked·0 render_failed(2026-08-10 측정) — 그때는 `TripleLine`만(`DoubleLine`은 뒤에 한/글이 다시 저장한 파일로 확인, `Margin`은 typed 거부) |