python-hwpx 6.7.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 (68) hide show
  1. hwpx/__init__.py +84 -34
  2. hwpx/_document/fields.py +1 -1
  3. hwpx/_document/layout.py +46 -25
  4. hwpx/_document/media.py +45 -8
  5. hwpx/_document/memos.py +3 -1
  6. hwpx/_document/ns/shapes.py +2 -2
  7. hwpx/_document/ns/tables.py +49 -3
  8. hwpx/_document/persistence.py +14 -2
  9. hwpx/_document/shapes.py +15 -7
  10. hwpx/body_patch.py +27 -12
  11. hwpx/capabilities.py +1 -1
  12. hwpx/data/contract_docs/support-matrix.md +4 -4
  13. hwpx/document.py +39 -3
  14. hwpx/equation/authoring.py +6 -3
  15. hwpx/equation/eqedit.py +7 -2
  16. hwpx/equation/measure.py +169 -73
  17. hwpx/errors.py +48 -1
  18. hwpx/form_fit/_glyph_table.py +6951 -0
  19. hwpx/form_fit/measure.py +1392 -85
  20. hwpx/hwp5/docinfo_writer.py +4 -3
  21. hwpx/hwp5/errors.py +12 -22
  22. hwpx/hwp5/header_xml.py +4 -3
  23. hwpx/hwp5/package.py +70 -3
  24. hwpx/hwp5/records.py +2 -0
  25. hwpx/hwp5/section_writer.py +6 -5
  26. hwpx/hwp5/section_xml.py +69 -6
  27. hwpx/hwp5/shape_xml.py +6 -4
  28. hwpx/hwp5/shapes.py +22 -16
  29. hwpx/ingest/hwpx_converter.py +2 -1
  30. hwpx/layout/line_cache.py +636 -0
  31. hwpx/layout/lint.py +4 -3
  32. hwpx/layout/pages.py +2516 -349
  33. hwpx/oxml/__init__.py +4 -0
  34. hwpx/oxml/_document_primitives.py +31 -20
  35. hwpx/oxml/_paragraph_text_edit.py +5 -11
  36. hwpx/oxml/body.py +3 -3
  37. hwpx/oxml/container.py +377 -0
  38. hwpx/oxml/curves.py +10 -3
  39. hwpx/oxml/document_parts.py +38 -5
  40. hwpx/oxml/dutmal_compose.py +1 -1
  41. hwpx/oxml/header.py +4 -0
  42. hwpx/oxml/header_compat.py +6 -0
  43. hwpx/oxml/header_fonts.py +75 -4
  44. hwpx/oxml/header_part.py +1 -1
  45. hwpx/oxml/memo.py +4 -1
  46. hwpx/oxml/note_authoring.py +2 -0
  47. hwpx/oxml/objects.py +145 -215
  48. hwpx/oxml/paragraph.py +54 -81
  49. hwpx/oxml/run.py +1 -0
  50. hwpx/oxml/section.py +69 -15
  51. hwpx/oxml/section_format.py +185 -54
  52. hwpx/oxml/shape_position.py +320 -2
  53. hwpx/oxml/table.py +6 -4
  54. hwpx/oxml/table_sizes.py +82 -1
  55. hwpx/oxml/table_structure.py +166 -0
  56. hwpx/oxml/utils.py +46 -1
  57. hwpx/patch.py +11 -8
  58. hwpx/table_patch.py +428 -56
  59. hwpx/tools/__init__.py +220 -74
  60. hwpx/tools/package_validator.py +29 -6
  61. hwpx/tools/toc_author.py +1 -1
  62. {python_hwpx-6.7.0.dist-info → python_hwpx-6.9.0.dist-info}/METADATA +9 -4
  63. {python_hwpx-6.7.0.dist-info → python_hwpx-6.9.0.dist-info}/RECORD +68 -64
  64. {python_hwpx-6.7.0.dist-info → python_hwpx-6.9.0.dist-info}/WHEEL +0 -0
  65. {python_hwpx-6.7.0.dist-info → python_hwpx-6.9.0.dist-info}/entry_points.txt +0 -0
  66. {python_hwpx-6.7.0.dist-info → python_hwpx-6.9.0.dist-info}/licenses/LICENSE +0 -0
  67. {python_hwpx-6.7.0.dist-info → python_hwpx-6.9.0.dist-info}/licenses/NOTICE +0 -0
  68. {python_hwpx-6.7.0.dist-info → python_hwpx-6.9.0.dist-info}/top_level.txt +0 -0
hwpx/__init__.py CHANGED
@@ -14,7 +14,7 @@
14
14
  import importlib
15
15
  import warnings
16
16
  from dataclasses import dataclass
17
- from typing import Literal
17
+ from typing import TYPE_CHECKING, Literal
18
18
 
19
19
 
20
20
  def _resolve_version() -> str:
@@ -28,9 +28,9 @@ def _resolve_version() -> str:
28
28
 
