python-hwpx 6.0.3__py3-none-any.whl → 6.2.1__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 (57) hide show
  1. hwpx/_document/_legacy.py +16 -1
  2. hwpx/_document/highlight.py +164 -0
  3. hwpx/_document/layout.py +128 -11
  4. hwpx/_document/ns/__init__.py +1 -1
  5. hwpx/_document/ns/page.py +125 -1
  6. hwpx/_document/ns/parts.py +210 -4
  7. hwpx/_document/ns/shapes.py +182 -1
  8. hwpx/_document/ns/styles.py +269 -4
  9. hwpx/_document/ns/text.py +30 -1
  10. hwpx/_document/shapes.py +244 -1
  11. hwpx/capabilities.py +273 -2
  12. hwpx/data/contract_docs/support-matrix.md +56 -12
  13. hwpx/errors.py +59 -0
  14. hwpx/objects/__init__.py +2 -0
  15. hwpx/objects/highlight.py +41 -0
  16. hwpx/opc/package.py +123 -1
  17. hwpx/opc/relationships.py +6 -0
  18. hwpx/oxml/__init__.py +46 -2
  19. hwpx/oxml/_document_primitives.py +881 -12
  20. hwpx/oxml/body.py +488 -0
  21. hwpx/oxml/document_metadata.py +94 -0
  22. hwpx/oxml/document_parts.py +321 -106
  23. hwpx/oxml/drop_cap.py +218 -0
  24. hwpx/oxml/dutmal_compose.py +125 -0
  25. hwpx/oxml/field_marks.py +409 -0
  26. hwpx/oxml/header.py +349 -4
  27. hwpx/oxml/header_compat.py +317 -0
  28. hwpx/oxml/header_part.py +260 -123
  29. hwpx/oxml/history_part.py +162 -0
  30. hwpx/oxml/master_page.py +104 -0
  31. hwpx/oxml/master_page_authoring.py +153 -0
  32. hwpx/oxml/namespaces.py +8 -0
  33. hwpx/oxml/note_authoring.py +193 -0
  34. hwpx/oxml/numbering_kinds.py +98 -0
  35. hwpx/oxml/objects.py +934 -4
  36. hwpx/oxml/paragraph.py +325 -281
  37. hwpx/oxml/run.py +23 -0
  38. hwpx/oxml/section_format.py +79 -0
  39. hwpx/oxml/section_layout.py +133 -0
  40. hwpx/oxml/settings.py +155 -0
  41. hwpx/oxml/simple_parts.py +58 -4
  42. hwpx/oxml/table.py +253 -0
  43. hwpx/oxml/version_part.py +68 -0
  44. hwpx/table_patch.py +229 -30
  45. hwpx/tools/document_merge.py +1306 -0
  46. hwpx/tools/id_integrity.py +15 -0
  47. hwpx/tools/mail_merge.py +2 -2
  48. hwpx/tools/markdown_export.py +25 -2
  49. hwpx/tools/package_validator.py +83 -0
  50. hwpx/tools/text_extractor.py +31 -2
  51. {python_hwpx-6.0.3.dist-info → python_hwpx-6.2.1.dist-info}/METADATA +1 -1
  52. {python_hwpx-6.0.3.dist-info → python_hwpx-6.2.1.dist-info}/RECORD +57 -41
  53. {python_hwpx-6.0.3.dist-info → python_hwpx-6.2.1.dist-info}/WHEEL +0 -0
  54. {python_hwpx-6.0.3.dist-info → python_hwpx-6.2.1.dist-info}/entry_points.txt +0 -0
  55. {python_hwpx-6.0.3.dist-info → python_hwpx-6.2.1.dist-info}/licenses/LICENSE +0 -0
  56. {python_hwpx-6.0.3.dist-info → python_hwpx-6.2.1.dist-info}/licenses/NOTICE +0 -0
  57. {python_hwpx-6.0.3.dist-info → python_hwpx-6.2.1.dist-info}/top_level.txt +0 -0
hwpx/_document/_legacy.py CHANGED
@@ -69,6 +69,7 @@ from . import tracked as _tracked
69
69
 
70
70
  if TYPE_CHECKING:
71
71
  from ..form_fit.policy import FitPolicy
