diff --git a/CHANGELOG.md b/CHANGELOG.md index 07bd7d57..368b76c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,47 @@ 전처럼 가로줄 없는 분수다. - 상자는 여전히 `baseUnit`에 비례해 커진다. 한/글(macOS)은 글자 폭을 크기에 꼭 비례해 두지 않아(1100에서 글자가 1000과 같은 폭) 1100의 오차가 가장 크다. Windows 한/글에서의 비례는 아직 확인하지 않았다. +- 도형·그림·수식을 넣을 때 음수 크기와 사각형의 음수 모서리 곡률을 그대로 쓰던 것을 고친다. + - 한/글은 음수 크기를 0으로 읽고 0으로 저장한다. 그 크기에서 쓴 원래 크기(`orgSz`)·회전 중심·그림의 이미지 + 크기(`imgDim`)도 0이 된다. 음수 곡률(`ratio`)도 0으로 읽는다. + - 사각형·타원·호(`doc.shapes.add_rectangle`·`add_ellipse`·`add_arc`), 그림(`doc.add_picture`), 수식 상자 + (`doc.shapes.add_equation`의 `size`), 도형 크기 바꾸기(`HwpxOxmlShape.resize`)는 이제 0 이상 2**31 미만의 + 크기만 받는다. 그 밖의 값은 문단·런·그림을 더하기 전에 `shape-size-value` 오류로 거부한다. 곡률은 + `shape-rect-ratio-value`로 거부한다. + - 수식의 `base_unit`이 0 이하일 때 내던 `ValueError`를 `shape-equation-base-unit-value`(`HwpxValueError`)로 + 바꾸고 문단을 더하기 전에 낸다. 2**31 이상도 거부한다. 아주 큰 `base_unit`으로 잰 상자가 2**31 이상이면 + `shape-size-value`로 거부한다. 한/글은 그런 상자를 2**32 - 1로 줄여 저장한다. + - `HwpxOxmlParagraph.add_picture`는 그림 요소를 만든 뒤에 런을 더한다. 값이 거부되면 빈 런이 남지 않는다. + - 그림의 HWPUNIT 크기는 정수 변환 전에 검사한다. bool·소수·문자열을 정수로 바꾸어 받아들이지 않는다. +- 캡션 간격, 같은 폭 단의 간격, 새 번호를 한/글이 그대로 두지 않는 값도 그대로 쓰던 것을 고친다. + - 한/글은 캡션 간격(`hp:caption@gap`)과 단 간격(`hp:colPr@sameGap`)을 부호 있는 16비트 수로, 새 번호 + (`hp:newNum@num`)를 부호 없는 16비트 수로 읽는다. 범위 밖 값은 감싸 읽는다. 캡션 간격 32768은 −32768, + 새 번호 −1은 65535, 65536은 0이 된다. 단 간격 32768은 −32768로 읽어 둘째 단이 첫째 단과 겹친다. 음수 글로 + 쓴 단 간격은 0으로 읽는다. + - 이제 캡션 간격(`set_caption`의 `gap`)은 −32768 이상 32767 이하, 단 간격(`doc.page.set_columns`의 + `same_gap`, `doc.page.setup`의 `column_gap_mm`)은 0 이상 32767 이하, 새 번호(`doc.page.restart_page_number`의 + `number`)는 0 이상 65535 이하만 받는다. 그 밖의 값은 무엇도 바꾸기 전에 `shape-caption-gap-value`, + `page-column-gap-value`, `page-new-num-value` 오류로 거부한다. `doc.page.setup`은 쪽 크기와 여백을 바꾸기 + 전에 단 간격을 검사한다. +- 너비가 다른 단(`doc.page.set_columns(same_size=False, column_widths=...)`)의 너비와 간격을 한/글이 읽는 꼴로 + 쓴다. 한/글은 `hp:colSz`의 너비와 간격을 HWPUNIT이 아니라 본문 폭을 32768로 나눈 몫으로 읽고, 단을 + 너비 × 본문 폭 / 32768로 놓는다. 전에는 받은 값을 그대로 써서, 본문 폭에 맞춰 HWPUNIT으로 준 단(A4에서 + `[(20000, 1000), (21520, 0)]`)이 1.3배 넓게 놓여 둘째 단이 종이 밖으로 나갔다. + - 이제 `column_widths`를 비율로 받아 합이 32768이 되게 맞춰 쓴다. 본문 폭에 맞춘 HWPUNIT 값은 그 너비 그대로 + 나오고, 합이 32768인 값은 그대로 쓴다. + - 음수, 정수가 아닌 값, 모두 0인 값, 두 값이 아닌 쌍은 무엇도 바꾸기 전에 `page-column-widths-value` 오류로 + 거부한다. + - 쪽 수 추정(실험, `estimate_pages`)도 단 너비를 너비 × 본문 폭 / 32768로 센다. 전에는 너비들의 합으로 나눴다. + 한/글이 만든 문서는 합이 32768이라 결과가 같다. +- 새 표(`doc.add_table`, 칸 안의 `add_table`)와 열 너비 바꾸기(`HwpxOxmlTable.set_column_widths`)가, 한/글이 그리는 + 것보다 좁은 열을 그대로 쓰던 것을 고친다. + - 한/글은 표의 열을 적어도 그 칸들의 왼쪽·오른쪽 여백과 283을 더한 폭(새 표는 510 + 510 + 283 = 1303)으로 + 그린다. 칸에 적힌 좁은 폭은 그대로 두고, 표는 그만큼 넓게 저장한다. 예를 들어 폭 0으로 쓴 2×2 표는 2606이 되고, + 42520 폭 표에 열 비율 0과 1을 주면 43823이 된다. + - 이제 새 표는 적어도 열마다 그 폭으로, `set_column_widths`는 그보다 좁은 열을 그 폭으로 쓰고 표 폭을 그만큼 + 늘린다. 그래서 쓴 표가 한/글이 그리고 저장하는 표와 같다. 폭을 주지 않은 좁은 칸 안의 표도 그렇다. + - 새 표의 폭이 0 초과 2**31 미만의 int가 아니면, 문단이나 테두리 채우기를 더하기 전에 `table-width-value` + 오류로 거부한다. 전에는 0이나 음수 폭을 0으로 썼다. - 쪽 수 추정(실험, `estimate_pages`)이 쪽 번호 조판 부호(`hp:ctrl/hp:pageNum`)와 줄보다 넓은 글자처럼 둔 표만 있는 문단을 표 높이의 한 줄로 세던 것을 고친다. - 한/글은 둘을 한 줄에 두지 않는다. 부호의 줄은 문단 글의 빈 줄이다. @@ -257,6 +298,10 @@ 자리에 겹쳐 두어 감춘다. 감춘 문단은 자리를 차지하지 않고 쪽을 만들지 않으며, 다음 글은 다음 쪽 맨 위에서 시작한다. 셋째 빈 문단부터, 쪽을 나누는 문단, 빈칸이 든 문단은 감추지 않는다. 전에는 그런 빈 문단을 다음 쪽으로 넘겨, 문서가 빈 문단으로 끝나면 빈 쪽을 하나 더 세기도 했다. +- 그림 넣기(`doc.add_picture`)가 `align`에 받은 값을 검사 없이 `hp:pos@horzAlign`에 쓰던 것을 고친다. + - 한/글은 모르는 정렬 값을 왼쪽(LEFT)으로 읽는다. 정수 같은 값은 그림과 문단을 더한 뒤에야 오류가 났다. + - 이제 대소문자와 상관없이 `LEFT`·`CENTER`·`RIGHT`·`INSIDE`·`OUTSIDE`만 받는다. 그 밖의 값은 아무것도 더하기 + 전에 `shape-position-frame` 오류로 거부한다. - 쪽 수 추정(실험, `estimate_pages`)이 칸 단위로 나누는 흐르는 표에서 문단이 여럿인 칸을 줄 간격 하나로만 세어 나누던 것을 고친다. 한/글은 문단 사이 간격(앞 문단의 아래 간격과 다음 문단의 위 간격)도 세고, 다음 쪽 부분이 문단의 첫 줄로 시작하면 그 문단의 위 간격을 그대로 둔다. 이제 그런 칸은 줄마다 제 자리로 나눈다. 전에는 문단 diff --git a/docs/error-codes.md b/docs/error-codes.md index fdd53181..28a40780 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -30,7 +30,7 @@ except HwpxError as exc: | 형태 | `style-not-found` | `VISUAL_COMPLETE_FAILED` | | 쓰임 | 예외 분기 | **발행된 영수증 스키마의 필드값** | | 관리 | major 경계 | 영수증 스키마 버전 | -| 개수 | 176 | 11 | +| 개수 | 184 | 11 | 통합하지 않는 이유: quality 코드는 `hwpx.mutation-report/v1` 과 `VisualCompleteReport` 에 이미 실려 나간 값이다. 이름을 바꾸면 영수증을 읽는 @@ -183,9 +183,12 @@ except HwpxError as exc: |---|---| | `page-argument-conflict` | text 와 content 를 동시에 지정했다. | | `page-argument-missing` | text 또는 content 중 하나는 있어야 한다. | +| `page-column-gap-value` | 같은 폭 단의 간격(same_gap)이 0 이상 32767 이하의 int(HWPUNIT)가 아니다(bool 도 거부). | +| `page-column-widths-value` | 단 너비(column_widths)가 0 이상의 int 로 된 (너비, 간격) 쌍이 아니거나 모두 0이다. | | `page-columns-invalid` | 단 수는 1 이상이어야 한다. | | `page-kind-invalid` | kind 는 'header' 또는 'footer' 여야 한다. | | `page-new-num-kind-invalid` | 쪽번호 재시작 kind 값이 OWPML 어휘(hp:AutoNumNewNumType/@numType) 밖이다. | +| `page-new-num-value` | 새 번호(hp:newNum/@num)가 0 이상 65535 이하가 아니다. | | `page-orientation-unsupported` | 지원하지 않는 용지 방향이다. | | `page-paper-size-unsupported` | 지원하지 않는 용지 규격이다. | | `page-text-direction-unsupported` | 글자 방향 값이 OWPML 어휘(hp:secPr/@textDirection: HORIZONTAL/VERTICAL/VERTICALALL) 밖이다. | @@ -267,6 +270,7 @@ except HwpxError as exc: |---|---| | `shape-arc-corner-invalid` | add_arc 의 corner 인자가 지원하는 모서리(TOP_LEFT 등) 밖이다. | | `shape-arc-type-invalid` | add_arc 의 arc_type 인자가 OWPML 어휘(NORMAL/PIE/CHORD) 밖이다. | +| `shape-caption-gap-value` | 캡션 간격(gap)이 -32768 이상 32767 이하의 int(HWPUNIT)가 아니다(bool 도 거부). | | `shape-caption-side-invalid` | 캡션 side 값이 OWPML 어휘(LEFT/RIGHT/TOP/BOTTOM) 밖이다. | | `shape-chart-anchor-detached` | 만든 차트 앵커가 자기 파트를 가리키지 않는다. | | `shape-chart-axes-missing` | 축이 필요한 차트(막대·꺾은선·영역·분산형·방사형·거품형 등)에 두 축(c:axId와 정의된 축)이 없다(한컴이 빈 차트를 그리거나 멈춘다). | @@ -285,6 +289,7 @@ except HwpxError as exc: | `shape-drop-cap-character-empty` | 드롭캡으로 키울 문자가 비어 있다. | | `shape-drop-cap-not-created` | 만든 드롭캡을 표준 섹션 스캔이 다시 찾지 못했다. | | `shape-drop-cap-style-unsupported` | 드롭캡 dropcapstyle 값이 실증된 어휘(DoubleLine, TripleLine) 밖이다. | +| `shape-equation-base-unit-value` | 수식 기준 크기(base_unit)가 1 이상 2**31 미만의 int(1/100 pt)가 아니다(bool 도 거부). | | `shape-equation-not-created` | 만든 수식을 표준 스캔이 다시 찾지 못했다. | | `shape-equation-not-verbatim` | 만든 수식이 스크립트를 그대로 담지 않았다. | | `shape-equation-script-empty` | 수식 스크립트가 비어 있다. | @@ -294,6 +299,8 @@ except HwpxError as exc: | `shape-position-frame` | 도형 기준 프레임·정렬 값이 OWPML 어휘(hp:pos 의 vertRelTo/horzRelTo/vertAlign/horzAlign) 밖이다. | | `shape-position-unsupported` | 위치를 바꿀 도형이 떠 있는 개체가 아니거나 hp:pos 가 없다. | | `shape-position-value` | 도형 오프셋이 signed 32-bit 정수(HWPUNIT)가 아니다. | +| `shape-rect-ratio-value` | 사각형 모서리 곡률(ratio)이 0 이상 2**31 미만의 int(%)가 아니다(bool 도 거부). | +| `shape-size-value` | 도형·그림·수식 크기가 0 이상 2**31 미만의 int(HWPUNIT)가 아니다(bool 도 거부). | ### `story-*` @@ -340,6 +347,7 @@ except HwpxError as exc: |---|---| | `table-cell-zone-grid-mismatch` | 셀 영역(hp:cellzone)이 새 열 격자에서 같은 셀들을 덮을 수 없어 열 너비를 바꾸지 않았다. | | `table-position-missing` | 표에 hp:pos 가 없어 글자처럼 취급을 바꿀 수 없다. | +| `table-width-value` | 새 표 폭(width)이 0 초과 2**31 미만의 int(HWPUNIT)가 아니다(bool 도 거부). | ### `text-*` diff --git a/src/hwpx/_document/layout.py b/src/hwpx/_document/layout.py index 98799ec4..0f309b6f 100644 --- a/src/hwpx/_document/layout.py +++ b/src/hwpx/_document/layout.py @@ -19,7 +19,9 @@ from ..oxml.namespaces import HH, HP from ..oxml.numbering_kinds import ensure_numbering_levels from ..oxml.objects import HwpxOxmlInlineObject -from ..oxml.section_format import _PAGE_LANDSCAPE, _PAGE_PORTRAIT, _page_orientation_value +from ..oxml.section_format import ( + _PAGE_LANDSCAPE, _PAGE_PORTRAIT, _page_orientation_value, column_shares, validate_column_gap, +) from ..oxml.table_sizes import cell_margins_of from ._units import _mm_to_hwp_units, _pt_to_hwp_units @@ -609,6 +611,8 @@ def set_page_setup( """ normalized_orientation = _normalize_page_orientation(orientation) + if columns is not None: # the gap checked before the page or its margins change + validate_column_gap(_mm_to_hwp_units(float(column_gap_mm or 0))) target_width_mm = width_mm target_height_mm = height_mm if paper_size: @@ -732,6 +736,9 @@ def set_columns( col_count: Number of columns (1–255). col_type: ``NEWSPAPER``, ``BALANCED_NEWSPAPER``, or ``PARALLEL``. same_gap: Gap in HWPUNIT (7200 = 1 inch). + column_widths: With ``same_size=False``, a ``(width, gap)`` pair per + column, in HWPUNIT adding up to the text width or in plain + proportions; Hancom keeps them as shares of 32768 of the text width. separator_type: Optional column separator line type (e.g. ``SOLID``). """ if not 1 <= col_count <= 255: @@ -741,6 +748,10 @@ def set_columns( context={"requested": col_count}, suggestion="Use columns=1 to remove columns.", ) + if same_size: + validate_column_gap(same_gap) # before a paragraph is added + elif column_widths: + column_shares(column_widths) # checked before a paragraph is added too if paragraph is None: target_section = _resolve_section(doc, section=section, section_index=section_index) ctrl = target_section.properties.set_columns( diff --git a/src/hwpx/_document/media.py b/src/hwpx/_document/media.py index 815c091a..541615e4 100644 --- a/src/hwpx/_document/media.py +++ b/src/hwpx/_document/media.py @@ -19,6 +19,7 @@ ) from ..oxml import HwpxOxmlInlineObject, HwpxOxmlParagraph from ..oxml.namespaces import HC, HP +from ..oxml.shape_position import validate_picture_align, validate_shape_size from ._units import _mm_to_hwp_units if TYPE_CHECKING: @@ -114,15 +115,11 @@ def add_picture( ) -> HwpxOxmlInlineObject: """Embed image data and place a picture object in a new paragraph.""" - # Call the local primitive directly rather than `doc.add_image` — that - # facade name moved in 6.0 (design table row 33), and going through it - # would fire a DeprecationWarning on every `add_picture` call even though - # `add_picture` itself is a kept (unmoved) root method. - binary_item_id_ref = str(add_image(doc, image_data, image_format)) - + validate_picture_align(align) # before anything is stored: Hancom reads an unknown one as LEFT resolved_width = width if resolved_width is None: resolved_width = _mm_to_hwp_units(width_mm) if width_mm is not None else 14400 + validate_shape_size(resolved_width, 0) # validate before aspect-ratio arithmetic too resolved_height = height if resolved_height is None: @@ -135,6 +132,13 @@ def add_picture( resolved_height = round(resolved_width * source_height / source_width) else: resolved_height = resolved_width + validate_shape_size(resolved_width, resolved_height) # before anything is stored too + + # Call the local primitive directly rather than `doc.add_image` — that + # facade name moved in 6.0 (design table row 33), and going through it + # would fire a DeprecationWarning on every `add_picture` call even though + # `add_picture` itself is a kept (unmoved) root method. + binary_item_id_ref = str(add_image(doc, image_data, image_format)) paragraph = doc.add_paragraph( "", diff --git a/src/hwpx/_document/shapes.py b/src/hwpx/_document/shapes.py index b60198e1..95464b97 100644 --- a/src/hwpx/_document/shapes.py +++ b/src/hwpx/_document/shapes.py @@ -8,7 +8,9 @@ from ._units import _mm_to_hwp_units from ..errors import HwpxStateError, HwpxValueError from ..oxml.objects import _closed_points -from ..oxml.shape_position import validate_original_size +from ..oxml.shape_position import ( + validate_equation_base_unit, validate_original_size, validate_rect_ratio, validate_shape_size, +) if TYPE_CHECKING: from hwpx.document import HwpxDocument @@ -265,6 +267,8 @@ def add_rectangle( Dimensions are in HWPUNIT. *ratio* controls corner roundness (0 = sharp, 50 = semicircle). """ + validate_shape_size(width, height) + validate_rect_ratio(ratio) validate_original_size(original_size) if paragraph is None: paragraph = doc.add_paragraph( @@ -297,6 +301,7 @@ def add_ellipse( Dimensions are in HWPUNIT. """ + validate_shape_size(width, height) validate_original_size(original_size) if paragraph is None: paragraph = doc.add_paragraph( @@ -334,6 +339,7 @@ def add_arc( mechanism every other shape here already uses. *arc_type* is the schema's own ``NORMAL``/``PIE``/``CHORD`` passthrough. """ + validate_shape_size(width, height) if paragraph is None: paragraph = doc.add_paragraph( "", section=section, section_index=section_index, @@ -522,15 +528,16 @@ def add_equation( context={"length": len(text), "limit": MAX_SOURCE_LENGTH}, suggestion="Split the equation.", ) + validate_equation_base_unit(base_unit) + width, height = size if size is not None else estimate_equation_size(text, base_unit=base_unit) + validate_shape_size(width, height) # one measured at a huge base_unit too if paragraph is None: paragraph = doc.add_paragraph( "", section=section, section_index=section_index, include_run=False, ) - if size is None: - size = estimate_equation_size(text, base_unit=base_unit) inline_object = paragraph.add_equation( - text, base_unit=base_unit, size=size, char_pr_id_ref=char_pr_id_ref, + text, base_unit=base_unit, size=(width, height), char_pr_id_ref=char_pr_id_ref, ) created_id = inline_object.element.get("id", "") diff --git a/src/hwpx/document.py b/src/hwpx/document.py index 18bc8275..29147f42 100644 --- a/src/hwpx/document.py +++ b/src/hwpx/document.py @@ -29,6 +29,7 @@ HwpxPackage, ) from .oxml.namespaces import register_owpml_namespaces +from .oxml.table_sizes import new_table_width from .mutation_report import Fallback, Mode, MutationReport from .quality import QualityPolicy, SavePipeline, VisualCompleteReport from .templates import blank_document_bytes @@ -585,6 +586,7 @@ def add_table( self, section, section_index, caller="add_table" ) section_index = None + new_table_width(width, cols) # checked before a paragraph or a border fill is added resolved_border_fill: str | int | None = border_fill_id_ref if resolved_border_fill is None: resolved_border_fill = self._root.ensure_basic_border_fill() diff --git a/src/hwpx/errors.py b/src/hwpx/errors.py index be00a160..775b4923 100644 --- a/src/hwpx/errors.py +++ b/src/hwpx/errors.py @@ -252,6 +252,7 @@ class Hwp5ConversionWarning(UserWarning): "cell-margin-value": "셀 여백 값이 0 이상 2**31 미만의 int(HWPUNIT)가 아니다(bool 도 거부).", "cell-border-edit-unsupported": "셀 테두리 편집 대상 스타일이 없거나 모호하거나 네 변이 온전하지 않다(또는 색·선 종류가 잘못됐다).", "table-position-missing": "표에 hp:pos 가 없어 글자처럼 취급을 바꿀 수 없다.", + "table-width-value": "새 표 폭(width)이 0 초과 2**31 미만의 int(HWPUNIT)가 아니다(bool 도 거부).", "table-cell-zone-grid-mismatch": "셀 영역(hp:cellzone)이 새 열 격자에서 같은 셀들을 덮을 수 없어 열 너비를 바꾸지 않았다.", # -- 머리말·꼬리말 story ------------------------------------------------ "story-ambiguous": "그 쪽 종류·id 에 맞는 머리말/꼬리말 story 가 여럿이거나 식별이 모호하다.", @@ -307,9 +308,12 @@ class Hwp5ConversionWarning(UserWarning): "page-argument-missing": "text 또는 content 중 하나는 있어야 한다.", "page-kind-invalid": "kind 는 'header' 또는 'footer' 여야 한다.", "page-columns-invalid": "단 수는 1 이상이어야 한다.", + "page-column-gap-value": "같은 폭 단의 간격(same_gap)이 0 이상 32767 이하의 int(HWPUNIT)가 아니다(bool 도 거부).", + "page-column-widths-value": "단 너비(column_widths)가 0 이상의 int 로 된 (너비, 간격) 쌍이 아니거나 모두 0이다.", "page-orientation-unsupported": "지원하지 않는 용지 방향이다.", "page-paper-size-unsupported": "지원하지 않는 용지 규격이다.", "page-new-num-kind-invalid": "쪽번호 재시작 kind 값이 OWPML 어휘(hp:AutoNumNewNumType/@numType) 밖이다.", + "page-new-num-value": "새 번호(hp:newNum/@num)가 0 이상 65535 이하가 아니다.", "page-text-direction-unsupported": "글자 방향 값이 OWPML 어휘(hp:secPr/@textDirection: HORIZONTAL/VERTICAL/VERTICALALL) 밖이다.", # -- 양식개체 -------------------------------------------------------- "field-name-empty": "누름틀 이름이 비어 있다.", @@ -342,8 +346,12 @@ class Hwp5ConversionWarning(UserWarning): "shape-chart-anchor-detached": "만든 차트 앵커가 자기 파트를 가리키지 않는다.", "shape-chart-not-created": "만든 차트를 표준 스캔이 다시 찾지 못했다.", "shape-caption-side-invalid": "캡션 side 값이 OWPML 어휘(LEFT/RIGHT/TOP/BOTTOM) 밖이다.", + "shape-caption-gap-value": "캡션 간격(gap)이 -32768 이상 32767 이하의 int(HWPUNIT)가 아니다(bool 도 거부).", "shape-draw-text-vert-align": "도형 글상자 vert_align 값이 OWPML 어휘(hp:subList/@vertAlign, TOP/CENTER/BOTTOM) 밖이다.", "shape-original-size-invalid": "add_rectangle/add_ellipse 의 original_size 가 양의 정수 두 개(HWPUNIT)가 아니다.", + "shape-size-value": "도형·그림·수식 크기가 0 이상 2**31 미만의 int(HWPUNIT)가 아니다(bool 도 거부).", + "shape-rect-ratio-value": "사각형 모서리 곡률(ratio)이 0 이상 2**31 미만의 int(%)가 아니다(bool 도 거부).", + "shape-equation-base-unit-value": "수식 기준 크기(base_unit)가 1 이상 2**31 미만의 int(1/100 pt)가 아니다(bool 도 거부).", "shape-position-value": "도형 오프셋이 signed 32-bit 정수(HWPUNIT)가 아니다.", "shape-position-unsupported": "위치를 바꿀 도형이 떠 있는 개체가 아니거나 hp:pos 가 없다.", "shape-position-frame": "도형 기준 프레임·정렬 값이 OWPML 어휘(hp:pos 의 vertRelTo/horzRelTo/vertAlign/horzAlign) 밖이다.", diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index b186304c..c9062fb8 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -28,7 +28,7 @@ other, and take no room). Page and column breaks, page break before, keep lines together, keep with next and widow/orphan control; columns of equal width, and columns of - unequal width (each its share of the text width with the gaps, rounded) holding objects only as + unequal width (each its share of 32768 of the text width, ``hp:colSz``, rounded) holding objects only as characters: a paragraph without a valid layout cache breaks the lines a column holds at that column's width, and going on into a column of another width breaks its rest there again, from the first character that column holds. @@ -181,7 +181,7 @@ from ..oxml.paragraph_heading import paragraph_heading from ..oxml.section import _remove_short_paragraph_layout_cache from ..oxml.header_part import HwpxOxmlHeader -from ..oxml.section_format import _drawn_page_size +from ..oxml.section_format import COLUMN_SHARES, _drawn_page_size from ..oxml.table_sizes import cell_margins_of, grid_widths_of if TYPE_CHECKING: @@ -1274,12 +1274,13 @@ def _columns(section: Any, text_width: int) -> tuple[int, int, tuple[int, ...], count = int(settings[0].get("colCount", "1")) if settings else 1 if count <= 1: return 1, text_width, (), 0 - if settings[0].get("sameSz") != "1": # each column takes its share of the width with the gaps (hp:colSz), - sizes = settings[0].findall(f"{HP}colSz") # rounded + if settings[0].get("sameSz") != "1": # each column takes its share of 32768 of the text width (hp:colSz), + sizes = settings[0].findall(f"{HP}colSz") # rounded, whatever the shares add up to total = sum(int(size.get("width", 0)) + int(size.get("gap", 0)) for size in sizes) if len(sizes) != count or total <= 0: raise _Unsupported("columns of unequal width") - widths = tuple((2 * int(size.get("width", 0)) * text_width + total) // (2 * total) for size in sizes) + widths = tuple((2 * int(size.get("width", 0)) * text_width + COLUMN_SHARES) // (2 * COLUMN_SHARES) + for size in sizes) return count, min(widths), widths, 0 gap = int(settings[0].get("sameGap", 0)) return count, (text_width - (count - 1) * gap) // count // 4 * 4, (), gap diff --git a/src/hwpx/oxml/objects.py b/src/hwpx/oxml/objects.py index 596b8fb5..a670ad0c 100644 --- a/src/hwpx/oxml/objects.py +++ b/src/hwpx/oxml/objects.py @@ -25,7 +25,8 @@ _paragraph_id, ) from .shape_position import ( - _shape_set_position, build_at_original_size, resize_group, validate_draw_text_vert_align, + _shape_set_position, build_at_original_size, resize_group, validate_draw_text_vert_align, validate_picture_align, + validate_caption_gap, validate_caption_side, validate_rect_ratio, validate_shape_size, ) if TYPE_CHECKING: @@ -178,8 +179,7 @@ def _build_shape_common_children( AbstractShapeObjectType children (last, via ``_build_shape_base_children``): sz, pos, outMargin """ - w = str(width) - h = str(height) + w, h = map(str, validate_shape_size(width, height)) # Hancom reads a negative size as 0 the_id = inst_id or _object_id() parent.set("id", the_id) @@ -344,7 +344,7 @@ def _create_rectangle_element( treat_as_char: bool = True, ) -> ET.Element: """Build a complete ```` element matching real HWPX output.""" - el = ET.Element(f"{_HP}rect", {"ratio": str(ratio)}) + el = ET.Element(f"{_HP}rect", {"ratio": str(validate_rect_ratio(ratio))}) _build_shape_common_children(el, width, height, treat_as_char=treat_as_char) _build_drawing_object_children( el, line_color=line_color, line_width=line_width, @@ -746,7 +746,7 @@ def _create_picture_element( if align: pos = el.find(f"{_HP}pos") if pos is not None: - pos.set("horzAlign", align.upper()) + pos.set("horzAlign", validate_picture_align(align) or "LEFT") if pos_overrides: pos = el.find(f"{_HP}pos") if pos is not None: @@ -1023,10 +1023,6 @@ def _paragraph_shapes(self: "HwpxOxmlParagraph") -> list["HwpxOxmlShape"]: # reused by ``HwpxOxmlTable`` (table.py), ``HwpxOxmlShape``, and # ``HwpxOxmlInlineObject`` (this module) rather than duplicated per host. -#: ``hp:caption/@side`` 어휘(스키마 기본값은 LEFT). 실코퍼스 15건 전수는 -#: TOP 14 · BOTTOM 1 — LEFT/RIGHT 관측 0(테두리 옆 캡션은 실무에서 안 쓴다). -_CAPTION_SIDES = frozenset({"LEFT", "RIGHT", "TOP", "BOTTOM"}) - #: 실코퍼스 15건 전수: fullSz="0"(전부) · width="8504"(전부, 호스트 크기와 #: 무관한 고정값) · gap="850"(11) 또는 "566"(4, 스키마 기본은 850). _CAPTION_DEFAULT_WIDTH = "8504" @@ -1227,16 +1223,8 @@ def _write_caption( gap: int, char_pr_id_ref: str | int | None, ) -> Caption: - normalized_side = side.strip().upper() - if normalized_side not in _CAPTION_SIDES: - from ..errors import HwpxValueError - - raise HwpxValueError( - f"unsupported caption side {side!r}", - code="shape-caption-side-invalid", - context={"requested": side, "available": sorted(_CAPTION_SIDES)}, - suggestion=f"side 는 {sorted(_CAPTION_SIDES)} 중 하나여야 합니다.", - ) + normalized_side = validate_caption_side(side) + gap = validate_caption_gap(gap) # before anything changes: Hancom keeps a signed 16-bit gap element = host.find(f"{_HP}caption") if element is None: @@ -1383,6 +1371,7 @@ def resize(self, width: int, height: int) -> None: :class:`UserWarning` is raised, because the drawn shape cannot follow the requested size. """ + validate_shape_size(width, height) if self.shape_type == "container" and resize_group(self, width, height): return # a group is drawn at its sz: see shape_position.resize_group old_width, old_height = self._geometry_size() diff --git a/src/hwpx/oxml/paragraph.py b/src/hwpx/oxml/paragraph.py index 55fca391..3c516286 100644 --- a/src/hwpx/oxml/paragraph.py +++ b/src/hwpx/oxml/paragraph.py @@ -65,8 +65,10 @@ ) from .curves import _paragraph_add_connector, _paragraph_add_curve from .run import HwpxOxmlRun +from .section_format import column_shares, validate_column_gap, validate_new_number +from .shape_position import validate_equation_base_unit, validate_shape_size from .table import HwpxOxmlTable -from .table_sizes import cell_margins_of +from .table_sizes import NEW_TABLE_COLUMN_FLOOR, cell_margins_of, new_table_width if TYPE_CHECKING: from .section import HwpxOxmlSection @@ -577,8 +579,10 @@ def add_table( run_attributes: dict[str, str] | None = None, char_pr_id_ref: str | int | None = None, ) -> HwpxOxmlTable: - if width is None: - width = self._context_table_width() + if width is not None: + width = new_table_width(width, cols) # checked, and floored as Hancom draws it, before its run is added + elif (context := self._context_table_width()) is not None: # a narrow cell's may be below the floor + width = max(context, cols * NEW_TABLE_COLUMN_FLOOR) if border_fill_id_ref is None: document = self.section.document if document is not None: @@ -676,19 +680,19 @@ def add_picture( "(a PAPER-relative on an inline pic is contradictory)" ) - run = self._create_run_for_object( - run_attributes, - char_pr_id_ref=char_pr_id_ref, - ) - element = _create_picture_element( + element = _create_picture_element( # checked before its run is added str(binary_item_id_ref), - int(width), - int(height), + width, + height, align=align, treat_as_char=treat_as_char, pos_overrides=pos_overrides, text_wrap=text_wrap, ) + run = self._create_run_for_object( + run_attributes, + char_pr_id_ref=char_pr_id_ref, + ) if type(element) is not type(run): element = LET.fromstring(ET.tostring(element, encoding="utf-8")) run.append(element) @@ -794,14 +798,19 @@ def add_column_definition( layout: ``LEFT``, ``RIGHT``, or ``MIRROR``. same_size: If ``True`` all columns have equal width. same_gap: Gap between columns when *same_size* is ``True`` (HWPUNIT). - column_widths: When *same_size* is ``False``, a sequence of - ``(width, gap)`` tuples – one per column. + column_widths: When *same_size* is ``False``, a ``(width, gap)`` + pair per column, in HWP units adding up to the text width or + in plain proportions: they are written as the shares of 32768 + Hancom keeps (see :func:`~hwpx.oxml.section_format.column_shares`). separator_type: Line type for the column separator (e.g. ``SOLID``). separator_width: Line width (e.g. ``0.12 mm``). separator_color: Line colour (e.g. ``#000000``). """ if not 1 <= col_count <= 255: raise ValueError("col_count must be between 1 and 255") + if same_size: + validate_column_gap(same_gap) # before its run is added + sizes = column_shares(column_widths) if column_widths and not same_size else [] run = self._create_run_for_object( run_attributes, char_pr_id_ref=char_pr_id_ref, @@ -828,12 +837,11 @@ def add_column_definition( line_attrs["color"] = separator_color _append_child(col_pr, f"{_HP}colLine", line_attrs) - # Individual column sizes when same_size=False - if not same_size and column_widths: - for w, g in column_widths: - _append_child(col_pr, f"{_HP}colSz", { - "width": str(w), "gap": str(g), - }) + # Individual column sizes when same_size=False, as shares of 32768 + for w, g in sizes: + _append_child(col_pr, f"{_HP}colSz", { + "width": str(w), "gap": str(g), + }) self.section.mark_dirty() return HwpxOxmlInlineObject(ctrl, self) @@ -1036,10 +1044,11 @@ def add_new_num( """ normalized_kind = _normalize_enum_attr(kind or "PAGE", NEW_NUM_KINDS, label="kind") + number = validate_new_number(number) # before its run is added run = self._create_run_for_object(run_attributes, char_pr_id_ref=char_pr_id_ref) ctrl = _append_child(run, f"{_HP}ctrl", {}) _append_child( - ctrl, f"{_HP}newNum", {"num": str(int(number)), "numType": normalized_kind}, + ctrl, f"{_HP}newNum", {"num": str(number), "numType": normalized_kind}, ) self.section.mark_dirty() return HwpxOxmlInlineObject(ctrl, self) @@ -1184,12 +1193,12 @@ def add_equation( text = script.strip() if not text: raise ValueError("equation script must be a non-empty string") - if base_unit <= 0: - raise ValueError("base_unit must be positive") + validate_equation_base_unit(base_unit) from ..equation.measure import measure_equation measured = measure_equation(text, base_unit=base_unit) width, height = size if size is not None else (measured.width, measured.height) + validate_shape_size(width, height) run = self._create_run_for_object( run_attributes, char_pr_id_ref=char_pr_id_ref ) diff --git a/src/hwpx/oxml/section_format.py b/src/hwpx/oxml/section_format.py index 58aa1875..eb31334f 100644 --- a/src/hwpx/oxml/section_format.py +++ b/src/hwpx/oxml/section_format.py @@ -248,6 +248,74 @@ class NoteShape: placement: NotePlacement +def validate_new_number(number: object) -> int: + """A ``hp:newNum/@num``: *number* as an int, checked to be in ``0 <= number <= 65535``. Hancom keeps it as + an unsigned 16-bit number, reading -1 as 65535 and 65536 as 0.""" + + from ..errors import HwpxValueError + + value = int(number) # type: ignore[call-overload] + if not 0 <= value < 2**16: + raise HwpxValueError( + f"number must be in 0 <= number <= 65535; got {number!r}", + code="page-new-num-value", + context={"value": repr(number)}, + suggestion="Pass the number the count restarts at, from 0 to 65535.", + ) + return value + + +def validate_column_gap(gap: object) -> int: + """The gap between columns of the same width (``hp:colPr/@sameGap``, HWPUNIT), checked to be an int in + ``0 <= gap <= 32767``: Hancom reads it as a signed 16-bit number (32768 as -32768, the columns then + overlapping; 65536 as 0) and a negative one written as text as 0.""" + + from ..errors import HwpxValueError + + if isinstance(gap, bool) or not isinstance(gap, int) or not 0 <= gap < 2**15: + raise HwpxValueError( + f"same_gap must be an int in 0 <= same_gap <= 32767 (HWPUNIT); got {gap!r}", + code="page-column-gap-value", + context={"value": repr(gap)}, + suggestion="Pass the gap between columns in HWP units, e.g. 1200 (about 4 mm).", + ) + return gap + + +#: Hancom keeps each column's width and gap (``hp:colSz``) as a share of the text width out of 32768 and lays a +#: column out ``width * text width / 32768`` wide, whatever the shares add up to; its own add up to 32768. +COLUMN_SHARES = 32768 + + +def column_shares(column_widths: Sequence[tuple[int, int]]) -> list[tuple[int, int]]: + """*column_widths*, a ``(width, gap)`` pair per column in any one unit (HWP units adding up to the text width, + or plain proportions), as the ``hp:colSz`` shares Hancom keeps: in the same proportions, adding up to + :data:`COLUMN_SHARES`, so that the columns and their gaps fill the text width. Every value must be an int of + 0 or more, not all of them 0.""" + + from ..errors import HwpxValueError + + pairs = [tuple(pair) for pair in column_widths] + values = [value for pair in pairs for value in pair] + if ( + any(len(pair) != 2 for pair in pairs) + or not all(isinstance(value, int) and not isinstance(value, bool) and value >= 0 for value in values) + or sum(values) <= 0 + ): + raise HwpxValueError( + f"column_widths must be (width, gap) pairs of ints, 0 or more and not all 0; got {column_widths!r}", + code="page-column-widths-value", + context={"value": repr(column_widths)}, + suggestion="Pass a (width, gap) pair per column, e.g. [(20000, 1000), (21520, 0)] in HWP units.", + ) + total, run, edges = sum(values), 0, [0] + for value in values: # rounded at each edge, so that the shares add up to COLUMN_SHARES + run += value + edges.append((2 * run * COLUMN_SHARES + total) // (2 * total)) + shares = [end - start for start, end in zip(edges, edges[1:])] + return list(zip(shares[::2], shares[1::2])) + + class HwpxOxmlSectionProperties: """Provides convenient access to ```` configuration.""" @@ -437,6 +505,9 @@ def set_columns( ``hp:secPr``. """ + if same_size: + validate_column_gap(same_gap) # checked before anything changes, as the separator below + sizes = column_shares(column_widths) if column_widths and not same_size else [] line = None if separator_type or separator_width or separator_color: # checked before anything changes: a refused value leaves the columns as they were @@ -458,7 +529,7 @@ def set_columns( col_pr.remove(child) if line is not None: _append_child(col_pr, f"{_HP}colLine", line) - for width, gap in () if same_size else (column_widths or ()): + for width, gap in sizes: _append_child(col_pr, f"{_HP}colSz", {"width": str(width), "gap": str(gap)}) self.section.mark_dirty() return ctrl diff --git a/src/hwpx/oxml/shape_position.py b/src/hwpx/oxml/shape_position.py index ea841689..0825b435 100644 --- a/src/hwpx/oxml/shape_position.py +++ b/src/hwpx/oxml/shape_position.py @@ -44,6 +44,9 @@ POS_VERT_ALIGN = ("TOP", "CENTER", "BOTTOM", "INSIDE", "OUTSIDE") POS_HORZ_ALIGN = ("LEFT", "CENTER", "RIGHT", "INSIDE", "OUTSIDE") SUBLIST_VERT_ALIGN = ("TOP", "CENTER", "BOTTOM") +#: ``hp:caption/@side`` 어휘(스키마 기본값은 LEFT). 실코퍼스 15건 전수는 +#: TOP 14 · BOTTOM 1 — LEFT/RIGHT 관측 0(테두리 옆 캡션은 실무에서 안 쓴다). +CAPTION_SIDES = frozenset({"LEFT", "RIGHT", "TOP", "BOTTOM"}) def _require_member( @@ -61,6 +64,16 @@ def _require_member( ) +def validate_picture_align(align: object) -> str | None: + """A new picture's *align* as its ``hp:pos@horzAlign``, any case (``"left"`` is ``LEFT``); ``None`` when it is + not given. Hancom reads a value outside :data:`POS_HORZ_ALIGN` as ``LEFT``, so one is refused.""" + + if align is None or align == "": + return None + return _require_member(align.upper() if isinstance(align, str) else align, POS_HORZ_ALIGN, argument="align", + code="shape-position-frame") + + def validate_draw_text_vert_align(vert_align: str | None) -> str | None: """Check ``set_draw_text``'s *vert_align* (``hp:subList/@vertAlign``).""" @@ -71,6 +84,34 @@ def validate_draw_text_vert_align(vert_align: str | None) -> str | None: ) +def validate_caption_side(side: str) -> str: + """A caption's *side* as ``hp:caption/@side``: any case, surrounding spaces dropped.""" + + normalized_side = side.strip().upper() + if normalized_side not in CAPTION_SIDES: + raise HwpxValueError( + f"unsupported caption side {side!r}", + code="shape-caption-side-invalid", + context={"requested": side, "available": sorted(CAPTION_SIDES)}, + suggestion=f"side 는 {sorted(CAPTION_SIDES)} 중 하나여야 합니다.", + ) + return normalized_side + + +def validate_caption_gap(gap: object) -> int: + """A caption's *gap* from its object (HWPUNIT), checked to be an int in ``-32768 <= gap <= 32767``: Hancom + keeps it as a signed 16-bit number, reading 32768 as -32768 and 65536 as 0.""" + + if isinstance(gap, bool) or not isinstance(gap, int) or not -(2**15) <= gap < 2**15: + raise HwpxValueError( + f"gap must be an int in -32768 <= gap <= 32767 (HWPUNIT); got {gap!r}", + code="shape-caption-gap-value", + context={"value": repr(gap)}, + suggestion="Pass the caption's distance from its object in HWP units, e.g. 850 (3 mm).", + ) + return gap + + def _shape_set_position( self: "HwpxOxmlShape", *, @@ -141,6 +182,48 @@ def _matrix_number(value: float) -> str: return "0" if text in ("-0", "") else text +def validate_shape_size(width: object, height: object) -> tuple[int, int]: + """*width* and *height* of a shape, picture or equation, checked to be ints in ``0 <= value < 2**31`` + (HWPUNIT): Hancom reads a negative size as 0, and the original size and rotation centre written from it.""" + + for argument, value in (("width", width), ("height", height)): + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value < 2**31: + raise HwpxValueError( + f"{argument} must be an int in 0 <= {argument} < 2**31 (HWPUNIT); got {value!r}", + code="shape-size-value", + context={"argument": argument, "value": repr(value)}, + suggestion="Pass a size from 0 to 2**31 - 1 HWP units (an equation's is measured at its base_unit).", + ) + return width, height # type: ignore[return-value] + + +def validate_rect_ratio(ratio: object) -> int: + """A rectangle's corner *ratio* (per cent), checked to be an int in ``0 <= ratio < 2**31``: Hancom reads a + negative one as 0.""" + + if isinstance(ratio, bool) or not isinstance(ratio, int) or not 0 <= ratio < 2**31: + raise HwpxValueError( + f"ratio must be an int in 0 <= ratio < 2**31 (per cent); got {ratio!r}", + code="shape-rect-ratio-value", + context={"value": repr(ratio)}, + suggestion="Pass the corner roundness in per cent: 0 sharp, 50 a semicircle.", + ) + return ratio + + +def validate_equation_base_unit(base_unit: object) -> int: + """An equation's *base_unit* (1/100 pt), checked to be an int in ``1 <= base_unit < 2**31``.""" + + if isinstance(base_unit, bool) or not isinstance(base_unit, int) or not 0 < base_unit < 2**31: + raise HwpxValueError( + f"base_unit must be positive: an int in 1 <= base_unit < 2**31 (1/100 pt); got {base_unit!r}", + code="shape-equation-base-unit-value", + context={"value": repr(base_unit)}, + suggestion="Pass the equation's base font size in 1/100 pt, e.g. 1000 for 10 pt.", + ) + return base_unit + + def validate_original_size(original_size: object) -> tuple[int, int] | None: """Check *original_size* is ``None`` or two positive integers.""" @@ -181,6 +264,7 @@ def build_at_original_size( """ checked = validate_original_size(original_size) + validate_shape_size(width, height) if checked is None: return factory(width, height, **options) org_width, org_height = checked @@ -270,6 +354,7 @@ def _set_scale(matrix: "ET.Element", factors: tuple[float, float], offset: tuple __all__ = [ + "CAPTION_SIDES", "POS_HORZ_ALIGN", "POS_HORZ_REL_TO", "POS_VERT_ALIGN", @@ -277,6 +362,11 @@ def _set_scale(matrix: "ET.Element", factors: tuple[float, float], offset: tuple "SUBLIST_VERT_ALIGN", "build_at_original_size", "resize_group", + "validate_caption_gap", + "validate_caption_side", "validate_draw_text_vert_align", + "validate_equation_base_unit", "validate_original_size", + "validate_rect_ratio", + "validate_shape_size", ] diff --git a/src/hwpx/oxml/table.py b/src/hwpx/oxml/table.py index 6dea1cc8..1774a184 100644 --- a/src/hwpx/oxml/table.py +++ b/src/hwpx/oxml/table.py @@ -521,6 +521,7 @@ def add_table( The table is created inside a new paragraph within the cell's ````. """ + _table_sizes.new_table_width(width, cols) # checked before a paragraph is added # Resolve border fill ID if border_fill_id_ref is None: document = self.table.paragraph.section.document @@ -751,7 +752,7 @@ def create( if rows <= 0 or cols <= 0: raise ValueError("rows and cols must be positive integers") - table_width = width if width is not None else cols * _DEFAULT_CELL_WIDTH + table_width = _table_sizes.new_table_width(width if width is not None else cols * _DEFAULT_CELL_WIDTH, cols) table_height = height if height is not None else rows * _DEFAULT_CELL_HEIGHT if border_fill_id_ref is None: raise ValueError("border_fill_id_ref must be provided") diff --git a/src/hwpx/oxml/table_sizes.py b/src/hwpx/oxml/table_sizes.py index 839987aa..28916283 100644 --- a/src/hwpx/oxml/table_sizes.py +++ b/src/hwpx/oxml/table_sizes.py @@ -12,12 +12,13 @@ from dataclasses import replace from math import lcm -from typing import TYPE_CHECKING, Any, Iterator, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Iterator, Mapping, Sequence, overload from ..objects.results import CellMargins from ._document_primitives import ( _HP, _clear_paragraph_layout_cache, + _default_cell_inner_margin_attributes, _distribute_size, _element_local_name, ) @@ -34,10 +35,57 @@ "effective_cell_margin_source", "set_column_widths", "grid_widths_of", + "COLUMN_FLOOR_EXTRA", + "NEW_TABLE_COLUMN_FLOOR", + "new_table_width", ] _MARGIN_SIDES = ("left", "right", "top", "bottom") +#: Hancom draws a table column at least its cells' left and right margins and this wide, the table as much +#: wider (it keeps the narrower width written in the cells). +COLUMN_FLOOR_EXTRA = 283 +#: The floor of a new table's column: its cells take the new table's margins (510 left and right). +NEW_TABLE_COLUMN_FLOOR = sum( + int(_default_cell_inner_margin_attributes()[side]) for side in ("left", "right") +) + COLUMN_FLOOR_EXTRA + + +@overload +def new_table_width(width: None, cols: int) -> None: ... +@overload +def new_table_width(width: int, cols: int) -> int: ... +def new_table_width(width: object, cols: int) -> int | None: + """The width a new table of *cols* columns is written at for *width* (``None`` when not given): at least + :data:`NEW_TABLE_COLUMN_FLOOR` a column, as Hancom draws it. A width that is not an int in + ``0 < width < 2**31`` is refused.""" + + from ..errors import HwpxValueError + + if width is None: + return None + if isinstance(width, bool) or not isinstance(width, int) or not 0 < width < 2**31: + raise HwpxValueError( + f"width must be an int in 0 < width < 2**31 (HWPUNIT); got {width!r}", + code="table-width-value", + context={"value": repr(width)}, + suggestion="Pass the table's width in HWP units, or leave it out to fit the text width.", + ) + return max(width, cols * NEW_TABLE_COLUMN_FLOOR) + + +def _column_floors(table: "HwpxOxmlTable") -> list[int]: + """The narrowest Hancom draws each grid column: the widest left and right margins and + :data:`COLUMN_FLOOR_EXTRA` of its cells one column wide.""" + + floors = [0] * table.column_count + for entry in table.iter_grid(): + if entry.is_anchor and entry.span[1] == 1: + margins = cell_margins_of(entry.cell.element, table.element) + sides = 0 if margins is None else margins.left + margins.right + floors[entry.column] = max(floors[entry.column], sides + COLUMN_FLOOR_EXTRA) + return floors + def set_column_widths(table: "HwpxOxmlTable", weights: Sequence[int | float]) -> None: if len(weights) != table.column_count: @@ -61,6 +109,10 @@ def set_column_widths(table: "HwpxOxmlTable", weights: Sequence[int | float]) -> width = round(total_width * weight / weight_total) allocated += width column_widths.append(width) + # A column narrower than its floor is drawn at the floor and the table that much wider: written so too. + column_widths = [max(width, floor) for width, floor in zip(column_widths, _column_floors(table))] + if sz is not None and sum(column_widths) > total_width: + sz.set("width", str(sum(column_widths))) updated_cells: set[int] = set() for entry in table.iter_grid(): diff --git a/tests/data/error_census.json b/tests/data/error_census.json index 3a5e94ad..27a3285c 100644 --- a/tests/data/error_census.json +++ b/tests/data/error_census.json @@ -479,7 +479,7 @@ "observed": { "other": 215, "typed": 216, - "untyped": 203, + "untyped": 202, "untypedByFile": { "body_patch.py": 20, "equation/mathml.py": 1, @@ -494,7 +494,7 @@ "oxml/header_part.py": 16, "oxml/memo.py": 1, "oxml/numbering_kinds.py": 1, - "oxml/paragraph.py": 19, + "oxml/paragraph.py": 18, "oxml/parser.py": 2, "oxml/run.py": 2, "oxml/section.py": 1, diff --git a/tests/fixtures/hancom_saved/caption_gap_largest_kept.hwpx b/tests/fixtures/hancom_saved/caption_gap_largest_kept.hwpx new file mode 100644 index 00000000..8ec91f99 Binary files /dev/null and b/tests/fixtures/hancom_saved/caption_gap_largest_kept.hwpx differ diff --git a/tests/fixtures/hancom_saved/caption_gap_negative_kept.hwpx b/tests/fixtures/hancom_saved/caption_gap_negative_kept.hwpx new file mode 100644 index 00000000..34a854e7 Binary files /dev/null and b/tests/fixtures/hancom_saved/caption_gap_negative_kept.hwpx differ diff --git a/tests/fixtures/hancom_saved/caption_gap_wrapped.hwpx b/tests/fixtures/hancom_saved/caption_gap_wrapped.hwpx new file mode 100644 index 00000000..692dbbce Binary files /dev/null and b/tests/fixtures/hancom_saved/caption_gap_wrapped.hwpx differ diff --git a/tests/fixtures/hancom_saved/columns_gap_largest_kept.hwpx b/tests/fixtures/hancom_saved/columns_gap_largest_kept.hwpx new file mode 100644 index 00000000..c1a6a86e Binary files /dev/null and b/tests/fixtures/hancom_saved/columns_gap_largest_kept.hwpx differ diff --git a/tests/fixtures/hancom_saved/columns_gap_negative_read_as_0.hwpx b/tests/fixtures/hancom_saved/columns_gap_negative_read_as_0.hwpx new file mode 100644 index 00000000..e2d44292 Binary files /dev/null and b/tests/fixtures/hancom_saved/columns_gap_negative_read_as_0.hwpx differ diff --git a/tests/fixtures/hancom_saved/columns_gap_wrapped.hwpx b/tests/fixtures/hancom_saved/columns_gap_wrapped.hwpx new file mode 100644 index 00000000..c54b86c6 Binary files /dev/null and b/tests/fixtures/hancom_saved/columns_gap_wrapped.hwpx differ diff --git a/tests/fixtures/hancom_saved/columns_unequal_widths_in_hwpunit.hwpx b/tests/fixtures/hancom_saved/columns_unequal_widths_in_hwpunit.hwpx new file mode 100644 index 00000000..feead24f Binary files /dev/null and b/tests/fixtures/hancom_saved/columns_unequal_widths_in_hwpunit.hwpx differ diff --git a/tests/fixtures/hancom_saved/columns_unequal_widths_in_shares.hwpx b/tests/fixtures/hancom_saved/columns_unequal_widths_in_shares.hwpx new file mode 100644 index 00000000..745e43d6 Binary files /dev/null and b/tests/fixtures/hancom_saved/columns_unequal_widths_in_shares.hwpx differ diff --git a/tests/fixtures/hancom_saved/columns_unequal_widths_written_as_shares.hwpx b/tests/fixtures/hancom_saved/columns_unequal_widths_written_as_shares.hwpx new file mode 100644 index 00000000..a5accd72 Binary files /dev/null and b/tests/fixtures/hancom_saved/columns_unequal_widths_written_as_shares.hwpx differ diff --git a/tests/fixtures/hancom_saved/equation_base_unit_largest_given_size.hwpx b/tests/fixtures/hancom_saved/equation_base_unit_largest_given_size.hwpx new file mode 100644 index 00000000..5307ebb2 Binary files /dev/null and b/tests/fixtures/hancom_saved/equation_base_unit_largest_given_size.hwpx differ diff --git a/tests/fixtures/hancom_saved/equation_base_unit_largest_measured.hwpx b/tests/fixtures/hancom_saved/equation_base_unit_largest_measured.hwpx new file mode 100644 index 00000000..a07adcdd Binary files /dev/null and b/tests/fixtures/hancom_saved/equation_base_unit_largest_measured.hwpx differ diff --git a/tests/fixtures/hancom_saved/equation_size_negative.hwpx b/tests/fixtures/hancom_saved/equation_size_negative.hwpx new file mode 100644 index 00000000..98b1a980 Binary files /dev/null and b/tests/fixtures/hancom_saved/equation_size_negative.hwpx differ diff --git a/tests/fixtures/hancom_saved/new_page_number_largest_kept.hwpx b/tests/fixtures/hancom_saved/new_page_number_largest_kept.hwpx new file mode 100644 index 00000000..969ab5f1 Binary files /dev/null and b/tests/fixtures/hancom_saved/new_page_number_largest_kept.hwpx differ diff --git a/tests/fixtures/hancom_saved/new_page_number_negative_wrapped.hwpx b/tests/fixtures/hancom_saved/new_page_number_negative_wrapped.hwpx new file mode 100644 index 00000000..c362fb25 Binary files /dev/null and b/tests/fixtures/hancom_saved/new_page_number_negative_wrapped.hwpx differ diff --git a/tests/fixtures/hancom_saved/new_page_number_smallest_kept.hwpx b/tests/fixtures/hancom_saved/new_page_number_smallest_kept.hwpx new file mode 100644 index 00000000..bad71883 Binary files /dev/null and b/tests/fixtures/hancom_saved/new_page_number_smallest_kept.hwpx differ diff --git a/tests/fixtures/hancom_saved/new_page_number_wrapped.hwpx b/tests/fixtures/hancom_saved/new_page_number_wrapped.hwpx new file mode 100644 index 00000000..ff0f619a Binary files /dev/null and b/tests/fixtures/hancom_saved/new_page_number_wrapped.hwpx differ diff --git a/tests/fixtures/hancom_saved/picture_size_negative.hwpx b/tests/fixtures/hancom_saved/picture_size_negative.hwpx new file mode 100644 index 00000000..0b995ec6 Binary files /dev/null and b/tests/fixtures/hancom_saved/picture_size_negative.hwpx differ diff --git a/tests/fixtures/hancom_saved/shape_rect_ratio_negative.hwpx b/tests/fixtures/hancom_saved/shape_rect_ratio_negative.hwpx new file mode 100644 index 00000000..a150975d Binary files /dev/null and b/tests/fixtures/hancom_saved/shape_rect_ratio_negative.hwpx differ diff --git a/tests/fixtures/hancom_saved/shape_rect_size_negative.hwpx b/tests/fixtures/hancom_saved/shape_rect_size_negative.hwpx new file mode 100644 index 00000000..611a7776 Binary files /dev/null and b/tests/fixtures/hancom_saved/shape_rect_size_negative.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_column_floor_1302.hwpx b/tests/fixtures/hancom_saved/table_column_floor_1302.hwpx new file mode 100644 index 00000000..9783d04c Binary files /dev/null and b/tests/fixtures/hancom_saved/table_column_floor_1302.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_column_floor_1304.hwpx b/tests/fixtures/hancom_saved/table_column_floor_1304.hwpx new file mode 100644 index 00000000..1d95d2e0 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_column_floor_1304.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_column_floor_m0_282.hwpx b/tests/fixtures/hancom_saved/table_column_floor_m0_282.hwpx new file mode 100644 index 00000000..8afe6100 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_column_floor_m0_282.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_column_floor_m1000_2282.hwpx b/tests/fixtures/hancom_saved/table_column_floor_m1000_2282.hwpx new file mode 100644 index 00000000..e399c798 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_column_floor_m1000_2282.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_column_weights_zero_one.hwpx b/tests/fixtures/hancom_saved/table_column_weights_zero_one.hwpx new file mode 100644 index 00000000..0fa2f5da Binary files /dev/null and b/tests/fixtures/hancom_saved/table_column_weights_zero_one.hwpx differ diff --git a/tests/fixtures/hancom_saved/table_width_zero.hwpx b/tests/fixtures/hancom_saved/table_width_zero.hwpx new file mode 100644 index 00000000..331b1314 Binary files /dev/null and b/tests/fixtures/hancom_saved/table_width_zero.hwpx differ diff --git a/tests/test_column_widths_as_shares.py b/tests/test_column_widths_as_shares.py new file mode 100644 index 00000000..020b6f5c --- /dev/null +++ b/tests/test_column_widths_as_shares.py @@ -0,0 +1,119 @@ +"""Columns of unequal width are written as the shares of the text width out of 32768 Hancom keeps (``hp:colSz``). + +Hancom lays a column out ``width * text width / 32768`` wide, whatever the shares add up to. +``tests/fixtures/hancom_saved/columns_unequal_widths_in_*.hwpx`` are its saves of two columns written +``[(20000, 1000), (21520, 0)]`` (HWP units adding up to the text width, 42520: the second column then runs off +the paper) and ``[(16000, 768), (16000, 0)]`` (shares adding up to 32768); +``columns_unequal_widths_written_as_shares.hwpx`` of the shares now written for the first. +""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest +from lxml import etree + +from hwpx import HwpxDocument +from hwpx.errors import HwpxValueError +from hwpx.layout import pages as page_layout + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +FIXTURES = Path(__file__).parent / "fixtures" / "hancom_saved" + + +def _saved_section(name: str) -> etree._Element: + with zipfile.ZipFile(FIXTURES / name) as archive: + return etree.fromstring(archive.read("Contents/section0.xml")) + + +def _sizes(section: etree._Element) -> list[tuple[str | None, str | None]]: + return [(size.get("width"), size.get("gap")) for size in section.iter(f"{HP}colSz")] + + +def _sections(document: HwpxDocument) -> list[bytes]: + return [etree.tostring(section.element) for section in document.sections] + + +@pytest.mark.parametrize( + ("fixture", "widths"), + [("columns_unequal_widths_in_hwpunit.hwpx", (25952, 27925)), ("columns_unequal_widths_in_shares.hwpx", (20762,) * 2)], +) +def test_hancom_lays_a_column_out_its_share_of_32768_of_the_text_width(fixture: str, widths: tuple[int, ...]) -> None: + # 20000 and 21520 of 32768 over a text width of 42520 are 25952 and 27925 wide (not 20000 and 21520, their + # share of what the sizes add up to); 16000 of 32768 is 20762. + section = _saved_section(fixture) + + assert {int(line.get("horzsize")) for line in section.iter(f"{HP}lineseg")} == set(widths) + assert page_layout._columns(section, 42520) == (2, min(widths), widths, 0) + + +def test_hancom_lays_the_shares_written_for_widths_in_hwp_units_out_at_those_widths() -> None: + # [(20000, 1000), (21520, 0)] written as shares, saved by Hancom: its lines are 20000 and 21520 wide. + section = _saved_section("columns_unequal_widths_written_as_shares.hwpx") + + assert _sizes(section) == [("15413", "771"), ("16584", "0")] + assert {int(line.get("horzsize")) for line in section.iter(f"{HP}lineseg")} == {20000, 21520} + + +@pytest.mark.parametrize("where", ["section", "paragraph"]) +def test_column_widths_in_hwp_units_are_written_as_shares(where: str) -> None: + document = HwpxDocument.new() + paragraph = document.add_paragraph("단 앞 글") + + document.page.set_columns(2, same_size=False, column_widths=[(20000, 1000), (21520, 0)], + **({"paragraph": paragraph} if where == "paragraph" else {})) + + written = paragraph.element if where == "paragraph" else document.sections[0].element + assert _sizes(written) == [("15413", "771"), ("16584", "0")] # 20000, 1000 and 21520 of 42520 + + +def test_column_widths_in_hwp_units_adding_up_to_the_text_width_come_out_as_wide_as_given() -> None: + document = HwpxDocument.new() + + document.page.set_columns(2, same_size=False, column_widths=[(20000, 1000), (21520, 0)]) + + assert page_layout._columns(document.sections[0].element, 42520)[2] == (20000, 21520) + + +@pytest.mark.parametrize( + ("given", "written"), + [ + ([(10632, 873), (21263, 0)], [(10632, 873), (21263, 0)]), # shares already + ([(6552, 873), (12234, 873), (12236, 0)], [(6552, 873), (12234, 873), (12236, 0)]), + ([(1, 0), (2, 0)], [(10923, 0), (21845, 0)]), # plain proportions + ], +) +def test_column_widths_are_written_in_their_proportions_adding_up_to_32768( + given: list[tuple[int, int]], written: list[tuple[int, int]] +) -> None: + document = HwpxDocument.new() + + document.page.set_columns(len(given), same_size=False, column_widths=given) + + assert _sizes(document.sections[0].element) == [(str(width), str(gap)) for width, gap in written] + + +@pytest.mark.parametrize( + "column_widths", + [ + [(-1, 0), (20000, 0)], + [(20000, -1000), (20000, 0)], + [(0, 0), (0, 0)], + [(20000.0, 0), (20000, 0)], + [(True, 0), (1, 0)], + [(20000,), (20000, 0)], + ], +) +def test_column_widths_that_are_no_shares_are_refused_before_anything_changes(column_widths: list[tuple]) -> None: + document = HwpxDocument.new() + paragraph = document.add_paragraph("단 앞 글") + before = (len(document.paragraphs), _sections(document)) + + for where in ({}, {"paragraph": paragraph}): # the section's own columns, new columns at a paragraph + with pytest.raises(HwpxValueError) as caught: + document.page.set_columns(2, same_size=False, column_widths=column_widths, **where) + assert caught.value.code == "page-column-widths-value" + + assert (len(document.paragraphs), _sections(document)) == before diff --git a/tests/test_gap_and_new_number_values.py b/tests/test_gap_and_new_number_values.py new file mode 100644 index 00000000..389a4d50 --- /dev/null +++ b/tests/test_gap_and_new_number_values.py @@ -0,0 +1,143 @@ +"""Caption gaps, column gaps and new numbers Hancom does not keep are refused before anything changes. + +Hancom keeps a caption's gap and the gap between columns of the same width as signed 16-bit numbers and a new +number (``hp:newNum/@num``) as an unsigned one, reading anything else wrapped (and a column gap written as negative +text as 0). ``tests/fixtures/hancom_saved/caption_gap_*.hwpx``, ``columns_gap_*.hwpx`` and ``new_page_number_*.hwpx`` +are its saves of a table caption written 32767, -1 and 32768 from the table, of two columns written 32767, 32768 +and -1 apart and of a page number restarted at 0, 65535, 65536 and -1. +""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest +from lxml import etree + +from hwpx import HwpxDocument +from hwpx.errors import HwpxValueError + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +FIXTURES = Path(__file__).parent / "fixtures" / "hancom_saved" + + +_SAVED_VALUES = {"caption": "gap", "colPr": "sameGap", "newNum": "num"} + + +def _saved(name: str, tag: str) -> str | None: + """The first ``hp:``'s saved value in the Hancom-saved fixture *name*: a caption's gap, the columns' gap + or a newNum's num.""" + + with zipfile.ZipFile(FIXTURES / name) as archive: + root = etree.fromstring(archive.read("Contents/section0.xml")) + return next(root.iter(f"{HP}{tag}")).get(_SAVED_VALUES[tag]) + + +def _sections(document: HwpxDocument) -> list[bytes]: + return [etree.tostring(section.element) for section in document.sections] + + +def test_hancom_keeps_a_caption_gap_as_a_signed_16_bit_number() -> None: + assert _saved("caption_gap_largest_kept.hwpx", "caption") == "32767" + assert _saved("caption_gap_negative_kept.hwpx", "caption") == "-1" + assert _saved("caption_gap_wrapped.hwpx", "caption") == "-32768" # written 32768 + + +@pytest.mark.parametrize("gap", [32768, -32769, 65536, 850.0, True]) +def test_a_caption_gap_hancom_does_not_keep_is_refused_before_anything_changes(gap: object) -> None: + document = HwpxDocument.new() + table = document.add_table(2, 2) + shape = document.shapes.add_rectangle(width=14400, height=7200) + shape.set_caption("그림 1", side="BOTTOM", gap=850) + before = etree.tostring(table.element), etree.tostring(shape.element) + + for host in (table, shape): + with pytest.raises(HwpxValueError) as caught: + host.set_caption("새 캡션", side="BOTTOM", gap=gap) # type: ignore[arg-type] + assert caught.value.code == "shape-caption-gap-value" + + assert (etree.tostring(table.element), etree.tostring(shape.element)) == before + + +@pytest.mark.parametrize("gap", [-32768, -1, 0, 32767]) +def test_the_caption_gaps_hancom_keeps_are_written_as_given(gap: int) -> None: + document = HwpxDocument.new() + table = document.add_table(2, 2) + + table.set_caption("표 1", side="BOTTOM", gap=gap) + + assert table.element.find(f"{HP}caption").get("gap") == str(gap) + + +def test_hancom_reads_the_gap_between_columns_as_a_signed_16_bit_number() -> None: + assert _saved("columns_gap_largest_kept.hwpx", "colPr") == "32767" + # written 32768: read as -32768 (the second column then starts inside the first), saved unsigned 32-bit + assert _saved("columns_gap_wrapped.hwpx", "colPr") == str(2**32 - 2**15) + assert _saved("columns_gap_negative_read_as_0.hwpx", "colPr") == "0" # written "-1" + + +@pytest.mark.parametrize("gap", [-1, 32768, 65536, 1200.0, True]) +def test_a_column_gap_hancom_does_not_keep_is_refused_before_anything_changes(gap: object) -> None: + document = HwpxDocument.new() + paragraph = document.add_paragraph("단 앞 글") + before = (len(document.paragraphs), _sections(document)) + + for where in ({}, {"paragraph": paragraph}): # the section's own columns, new columns at a paragraph + with pytest.raises(HwpxValueError) as caught: + document.page.set_columns(col_count=2, same_gap=gap, **where) # type: ignore[arg-type] + assert caught.value.code == "page-column-gap-value" + + assert (len(document.paragraphs), _sections(document)) == before + + +@pytest.mark.parametrize("gap_mm", [-1.0, 200.0]) +def test_page_setup_checks_the_column_gap_before_the_page_changes(gap_mm: float) -> None: + document = HwpxDocument.new() + before = _sections(document) + + with pytest.raises(HwpxValueError) as caught: + document.page.setup(paper_size="A4", orientation="LANDSCAPE", columns=2, column_gap_mm=gap_mm) + + assert caught.value.code == "page-column-gap-value" + assert _sections(document) == before + + +@pytest.mark.parametrize("gap", [0, 32767]) +def test_the_column_gaps_hancom_keeps_are_written_as_given(gap: int) -> None: + document = HwpxDocument.new() + + document.page.set_columns(col_count=2, same_gap=gap) + + assert next(document.sections[0].element.iter(f"{HP}colPr")).get("sameGap") == str(gap) + + +def test_hancom_keeps_a_new_number_as_an_unsigned_16_bit_number() -> None: + assert _saved("new_page_number_smallest_kept.hwpx", "newNum") == "0" + assert _saved("new_page_number_largest_kept.hwpx", "newNum") == "65535" + assert _saved("new_page_number_wrapped.hwpx", "newNum") == "0" # written 65536 + assert _saved("new_page_number_negative_wrapped.hwpx", "newNum") == "65535" # written -1 + + +@pytest.mark.parametrize("number", [-1, 65536, 2**31]) +@pytest.mark.parametrize("kind", ["PAGE", "TABLE"]) +def test_a_new_number_hancom_does_not_keep_is_refused_before_anything_is_added(number: int, kind: str) -> None: + document = HwpxDocument.new() + paragraph = document.add_paragraph("2쪽 첫 글") + before = etree.tostring(paragraph.element) + + with pytest.raises(HwpxValueError) as caught: + document.page.restart_page_number(paragraph, number=number, kind=kind) + + assert caught.value.code == "page-new-num-value" + assert etree.tostring(paragraph.element) == before + + +@pytest.mark.parametrize("number", [0, 65535]) +def test_the_new_numbers_hancom_keeps_are_written_as_given(number: int) -> None: + document = HwpxDocument.new() + paragraph = document.add_paragraph("2쪽 첫 글") + + document.page.restart_page_number(paragraph, number=number) + + assert next(paragraph.element.iter(f"{HP}newNum")).get("num") == str(number) diff --git a/tests/test_image_object_workflow.py b/tests/test_image_object_workflow.py index 8703252f..b4385351 100644 --- a/tests/test_image_object_workflow.py +++ b/tests/test_image_object_workflow.py @@ -228,6 +228,26 @@ def test_an_aligned_inline_picture_takes_the_paragraph_alignment() -> None: assert _picture_paragraph_alignment(document) == align.upper() +@pytest.mark.parametrize("align", ["BOGUS", "TOP", " right ", 1]) +def test_a_picture_alignment_outside_the_schema_is_refused_before_anything_is_added(align: object) -> None: + # Hancom reads a horizontal alignment it does not know as LEFT; the picture was written with it as given. + document = HwpxDocument.new() + paragraphs, images = len(document.paragraphs), len(document.list_images()) + + with pytest.raises(HwpxValueError) as caught: + document.add_picture(PNG_1X1, "png", width=7200, height=7200, align=align) # type: ignore[arg-type] + + assert caught.value.code == "shape-position-frame" + assert (len(document.paragraphs), len(document.list_images())) == (paragraphs, images) + + +def test_a_picture_takes_the_inside_and_outside_alignments_too() -> None: + for align in ("INSIDE", "outside"): + document = HwpxDocument.new() + document.add_picture(PNG_1X1, "png", width=7200, height=7200, align=align) + assert _first_picture(document).find(f"{HP}pos").get("horzAlign") == align.upper() + + def test_a_picture_without_align_keeps_the_paragraph_alignment() -> None: document = HwpxDocument.new() document.add_picture(PNG_1X1, "png", width=7200, height=7200) diff --git a/tests/test_section_columns.py b/tests/test_section_columns.py index a85f9b09..ed3313e5 100644 --- a/tests/test_section_columns.py +++ b/tests/test_section_columns.py @@ -86,7 +86,8 @@ def test_unequal_columns_write_their_sizes() -> None: col_pr = _section_column_definition(_section_xml(document)) assert (col_pr.get("sameSz"), col_pr.get("sameGap")) == ("0", "0") sizes = [(size.get("width"), size.get("gap")) for size in col_pr.findall(f"{HP}colSz")] - assert sizes == [("20000", "1000"), ("21520", "0")] + # as the shares of 32768 of the text width Hancom keeps: 20000, 1000 and 21520 of 42520 + assert sizes == [("15413", "771"), ("16584", "0")] def test_setting_one_column_clears_the_previous_layout() -> None: diff --git a/tests/test_set_column_widths_uniform_split.py b/tests/test_set_column_widths_uniform_split.py index 20a9132f..1f4ef3bd 100644 --- a/tests/test_set_column_widths_uniform_split.py +++ b/tests/test_set_column_widths_uniform_split.py @@ -34,13 +34,14 @@ def test_uniform_weights_give_the_remainder_to_the_last_column() -> None: def test_uniform_weights_round_half_to_even() -> None: + # 2609 / 2 = 1304.5 rounds to 1304 (each column above its 1303 floor) document = HwpxDocument.new() - table = document.add_table(rows=1, cols=2, width=13) - assert _row_widths(table) == [7, 6] + table = document.add_table(rows=1, cols=2, width=2609) + assert _row_widths(table) == [1305, 1304] _uniform(table) - assert _row_widths(table) == [6, 7] + assert _row_widths(table) == [1304, 1305] def test_uniform_weights_give_a_merged_cell_the_sum_of_its_columns() -> None: @@ -56,9 +57,9 @@ def test_uniform_weights_give_a_merged_cell_the_sum_of_its_columns() -> None: def test_uniform_weights_fall_back_to_the_row_zero_sum_without_a_table_width() -> None: document = HwpxDocument.new() - table = document.add_table(rows=1, cols=2, width=13) + table = document.add_table(rows=1, cols=2, width=2609) table.element.find(f"{HP}sz").attrib.pop("width") _uniform(table) - assert _row_widths(table) == [6, 7] + assert _row_widths(table) == [1304, 1305] diff --git a/tests/test_shape_size_values.py b/tests/test_shape_size_values.py new file mode 100644 index 00000000..4464fd2a --- /dev/null +++ b/tests/test_shape_size_values.py @@ -0,0 +1,212 @@ +"""Shape, picture and equation sizes, rectangle ratios and equation base units Hancom does not keep are refused. + +Hancom reads a negative size or corner ratio as 0 (and saves 0), so they are refused before anything is added: +no paragraph, run or image is left behind. ``tests/fixtures/hancom_saved/*_negative.hwpx`` are Hancom's saves of +a rectangle, a picture and an equation written 1 unit below 0 and of a rectangle with ratio -1. +""" + +from __future__ import annotations + +import base64 +import zipfile +from collections.abc import Callable +from pathlib import Path + +import pytest +from lxml import etree + +from hwpx import HwpxDocument +from hwpx.errors import HwpxValueError + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +FIXTURES = Path(__file__).parent / "fixtures" / "hancom_saved" +PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/axwAqkAAAAASUVORK5CYII=" +) + + +def _saved(name: str, tag: str) -> etree._Element: + """The first ``hp:`` object in the Hancom-saved fixture *name*.""" + + with zipfile.ZipFile(FIXTURES / name) as archive: + root = etree.fromstring(archive.read("Contents/section0.xml")) + return next(root.iter(f"{HP}{tag}")) + + +def _size(element: etree._Element, child: str = "sz") -> tuple[str | None, str | None]: + node = element.find(f"{HP}{child}") + return node.get("width"), node.get("height") + + +def _state(document: HwpxDocument) -> tuple[int, int, list[bytes]]: + """What a refused object must leave as it was: the paragraphs, the stored images and every section's XML.""" + + return ( + len(document.paragraphs), + len(document.media.images), + [etree.tostring(section.element) for section in document.sections], + ) + + +def test_hancom_reads_a_negative_size_or_corner_ratio_as_0() -> None: + # Written as -1 by -1, Hancom saved 0 for the size, the original size and the rotation centre written from + # it, and a picture's image size; a corner ratio of -1 it saved as 0. + rectangle = _saved("shape_rect_size_negative.hwpx", "rect") + assert {_size(rectangle, child) for child in ("sz", "orgSz")} == {("0", "0")} + rotation = rectangle.find(f"{HP}rotationInfo") + assert (rotation.get("centerX"), rotation.get("centerY")) == ("0", "0") + + assert _saved("shape_rect_ratio_negative.hwpx", "rect").get("ratio") == "0" + + picture = _saved("picture_size_negative.hwpx", "pic") + assert {_size(picture, child) for child in ("sz", "orgSz")} == {("0", "0")} + image = picture.find(f"{HP}imgDim") + assert (image.get("dimwidth"), image.get("dimheight")) == ("0", "0") + + assert _size(_saved("equation_size_negative.hwpx", "equation")) == ("0", "0") + + +_SHAPES: dict[str, Callable[[HwpxDocument, object, object], object]] = { + "rectangle": lambda document, width, height: document.shapes.add_rectangle(width=width, height=height), + "ellipse": lambda document, width, height: document.shapes.add_ellipse(width=width, height=height), + "arc": lambda document, width, height: document.shapes.add_arc(width=width, height=height), + "equation": lambda document, width, height: document.shapes.add_equation("x + 1 over 2", size=(width, height)), +} + + +@pytest.mark.parametrize("kind", sorted(_SHAPES)) +@pytest.mark.parametrize("width, height", [(-1, 7200), (7200, -1), (2**31, 7200), (7200.0, 7200), (True, 7200)]) +def test_a_size_hancom_does_not_keep_is_refused_before_anything_is_added( + kind: str, width: object, height: object +) -> None: + document = HwpxDocument.new() + before = _state(document) + + with pytest.raises(HwpxValueError) as caught: + _SHAPES[kind](document, width, height) + + assert caught.value.code == "shape-size-value" + assert _state(document) == before + + +@pytest.mark.parametrize( + "size", [{"width": -1, "height": 7200}, {"width": 7200, "height": -1}, {"width": 2**31}, {"width_mm": -1.0}] +) +def test_a_picture_size_hancom_does_not_keep_is_refused_before_the_image_is_stored(size: dict[str, object]) -> None: + document = HwpxDocument.new() + before = _state(document) + + with pytest.raises(HwpxValueError) as caught: + document.add_picture(PNG_1X1, "png", **size) # type: ignore[arg-type] + + assert caught.value.code == "shape-size-value" + assert _state(document) == before + + +@pytest.mark.parametrize("value", [True, False, -0.5, 14400.5, "14400"]) +@pytest.mark.parametrize("dimension", ["width", "height"]) +@pytest.mark.parametrize("direct", [False, True]) +def test_picture_units_are_validated_without_coercion(value: object, dimension: str, direct: bool) -> None: + document = HwpxDocument.new() + paragraph = document.paragraphs[0] + before = _state(document) + sizes = {dimension: value} + + with pytest.raises(HwpxValueError) as caught: + if direct: + paragraph.add_picture("image1", **sizes) # type: ignore[arg-type] + else: + document.add_picture(PNG_1X1, "png", **sizes) # type: ignore[arg-type] + + assert caught.value.code == "shape-size-value" + assert _state(document) == before + + +@pytest.mark.parametrize("ratio", [-1, 2**31, 12.5, True]) +def test_a_corner_ratio_hancom_does_not_keep_is_refused_before_anything_is_added(ratio: object) -> None: + document = HwpxDocument.new() + before = _state(document) + + with pytest.raises(HwpxValueError) as caught: + document.shapes.add_rectangle(width=14400, height=7200, ratio=ratio) # type: ignore[arg-type] + + assert caught.value.code == "shape-rect-ratio-value" + assert _state(document) == before + + +def test_the_smallest_and_largest_sizes_are_written_as_given() -> None: + document = HwpxDocument.new() + + rectangle = document.shapes.add_rectangle(width=0, height=2**31 - 1, ratio=50) + equation = document.shapes.add_equation("x + 1 over 2", size=(0, 2**31 - 1)) + + assert _size(rectangle.element) == ("0", str(2**31 - 1)) + assert rectangle.element.get("ratio") == "50" + assert _size(equation.element) == ("0", str(2**31 - 1)) + + +@pytest.mark.parametrize("base_unit", [0, -1100, 2**31, 1100.0, True]) +def test_an_equation_base_unit_outside_1_to_2_31_is_refused_before_anything_is_added(base_unit: object) -> None: + document = HwpxDocument.new() + before = _state(document) + + with pytest.raises(HwpxValueError) as caught: + document.shapes.add_equation("x + 1 over 2", base_unit=base_unit) # type: ignore[arg-type] + + assert caught.value.code == "shape-equation-base-unit-value" + assert isinstance(caught.value, ValueError) # what it raised before + assert _state(document) == before + + +def test_an_equation_box_measured_past_31_bits_is_refused_and_a_given_box_keeps_the_largest_base_unit() -> None: + # Hancom keeps base_unit 2**31 - 1 and lays the page out with the stored box. The box measured at it is past + # 32 bits: Hancom saved 2**32 - 1 for it. With a box given, it kept both. + measured = _saved("equation_base_unit_largest_measured.hwpx", "equation") + assert measured.get("baseUnit") == "2147483647" + assert _size(measured) == ("4294967295", "4294967295") + given = _saved("equation_base_unit_largest_given_size.hwpx", "equation") + assert given.get("baseUnit") == "2147483647" + assert _size(given) == ("1342", "1089") + + document = HwpxDocument.new() + before = _state(document) + with pytest.raises(HwpxValueError) as caught: + document.shapes.add_equation("x + 1 over 2", base_unit=2**31 - 1) + assert caught.value.code == "shape-size-value" + assert _state(document) == before + + equation = document.shapes.add_equation("(x)", base_unit=2**31 - 1, size=(1342, 1089)) + assert equation.element.get("baseUnit") == "2147483647" + assert _size(equation.element) == ("1342", "1089") + + +def test_the_paragraph_writers_check_before_adding_a_run() -> None: + document = HwpxDocument.new() + paragraph = document.paragraphs[0] + runs = len(paragraph.element.findall(f"{HP}run")) + + for add, code in ( + (lambda: paragraph.add_rectangle(width=-1, height=7200), "shape-size-value"), + (lambda: paragraph.add_rectangle(width=-1, height=7200, original_size=(100, 100)), "shape-size-value"), + (lambda: paragraph.add_rectangle(width=14400, height=7200, ratio=-1), "shape-rect-ratio-value"), + (lambda: paragraph.add_picture("image1", width=-1, height=7200), "shape-size-value"), + (lambda: paragraph.add_equation("x", size=(-1, 7200)), "shape-size-value"), + (lambda: paragraph.add_equation("x", base_unit=0), "shape-equation-base-unit-value"), + ): + with pytest.raises(HwpxValueError) as caught: + add() + assert caught.value.code == code + + assert len(paragraph.element.findall(f"{HP}run")) == runs + + +def test_resizing_a_shape_to_a_negative_size_is_refused_and_leaves_it_as_it_was() -> None: + document = HwpxDocument.new() + shape = document.shapes.add_rectangle(width=14400, height=7200, treat_as_char=False) + before = etree.tostring(shape.element) + + with pytest.raises(HwpxValueError) as caught: + shape.resize(-1, 7200) + + assert caught.value.code == "shape-size-value" + assert etree.tostring(shape.element) == before diff --git a/tests/test_table_column_floor.py b/tests/test_table_column_floor.py new file mode 100644 index 00000000..3d596fac --- /dev/null +++ b/tests/test_table_column_floor.py @@ -0,0 +1,119 @@ +"""New tables and ``set_column_widths`` write a column narrower than Hancom draws it at its floor. + +Hancom draws a table column at least its cells' left and right margins and 283 wide, keeps the narrower width +written in the cells, and saves the table as wide as it draws the columns. +``tests/fixtures/hancom_saved/table_column_floor_*.hwpx`` are its saves of two-column tables whose first column +is written just below or above the floor (cell margins 510 + 510, 0 + 0 and 1000 + 1000) and the second 20000 +wide; ``table_width_zero.hwpx`` of a 2x2 table written 0 wide, ``table_column_weights_zero_one.hwpx`` of a table +42520 wide given column weights 0 and 1. +""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +import pytest +from lxml import etree + +from hwpx import HwpxDocument +from hwpx.errors import HwpxValueError + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +FIXTURES = Path(__file__).parent / "fixtures" / "hancom_saved" + + +def _saved_table(name: str) -> etree._Element: + with zipfile.ZipFile(FIXTURES / name) as archive: + return next(etree.fromstring(archive.read("Contents/section0.xml")).iter(f"{HP}tbl")) + + +def _table_width(table: etree._Element) -> int: + return int(table.find(f"{HP}sz").get("width")) + + +def _sections(document: HwpxDocument) -> list[bytes]: + return [etree.tostring(section.element) for section in document.sections] + + +@pytest.mark.parametrize( + ("fixture", "written", "drawn"), + [ + ("table_column_floor_1302.hwpx", 1302, 1303), # the table's margins, 510 + 510 + ("table_column_floor_1304.hwpx", 1304, 1304), + ("table_column_floor_m0_282.hwpx", 282, 283), # the cells' own margins, 0 + 0 + ("table_column_floor_m1000_2282.hwpx", 2282, 2283), # 1000 + 1000 + ], +) +def test_hancom_draws_a_column_at_least_its_cell_margins_and_283_wide(fixture: str, written: int, drawn: int) -> None: + table = _saved_table(fixture) + + assert [int(size.get("width")) for size in table.iter(f"{HP}cellSz")] == [written, 20000] # kept + assert _table_width(table) == drawn + 20000 + + +def test_hancom_saves_a_table_as_wide_as_it_draws_its_columns() -> None: + assert _table_width(_saved_table("table_width_zero.hwpx")) == 2 * 1303 # written 0 wide + assert _table_width(_saved_table("table_column_weights_zero_one.hwpx")) == 1303 + 42520 # weights 0 and 1 + + +@pytest.mark.parametrize(("width", "written"), [(1, 2606), (2605, 2606), (2606, 2606), (2609, 2609)]) +def test_a_new_table_is_written_as_wide_as_hancom_draws_its_columns(width: int, written: int) -> None: + document = HwpxDocument.new() + + table = document.add_table(2, 2, width=width) + + assert _table_width(table.element) == written + assert sum(table.cell(0, column).width for column in range(2)) == written + assert min(table.cell(row, column).width for row in range(2) for column in range(2)) >= 1303 + + +def test_a_nested_table_in_a_narrow_cell_is_written_as_wide_as_hancom_draws_its_columns() -> None: + document = HwpxDocument.new() + outer = document.add_table(1, 2, width=2606) # 1303-wide cells: 283 inside their margins + + nested = outer.cell(0, 0).add_table(1, 2) + + assert _table_width(nested.element) == 2606 + + +@pytest.mark.parametrize("width", [0, -1, 2**31, 2606.0, True]) +def test_a_table_width_that_is_no_positive_int_is_refused_before_anything_is_added(width: object) -> None: + document = HwpxDocument.new() + outer = document.add_table(1, 1) + paragraph = document.add_paragraph("표 앞 글") + before = (len(document.paragraphs), _sections(document)) + + for add in ( + lambda: document.add_table(2, 2, width=width), + lambda: outer.cell(0, 0).add_table(2, 2, width=width), + lambda: paragraph.add_table(2, 2, width=width), + ): + with pytest.raises(HwpxValueError) as caught: + add() # type: ignore[no-untyped-call] + assert caught.value.code == "table-width-value" + + assert (len(document.paragraphs), _sections(document)) == before + + +def test_set_column_widths_writes_a_column_below_its_floor_at_the_floor_and_the_table_wider() -> None: + # As Hancom saved the same table and weights: 1303 and 42520 wide, 43823 in all. + document = HwpxDocument.new() + table = document.add_table(2, 2) + + table.set_column_widths([0, 1]) + + assert [[table.cell(row, column).width for column in range(2)] for row in range(2)] == [[1303, 42520]] * 2 + assert _table_width(table.element) == _table_width(_saved_table("table_column_weights_zero_one.hwpx")) + + +def test_a_column_takes_the_floor_of_its_own_cell_margins() -> None: + # As Hancom saved the table: cell margins 0 + 0, the column 282 written and 283 drawn, 20283 in all. + document = HwpxDocument.new() + table = document.add_table(1, 2, width=20282) + table.cell(0, 0).set_margins(left=0, right=0) + + table.set_column_widths([282, 20000]) + + assert [table.cell(0, column).width for column in range(2)] == [283, 20000] + assert _table_width(table.element) == _table_width(_saved_table("table_column_floor_m0_282.hwpx"))