From afa655fab3ac623641ed2caaf875f7f807fe073d Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:42:29 +0900 Subject: [PATCH 01/10] feat(layout): leave line caches whose breaks sit close to a line's end for Hancom Hancom takes a cached paragraph's line breaks as written, so a break that a width a little off from Hancom's would move must not be written. write_line_caches now leaves a paragraph for Hancom when its lines break elsewhere once every line is laid out 100 HWPUNIT narrower or wider, or when the text before a tab is within 100 HWPUNIT of a tab stop. The changelog entry for the experimental line caches is written out once. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 ++++++++++++------ src/hwpx/layout/line_cache.py | 30 ++++++++++++++++++++++-------- tests/test_layout_line_cache.py | 25 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9fb09e7..65f9f665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,19 @@ - 쪽 수 추정이 문서 끝 미주를 따른다. 미주는 본문 마지막 줄 바닥 아래에 미주 영역의 위 간격·구분선·아래 간격을 두고 줄마다 이어지며 쪽 끝을 넘어간다. 각주가 자리를 차지한 쪽 뒤에서는 다음 쪽에서 시작한다. 전에는 미주가 있으면 추정하지 않았다. 구역 끝 미주와 여러 단 구역 뒤 미주는 아직 추정하지 않는다. -- (실험) `hwpx.layout.line_cache.write_line_caches(doc)`가 본문의 글만 든 문단에 한/글이 저장할 때 쓰는 줄 배치 정보 +- (실험) `hwpx.layout.line_cache.write_line_caches(doc)`가 글만 든 문단에 한/글이 저장할 때 쓰는 줄 배치 정보 (`hp:linesegarray`)를 쓴다. 줄 높이·글 높이·기준선·줄 간격은 한/글이 쓰는 값 그대로, 줄 시작·위치·폭은 쪽 수 추정이 - 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. 글자 크기와 모양(글꼴·장평·자간)이 하나이고 개체·사용자 탭 정의가 없는 문단만(내어 쓰는 번호·글머리표는 된다), 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역에서만 쓰고, 나머지 문단은 전처럼 - 한/글이 열 때 배치한다. 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단도 쓰지 않는다. 이미 배치 정보가 있는 문단은 그대로 두고, 쪽 수 추정이 문서를 따르지 못하면 아무것도 쓰지 않는다. - 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. 개체·사용자 탭 정의가 없는 문단만(run마다 글꼴·장평·자간이 달라도 된다)(머리말·꼬리말·쪽 번호처럼 줄에 자리를 차지하지 않는 컨트롤은 된다)(글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다)(내어 쓰는 번호·글머리표는 된다), 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역에서만 쓰고, 나머지 문단은 전처럼 - 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. 글자 모양(글꼴·장평·자간)이 하나이고 개체·사용자 탭 정의가 없는 문단만(글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다)(내어 쓰는 번호·글머리표는 된다), 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역에서만 쓰고(그 구역 표의 칸도 칸 안 문단이 모두 그런 문단이면 쓰고, 글자처럼 놓인 표만 든 문단에는 표 높이의 한 줄을 쓰며 표의 `hp:sz` 높이도 행 높이의 합으로 쓴다), 나머지 문단은 전처럼 - 한/글이 열 때 배치한다. 이미 배치 정보가 있는 문단은 그대로 두고, 쪽 수 추정이 문서를 따르지 못하면 아무것도 쓰지 않는다. + 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. + - 쓰는 문단: 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역의, 개체·사용자 탭 정의가 없는 문단. run마다 글꼴·장평· + 자간이 달라도 되고, 글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다. 내어 쓰는 번호·글머리표와 머리말·꼬리말· + 쪽 번호처럼 줄에 자리를 차지하지 않는 컨트롤은 된다. + - 그 구역 표의 칸도 칸 안 문단이 모두 그런 문단이면 쓴다. 글자처럼 놓인 표만 든 문단에는 표 높이의 한 줄을 쓰고, 표의 + `hp:sz` 높이도 행 높이의 합으로 쓴다. + - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 글자 폭 표에 없는 + 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 + 정지 위치에서 100 HWPUNIT 안쪽인 문단. + - 나머지 문단과 이미 배치 정보가 있는 문단은 그대로 두어 한/글이 열 때 배치한다. 쪽 수 추정이 문서를 따르지 못하면 + 아무것도 쓰지 않는다. 기본 저장은 바뀌지 않는다. - 그림을 만들 때(`doc.add_picture`, `paragraph.add_picture`) 그림 효과와 테두리를 줄 수 있다. - `brightness`(밝기)·`contrast`(대비): -100~100. `effect`: `REAL_PIC`(원래 그림)·`GRAY_SCALE`(회색조)· diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 58932917..9ddb14cf 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -28,7 +28,8 @@ paragraph of its cells is laid out surely. A paragraph is left for Hancom when its line breaks are less sure: a character no glyph table gives an advance -for, or a word too long for a line. A paragraph that has a cache keeps it. A document mixing paragraphs with and +for, a word too long for a line, or a line break that moves when every line is laid out :data:`_MARGIN` narrower +or wider (a line ending that close to its room may end elsewhere in Hancom). A paragraph that has a cache keeps it. A document mixing paragraphs with and without a cache is laid out as one. Nothing is written when the estimate does not follow the document. """ @@ -72,6 +73,9 @@ _PAST_A_BREAK = 40 #: The line starts with the paragraph's bullet or number label. _LABELLED = 0x200000 +#: How much narrower or wider (HWPUNIT) every line may be laid out and still break at the same places for its +#: paragraph to get a cache: a width a little off from Hancom's must not move a line break. +_MARGIN = 100 #: How many characters a control (a section or column definition) takes in a line's text position. _CONTROL_LENGTH = 8 #: The controls that take no room on a line: page headers and footers, page numbers and the page controls. @@ -159,8 +163,6 @@ def _table_line(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[str return None if not _cells_sure(measure, table): # the rows are as tall as their cells' lines: those must be sure too return None - if not _cells_sure(measure, table): # the rows are as tall as their cells' lines: those must be sure too - return None shape = measure.shape(paragraph.get("paraPrIDRef")) size = max(measure.char_height(run.get("charPrIDRef")) for run in runs) # spaced from its largest characters rows = _pages._inline_table_height(measure, table) @@ -264,10 +266,14 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, if mixed or looks is not None: if mixed and "\t" in text: # a tab among characters of several sizes: its width is less sure return None - starts = _pages._metric_starts(measure, text, widths, sizes, style, {}, looks) + def lay_out(room: list[float]) -> list[int]: + return _pages._metric_starts(measure, text, room, sizes, style, {}, looks) + starts = lay_out(widths) metrics = _pages._line_metrics(measure, text, widths, sizes, style, shape, {}, looks, end=end) else: - starts = measure.line_starts(text, widths, size, style) if text else [0] + def lay_out(room: list[float]) -> list[int]: + return measure.line_starts(text, room, size, style) if text else [0] + starts = lay_out(widths) metrics = ((size, _pages._pitch(shape.kind, shape.value, size)),) * len(starts) if len(metrics) != len(starts): return None @@ -275,6 +281,8 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, return None if not _sure_of(text, starts, style, looks) or any(place in starts[1:] for place in controls): return None # a line starting at a control: on which side Hancom starts it is not known + if any(lay_out([room + shift for room in widths]) != starts for shift in (-_MARGIN, _MARGIN)): + return None # a line break this close to a line's end may fall elsewhere in Hancom tabs = _tab_widths(text, starts, widths, size, style, looks) if "\t" in text else [] if tabs is None: return None @@ -365,11 +373,17 @@ def _tab_widths(text: str, starts: list[int], widths: list[float], size: int, st used += char_advance(ch, points, style if looks is None else looks[index]) continue at = index - logical - reach = tab_reach(origin + used, origin + width, style, - lambda point, at=at: _width_after_tab(own, at, points, style, None, own_looks, None, - point)) + + def after(point: bool, at: int = at) -> float: + return _width_after_tab(own, at, points, style, None, own_looks, None, point) + + reach = tab_reach(origin + used, origin + width, style, after) if reach is None: return None + for shift in (-_MARGIN, _MARGIN): # text this close to a tab stop may reach the next one in Hancom + moved = tab_reach(origin + used + shift, origin + width, style, after) + if moved is None or abs(shift + moved - reach) > 0.5: + return None found.append(round(reach)) used += reach return found diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index d0d172be..8f3642bc 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -114,6 +114,18 @@ def test_a_tab_gets_the_width_hancom_lays_it_out_at() -> None: assert dict(tab.attrib) == {"width": "2056", "leader": "0", "type": "1"} +@pytest.mark.parametrize(("head", "written"), [("가가가!", 1), ("가가가!!", 0)]) +def test_a_tab_close_to_a_tab_stop_is_left_for_hancom(head: str, written: int) -> None: + # "가가가!" takes 3,416 (가 972, ! half an em): its tab reaches the stop at 4000 surely. "가가가!!" takes 3,916, 84 + # short of it, and text a little wider than Hancom's would take its tab on to the next stop. + document = HwpxDocument.new() + document.add_paragraph(f"{head}\t내용") + + report = write_line_caches(document) + + assert report.written == written and report.skipped == 1 - written + + def test_a_line_break_typed_into_the_text_is_left_for_hancom() -> None: # Hancom writes a line break as hp:lineBreak; a newline in hp:t's own text is left for it to lay out. document = HwpxDocument.new() @@ -136,6 +148,19 @@ def test_a_paragraph_laid_out_less_surely_is_left_for_hancom(text: str) -> None: assert report.written == 0 and report.skipped == 1 +@pytest.mark.parametrize(("right_mm", "written"), [(6.0, 1), (7.0, 0)]) +def test_a_line_ending_close_to_its_room_is_left_for_hancom(right_mm: float, written: int) -> None: + # Twelve words of 3,416 HWPUNIT (the last without its space) take 40,492 of the line. 7 mm to the right leaves + # it 44 more, and a width a little off from Hancom's could break the line elsewhere; 6 mm leaves 327. + document = HwpxDocument.new() + paragraph = document.add_paragraph("가나다 " * 20) + document.styles.apply_paragraph_format(paragraphs=[paragraph], indent_right_mm=right_mm) + + report = write_line_caches(document) + + assert report.written == written and report.skipped == 1 - written + + def test_a_paragraph_of_several_character_sizes_gets_a_cache() -> None: # Hancom makes each line as tall as its largest character and draws its text that high. document = HwpxDocument.open(FIXTURES / "line_cache_mixed_sizes.hwpx") From 29ebb469071da683d39f4d036dd4aefc08f00156 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:26:51 +0900 Subject: [PATCH 02/10] feat(layout): scale the tab stop margin of line caches by the characters before the tab A tab's text was left for Hancom when it ended within 100 HWPUNIT of a tab stop, which left many tabbed paragraphs without a cache. The margin is now a layout unit for each character before the tab on its line (at least two, at most 100): what that text could be off by if each of its glyphs were a unit off. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/hwpx/layout/line_cache.py | 13 +++++++++---- tests/test_layout_line_cache.py | 7 ++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f9f665..2497465e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ `hp:sz` 높이도 행 높이의 합으로 쓴다. - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 - 정지 위치에서 100 HWPUNIT 안쪽인 문단. + 정지 위치에서 그 줄의 탭 앞 글자 수 × 4 HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. - 나머지 문단과 이미 배치 정보가 있는 문단은 그대로 두어 한/글이 열 때 배치한다. 쪽 수 추정이 문서를 따르지 못하면 아무것도 쓰지 않는다. 기본 저장은 바뀌지 않는다. diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 9ddb14cf..a0fb0321 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -28,9 +28,11 @@ paragraph of its cells is laid out surely. A paragraph is left for Hancom when its line breaks are less sure: a character no glyph table gives an advance -for, a word too long for a line, or a line break that moves when every line is laid out :data:`_MARGIN` narrower -or wider (a line ending that close to its room may end elsewhere in Hancom). A paragraph that has a cache keeps it. A document mixing paragraphs with and -without a cache is laid out as one. Nothing is written when the estimate does not follow the document. +for, a word too long for a line, a line break that moves when every line is laid out :data:`_MARGIN` narrower or +wider (a line ending that close to its room may end elsewhere in Hancom), or a tab whose text before it is closer +to a tab stop than a layout unit for each of its characters on the line (at most :data:`_MARGIN`). A paragraph that +has a cache keeps it. A document mixing paragraphs with and without a cache is laid out as one. Nothing is written +when the estimate does not follow the document. """ from __future__ import annotations @@ -380,7 +382,10 @@ def after(point: bool, at: int = at) -> float: reach = tab_reach(origin + used, origin + width, style, after) if reach is None: return None - for shift in (-_MARGIN, _MARGIN): # text this close to a tab stop may reach the next one in Hancom + # text this close to a tab stop may reach another one in Hancom: a layout unit off for each character + # before the tab on its line, at most _MARGIN + margin = min(max(index - start, 2) * _LAYOUT_UNIT, _MARGIN) + for shift in (-margin, margin): moved = tab_reach(origin + used + shift, origin + width, style, after) if moved is None or abs(shift + moved - reach) > 0.5: return None diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 8f3642bc..ba5bb006 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -114,10 +114,11 @@ def test_a_tab_gets_the_width_hancom_lays_it_out_at() -> None: assert dict(tab.attrib) == {"width": "2056", "leader": "0", "type": "1"} -@pytest.mark.parametrize(("head", "written"), [("가가가!", 1), ("가가가!!", 0)]) +@pytest.mark.parametrize(("head", "written"), [("가" * 16, 1), ("가" * 16 + "!", 0)]) def test_a_tab_close_to_a_tab_stop_is_left_for_hancom(head: str, written: int) -> None: - # "가가가!" takes 3,416 (가 972, ! half an em): its tab reaches the stop at 4000 surely. "가가가!!" takes 3,916, 84 - # short of it, and text a little wider than Hancom's would take its tab on to the next stop. + # Sixteen 가 take 15,552 (972 each), 448 short of the stop at 16,000: their tab reaches it surely. With a "!" (half + # an em) they take 16,052, 52 past it, closer than a layout unit for each of the 17 characters: text that much + # off from Hancom's would take the tab to another stop. document = HwpxDocument.new() document.add_paragraph(f"{head}\t내용") From 1c9d53e15437ee48f1a68ae4cee905ed7127307c Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:40:49 +0900 Subject: [PATCH 03/10] feat(layout): count a tab's margin from the last tab stop it follows A tab right after another starts exactly at the stop the first reached, yet its margin counted every character from the line's start and so left it for Hancom. The characters that could be off are only those since the line's start or the last tab stop: a tab with none before it needs no margin. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/hwpx/layout/line_cache.py | 15 +++++++++------ tests/test_layout_line_cache.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2497465e..e4799c8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ `hp:sz` 높이도 행 높이의 합으로 쓴다. - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 - 정지 위치에서 그 줄의 탭 앞 글자 수 × 4 HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. + 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. - 나머지 문단과 이미 배치 정보가 있는 문단은 그대로 두어 한/글이 열 때 배치한다. 쪽 수 추정이 문서를 따르지 못하면 아무것도 쓰지 않는다. 기본 저장은 바뀌지 않는다. diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index a0fb0321..28765f63 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -30,9 +30,9 @@ A paragraph is left for Hancom when its line breaks are less sure: a character no glyph table gives an advance for, a word too long for a line, a line break that moves when every line is laid out :data:`_MARGIN` narrower or wider (a line ending that close to its room may end elsewhere in Hancom), or a tab whose text before it is closer -to a tab stop than a layout unit for each of its characters on the line (at most :data:`_MARGIN`). A paragraph that -has a cache keeps it. A document mixing paragraphs with and without a cache is laid out as one. Nothing is written -when the estimate does not follow the document. +to a tab stop than a layout unit for each character since the line's start or the last tab stop (at most +:data:`_MARGIN`). A paragraph that has a cache keeps it. A document mixing paragraphs with and without a cache is +laid out as one. Nothing is written when the estimate does not follow the document. """ from __future__ import annotations @@ -369,6 +369,7 @@ def _tab_widths(text: str, starts: list[int], widths: list[float], size: int, st origin = _line_origin(style, logical == 0 and visual == 0) own_looks = None if looks is None else looks[logical:logical + len(own)] used = 0.0 + since = start # the first character after the line's start or the last tab stop reached: exact places for index in range(start, end): ch = text[index] if ch != "\t": @@ -383,14 +384,16 @@ def after(point: bool, at: int = at) -> float: if reach is None: return None # text this close to a tab stop may reach another one in Hancom: a layout unit off for each character - # before the tab on its line, at most _MARGIN - margin = min(max(index - start, 2) * _LAYOUT_UNIT, _MARGIN) - for shift in (-margin, margin): + # since the line's start or the last tab stop, at most _MARGIN + margin = min((index - since) * _LAYOUT_UNIT, _MARGIN) + for shift in (-margin, margin) if margin else (): moved = tab_reach(origin + used + shift, origin + width, style, after) if moved is None or abs(shift + moved - reach) > 0.5: return None found.append(round(reach)) used += reach + if reach: + since = index + 1 return found diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index ba5bb006..a7f4d9eb 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -127,6 +127,18 @@ def test_a_tab_close_to_a_tab_stop_is_left_for_hancom(head: str, written: int) - assert report.written == written and report.skipped == 1 - written +def test_a_tab_right_after_a_tab_starts_at_its_stop_surely() -> None: + # The first tab ends at the stop at 4000, exactly where the second one starts: no character before the second + # tab could be off, so it reaches the stop at 8000 surely. + document = HwpxDocument.new() + document.add_paragraph("항목\t\t내용") + + report = write_line_caches(document) + + assert report.written == 1 + assert [tab.get("width") for tab in _body(document)[-1].iter(f"{HP}tab")] == ["2056", "4000"] + + def test_a_line_break_typed_into_the_text_is_left_for_hancom() -> None: # Hancom writes a line break as hp:lineBreak; a newline in hp:t's own text is left for it to lay out. document = HwpxDocument.new() From ea0582152443ea31492679a6a9786679f628bd01 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:39:05 +0900 Subject: [PATCH 04/10] feat(layout): write line caches only for faces, glyphs and looks seen on the desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public documents showed written caches whose lines or heights differed from Hancom Office's: - a face Hancom may draw in another one, a glyph whose advance comes from the font file only, or 장평, 자간 and 최소 공백 other than the default; such paragraphs are now left for Hancom; - characters aligned to the centre or the bottom of their line, for which Hancom writes another baseline: left too; - a table with a caption, whose line holds the caption: left too; - a larger empty run ending the paragraph makes the last line's text as tall as itself, not only the line. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 ++++--- src/hwpx/layout/line_cache.py | 59 +++++++++++++++++++++++++----- tests/test_layout_line_cache.py | 65 +++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4799c8c..a7131c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,17 @@ - (실험) `hwpx.layout.line_cache.write_line_caches(doc)`가 글만 든 문단에 한/글이 저장할 때 쓰는 줄 배치 정보 (`hp:linesegarray`)를 쓴다. 줄 높이·글 높이·기준선·줄 간격은 한/글이 쓰는 값 그대로, 줄 시작·위치·폭은 쪽 수 추정이 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. - - 쓰는 문단: 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역의, 개체·사용자 탭 정의가 없는 문단. run마다 글꼴·장평· - 자간이 달라도 되고, 글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다. 내어 쓰는 번호·글머리표와 머리말·꼬리말· + - 쓰는 문단: 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역의, 개체·사용자 탭 정의가 없는 문단. run마다 글꼴이 + 달라도 되고, 글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다. 내어 쓰는 번호·글머리표와 머리말·꼬리말· 쪽 번호처럼 줄에 자리를 차지하지 않는 컨트롤은 된다. - 그 구역 표의 칸도 칸 안 문단이 모두 그런 문단이면 쓴다. 글자처럼 놓인 표만 든 문단에는 표 높이의 한 줄을 쓰고, 표의 - `hp:sz` 높이도 행 높이의 합으로 쓴다. - - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 글자 폭 표에 없는 - 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 + `hp:sz` 높이도 행 높이의 합으로 쓴다(캡션이 있는 표는 쓰지 않는다). + - 글자 크기보다 큰 빈 run으로 끝나는 문단은 마지막 줄의 글 높이도 그 크기로 쓴다. 글자를 줄의 가운데·아래에 맞추는 + 문단(문단 모양의 세로 정렬)은 한/글이 기준선을 다르게 쓰므로 쓰지 않는다. + - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 한컴오피스 2024 + 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·한양·HCI)이 + 아니거나, 장평 100 %·자간 0이 아니거나 최소 공백이 있거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 글자 폭 표에 + 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. - 나머지 문단과 이미 배치 정보가 있는 문단은 그대로 두어 한/글이 열 때 배치한다. 쪽 수 추정이 문서를 따르지 못하면 아무것도 쓰지 않는다. diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 28765f63..80c37ecd 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -17,9 +17,11 @@ section of one column where no object lets text run beside it. Tabs to the default tab stops, line breaks, controls that take no room on a line (a header, a footer, a page number, the section's own definitions in its first paragraph) and a hanging bullet or number label in the paragraph's size are fine. Its runs may differ in -face, 장평 or 자간, each character taking its own run's advance, and in size: each line is then as tall as its -largest character, unless a larger one past the line's end may be the one that did not fit on it (Hancom writes -the line as tall as that one), and has no tab. +face, each character taking its own run's advance, and in size: each line is then as tall as its largest +character, unless a larger one past the line's end may be the one that did not fit on it (Hancom writes the line as +tall as that one), and has no tab; a larger empty run ending the paragraph makes its last line's text as tall. A +paragraph whose characters align to the centre or the bottom of their lines is left (Hancom writes another +baseline), and so is a table with a caption. The cells of a table in such a section get caches the same way when every paragraph of the cell is one of these: their lines go down from the top of the cell's text area, as wide as that area in whole layout units and @@ -43,6 +45,7 @@ from ..form_fit.measure import ( _FIXED_SPACE, _GLYPH_SPACE, + _GLYPHS, _HALF_EM_GLYPHS, _HALF_EM_SPACES, _design_units, @@ -57,7 +60,7 @@ ) from ..oxml.table_sizes import cell_margins_of, grid_widths_of from . import pages as _pages -from .pages import HP +from .pages import HH, HP if TYPE_CHECKING: from ..document import HwpxDocument @@ -75,6 +78,15 @@ _PAST_A_BREAK = 40 #: The line starts with the paragraph's bullet or number label. _LABELLED = 0x200000 +#: The faces whose glyphs Hancom Office 2024 was seen to lay out at the advances FormFit gives them, on the +#: desktop: a face Hancom may draw in another (one not installed, a name no table lists) is left for Hancom. +_SURE_FACES = frozenset({ + "함초롬바탕", "함초롬돋움", "맑은 고딕", "바탕", "바탕체", "돋움", "돋움체", "굴림", "굴림체", "궁서", "궁서체", + "한컴 고딕", "HY헤드라인M", "HY견고딕", "HY울릉도M", "HY견명조", "HY동녘M", "휴먼명조", "휴먼고딕", "휴먼옛체", + "한양중고딕", "한양신명조", "한양견고딕", "HCI Poppy", "HCI Hollyhock", +}) +#: The glyphs other than Hangul syllables whose advances were seen on the desktop in those faces. +_SURE_GLYPHS = frozenset(_GLYPHS) #: How much narrower or wider (HWPUNIT) every line may be laid out and still break at the same places for its #: paragraph to get a cache: a width a little off from Hancom's must not move a line break. _MARGIN = 100 @@ -165,6 +177,10 @@ def _table_line(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[str return None if not _cells_sure(measure, table): # the rows are as tall as their cells' lines: those must be sure too return None + if not _on_the_baseline(measure, paragraph.get("paraPrIDRef")): + return None + if table.find(f"{HP}caption") is not None: # the line holds the caption too: left for Hancom + return None shape = measure.shape(paragraph.get("paraPrIDRef")) size = max(measure.char_height(run.get("charPrIDRef")) for run in runs) # spaced from its largest characters rows = _pages._inline_table_height(measure, table) @@ -251,7 +267,7 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, return None headed = measure.headed(paragraph) shape = measure.shape(paragraph.get("paraPrIDRef")) - if shape.font_line: + if shape.font_line or not _on_the_baseline(measure, paragraph.get("paraPrIDRef")): return None size, refs, sizes = _pages._text_size(measure, runs) end = _pages._end_size(measure, runs, sizes) @@ -293,6 +309,8 @@ def lay_out(room: list[float]) -> list[int]: ends = [*starts[1:], len(text)] for line, (start, (height, advance)) in enumerate(zip(starts, metrics)): drawn = [sizes[index] for index in range(start, ends[line]) if text[index] != "\n"] if mixed else [] + if end and line == len(starts) - 1: + drawn.append(end) # the paragraph's end, larger than its text: the last line's text is as tall text_height = max(drawn) if drawn else height # a line break larger than the text makes the line taller takes_indent = (indented and (shape.indent > 0 if line == 0 else shape.indent < 0)) or (headed and line > 0) segments.append({ @@ -315,25 +333,46 @@ def lay_out(room: list[float]) -> list[int]: def _sure_of(text: str, starts: list[int], style: Any, looks: list[Any] | None = None) -> bool: - """Whether the line breaks at *starts* can be trusted to be Hancom's: every character takes an advance a - glyph table gives (not a class average), and no line starts inside a word that did not fit on a line of its - own. Hancom takes the line breaks from a cache as written (a line short of a break is drawn with its text + """Whether the line breaks at *starts* can be trusted to be Hancom's: every character is set in a face of + :data:`_SURE_FACES` at 장평 100 % and 자간 0 without 최소 공백, takes an advance a glyph table gives (not a class + average) and is a Hangul syllable or one of :data:`_SURE_GLYPHS`, and no line starts inside a word that did not + fit on a line of its own. Hancom takes the line breaks from a cache as written (a line short of a break is drawn with its text pressed together), so a paragraph laid out less surely is left for Hancom.""" - if style is None: + if style is None or style.condense: return False + for look in set(looks or [style]): + if look.ratio != 100 or look.spacing or any(ratio != 100 or spacing for _, ratio, spacing in look.scripts): + return False # 장평 and 자간 other than the default: left until seen on the desktop + if look.hangul_face not in _SURE_FACES or look.glyph_face not in _SURE_FACES: + return False for ch, look in set(zip(text, looks or [style] * len(text))): if ch in "\t\n" or ch in _HALF_EM_SPACES or ch in _HALF_EM_GLYPHS or ch in (_FIXED_SPACE, _GLYPH_SPACE): continue if classify_char(ch) == "hangul": if _hangul_units(_laid_out_face(look.hangul_face), ch) is None: return False - elif _design_units(_laid_out_face(look.glyph_face), ch) is None: + elif ch not in _SURE_GLYPHS or _design_units(_laid_out_face(look.glyph_face), ch) is None: return False breaks = _hancom_break_opportunities(text, style) return all(start in breaks or text[start - 1] == "\n" for start in starts[1:]) +def _on_the_baseline(measure: Any, para_pr_id: str | None) -> bool: + """Whether a paragraph of the shape *para_pr_id* sets its characters on their baseline (``hh:align``'s + ``vertical``, the default): Hancom writes the baseline elsewhere for the others (half the text height for + CENTER, all of it for BOTTOM).""" + + aligned = getattr(measure, "_line_cache_baseline", None) + if aligned is None: + aligned = {} + for shape in measure._header.iter(f"{HH}paraPr"): + align = shape.find(f"{HH}align") + aligned[shape.get("id")] = align is None or align.get("vertical", "BASELINE") == "BASELINE" + measure._line_cache_baseline = aligned + return aligned.get(para_pr_id, False) + + def _taller_past_a_break(text: str, sizes: list[int], starts: list[int], metrics: tuple[tuple[int, int], ...]) -> bool: """Whether a character past a line's end that may be the one that did not fit on it (the next line's first word diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index a7f4d9eb..4b5e7924 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -11,6 +11,7 @@ from hwpx.layout.line_cache import write_line_caches HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +HH = "{http://www.hancom.co.kr/hwpml/2011/head}" FIXTURES = Path(__file__).parent / "fixtures" / "hancom_saved" # Hancom Office laid these documents out from python-hwpx's paragraphs without a cache and saved them. HANCOM_LAID_OUT = ["line_cache_tabs.hwpx", "line_cache_line_breaks.hwpx", "line_cache_hanging_indents.hwpx", @@ -79,6 +80,70 @@ def test_a_paragraph_holding_a_table_gets_one_line_as_tall_as_it() -> None: assert int(line["vertsize"]) == int(table.find(f"{HP}sz").get("height")) > 282 * 2 +def test_a_table_with_a_caption_keeps_its_line_for_hancom() -> None: + # The line of a table with a caption holds the caption too: left for Hancom. + document = HwpxDocument.new() + table = document.add_table(2, 2) + table.set_caption("표 1") + + write_line_caches(document) + + assert not _segments(_body(document)[-1]) + + +@pytest.mark.parametrize("vertical", ["CENTER", "BOTTOM", "TOP"]) +def test_a_paragraph_not_set_on_its_baseline_is_left_for_hancom(vertical: str) -> None: + # Hancom writes another baseline for characters aligned to the centre (half the text height) or the bottom + # (all of it) of their line. + document = HwpxDocument.new() + paragraph = document.add_paragraph("가나다") + document.styles.apply_paragraph_format(paragraphs=[paragraph], alignment="LEFT") + shape_id = _body(document)[-1].get("paraPrIDRef") + shape = next(el for el in document.oxml.headers[0].element.iter(f"{HH}paraPr") if el.get("id") == shape_id) + shape.find(f"{HH}align").set("vertical", vertical) + + report = write_line_caches(document) + + assert report.written == 0 and report.skipped == 1 + + +def test_a_larger_paragraph_end_makes_the_last_lines_text_as_tall() -> None: + # An empty run ending the paragraph, larger than its text, makes its last line and that line's text as tall. + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + paragraph.add_run("가나다", char_pr_id_ref=document.styles.ensure_run(size=10)) + paragraph.add_run("", char_pr_id_ref=document.styles.ensure_run(size=16)) + + report = write_line_caches(document) + + assert report.written == 1 + [line] = _segments(_body(document)[-1]) + assert (line["vertsize"], line["textheight"], line["baseline"]) == ("1600", "1600", "1360") + + +@pytest.mark.parametrize("style", [{"font": "HY신명조"}, {"font": "KoPub바탕체 Light"}, {"letter_spacing": -5}, + {"ratio": 95}]) +def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hancom(style: dict) -> None: + # A face Hancom may draw in another one, or 장평 and 자간 other than the default, are left for Hancom. + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + paragraph.add_run("가나다 " * 30, char_pr_id_ref=document.styles.ensure_run(**style)) + + report = write_line_caches(document) + + assert report.written == 0 and report.skipped == 1 + + +def test_a_glyph_not_seen_on_the_desktop_is_left_for_hancom() -> None: + # "「" (U+FF62) has a table advance read from the font, not one seen on the desktop. + document = HwpxDocument.new() + document.add_paragraph("「가나다」") + + report = write_line_caches(document) + + assert report.written == 0 and report.skipped == 1 + + def _cell_paragraphs(document: HwpxDocument) -> list: return [p for section in document.oxml.sections for cell in section.element.iter(f"{HP}tc") for p in cell.findall(f"{HP}subList/{HP}p")] From 289648057377d9b69c987f88c5acdb8b8338118d Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:01:47 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(layout):=20write=20line=20caches=20a?= =?UTF-8?q?t=20the=20=EC=9E=A5=ED=8F=89,=20=EC=9E=90=EA=B0=84=20and=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=20=EA=B3=B5=EB=B0=B1=20seen=20on=20the=20des?= =?UTF-8?q?ktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hancom Office lays lines out as FormFit does at 장평 80 to 110 %, 자간 -10 to 5 % and 최소 공백 up to 20 %, so such paragraphs get caches again; looks past those are still left for Hancom. A page control among the text (not before it) was seen to break a line elsewhere, so such a paragraph is left for Hancom. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++--- src/hwpx/layout/line_cache.py | 19 ++++++++++------- tests/test_layout_line_cache.py | 36 ++++++++++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7131c94..9511923f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,10 @@ 문단(문단 모양의 세로 정렬)은 한/글이 기준선을 다르게 쓰므로 쓰지 않는다. - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 한컴오피스 2024 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·한양·HCI)이 - 아니거나, 장평 100 %·자간 0이 아니거나 최소 공백이 있거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 글자 폭 표에 - 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 - 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. + 아니거나, 장평 80~110 %·자간 −10~5 %·최소 공백 20 % 밖이거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 쪽 + 컨트롤이 글자 뒤에 있는 문단, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT + 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 + HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. - 나머지 문단과 이미 배치 정보가 있는 문단은 그대로 두어 한/글이 열 때 배치한다. 쪽 수 추정이 문서를 따르지 못하면 아무것도 쓰지 않는다. 기본 저장은 바뀌지 않는다. diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 80c37ecd..fe377ab2 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -85,6 +85,10 @@ "한컴 고딕", "HY헤드라인M", "HY견고딕", "HY울릉도M", "HY견명조", "HY동녘M", "휴먼명조", "휴먼고딕", "휴먼옛체", "한양중고딕", "한양신명조", "한양견고딕", "HCI Poppy", "HCI Hollyhock", }) +#: The 장평 (%), 자간 (%) and 최소 공백 (%) Hancom was seen to lay lines out at as FormFit does, on the desktop. +_SURE_RATIOS = (80, 110) +_SURE_SPACINGS = (-10, 5) +_SURE_CONDENSE = 20 #: The glyphs other than Hangul syllables whose advances were seen on the desktop in those faces. _SURE_GLYPHS = frozenset(_GLYPHS) #: How much narrower or wider (HWPUNIT) every line may be laid out and still break at the same places for its @@ -334,16 +338,17 @@ def lay_out(room: list[float]) -> list[int]: def _sure_of(text: str, starts: list[int], style: Any, looks: list[Any] | None = None) -> bool: """Whether the line breaks at *starts* can be trusted to be Hancom's: every character is set in a face of - :data:`_SURE_FACES` at 장평 100 % and 자간 0 without 최소 공백, takes an advance a glyph table gives (not a class + :data:`_SURE_FACES` at 장평, 자간 and 최소 공백 within those seen on the desktop, takes an advance a glyph table gives (not a class average) and is a Hangul syllable or one of :data:`_SURE_GLYPHS`, and no line starts inside a word that did not fit on a line of its own. Hancom takes the line breaks from a cache as written (a line short of a break is drawn with its text pressed together), so a paragraph laid out less surely is left for Hancom.""" - if style is None or style.condense: + if style is None or style.condense > _SURE_CONDENSE: return False for look in set(looks or [style]): - if look.ratio != 100 or look.spacing or any(ratio != 100 or spacing for _, ratio, spacing in look.scripts): - return False # 장평 and 자간 other than the default: left until seen on the desktop + if any(not _SURE_RATIOS[0] <= ratio <= _SURE_RATIOS[1] or not _SURE_SPACINGS[0] <= spacing <= _SURE_SPACINGS[1] + for ratio, spacing in [(look.ratio, look.spacing), *((r, sp) for _, r, sp in look.scripts)]): + return False # 장평 and 자간 past those seen on the desktop if look.hangul_face not in _SURE_FACES or look.glyph_face not in _SURE_FACES: return False for ch, look in set(zip(text, looks or [style] * len(text))): @@ -450,7 +455,7 @@ def _wraps_text(section: Any) -> bool: def _control_places(runs: list[Any], first: bool) -> list[int] | None: """Where in the paragraph's text each of its controls stands, or None when the paragraph holds anything but text and controls that take no room on a line: a header, a footer, a page number and the other page controls - (and in a section's first paragraph, the section's own definitions).""" + (and in a section's first paragraph, the section's own definitions), all of them before the text.""" places: list[int] = [] length = 0 @@ -463,8 +468,8 @@ def _control_places(runs: list[Any], first: bool) -> list[int] | None: if "\n" in (child.text or "") or any("\n" in (mark.tail or "") for mark in child): return None # a line break typed into the text, which Hancom writes as hp:lineBreak length += len(_pages._t_text(child)) - elif name == "secPr" and first or name == "ctrl" and _takes_no_room(child, first): - places.append(length) + elif (name == "secPr" and first or name == "ctrl" and _takes_no_room(child, first)) and not length: + places.append(length) # before the text only: one among it was seen to break the line elsewhere else: return None return places diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 4b5e7924..3698b2b8 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -121,10 +121,10 @@ def test_a_larger_paragraph_end_makes_the_last_lines_text_as_tall() -> None: assert (line["vertsize"], line["textheight"], line["baseline"]) == ("1600", "1600", "1360") -@pytest.mark.parametrize("style", [{"font": "HY신명조"}, {"font": "KoPub바탕체 Light"}, {"letter_spacing": -5}, - {"ratio": 95}]) +@pytest.mark.parametrize("style", [{"font": "HY신명조"}, {"font": "KoPub바탕체 Light"}, {"letter_spacing": -17}, + {"ratio": 70}]) def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hancom(style: dict) -> None: - # A face Hancom may draw in another one, or 장평 and 자간 other than the default, are left for Hancom. + # A face Hancom may draw in another one, or 장평 and 자간 past those seen on the desktop, are left for Hancom. document = HwpxDocument.new() paragraph = document.add_paragraph("", include_run=False) paragraph.add_run("가나다 " * 30, char_pr_id_ref=document.styles.ensure_run(**style)) @@ -134,6 +134,36 @@ def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hanco assert report.written == 0 and report.skipped == 1 +@pytest.mark.parametrize("style", [{"letter_spacing": -5}, {"ratio": 90}, {"ratio": 110, "letter_spacing": 5}]) +def test_a_paragraph_at_a_look_seen_on_the_desktop_gets_a_cache(style: dict) -> None: + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + paragraph.add_run("가나다 " * 5, char_pr_id_ref=document.styles.ensure_run(**style)) + + report = write_line_caches(document) + + assert report.written == 1 + + +def test_a_page_control_among_the_text_is_left_for_hancom() -> None: + # A page control among the text (not before it) was seen to break the line elsewhere than FormFit. + document = HwpxDocument.new() + document.add_paragraph("가나다 " * 40) + run = _body(document)[-1].find(f"{HP}run") + ctrl = run.makeelement(f"{HP}ctrl", {}) + ctrl.append(ctrl.makeelement(f"{HP}pageHiding", {"hideHeader": "0", "hideFooter": "0", "hideMasterPage": "0", + "hideBorder": "0", "hideFill": "0", "hidePageNum": "1"})) + text = run.find(f"{HP}t") + tail = run.makeelement(f"{HP}t", {}) + tail.text, text.text = text.text[1:], text.text[:1] + text.addnext(ctrl) + ctrl.addnext(tail) + + report = write_line_caches(document) + + assert report.written == 0 and report.skipped == 1 + + def test_a_glyph_not_seen_on_the_desktop_is_left_for_hancom() -> None: # "「" (U+FF62) has a table advance read from the font, not one seen on the desktop. document = HwpxDocument.new() From d220ab43ed76b05ead5bcaeac0e02cfb96308a81 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:49:37 +0900 Subject: [PATCH 06/10] feat(layout): write the baseline of each vertical alignment and a caption's room in a table's line Hancom Office writes a line's baseline at 0 for characters set at the top of their line, half the text height at the centre and all of it at the bottom, besides 85 % on the baseline; such paragraphs get caches again (an odd text height at the centre is still left). A caption above or below a table set as a character takes its lines and its gap in the table's line, and one beside it none: the table's line now holds them when the caption's paragraphs are laid out surely. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++-- src/hwpx/layout/line_cache.py | 56 ++++++++++++++++++++++++--------- tests/test_layout_line_cache.py | 25 +++++++++------ 3 files changed, 61 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9511923f..1b008773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,10 @@ 달라도 되고, 글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다. 내어 쓰는 번호·글머리표와 머리말·꼬리말· 쪽 번호처럼 줄에 자리를 차지하지 않는 컨트롤은 된다. - 그 구역 표의 칸도 칸 안 문단이 모두 그런 문단이면 쓴다. 글자처럼 놓인 표만 든 문단에는 표 높이의 한 줄을 쓰고, 표의 - `hp:sz` 높이도 행 높이의 합으로 쓴다(캡션이 있는 표는 쓰지 않는다). - - 글자 크기보다 큰 빈 run으로 끝나는 문단은 마지막 줄의 글 높이도 그 크기로 쓴다. 글자를 줄의 가운데·아래에 맞추는 - 문단(문단 모양의 세로 정렬)은 한/글이 기준선을 다르게 쓰므로 쓰지 않는다. + `hp:sz` 높이도 행 높이의 합으로 쓴다. 표 위·아래 캡션은 그 줄 높이와 간격만큼 표의 줄에 더한다(옆 캡션은 더하지 + 않는다). + - 글자 크기보다 큰 빈 run으로 끝나는 문단은 마지막 줄의 글 높이도 그 크기로 쓴다. 기준선은 문단 모양의 세로 정렬대로 + 글꼴 기준이면 글 높이의 85 %, 위면 0, 가운데면 절반, 아래면 글 높이 그대로 쓴다. - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 한컴오피스 2024 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·한양·HCI)이 아니거나, 장평 80~110 %·자간 −10~5 %·최소 공백 20 % 밖이거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 쪽 diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index fe377ab2..31c240c2 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -20,8 +20,9 @@ face, each character taking its own run's advance, and in size: each line is then as tall as its largest character, unless a larger one past the line's end may be the one that did not fit on it (Hancom writes the line as tall as that one), and has no tab; a larger empty run ending the paragraph makes its last line's text as tall. A -paragraph whose characters align to the centre or the bottom of their lines is left (Hancom writes another -baseline), and so is a table with a caption. +paragraph that sets its characters at the top, the centre or the bottom of their lines gets the baseline Hancom +writes for that (0, half or all of the text height). A table's caption above or below it takes its lines and its +gap in the table's line, when the caption's paragraphs are laid out surely. The cells of a table in such a section get caches the same way when every paragraph of the cell is one of these: their lines go down from the top of the cell's text area, as wide as that area in whole layout units and @@ -78,6 +79,14 @@ _PAST_A_BREAK = 40 #: The line starts with the paragraph's bullet or number label. _LABELLED = 0x200000 +#: The baseline Hancom writes for a line of text *height* tall, by where its paragraph sets the characters in +#: their lines (``hh:align``'s ``vertical``). +_BASELINES = { + "BASELINE": lambda height: (height * 85 + 50) // 100, + "TOP": lambda height: 0, + "CENTER": lambda height: height // 2, + "BOTTOM": lambda height: height, +} #: The faces whose glyphs Hancom Office 2024 was seen to lay out at the advances FormFit gives them, on the #: desktop: a face Hancom may draw in another (one not installed, a name no table lists) is left for Hancom. _SURE_FACES = frozenset({ @@ -181,14 +190,15 @@ def _table_line(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[str return None if not _cells_sure(measure, table): # the rows are as tall as their cells' lines: those must be sure too return None - if not _on_the_baseline(measure, paragraph.get("paraPrIDRef")): + if _vertical_align(measure, paragraph.get("paraPrIDRef")) != "BASELINE": return None - if table.find(f"{HP}caption") is not None: # the line holds the caption too: left for Hancom + if not _caption_sure(measure, table): return None shape = measure.shape(paragraph.get("paraPrIDRef")) size = max(measure.char_height(run.get("charPrIDRef")) for run in runs) # spaced from its largest characters rows = _pages._inline_table_height(measure, table) - tall = rows + _pages._extent_margins(table) + # a caption above or below the table takes its lines and its gap in the line too, one beside it none + tall = rows + _pages._extent_margins(table) + sum(_pages._caption(measure, table)) advance = _pages._object_pitch(shape, table, tall, size) table.find(f"{HP}sz").set("height", str(rows)) room = width - shape.left - shape.right @@ -271,7 +281,8 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, return None headed = measure.headed(paragraph) shape = measure.shape(paragraph.get("paraPrIDRef")) - if shape.font_line or not _on_the_baseline(measure, paragraph.get("paraPrIDRef")): + vertical = _vertical_align(measure, paragraph.get("paraPrIDRef")) + if shape.font_line or vertical not in _BASELINES: return None size, refs, sizes = _pages._text_size(measure, runs) end = _pages._end_size(measure, runs, sizes) @@ -316,13 +327,15 @@ def lay_out(room: list[float]) -> list[int]: if end and line == len(starts) - 1: drawn.append(end) # the paragraph's end, larger than its text: the last line's text is as tall text_height = max(drawn) if drawn else height # a line break larger than the text makes the line taller + if vertical == "CENTER" and text_height % 2: + return None # half an odd text height: which way Hancom rounds it was not seen takes_indent = (indented and (shape.indent > 0 if line == 0 else shape.indent < 0)) or (headed and line > 0) segments.append({ "textpos": _hancom_offset(text, start, controls) if line else 0, # the first line starts at 0 "vertpos": 0, "vertsize": height, "textheight": text_height, - "baseline": (text_height * 85 + 50) // 100, + "baseline": _BASELINES[vertical](text_height), "spacing": advance - height, "horzpos": shape.left, # a cell's lines in whole layout units, and never narrower than Hancom's narrowest line @@ -363,19 +376,32 @@ def _sure_of(text: str, starts: list[int], style: Any, looks: list[Any] | None = return all(start in breaks or text[start - 1] == "\n" for start in starts[1:]) -def _on_the_baseline(measure: Any, para_pr_id: str | None) -> bool: - """Whether a paragraph of the shape *para_pr_id* sets its characters on their baseline (``hh:align``'s - ``vertical``, the default): Hancom writes the baseline elsewhere for the others (half the text height for - CENTER, all of it for BOTTOM).""" +def _vertical_align(measure: Any, para_pr_id: str | None) -> str | None: + """Where a paragraph of the shape *para_pr_id* sets its characters in their lines (``hh:align``'s + ``vertical``): BASELINE (the default), TOP, CENTER or BOTTOM; None for a shape not in the header.""" - aligned = getattr(measure, "_line_cache_baseline", None) + aligned = getattr(measure, "_line_cache_vertical", None) if aligned is None: aligned = {} for shape in measure._header.iter(f"{HH}paraPr"): align = shape.find(f"{HH}align") - aligned[shape.get("id")] = align is None or align.get("vertical", "BASELINE") == "BASELINE" - measure._line_cache_baseline = aligned - return aligned.get(para_pr_id, False) + aligned[shape.get("id")] = "BASELINE" if align is None else align.get("vertical", "BASELINE") + measure._line_cache_vertical = aligned + return aligned.get(para_pr_id) + + +def _caption_sure(measure: Any, table: Any) -> bool: + """Whether *table*'s caption, if any, takes room Hancom lays out surely: none beside the table, and above or + below it only when every paragraph of the caption is one this module lays out.""" + + caption = table.find(f"{HP}caption") + if caption is None or caption.get("side") in ("LEFT", "RIGHT"): + return True + if caption.get("side") not in ("TOP", "BOTTOM"): + return False + width = int(caption.get("lastWidth", 0)) or int(table.find(f"{HP}sz").get("width", 0)) + paragraphs = caption.findall(f"{HP}subList/{HP}p") + return bool(paragraphs) and all(_lines(measure, p, width, first=False, cell=True) is not None for p in paragraphs) def _taller_past_a_break(text: str, sizes: list[int], starts: list[int], diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 3698b2b8..810ddc54 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -80,21 +80,27 @@ def test_a_paragraph_holding_a_table_gets_one_line_as_tall_as_it() -> None: assert int(line["vertsize"]) == int(table.find(f"{HP}sz").get("height")) > 282 * 2 -def test_a_table_with_a_caption_keeps_its_line_for_hancom() -> None: - # The line of a table with a caption holds the caption too: left for Hancom. +@pytest.mark.parametrize(("side", "gap", "extra"), [("TOP", 850, 1000 + 850), ("BOTTOM", 566, 1000 + 566), + ("LEFT", 850, 0), ("RIGHT", 850, 0)]) +def test_a_table_with_a_caption_holds_it_in_its_line(side: str, gap: int, extra: int) -> None: + # A caption above or below the table takes its line (1000 for one line of 10 pt) and its gap in the table's + # line, as Hancom saves it; one beside the table takes none. document = HwpxDocument.new() table = document.add_table(2, 2) - table.set_caption("표 1") + table.set_caption("표 1", side=side, gap=gap) write_line_caches(document) - assert not _segments(_body(document)[-1]) + [line] = _segments(_body(document)[-1]) + rows = int(_body(document)[-1].find(f"{HP}run/{HP}tbl/{HP}sz").get("height")) + assert int(line["vertsize"]) == rows + extra -@pytest.mark.parametrize("vertical", ["CENTER", "BOTTOM", "TOP"]) -def test_a_paragraph_not_set_on_its_baseline_is_left_for_hancom(vertical: str) -> None: - # Hancom writes another baseline for characters aligned to the centre (half the text height) or the bottom - # (all of it) of their line. +@pytest.mark.parametrize(("vertical", "baseline"), [("BASELINE", "850"), ("TOP", "0"), ("CENTER", "500"), + ("BOTTOM", "1000")]) +def test_a_paragraph_gets_the_baseline_of_where_it_sets_its_characters(vertical: str, baseline: str) -> None: + # Hancom writes the baseline at 85 % of the text height for characters on their baseline, 0 at the top of + # their line, half of it at the centre and all of it at the bottom. document = HwpxDocument.new() paragraph = document.add_paragraph("가나다") document.styles.apply_paragraph_format(paragraphs=[paragraph], alignment="LEFT") @@ -104,7 +110,8 @@ def test_a_paragraph_not_set_on_its_baseline_is_left_for_hancom(vertical: str) - report = write_line_caches(document) - assert report.written == 0 and report.skipped == 1 + assert report.written == 1 + assert _segments(_body(document)[-1])[0]["baseline"] == baseline def test_a_larger_paragraph_end_makes_the_last_lines_text_as_tall() -> None: From c217245a555c0af87e885ebb1b9ee25fbf239168 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:01:02 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat(layout):=20leave=20the=20=ED=95=9C?= =?UTF-8?q?=EC=96=91=20faces=20for=20Hancom=20in=20line=20caches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One public document was seen to have Hancom Office draw its 한양신명조 and 한양견고딕 wider than in other documents (thirty syllables on a line where FormFit fits thirty-two), for a reason not yet known; the same faces lay out as FormFit does elsewhere. Until that is understood, paragraphs in the 한양 faces are left for Hancom. The table cell fixture, set in 한양신명조, allows the face in its own test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/hwpx/layout/line_cache.py | 5 +++-- tests/test_layout_line_cache.py | 7 +++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b008773..5766b419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ - 글자 크기보다 큰 빈 run으로 끝나는 문단은 마지막 줄의 글 높이도 그 크기로 쓴다. 기준선은 문단 모양의 세로 정렬대로 글꼴 기준이면 글 높이의 85 %, 위면 0, 가운데면 절반, 아래면 글 높이 그대로 쓴다. - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 한컴오피스 2024 - 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·한양·HCI)이 + 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·HCI)이 아니거나, 장평 80~110 %·자간 −10~5 %·최소 공백 20 % 밖이거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 쪽 컨트롤이 글자 뒤에 있는 문단, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 31c240c2..237c4a1d 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -88,11 +88,12 @@ "BOTTOM": lambda height: height, } #: The faces whose glyphs Hancom Office 2024 was seen to lay out at the advances FormFit gives them, on the -#: desktop: a face Hancom may draw in another (one not installed, a name no table lists) is left for Hancom. +#: desktop: a face Hancom may draw in another (one not installed, a name no table lists) is left for Hancom. The +#: 한양 faces are left too: one document was seen to draw them wider than in others, for a reason not yet known. _SURE_FACES = frozenset({ "함초롬바탕", "함초롬돋움", "맑은 고딕", "바탕", "바탕체", "돋움", "돋움체", "굴림", "굴림체", "궁서", "궁서체", "한컴 고딕", "HY헤드라인M", "HY견고딕", "HY울릉도M", "HY견명조", "HY동녘M", "휴먼명조", "휴먼고딕", "휴먼옛체", - "한양중고딕", "한양신명조", "한양견고딕", "HCI Poppy", "HCI Hollyhock", + "HCI Poppy", "HCI Hollyhock", }) #: The 장평 (%), 자간 (%) and 최소 공백 (%) Hancom was seen to lay lines out at as FormFit does, on the desktop. _SURE_RATIOS = (80, 110) diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 810ddc54..1f7ca0ac 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -8,6 +8,7 @@ import pytest from hwpx import HwpxDocument +from hwpx.layout import line_cache from hwpx.layout.line_cache import write_line_caches HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" @@ -186,9 +187,11 @@ def _cell_paragraphs(document: HwpxDocument) -> list: for p in cell.findall(f"{HP}subList/{HP}p")] -def test_the_caches_written_in_table_cells_are_the_ones_hancom_wrote() -> None: +def test_the_caches_written_in_table_cells_are_the_ones_hancom_wrote(monkeypatch: pytest.MonkeyPatch) -> None: # Hancom laid out a form of several tables from python-hwpx's cells without a cache: each cell's lines go - # down from the top of its text area, as wide as the area in whole layout units. + # down from the top of its text area, as wide as the area in whole layout units. Its cells are set in + # 한양신명조, which write_line_caches leaves for now: allowed here, where the cells' places are what is tested. + monkeypatch.setattr(line_cache, "_SURE_FACES", line_cache._SURE_FACES | {"한양신명조"}) hancom = HwpxDocument.open(FIXTURES / "line_cache_table_cells.hwpx") document = HwpxDocument.open(FIXTURES / "line_cache_table_cells.hwpx") for paragraph in document.oxml.sections[0].element.iter(f"{HP}p"): From c5530c452829a570881cb77e35bfc5c12ab05e96 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:07 +0900 Subject: [PATCH 08/10] feat(layout): leave bold characters in faces Hancom draws wider when bold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 한양 faces were left for Hancom because one document drew them wider. The cause was bold: Hancom Office draws bold 한양신명조 about a twentieth of an em wider than regular, while bold characters in 함초롬, 맑은 고딕, 굴림, 바탕 and 돋움 keep their regular advances. The 한양 faces get caches again; a paragraph holding bold characters in any other face is left for Hancom. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++-- src/hwpx/layout/line_cache.py | 27 +++++++++++++++++++++++---- tests/test_layout_line_cache.py | 12 +++++------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5766b419..7c4b9155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,9 @@ - 글자 크기보다 큰 빈 run으로 끝나는 문단은 마지막 줄의 글 높이도 그 크기로 쓴다. 기준선은 문단 모양의 세로 정렬대로 글꼴 기준이면 글 높이의 85 %, 위면 0, 가운데면 절반, 아래면 글 높이 그대로 쓴다. - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 한컴오피스 2024 - 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·HCI)이 - 아니거나, 장평 80~110 %·자간 −10~5 %·최소 공백 20 % 밖이거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 쪽 + 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·한양·HCI)이 + 아니거나, 굵은 글자가 함초롬·맑은 고딕·굴림·바탕·돋움 밖의 글꼴이거나(한/글은 한양신명조 같은 글꼴의 굵은 글자를 더 + 넓게 그린다), 장평 80~110 %·자간 −10~5 %·최소 공백 20 % 밖이거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 쪽 컨트롤이 글자 뒤에 있는 문단, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 237c4a1d..9db2c289 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -88,13 +88,15 @@ "BOTTOM": lambda height: height, } #: The faces whose glyphs Hancom Office 2024 was seen to lay out at the advances FormFit gives them, on the -#: desktop: a face Hancom may draw in another (one not installed, a name no table lists) is left for Hancom. The -#: 한양 faces are left too: one document was seen to draw them wider than in others, for a reason not yet known. +#: desktop: a face Hancom may draw in another (one not installed, a name no table lists) is left for Hancom. _SURE_FACES = frozenset({ "함초롬바탕", "함초롬돋움", "맑은 고딕", "바탕", "바탕체", "돋움", "돋움체", "굴림", "굴림체", "궁서", "궁서체", "한컴 고딕", "HY헤드라인M", "HY견고딕", "HY울릉도M", "HY견명조", "HY동녘M", "휴먼명조", "휴먼고딕", "휴먼옛체", - "HCI Poppy", "HCI Hollyhock", + "한양중고딕", "한양신명조", "한양견고딕", "HCI Poppy", "HCI Hollyhock", }) +#: The faces Hancom was seen to lay bold characters out in at their regular advances. Others it may draw wider +#: when bold (한양신명조 bold, for one, is about a twentieth of an em wider): bold characters in them are left. +_BOLD_SURE_FACES = frozenset({"함초롬바탕", "함초롬돋움", "맑은 고딕", "굴림", "바탕", "돋움"}) #: The 장평 (%), 자간 (%) and 최소 공백 (%) Hancom was seen to lay lines out at as FormFit does, on the desktop. _SURE_RATIOS = (80, 110) _SURE_SPACINGS = (-10, 5) @@ -283,7 +285,7 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, headed = measure.headed(paragraph) shape = measure.shape(paragraph.get("paraPrIDRef")) vertical = _vertical_align(measure, paragraph.get("paraPrIDRef")) - if shape.font_line or vertical not in _BASELINES: + if shape.font_line or vertical not in _BASELINES or not _bold_sure(measure, paragraph, runs): return None size, refs, sizes = _pages._text_size(measure, runs) end = _pages._end_size(measure, runs, sizes) @@ -377,6 +379,23 @@ def _sure_of(text: str, starts: list[int], style: Any, looks: list[Any] | None = return all(start in breaks or text[start - 1] == "\n" for start in starts[1:]) +def _bold_sure(measure: Any, paragraph: Any, runs: list[Any]) -> bool: + """Whether every bold run of *paragraph* holding text is set in faces of :data:`_BOLD_SURE_FACES`.""" + + bold = getattr(measure, "_line_cache_bold", None) + if bold is None: + bold = {shape.get("id"): shape.find(f"{HH}bold") is not None for shape in measure._header.iter(f"{HH}charPr")} + measure._line_cache_bold = bold + for run in runs: + ref = run.get("charPrIDRef") + if not bold.get(ref) or not _pages._run_text([run]): + continue + style = measure.style(paragraph.get("paraPrIDRef"), [ref], paragraph) + if style is None or style.hangul_face not in _BOLD_SURE_FACES or style.glyph_face not in _BOLD_SURE_FACES: + return False + return True + + def _vertical_align(measure: Any, para_pr_id: str | None) -> str | None: """Where a paragraph of the shape *para_pr_id* sets its characters in their lines (``hh:align``'s ``vertical``): BASELINE (the default), TOP, CENTER or BOTTOM; None for a shape not in the header.""" diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 1f7ca0ac..0fcd7095 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -8,7 +8,6 @@ import pytest from hwpx import HwpxDocument -from hwpx.layout import line_cache from hwpx.layout.line_cache import write_line_caches HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" @@ -130,7 +129,7 @@ def test_a_larger_paragraph_end_makes_the_last_lines_text_as_tall() -> None: @pytest.mark.parametrize("style", [{"font": "HY신명조"}, {"font": "KoPub바탕체 Light"}, {"letter_spacing": -17}, - {"ratio": 70}]) + {"ratio": 70}, {"font": "한양신명조", "bold": True}]) def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hancom(style: dict) -> None: # A face Hancom may draw in another one, or 장평 and 자간 past those seen on the desktop, are left for Hancom. document = HwpxDocument.new() @@ -142,7 +141,8 @@ def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hanco assert report.written == 0 and report.skipped == 1 -@pytest.mark.parametrize("style", [{"letter_spacing": -5}, {"ratio": 90}, {"ratio": 110, "letter_spacing": 5}]) +@pytest.mark.parametrize("style", [{"letter_spacing": -5}, {"ratio": 90}, {"ratio": 110, "letter_spacing": 5}, + {"font": "한양신명조"}, {"font": "맑은 고딕", "bold": True}]) def test_a_paragraph_at_a_look_seen_on_the_desktop_gets_a_cache(style: dict) -> None: document = HwpxDocument.new() paragraph = document.add_paragraph("", include_run=False) @@ -187,11 +187,9 @@ def _cell_paragraphs(document: HwpxDocument) -> list: for p in cell.findall(f"{HP}subList/{HP}p")] -def test_the_caches_written_in_table_cells_are_the_ones_hancom_wrote(monkeypatch: pytest.MonkeyPatch) -> None: +def test_the_caches_written_in_table_cells_are_the_ones_hancom_wrote() -> None: # Hancom laid out a form of several tables from python-hwpx's cells without a cache: each cell's lines go - # down from the top of its text area, as wide as the area in whole layout units. Its cells are set in - # 한양신명조, which write_line_caches leaves for now: allowed here, where the cells' places are what is tested. - monkeypatch.setattr(line_cache, "_SURE_FACES", line_cache._SURE_FACES | {"한양신명조"}) + # down from the top of its text area, as wide as the area in whole layout units. hancom = HwpxDocument.open(FIXTURES / "line_cache_table_cells.hwpx") document = HwpxDocument.open(FIXTURES / "line_cache_table_cells.hwpx") for paragraph in document.oxml.sections[0].element.iter(f"{HP}p"): From c2f77bcce2430d79d52bc6e829edef0fa52bde03 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:17:47 +0900 Subject: [PATCH 09/10] feat(layout): write line caches for bold characters in faces that keep their advances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hancom Office lays bold characters out at their regular advances in every face line caches take but the three 한양 faces, which it draws a twentieth of an em wider when bold, and HCI Hollyhock, some of whose glyphs it draws wider. Bold characters in the others now get caches. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- src/hwpx/layout/line_cache.py | 6 +++--- tests/test_layout_line_cache.py | 6 ++++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c4b9155..60dcad1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,8 @@ 글꼴 기준이면 글 높이의 85 %, 위면 0, 가운데면 절반, 아래면 글 높이 그대로 쓴다. - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 한컴오피스 2024 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·한양·HCI)이 - 아니거나, 굵은 글자가 함초롬·맑은 고딕·굴림·바탕·돋움 밖의 글꼴이거나(한/글은 한양신명조 같은 글꼴의 굵은 글자를 더 - 넓게 그린다), 장평 80~110 %·자간 −10~5 %·최소 공백 20 % 밖이거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 쪽 + 아니거나, 굵은 글자가 한양 글꼴·HCI Hollyhock이거나(한/글은 한양 글꼴의 굵은 글자를 1/20 em 넓게 그린다), + 장평 80~110 %·자간 −10~5 %·최소 공백 20 % 밖이거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 쪽 컨트롤이 글자 뒤에 있는 문단, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 9db2c289..c2241cd8 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -94,9 +94,9 @@ "한컴 고딕", "HY헤드라인M", "HY견고딕", "HY울릉도M", "HY견명조", "HY동녘M", "휴먼명조", "휴먼고딕", "휴먼옛체", "한양중고딕", "한양신명조", "한양견고딕", "HCI Poppy", "HCI Hollyhock", }) -#: The faces Hancom was seen to lay bold characters out in at their regular advances. Others it may draw wider -#: when bold (한양신명조 bold, for one, is about a twentieth of an em wider): bold characters in them are left. -_BOLD_SURE_FACES = frozenset({"함초롬바탕", "함초롬돋움", "맑은 고딕", "굴림", "바탕", "돋움"}) +#: The faces Hancom was seen to lay bold characters out in at their regular advances. The 한양 faces it draws a +#: twentieth of an em wider when bold, and some glyphs of HCI Hollyhock: bold characters in them are left. +_BOLD_SURE_FACES = _SURE_FACES - {"한양중고딕", "한양신명조", "한양견고딕", "HCI Hollyhock"} #: The 장평 (%), 자간 (%) and 최소 공백 (%) Hancom was seen to lay lines out at as FormFit does, on the desktop. _SURE_RATIOS = (80, 110) _SURE_SPACINGS = (-10, 5) diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 0fcd7095..8c0fde1e 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -129,7 +129,8 @@ def test_a_larger_paragraph_end_makes_the_last_lines_text_as_tall() -> None: @pytest.mark.parametrize("style", [{"font": "HY신명조"}, {"font": "KoPub바탕체 Light"}, {"letter_spacing": -17}, - {"ratio": 70}, {"font": "한양신명조", "bold": True}]) + {"ratio": 70}, {"font": "한양신명조", "bold": True}, + {"font": "한양견고딕", "bold": True}]) def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hancom(style: dict) -> None: # A face Hancom may draw in another one, or 장평 and 자간 past those seen on the desktop, are left for Hancom. document = HwpxDocument.new() @@ -142,7 +143,8 @@ def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hanco @pytest.mark.parametrize("style", [{"letter_spacing": -5}, {"ratio": 90}, {"ratio": 110, "letter_spacing": 5}, - {"font": "한양신명조"}, {"font": "맑은 고딕", "bold": True}]) + {"font": "한양신명조"}, {"font": "맑은 고딕", "bold": True}, + {"font": "휴먼명조", "bold": True}]) def test_a_paragraph_at_a_look_seen_on_the_desktop_gets_a_cache(style: dict) -> None: document = HwpxDocument.new() paragraph = document.add_paragraph("", include_run=False) From f4c1b40e707f1fbd261d5b6eeadbdb13c47cd9e6 Mon Sep 17 00:00:00 2001 From: airmang <38392618+airmang@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:36:17 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat(layout):=20write=20line=20caches=20a?= =?UTF-8?q?t=20the=20wider=20=EC=9E=A5=ED=8F=89,=20=EC=9E=90=EA=B0=84=20an?= =?UTF-8?q?d=20=EC=B5=9C=EC=86=8C=20=EA=B3=B5=EB=B0=B1=20seen=20on=20the?= =?UTF-8?q?=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hancom Office was seen to lay glyphs out as FormFit does at 장평 50 to 200 % and 자간 -50 to 50 %, and lines as it does at 장평 50 to 200 %, 자간 -30 to 30 % and 최소 공백 up to 50 %: such paragraphs get caches. A no-break space in a paragraph with 최소 공백 or 자간 is left for Hancom (it takes no 자간, and one among shrinking spaces was seen to break a line elsewhere). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++-- src/hwpx/layout/line_cache.py | 9 ++++++--- tests/test_layout_line_cache.py | 20 ++++++++++++++++++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60dcad1e..d652ed1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,9 @@ - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 한컴오피스 2024 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·한양·HCI)이 아니거나, 굵은 글자가 한양 글꼴·HCI Hollyhock이거나(한/글은 한양 글꼴의 굵은 글자를 1/20 em 넓게 그린다), - 장평 80~110 %·자간 −10~5 %·최소 공백 20 % 밖이거나, 한글 음절과 확인한 기호 밖의 글자가 든 문단, 쪽 - 컨트롤이 글자 뒤에 있는 문단, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단, 줄 폭을 100 HWPUNIT + 장평 50~200 %·자간 −30~30 %·최소 공백 50 % 밖이거나, 최소 공백이나 자간이 있는 문단에 묶음 빈칸이 있거나, 한글 음절과 + 확인한 기호 밖의 글자가 든 문단, 쪽 컨트롤이 글자 뒤에 있는 문단, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 + 문단, 줄 폭을 100 HWPUNIT 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. - 나머지 문단과 이미 배치 정보가 있는 문단은 그대로 두어 한/글이 열 때 배치한다. 쪽 수 추정이 문서를 따르지 못하면 diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index c2241cd8..32fa587a 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -98,9 +98,9 @@ #: twentieth of an em wider when bold, and some glyphs of HCI Hollyhock: bold characters in them are left. _BOLD_SURE_FACES = _SURE_FACES - {"한양중고딕", "한양신명조", "한양견고딕", "HCI Hollyhock"} #: The 장평 (%), 자간 (%) and 최소 공백 (%) Hancom was seen to lay lines out at as FormFit does, on the desktop. -_SURE_RATIOS = (80, 110) -_SURE_SPACINGS = (-10, 5) -_SURE_CONDENSE = 20 +_SURE_RATIOS = (50, 200) +_SURE_SPACINGS = (-30, 30) +_SURE_CONDENSE = 50 #: The glyphs other than Hangul syllables whose advances were seen on the desktop in those faces. _SURE_GLYPHS = frozenset(_GLYPHS) #: How much narrower or wider (HWPUNIT) every line may be laid out and still break at the same places for its @@ -361,6 +361,9 @@ def _sure_of(text: str, starts: list[int], style: Any, looks: list[Any] | None = if style is None or style.condense > _SURE_CONDENSE: return False + if " " in text and (style.condense or any( + look.spacing or any(spacing for _, _, spacing in look.scripts) for look in set(looks or [style]))): + return False # a no-break space among spaces that shrink, or with 자간 (it takes none), is left for look in set(looks or [style]): if any(not _SURE_RATIOS[0] <= ratio <= _SURE_RATIOS[1] or not _SURE_SPACINGS[0] <= spacing <= _SURE_SPACINGS[1] for ratio, spacing in [(look.ratio, look.spacing), *((r, sp) for _, r, sp in look.scripts)]): diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 8c0fde1e..9060eb8a 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -128,8 +128,8 @@ def test_a_larger_paragraph_end_makes_the_last_lines_text_as_tall() -> None: assert (line["vertsize"], line["textheight"], line["baseline"]) == ("1600", "1600", "1360") -@pytest.mark.parametrize("style", [{"font": "HY신명조"}, {"font": "KoPub바탕체 Light"}, {"letter_spacing": -17}, - {"ratio": 70}, {"font": "한양신명조", "bold": True}, +@pytest.mark.parametrize("style", [{"font": "HY신명조"}, {"font": "KoPub바탕체 Light"}, {"letter_spacing": -40}, + {"ratio": 210}, {"font": "한양신명조", "bold": True}, {"font": "한양견고딕", "bold": True}]) def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hancom(style: dict) -> None: # A face Hancom may draw in another one, or 장평 and 자간 past those seen on the desktop, are left for Hancom. @@ -174,6 +174,22 @@ def test_a_page_control_among_the_text_is_left_for_hancom() -> None: assert report.written == 0 and report.skipped == 1 +@pytest.mark.parametrize(("condense", "spacing", "written"), [("0", 0, 1), ("10", 0, 0), ("0", -10, 0)]) +def test_a_no_break_space_among_spaces_that_shrink_is_left_for_hancom(condense: str, spacing: int, written: int) -> None: + # A no-break space in a paragraph with 최소 공백 was seen to break a line elsewhere than FormFit, and one with + # 자간 takes none of it: such paragraphs are left for Hancom. + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + paragraph.add_run("가나 다라 마바", char_pr_id_ref=document.styles.ensure_run(letter_spacing=spacing)) + shape_id = _body(document)[-1].get("paraPrIDRef") + next(el for el in document.oxml.headers[0].element.iter(f"{HH}paraPr") if el.get("id") == shape_id).set( + "condense", condense) + + report = write_line_caches(document) + + assert report.written == written + + def test_a_glyph_not_seen_on_the_desktop_is_left_for_hancom() -> None: # "「" (U+FF62) has a table advance read from the font, not one seen on the desktop. document = HwpxDocument.new()