72
+ from ..objects.binary_item import BinaryItem
72
73
  from ..oxml import (
73
74
  HwpxOxmlDocument,
74
75
  HwpxOxmlHeader,
@@ -862,6 +863,8 @@ class _LegacyFacade:
862
863
  border_color: str = "#BFBFBF",
863
864
  border_width: str = "0.12 mm",
864
865
  fill_color: str | None = None,
866
+ fill_image: "str | BinaryItem | Mapping[str, object] | None" = None,
867
+ fill_gradient: Mapping[str, object] | None = None,
865
868
  active_borders: Sequence[str] | None = None,
866
869
  border_type: str = "SOLID",
867
870
  ) -> str:
@@ -869,13 +872,25 @@ class _LegacyFacade:
869
872
 
870
873
  ``border_type`` selects the OWPML line style (``SOLID``, ``DASH``,
871
874
  ``DOT``, ``DOUBLE_SLIM``, ``WAVE``, …); values outside the OWPML
872
- vocabulary are rejected.
875
+ vocabulary are rejected. ``fill_color``/``fill_image``/``fill_gradient``
876
+ are mutually exclusive (OWPML ``hc:fillBrush`` choice).
873
877
  """
874
878
 
879
+ from .ns.styles import _resolve_fill_gradient, _resolve_fill_image
880
+
881
+ given = sum(1 for v in (fill_color, fill_image, fill_gradient) if v is not None)
882
+ if given > 1:
883
+ raise HwpxValueError(
884
+ "fill_color/fill_image/fill_gradient are mutually exclusive",
885
+ code="style-border-fill-conflict",
886
+ suggestion="Pass exactly one of fill_color, fill_image, fill_gradient.",
887
+ )
875
888
  return self._root.ensure_border_fill(
876
889
  border_color=border_color,
877
890
  border_width=border_width,
878
891
  fill_color=fill_color,
892
+ fill_image=_resolve_fill_image(fill_image),
893
+ fill_gradient=_resolve_fill_gradient(fill_gradient),
879
894
  active_borders=active_borders,
880
895
  border_type=border_type,
881
896
  )
@@ -0,0 +1,164 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Highlight (``hp:markpenBegin``/``markpenEnd``) authoring and reading owner
3
+ behind the :class:`HwpxDocument` facade.
4
+
5
+ Real-corpus reversal (``hwpxlib_corpus/error__20251107__test*.hwpx``, the only
6
+ fixtures carrying this pair) plus the OWPML schema (``ParaList XML
7
+ schema.xml``) agree on the shape: both marks live inside a single ``hp:t``,
8
+ ``markpenBegin`` carries an optional ``color``, ``markpenEnd`` carries no
9
+ attributes at all — pairing is positional (innermost open begin closes first),
10
+ not by id. :mod:`hwpx.tools.text_extractor` already reads this shape with a
11
+ per-``hp:t`` stack; :func:`list_highlights` exposes the same reading as a
12
+ public model instead of inline text markers.
13
+
14
+ Authoring is scoped to one run's text the same way ``doc.tracking.delete``
15
+ scopes tracked deletes: a match that only exists once inline markup pieces
16
+ are concatenated crosses a boundary this module refuses to split.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import TYPE_CHECKING
23
+
24
+ from ..errors import HwpxValueError
25
+ from ..objects.highlight import Highlight
26
+
27
+ if TYPE_CHECKING:
28
+ from hwpx.document import HwpxDocument
29
+ from ..oxml import HwpxOxmlParagraph
30
+ from ..oxml.body import TextSpan
31
+
32
+ #: OWPML ``hc:RGBColorType`` — ``#`` followed by exactly six hex digits.
33
+ _COLOR_RE = re.compile(r"#[0-9A-Fa-f]{6}")
34
+
35
+ #: Matches the real-corpus fixtures' brightest observed value; any
36
+ #: schema-valid hex is otherwise accepted.
37
+ DEFAULT_COLOR = "#FFFF00"
38
+
39
+
40
+ def _validate_color(color: str) -> str:
41
+ if not isinstance(color, str) or not _COLOR_RE.fullmatch(color):
42
+ raise HwpxValueError(
43
+ f"highlight color must be a 6-digit hex value like '#FFFF00', got {color!r}",
44
+ code="text-highlight-color-invalid",
45
+ context={"color": color},
46
+ suggestion="Pass a colour matching #RRGGBB (hex digits, either case).",
47
+ )
48
+ return color
49
+
50
+
51
+ def _paragraph_has_highlightable_text(paragraph: "HwpxOxmlParagraph", match: str) -> bool:
52
+ """Return whether *match* lives inside one contiguous inline text piece.
53
+
54
+ Mirrors ``hwpx._document.tracked._paragraph_has_replaceable_text``: a
55
+ match that is only present after concatenating text across inline
56
+ markup (an existing highlight, a tracked-change mark, …) is real but
57
+ cannot be wrapped safely, so that case raises here instead of falling
58
+ through to a generic "not found".
59
+ """
60
+
61
+ crosses_inline_markup = False
62
+ for run in paragraph.runs:
63
+ model = run.to_model()
64
+ for span in model.text_spans:
65
+ if match not in span.text:
66
+ continue
67
+ if match in span.leading_text or any(
68
+ match in markup.trailing_text for markup in span.marks
69
+ ):
70
+ return True
71
+ crosses_inline_markup = True
72
+
73
+ if crosses_inline_markup:
74
+ raise HwpxValueError(
75
+ "match text crosses inline markup and cannot be wrapped safely",
76
+ code="text-highlight-match-crosses-markup",
77
+ context={"match": match},
78
+ suggestion="Target a substring that lives inside a single run.",
79
+ )
80
+ return False
81
+
82
+
83
+ def add_highlight(
84
+ doc: "HwpxDocument",
85
+ paragraph: "HwpxOxmlParagraph",
86
+ match: str,
87
+ *,
88
+ color: str = DEFAULT_COLOR,
89
+ ) -> Highlight:
90
+ """Wrap the first occurrence of *match* in *paragraph* in markpen marks."""
91
+
92
+ if not match:
93
+ raise HwpxValueError(
94
+ "match text must be a non-empty string",
95
+ code="text-highlight-match-empty",
96
+ suggestion="Pass the substring to highlight.",
97
+ )
98
+ validated_color = _validate_color(color)
99
+
100
+ if not _paragraph_has_highlightable_text(paragraph, match):
101
+ raise HwpxValueError(
102
+ "match text was not found in the paragraph",
103
+ code="text-highlight-match-not-found",
104
+ context={"match": match, "paragraphText": paragraph.text},
105
+ suggestion="Inspect paragraph.text for the actual string.",
106
+ )
107
+
108
+ paragraph.add_highlight(color=validated_color, match=match)
109
+ return Highlight(text=match, color=validated_color, paragraph=paragraph)
110
+
111
+
112
+ def _span_highlights(span: "TextSpan") -> list[tuple[str, "str | None"]]:
113
+ """Yield (text, color) for every markpen pair closed within *span*.
114
+
115
+ A LIFO stack, same as ``text_extractor._render_text_element``'s
116
+ ``highlight_stack`` — nested ``markpenBegin`` opens a new frame, the next
117
+ ``markpenEnd`` closes the innermost one. A begin left open at the end of
118
+ the span (no matching end inside this ``hp:t``) is still reported, same
119
+ as that reader closing it defensively at ``hp:t`` end; an end with
120
+ nothing open is silently dropped rather than fabricated.
121
+ """
122
+
123
+ results: list[tuple[str, "str | None"]] = []
124
+ stack: list[tuple["str | None", list[str]]] = []
125
+
126
+ for markup in span.marks:
127
+ element = markup.element
128
+ name = getattr(element, "name", None)
129
+ if name == "markpenBegin":
130
+ attributes = getattr(element, "attributes", None) or {}
131
+ stack.append((attributes.get("color"), []))
132
+ if markup.trailing_text:
133
+ stack[-1][1].append(markup.trailing_text)
134
+ continue
135
+ if name == "markpenEnd":
136
+ if stack:
137
+ mark_color, buffer = stack.pop()
138
+ results.append(("".join(buffer), mark_color))
139
+ if stack and markup.trailing_text:
140
+ stack[-1][1].append(markup.trailing_text)
141
+ continue
142
+ if stack and markup.trailing_text:
143
+ stack[-1][1].append(markup.trailing_text)
144
+
145
+ while stack:
146
+ mark_color, buffer = stack.pop()
147
+ results.append(("".join(buffer), mark_color))
148
+ return results
149
+
150
+
151
+ def list_highlights(doc: "HwpxDocument") -> tuple[Highlight, ...]:
152
+ """Return every markpen highlight in the document, in document order."""
153
+
154
+ results: list[Highlight] = []
155
+ for paragraph in doc.paragraphs:
156
+ for run in paragraph.runs:
157
+ model = run.to_model()
158
+ for span in model.text_spans:
159
+ for text, color in _span_highlights(span):
160
+ results.append(Highlight(text=text, color=color, paragraph=paragraph))
161
+ return tuple(results)
162
+
163
+
164
+ __all__ = ["DEFAULT_COLOR", "add_highlight", "list_highlights"]
hwpx/_document/layout.py CHANGED
@@ -15,6 +15,7 @@ from ..objects.results import (
15
15
  ParagraphFormatResult,
16
16
  Units,
17
17
  )
18
+ from ..oxml._document_primitives import NEW_NUM_KINDS
18
19
  from ..oxml.namespaces import HH
19
20
  from ._units import _mm_to_hwp_units, _pt_to_hwp_units
20
21
 
@@ -119,16 +120,31 @@ def set_paragraph_format(
119
120
  keep_with_next: bool | None = None,
120
121
  keep_lines: bool | None = None,
121
122
  page_break_before: bool | None = None,
123
+ column_break: bool | None = None,
122
124
  bottom_border: bool = False,
123
125
  border_color: str = "#BFBFBF",
124
126
  border_width: str = "0.12 mm",
127
+ tab_stops: Sequence[Mapping[str, Any]] | None = None,
128
+ auto_tab_left: bool | None = None,
129
+ auto_tab_right: bool | None = None,
125
130
  ) -> ParagraphFormatResult:
126
131
  """Apply paragraph-level formatting using human units.
127
132
 
128
133
  Millimetre inputs are converted to HWP units; paragraph spacing uses
129
134
  points; line spacing is stored as a percent value. ``keep_with_next`` /
130
135
  ``keep_lines`` / ``page_break_before`` set the paragraph's keep-together
131
- (``<hh:breakSetting>``) flags via a freshly minted paraPr.
136
+ (``<hh:breakSetting>``) flags via a freshly minted paraPr. ``column_break``
137
+ is a different mechanism -- ``hp:p``'s own ``columnBreak`` attribute, a
138
+ per-paragraph-instance forced break (not a shared paraPr style property
139
+ like ``page_break_before``) -- applied directly to each target paragraph.
140
+
141
+ ``tab_stops`` is a sequence of ``{"pos_mm": ..., "type": "LEFT"|"RIGHT"|
142
+ "CENTER"|"DECIMAL", "leader": "NONE"|...}`` mappings (``type``/``leader``
143
+ default to the real-corpus-majority ``"LEFT"``/``"NONE"``) — order is
144
+ meaningful, matching how real multi-stop documents list them
145
+ position-ascending. Passing ``tab_stops``/``auto_tab_left``/
146
+ ``auto_tab_right`` mints (or reuses — dedupe) a ``hh:tabPr`` and wires
147
+ the paragraph's ``tabPrIDRef`` to it.
132
148
  """
133
149
 
134
150
  if not doc._root.headers:
@@ -181,6 +197,10 @@ def set_paragraph_format(
181
197
  if page_break_before is not None:
182
198
  break_setting["page_break_before"] = bool(page_break_before)
183
199
 
200
+ wants_tab_definition = (
201
+ tab_stops is not None or auto_tab_left is not None or auto_tab_right is not None
202
+ )
203
+
184
204
  if (
185
205
  alignment is None
186
206
  and line_spacing_percent is None
@@ -188,6 +208,8 @@ def set_paragraph_format(
188
208
  and heading is None
189
209
  and not bottom_border
190
210
  and not break_setting
211
+ and not wants_tab_definition
212
+ and column_break is None
191
213
  ):
192
214
  raise HwpxValueError(
193
215
  "at least one paragraph formatting option is required",
@@ -195,6 +217,28 @@ def set_paragraph_format(
195
217
  suggestion="Pass alignment, line_spacing_percent, or another option to change.",
196
218
  )
197
219
 
220
+ tab_pr_id: str | None = None
221
+ if wants_tab_definition:
222
+ converted_stops: list[dict[str, object]] = []
223
+ for index, stop in enumerate(tab_stops or ()):
224
+ if "pos_mm" not in stop or stop["pos_mm"] is None:
225
+ raise HwpxValueError(
226
+ f"tab_stops[{index}] is missing 'pos_mm'",
227
+ code="paragraph-tab-pos-invalid",
228
+ context={"index": index},
229
+ suggestion="각 tab stop은 'pos_mm'(mm, 0 이상)가 필요합니다.",
230
+ )
231
+ converted_stops.append({
232
+ "pos": _mm_to_hwp_units(float(stop["pos_mm"])),
233
+ "type": stop.get("type"),
234
+ "leader": stop.get("leader"),
235
+ })
236
+ tab_pr_id = header.ensure_tab_definition(
237
+ tab_stops=converted_stops,
238
+ auto_tab_left=bool(auto_tab_left),
239
+ auto_tab_right=bool(auto_tab_right),
240
+ )
241
+
198
242
  border: dict[str, str] | None = None
199
243
  if bottom_border:
200
244
  border_fill_id = header.ensure_border_fill(
@@ -212,22 +256,40 @@ def set_paragraph_format(
212
256
  "ignoreMargin": "0",
213
257
  }
214
258
 
259
+ # column_break bypasses paraPr entirely (it's hp:p's own attribute, not
260
+ # a shared style) -- only mint a new paraPr when one of the *other*
261
+ # options actually needs it, so a column_break-only call doesn't churn
262
+ # a needless duplicate paraPr id.
263
+ wants_para_pr_change = (
264
+ alignment is not None
265
+ or line_spacing_percent is not None
266
+ or bool(margins)
267
+ or heading is not None
268
+ or bottom_border
269
+ or bool(break_setting)
270
+ or wants_tab_definition
271
+ )
272
+
215
273
  targets = _resolve_paragraph_targets(doc,
216
274
  paragraph_index=paragraph_index,
217
275
  paragraph_indexes=paragraph_indexes,
218
276
  )
219
277
  formatted: list[int] = []
220
278
  for index, paragraph in targets:
221
- para_pr_id = header.ensure_paragraph_format(
222
- base_para_pr_id=paragraph.para_pr_id_ref,
223
- alignment=alignment,
224
- line_spacing_percent=line_spacing_percent,
225
- margins=margins,
226
- heading=heading,
227
- border=border,
228
- break_setting=break_setting or None,
229
- )
230
- paragraph.para_pr_id_ref = para_pr_id
279
+ if wants_para_pr_change:
280
+ para_pr_id = header.ensure_paragraph_format(
281
+ base_para_pr_id=paragraph.para_pr_id_ref,
282
+ alignment=alignment,
283
+ line_spacing_percent=line_spacing_percent,
284
+ margins=margins,
285
+ heading=heading,
286
+ border=border,
287
+ break_setting=break_setting or None,
288
+ tab_pr_id_ref=tab_pr_id,
289
+ )
290
+ paragraph.para_pr_id_ref = para_pr_id
291
+ if column_break is not None:
292
+ paragraph.column_break = column_break
231
293
  formatted.append(index)
232
294
 
233
295
  return ParagraphFormatResult(
@@ -767,6 +829,61 @@ def set_page_number(
767
829
  )
768
830
 
769
831
 
832
+ def restart_page_number(
833
+ doc: "HwpxDocument",
834
+ paragraph: "HwpxOxmlParagraph",
835
+ *,
836
+ number: int = 1,
837
+ kind: str = "PAGE",
838
+ ) -> "HwpxOxmlInlineObject":
839
+ """Restart *kind*'s running count at *number* from *paragraph* onward.
840
+
841
+ Inserts ``<hp:ctrl><hp:newNum num="{number}" numType="{kind}"/></hp:ctrl>``
842
+ — a section-mid restart point, distinct from ``set_page_number`` (which
843
+ places the *display* field in a header/footer). ``kind`` defaults to
844
+ ``"PAGE"``, the only value real corpus (hwpxlib_corpus) observes; the
845
+ other six (``FOOTNOTE``/``ENDNOTE``/``PICTURE``/``TABLE``/``EQUATION``/
846
+ ``TOTAL_PAGE``) are schema-legal but unattested.
847
+ """
848
+
849
+ normalized_kind = str(kind or "PAGE").upper()
850
+ if normalized_kind not in NEW_NUM_KINDS:
851
+ raise HwpxValueError(
852
+ f"unsupported kind {kind!r}",
853
+ code="page-new-num-kind-invalid",
854
+ context={"kind": normalized_kind, "allowed": sorted(NEW_NUM_KINDS)},
855
+ suggestion="Use one of: " + ", ".join(sorted(NEW_NUM_KINDS)),
856
+ )
857
+ return paragraph.add_new_num(number=number, kind=normalized_kind)
858
+
859
+
860
+ def hide_page_elements(
861
+ doc: "HwpxDocument",
862
+ paragraph: "HwpxOxmlParagraph",
863
+ *,
864
+ header: bool = False,
865
+ footer: bool = False,
866
+ master_page: bool = False,
867
+ border: bool = False,
868
+ fill: bool = False,
869
+ page_num: bool = False,
870
+ ) -> "HwpxOxmlInlineObject":
871
+ """Hide the named page elements from *paragraph*'s page onward.
872
+
873
+ Inserts ``<hp:ctrl><hp:pageHiding .../></hp:ctrl>`` (``ParaList XML
874
+ schema.xml:148-163`` — six independent booleans, all default unhidden).
875
+ """
876
+
877
+ return paragraph.add_page_hiding(
878
+ header=header,
879
+ footer=footer,
880
+ master_page=master_page,
881
+ border=border,
882
+ fill=fill,
883
+ page_num=page_num,
884
+ )
885
+
886
+
770
887
  def remove_header(
771
888
  doc: "HwpxDocument",
772
889
  *,
@@ -9,7 +9,7 @@
9
9
 
10
10
  | 능력 영역 | 네임스페이스 |
11
11
  |---|---|
12
- | `shape-authoring`·`shape-escape-hatch`·`curve-objects`·`chart`·`equation` | `doc.shapes` |
12
+ | `shape-authoring`·`shape-escape-hatch`·`curve-objects`·`container-authoring`·`chart`·`equation` | `doc.shapes` |
13
13
  | `picture`(BinData 절반) | `doc.media` |
14
14
  | `redline` | `doc.tracking` |
15
15
  | `memo`·`footnote-endnote` | `doc.notes` |
hwpx/_document/ns/page.py CHANGED
@@ -27,7 +27,7 @@ from __future__ import annotations
27
27
  from typing import TYPE_CHECKING, Any, Mapping, Sequence
28
28
 
29
29
  from ...errors import HwpxValueError
30
- from .._resolve import resolve_section
30
+ from .._resolve import resolve_paragraph, resolve_section
31
31
  from ._base import _Namespace
32
32
 
33
33
  if TYPE_CHECKING:
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
37
37
  PageMargins,
38
38
  PageSize,
39
39
  SectionGrid,
40
+ SectionTextDirection,
40
41
  SectionVisibility,
41
42
  )
42
43
  from ...objects import PageSetup
@@ -44,6 +45,10 @@ if TYPE_CHECKING:
44
45
 
45
46
  __all__ = ["PageNamespace"]
46
47
 
48
+ #: 6.12 트레인㊸ 갭③ — 스키마 선언 열거값(다른 어떤 값도 실코퍼스에 없음,
49
+ #: 실측 근거는 SectionTextDirection 독스트링 참조).
50
+ _TEXT_DIRECTIONS = frozenset({"HORIZONTAL", "VERTICAL", "VERTICALALL"})
51
+
47
52
 
48
53
  class PageNamespace(_Namespace):
49
54
  """쪽 기하 — 용지·여백·단·머리말/꼬리말·쪽번호·격자·줄번호."""
@@ -339,6 +344,54 @@ class PageNamespace(_Namespace):
339
344
  section=self._section(section, section_index, "set_page_number"),
340
345
  )
341
346
 
347
+ def restart_page_number(
348
+ self,
349
+ paragraph: "HwpxOxmlParagraph | int",
350
+ *,
351
+ number: int = 1,
352
+ kind: str = "PAGE",
353
+ ) -> "HwpxOxmlInlineObject":
354
+ """*paragraph* 부터 *kind* 의 진행 번호를 *number* 로 재시작한다.
355
+
356
+ `set_page_number` 가 머리말/꼬리말의 *표시* 필드를 다루는 것과 달리,
357
+ 이건 구역 중간의 재시작 지점(`hp:newNum`)을 문단에 심는다.
358
+ """
359
+
360
+ from .. import layout as _layout
361
+
362
+ return _layout.restart_page_number(
363
+ self._doc,
364
+ resolve_paragraph(self._doc, paragraph, caller="doc.page.restart_page_number"),
365
+ number=number,
366
+ kind=kind,
367
+ )
368
+
369
+ def hide_page_elements(
370
+ self,
371
+ paragraph: "HwpxOxmlParagraph | int",
372
+ *,
373
+ header: bool = False,
374
+ footer: bool = False,
375
+ master_page: bool = False,
376
+ border: bool = False,
377
+ fill: bool = False,
378
+ page_num: bool = False,
379
+ ) -> "HwpxOxmlInlineObject":
380
+ """*paragraph* 가 속한 쪽부터 지정한 요소를 숨긴다(`hp:pageHiding`)."""
381
+
382
+ from .. import layout as _layout
383
+
384
+ return _layout.hide_page_elements(
385
+ self._doc,
386
+ resolve_paragraph(self._doc, paragraph, caller="doc.page.hide_page_elements"),
387
+ header=header,
388
+ footer=footer,
389
+ master_page=master_page,
390
+ border=border,
391
+ fill=fill,
392
+ page_num=page_num,
393
+ )
394
+
342
395
  # -- 읽기: 현재 지면 상태 ----------------------------------------------
343
396
 
344
397
  def size(
@@ -368,6 +421,33 @@ class PageNamespace(_Namespace):
368
421
  # 저수준 API 는 ``HwpxOxmlSectionProperties`` 가 소유하고(Q3b), 여기서는 쪽
369
422
  # 기하 축의 나머지와 같은 자리에 노출하기만 한다.
370
423
 
424
+ def master_page_refs(
425
+ self,
426
+ *,
427
+ section: "int | HwpxOxmlSection | None" = None,
428
+ section_index: int | None = None,
429
+ ) -> tuple[str, ...]:
430
+ """이 절이 참조하는 바탕쪽 id(``hp:masterPage/@idRef``) 목록."""
431
+
432
+ return self._section(
433
+ section, section_index, "master_page_refs"
434
+ ).properties.master_page_refs
435
+
436
+ def set_master_page(
437
+ self,
438
+ master_page_id: str,
439
+ *,
440
+ section: "int | HwpxOxmlSection | None" = None,
441
+ section_index: int | None = None,
442
+ ) -> None:
443
+ """이 절이 바탕쪽(``doc.parts.add_master_page``가 만든 id)을
444
+ 참조하도록 등록한다(6.13 트레인㊻). 이미 참조 중이면 아무 일도
445
+ 안 한다(멱등)."""
446
+
447
+ self._section(
448
+ section, section_index, "set_master_page"
449
+ ).properties.add_master_page_reference(master_page_id)
450
+
371
451
  def grid(
372
452
  self,
373
453
  *,
@@ -395,6 +475,50 @@ class PageNamespace(_Namespace):
395
475
  wonggoji_format=wonggoji_format,
396
476
  )
397
477
 
478
+ def text_direction(
479
+ self,
480
+ *,
481
+ section: "int | HwpxOxmlSection | None" = None,
482
+ section_index: int | None = None,
483
+ ) -> "SectionTextDirection":
484
+ """글자 방향(가로쓰기/세로쓰기)과 머리말/꼬리말 세로쓰기 여부."""
485
+
486
+ return self._section(
487
+ section, section_index, "text_direction"
488
+ ).properties.text_direction
489
+
490
+ def set_text_direction(
491
+ self,
492
+ direction: str | None = None,
493
+ *,
494
+ vertical_header_footer: bool | None = None,
495
+ section: "int | HwpxOxmlSection | None" = None,
496
+ section_index: int | None = None,
497
+ ) -> None:
498
+ """글자 방향(세로쓰기 포함)을 설정한다.
499
+
500
+ 실코퍼스 67파일 전수에 `VERTICAL`/`VERTICALALL` 실사용 예가 없다
501
+ (74/74 `HORIZONTAL`) — 스키마 열거값 자체는 명확해 저작을 막지
502
+ 않지만, 실한컴 렌더 검증 전까지는 "Create(experimental)"로만
503
+ 표기한다(``docs/support-matrix.md`` 참조).
504
+ """
505
+
506
+ if direction is not None and direction not in _TEXT_DIRECTIONS:
507
+ raise HwpxValueError(
508
+ f"unsupported text direction: {direction}",
509
+ code="page-text-direction-unsupported",
510
+ context={
511
+ "requested": direction,
512
+ "supported": sorted(_TEXT_DIRECTIONS),
513
+ },
514
+ suggestion=f"Supported: {', '.join(sorted(_TEXT_DIRECTIONS))}",
515
+ )
516
+ self._section(
517
+ section, section_index, "set_text_direction"
518
+ ).properties.set_text_direction(
519
+ direction, vertical_header_footer=vertical_header_footer
520
+ )
521
+
398
522
  def visibility(
399
523
  self,
400
524
  *,