29
29
  # --- experimental / deprecated 최상위 표면 (지연 접근, 접근 시 경고) ---------------
30
30
  #
31
- # stable 이름은 아래에서 eager import 되어 모듈 전역에 존재하므로 ``__getattr__``이
32
- # 호출되지 않습니다(=경고 없음). 여기 등록된 이름만 지연 해석되어 경고를 냅니다.
33
- # 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다.
31
+ # stable 이름은 경고 없이 해석됩니다. 대부분 아래에서 eager import 되고, ``hwpx.tools``의
32
+ # 이름은 ``_TOOLS_STABLE_EXPORTS``가 지연 해석합니다. 여기 등록된 이름만 지연 해석되어
33
+ # 경고를 냅니다. 4.0.0에서 제거되는 이름은 0개 — 모두 계속 import 가능합니다.
34
34
 
35
35
  _EXPERIMENTAL_EXPORTS = {
36
36
  # 문서 ingestion 프레임워크(임의 포맷 -> HWPX). 계약 유동.
@@ -293,8 +293,8 @@ class RetiredSurface(ImportError):
293
293
  def __getattr__(name: str) -> object:
294
294
  """Resolve dynamic module attributes.
295
295
 
296
- ``__version__``은 경고 없이 지연 해석하고, experimental/deprecated 이름은
297
- 해석 시 ``DeprecationWarning``을 냅니다.
296
+ ``__version__``과 ``hwpx.tools``의 stable 이름은 경고 없이 지연 해석하고,
297
+ experimental/deprecated 이름은 해석 시 ``DeprecationWarning``을 냅니다.
298
298
  """
299
299
 
300
300
  if name == "__version__":
@@ -318,6 +318,17 @@ def __getattr__(name: str) -> object:
318
318
  warnings.warn(_deprecated_message(name), DeprecationWarning, stacklevel=2)
319
319
  return getattr(importlib.import_module(module_name), name)
320
320
 
321
+ if name in _TOOLS_STABLE_EXPORTS or name == "tools":
322
+ # Not ``from . import tools``: that asks this module for ``tools`` first and
323
+ # re-enters this function.
324
+ import hwpx.tools as tools
325
+
326
+ if name == "tools":
327
+ return tools
328
+ value = getattr(tools, name)
329
+ globals()[name] = value
330
+ return value
331
+
321
332
  moved = _MOVED_TO_COMPANION.get(name)
322
333
  if moved is not None:
323
334
  statement = moved.import_statement(name)
@@ -346,11 +357,76 @@ def __dir__() -> list[str]:
346
357
  return sorted(
347
358
  set(globals())
348
359
  | set(__all__)
360
+ | {"tools"}
349
361
  | set(_EXPERIMENTAL_EXPORTS)
350
362
  | set(_DEPRECATED_EXPORTS)
351
363
  )
352
364
 
353
365
 
366
+ # --- 지연 stable 최상위 표면 (첫 접근 때 해석, 경고 없음) ---------------------------
367
+ #
368
+ # ``hwpx.tools``에 사는 stable 이름은 첫 접근 때 ``__getattr__``이 그 모듈을 읽어
369
+ # 같은 객체를 돌려주고 모듈 전역에 둡니다(경고 없음). ``import hwpx``가 도구 모듈
370
+ # 전부(내보내기·비교·검증 등)를 읽지 않게 하려는 것입니다. ``__all__``·``dir()``·
371
+ # ``from hwpx import *``에는 그대로 있습니다.
372
+
373
+ #: ``hwpx.tools``의 하위 모듈이 정의하고 ``hwpx.tools``가 다시 내보내는 stable 이름(텍스트 추출·개체
374
+ #: 찾기·문서 비교·메일 머지·패키지 검증). 정적 import로 해석하므로 동적 import 지점은
375
+ #: 늘지 않는다.
376
+ _TOOLS_STABLE_EXPORTS = frozenset(
377
+ {
378
+ "DEFAULT_NAMESPACES",
379
+ "ParagraphInfo",
380
+ "SectionInfo",
381
+ "TextExtractor",
382
+ "FoundElement",
383
+ "ObjectFinder",
384
+ "DOC_DIFF_REPORT_VERSION",
385
+ "REFERENCE_CONSISTENCY_REPORT_VERSION",
386
+ "diff_paragraphs",
387
+ "doc_diff",
388
+ "inspect_reference_consistency",
389
+ "MAIL_MERGE_REPORT_VERSION",
390
+ "inspect_mail_merge_placeholders",
391
+ "load_mail_merge_rows",
392
+ "merge_template_rows",
393
+ "EditorOpenSafetyReport",
394
+ "PackageValidationReport",
395
+ "validate_editor_open_safety",
396
+ "validate_package",
397
+ }
398
+ )
399
+
400
+
401
+ if TYPE_CHECKING: # type checkers see the real objects; at runtime they load lazily
402
+ from .tools.doc_diff import (
403
+ DOC_DIFF_REPORT_VERSION,
404
+ REFERENCE_CONSISTENCY_REPORT_VERSION,
405
+ diff_paragraphs,
406
+ doc_diff,
407
+ inspect_reference_consistency,
408
+ )
409
+ from .tools.mail_merge import (
410
+ MAIL_MERGE_REPORT_VERSION,
411
+ inspect_mail_merge_placeholders,
412
+ load_mail_merge_rows,
413
+ merge_template_rows,
414
+ )
415
+ from .tools.object_finder import FoundElement, ObjectFinder
416
+ from .tools.package_validator import (
417
+ EditorOpenSafetyReport,
418
+ PackageValidationReport,
419
+ validate_editor_open_safety,
420
+ validate_package,
421
+ )
422
+ from .tools.text_extractor import (
423
+ DEFAULT_NAMESPACES,
424
+ ParagraphInfo,
425
+ SectionInfo,
426
+ TextExtractor,
427
+ )
428
+
429
+
354
430
  # ``import hwpx.builder`` goes through the import system, not ``__getattr__``,
355
431
  # so the moved module names get their destination from a finder instead.
356
432
  from . import _moved_modules
@@ -359,35 +435,9 @@ _moved_modules.install(_MOVED_TO_COMPANION)
359
435
 
360
436
 
361
437
  # --- stable 최상위 표면 (eager import) ------------------------------------------
362
- from .tools.text_extractor import (
363
- DEFAULT_NAMESPACES,
364
- ParagraphInfo,
365
- SectionInfo,
366
- TextExtractor,
367
- )
368
- from .tools.object_finder import FoundElement, ObjectFinder
369
- from .tools.doc_diff import (
370
- DOC_DIFF_REPORT_VERSION,
371
- REFERENCE_CONSISTENCY_REPORT_VERSION,
372
- diff_paragraphs,
373
- doc_diff,
374
- inspect_reference_consistency,
375
- )
376
- from .tools.mail_merge import (
377
- MAIL_MERGE_REPORT_VERSION,
378
- inspect_mail_merge_placeholders,
379
- load_mail_merge_rows,
380
- merge_template_rows,
381
- )
382
- from .tools.package_validator import (
383
- EditorOpenSafetyReport,
384
- PackageValidationReport,
385
- validate_editor_open_safety,
386
- validate_package,
387
- )
438
+ # ``hwpx.tools``의 stable 이름은 위 ``_TOOLS_STABLE_EXPORTS``가 지연 해석한다.
388
439
  from .ingest import HwpxMarkdownConverter
389
- from .errors import HwpxError
390
- from .hwp5.errors import Hwp5ConversionWarning, Hwp5Error
440
+ from .errors import Hwp5ConversionWarning, Hwp5Error, HwpxError
391
441
  from .mutation_report import (
392
442
  MutationReport,
393
443
  PreservationDowngradeError,
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)
@@ -408,7 +408,8 @@ def save_report(
408
408
  oracle-verified ``visual_complete`` tier (plan §0.0).
409
409
 
410
410
  Like the legacy savers it raises only if the serialize step's open-safety
411
- check fails; all other gate outcomes are returned in the report.
411
+ check fails; all other gate outcomes are returned in the report. An ``.hwp``
412
+ path is written as HWP 5.0, as :func:`save_to_path` writes it.
412
413
  """
413
414
 
414
415
  _run_pre_save_validation(doc)
@@ -420,10 +421,15 @@ def save_report(
420
421
  output_path = path_or_stream
421
422
  elif path_or_stream is not None:
422
423
  output_stream = path_or_stream
424
+ # An .hwp target gets HWP 5.0 made from the HWPX parts the gate checks; it raises before anything is
425
+ # written when the HWP 5.0 writer cannot express the document.
426
+ hwp5 = None
427
+ if output_path is not None and str(output_path).lower().endswith(".hwp"):
428
+ hwp5 = _hwp5_bytes(archive_bytes)
423
429
 
424
430
  report = doc._save_pipeline.run(
425
431
  archive_bytes,
426
- output_path=output_path,
432
+ output_path=None if hwp5 is not None else output_path,
427
433
  output_stream=output_stream,
428
434
  quality=quality or QualityPolicy.transparent(),
429
435
  before=before,
@@ -432,6 +438,12 @@ def save_report(
432
438
  reference_document=doc,
433
439
  source_label="document.save_report",
434
440
  )
441
+ if hwp5 is not None and report.ok: # the gate passed: publish the HWP 5.0 file in its place
442
+ from ..quality.save_pipeline import write_bytes_atomically
443
+
444
+ assert output_path is not None
445
+ write_bytes_atomically(output_path, hwp5)
446
+ report.output_path = str(output_path)
435
447
  if report.ok:
436
448
  _mark_save_clean(doc)
437
449
  return report
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