Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a9f9d6c
fix(media): refuse a picture alignment Hancom does not read
airmang Oct 5, 2026
ca46d84
docs(changelog): note the fix
airmang Oct 5, 2026
6fa9e6d
fix(shapes): refuse sizes and corner ratios Hancom reads as 0
airmang Oct 6, 2026
89d1855
docs(changelog): note the fix
airmang Oct 6, 2026
8c8fae4
fix(captions,page): refuse gaps and new numbers Hancom does not keep
airmang Oct 6, 2026
ac07e6b
docs(changelog): note the fix
airmang Oct 6, 2026
f898027
fix(page): write unequal column widths as shares of 32768
airmang Oct 6, 2026
86b1216
docs(changelog): note the fix
airmang Oct 6, 2026
f7458fb
docs(layout): state the share of 32768 in the page estimate's notes
airmang Oct 6, 2026
c8a06be
test(page): Hancom lays the written shares out at the widths given
airmang Oct 6, 2026
d42a366
fix(table): write columns no narrower than Hancom draws them
airmang Oct 6, 2026
59be394
docs(changelog): note the fix
airmang Oct 6, 2026
d888186
test(errors): lower the census lock for the base unit check
airmang Oct 6, 2026
4ecdc84
Merge branch 'fix/shape-size-values' into fix/gap-and-new-number-values
airmang Oct 6, 2026
177b7bb
Merge branch 'fix/gap-and-new-number-values' into fix/column-widths-a…
airmang Oct 6, 2026
5d100ee
Merge branch 'fix/column-widths-as-shares' into fix/table-column-floor
airmang Oct 6, 2026
44bbd3f
fix(table): type new_table_width by its argument
airmang Oct 6, 2026
8790437
fix: validate picture units before coercion
airmang Oct 6, 2026
136671c
Merge commit '879043730b15e5b12f6b8b8451af7ef01fa6d591' into HEAD
airmang Oct 6, 2026
81a3bc2
Merge commit '136671c4a405427235d48d9518e1783d120560ac' into HEAD
airmang Oct 6, 2026
c16cedb
Merge commit '81a3bc2ba270046f3467b26d7da5e50b65d13bed' into HEAD
airmang Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)와 줄보다 넓은 글자처럼 둔 표만
있는 문단을 표 높이의 한 줄로 세던 것을 고친다.
- 한/글은 둘을 한 줄에 두지 않는다. 부호의 줄은 문단 글의 빈 줄이다.
Expand Down Expand Up @@ -257,6 +298,10 @@
자리에 겹쳐 두어 감춘다. 감춘 문단은 자리를 차지하지 않고 쪽을 만들지 않으며, 다음 글은 다음 쪽 맨 위에서
시작한다. 셋째 빈 문단부터, 쪽을 나누는 문단, 빈칸이 든 문단은 감추지 않는다. 전에는 그런 빈 문단을 다음 쪽으로
넘겨, 문서가 빈 문단으로 끝나면 빈 쪽을 하나 더 세기도 했다.
- 그림 넣기(`doc.add_picture`)가 `align`에 받은 값을 검사 없이 `hp:pos@horzAlign`에 쓰던 것을 고친다.
- 한/글은 모르는 정렬 값을 왼쪽(LEFT)으로 읽는다. 정수 같은 값은 그림과 문단을 더한 뒤에야 오류가 났다.
- 이제 대소문자와 상관없이 `LEFT`·`CENTER`·`RIGHT`·`INSIDE`·`OUTSIDE`만 받는다. 그 밖의 값은 아무것도 더하기
전에 `shape-position-frame` 오류로 거부한다.
- 쪽 수 추정(실험, `estimate_pages`)이 칸 단위로 나누는 흐르는 표에서 문단이 여럿인 칸을 줄 간격 하나로만 세어
나누던 것을 고친다. 한/글은 문단 사이 간격(앞 문단의 아래 간격과 다음 문단의 위 간격)도 세고, 다음 쪽 부분이
문단의 첫 줄로 시작하면 그 문단의 위 간격을 그대로 둔다. 이제 그런 칸은 줄마다 제 자리로 나눈다. 전에는 문단
Expand Down
10 changes: 9 additions & 1 deletion docs/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 에 이미 실려 나간 값이다. 이름을 바꾸면 영수증을 읽는
Expand Down Expand Up @@ -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) 밖이다. |
Expand Down Expand Up @@ -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와 정의된 축)이 없다(한컴이 빈 차트를 그리거나 멈춘다). |
Expand All @@ -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` | 수식 스크립트가 비어 있다. |
Expand All @@ -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-*`

Expand Down Expand Up @@ -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-*`

Expand Down
13 changes: 12 additions & 1 deletion src/hwpx/_document/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down
16 changes: 10 additions & 6 deletions src/hwpx/_document/media.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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(
"",
Expand Down
15 changes: 11 additions & 4 deletions src/hwpx/_document/shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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", "")
Expand Down
2 changes: 2 additions & 0 deletions src/hwpx/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading