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.
- hwpx/_document/fields.py +1 -1
- hwpx/_document/layout.py +46 -25
- hwpx/_document/media.py +45 -8
- hwpx/_document/memos.py +3 -1
- hwpx/_document/ns/shapes.py +2 -2
- hwpx/_document/ns/tables.py +49 -3
- hwpx/_document/shapes.py +15 -7
- hwpx/body_patch.py +27 -12
- hwpx/capabilities.py +1 -1
- hwpx/data/contract_docs/support-matrix.md +4 -4
- hwpx/document.py +35 -1
- hwpx/errors.py +20 -1
- hwpx/form_fit/_glyph_table.py +3328 -142
- hwpx/form_fit/measure.py +836 -94
- hwpx/hwp5/docinfo_writer.py +4 -3
- hwpx/hwp5/header_xml.py +4 -3
- hwpx/hwp5/package.py +70 -3
- hwpx/hwp5/records.py +2 -0
- hwpx/hwp5/section_writer.py +6 -5
- hwpx/hwp5/section_xml.py +69 -6
- hwpx/hwp5/shape_xml.py +6 -4
- hwpx/hwp5/shapes.py +22 -16
- hwpx/layout/line_cache.py +636 -0
- hwpx/layout/lint.py +4 -3
- hwpx/layout/pages.py +1114 -225
- hwpx/oxml/__init__.py +4 -0
- hwpx/oxml/_document_primitives.py +30 -19
- hwpx/oxml/_paragraph_text_edit.py +5 -11
- hwpx/oxml/body.py +3 -3
- hwpx/oxml/container.py +377 -0
- hwpx/oxml/curves.py +10 -3
- hwpx/oxml/document_parts.py +38 -5
- hwpx/oxml/dutmal_compose.py +1 -1
- hwpx/oxml/header.py +4 -0
- hwpx/oxml/header_compat.py +6 -0
- hwpx/oxml/header_fonts.py +75 -4
- hwpx/oxml/memo.py +4 -1
- hwpx/oxml/note_authoring.py +2 -0
- hwpx/oxml/objects.py +145 -215
- hwpx/oxml/paragraph.py +54 -81
- hwpx/oxml/run.py +1 -0
- hwpx/oxml/section.py +69 -15
- hwpx/oxml/section_format.py +185 -54
- hwpx/oxml/shape_position.py +320 -2
- hwpx/oxml/table.py +6 -4
- hwpx/oxml/table_sizes.py +53 -1
- hwpx/oxml/table_structure.py +166 -0
- hwpx/oxml/utils.py +28 -0
- hwpx/patch.py +11 -8
- hwpx/table_patch.py +428 -56
- hwpx/tools/package_validator.py +27 -4
- hwpx/tools/toc_author.py +1 -1
- {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/METADATA +9 -4
- {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/RECORD +59 -56
- {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/WHEEL +0 -0
- {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/entry_points.txt +0 -0
- {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/licenses/LICENSE +0 -0
- {python_hwpx-6.8.0.dist-info → python_hwpx-6.9.0.dist-info}/licenses/NOTICE +0 -0
- {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
|
-
(
|
|
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
|
|
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
|
|
517
|
+
if isinstance(level, bool) or not isinstance(level, int) or not 1 <= level <= 10:
|
|
516
518
|
raise HwpxValueError(
|
|
517
|
-
"level must be 1
|
|
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="
|
|
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(
|
|
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
|
|
727
|
-
``hp:colPr`` next to ``hp:secPr``) in
|
|
728
|
-
out in ``col_count`` columns. With
|
|
729
|
-
|
|
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
|
-
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
hwpx/_document/ns/shapes.py
CHANGED
|
@@ -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
|
|
hwpx/_document/ns/tables.py
CHANGED
|
@@ -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
|
|
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``
|
|
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=
|
|
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 (문단 첫 글자 장식),
|
|
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
|
|
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
|
|
189
|
-
# raw tab character inside hp:t.
|
|
190
|
-
esc_replace = "
|
|
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
|
-
|
|
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`으로 부재를 그룹 로컬 좌표에
|
|
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`로 거부한다). 단순 그림 개체 자동 생성은 지원하며 실한컴 개봉을 확인했다(실한컴 코퍼스와 자식 요소 단위 대조).
|
|
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 거부) |
|