diff --git a/CHANGELOG.md b/CHANGELOG.md index e08c16d2..aca8cc54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ ### 고침 +- 쪽 수 추정(실험, `estimate_pages`)이 `hp:t` 글 안에 글자로 든 전각 공백(U+3000)을 고정폭 빈칸(`hp:fwSpace`)으로 + 보던 것을 고친다(python-hwpx는 둘을 모두 U+3000으로 읽는다). 글자 전각 공백은 글꼴의 폭(대개 글자 크기만큼)을 + 차지하고, 그 뒤에서 줄이 나뉘며, 줄 끝에서는 몇 개가 이어져도 모두 매달린다. 전에는 1/4 폭의 고정폭 빈칸으로 + 재어 줄을 늦게 나누었다. + - FormFit은 고정폭 빈칸이 낱말 바로 뒤라도 여백에서(또는 넘어서) 시작하면 다음 줄로 넘긴다. 전에는 낱말이 + 여백에 딱 맞을 때 그 뒤 고정폭 빈칸을 줄 끝에 매달았다. - 쪽 수 추정(실험, `estimate_pages`)이 글 문단의 첫 줄에서 아래로 띄운 흐르는 위아래 표를 쪽 끝에서도 따른다. - 그 줄이 쪽 바닥 위에 들어가지 않으면 줄과 표가 함께 다음 쪽으로 가고, 표는 거기서 띄운 만큼 아래에서 흐른다. - 줄은 들어가는데 표 윗선이 바닥을 넘거나, 행 단위로 옮기는(TABLE) 표의 첫 행이 들어가지 않으면, 표만 다음 쪽 맨 diff --git a/src/hwpx/form_fit/measure.py b/src/hwpx/form_fit/measure.py index 9ee2b63b..f4eaf7b0 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -102,12 +102,19 @@ # --- Hancom line layout rules (no font file needed) --------------------------- # #: Spaces that hang past the right margin at a line end; a line never starts -#: with one: the space and the fixed-width space (고정폭 빈칸, U+3000). A no-break -#: space (묶음 빈칸, U+00A0) does neither: it is a half-em space like the space -#: that keeps the words beside it on one line. -_HANGING_SPACES = " \u3000" +#: with one: the space, the fixed-width space (고정폭 빈칸, U+3000) and a typed +#: ideographic space (see ``_GLYPH_SPACE``). A no-break space (묶음 빈칸, U+00A0) +#: does neither: it is a half-em space like the space that keeps the words +#: beside it on one line. +_HANGING_SPACES = " \u3000\ufdd0" #: The fixed-width space: a quarter of the em, whatever the 장평, 자간 or 최소 공백. _FIXED_SPACE = "\u3000" +#: An ideographic space typed as a character (U+3000 in the text of ``hp:t``; +#: python-hwpx reads ``hp:fwSpace`` as U+3000 too), as the page estimate passes it: +#: the glyph U+3000 at the face's advance, which Hancom may start a line after +#: and which hangs at a line end however many follow each other (after a word, +#: only the first fixed-width space or space hangs). +_GLYPH_SPACE = "\ufdd0" #: The spaces Hancom makes half an em wide (unless the font's own space is used). _HALF_EM_SPACES = frozenset(" \u00a0") #: Closing punctuation that never starts a line; it moves down with the @@ -534,11 +541,13 @@ def char_advance(ch: str, font_pt: float, style: TextStyle | None = None) -> flo ``_LAYOUT_UNIT``); other glyphs scale their class average. """ + typed = ch == _GLYPH_SPACE + ch = "\u3000" if typed else ch if style is None: return _ADVANCE_EM[classify_char(ch)] * font_pt * 100.0 cls = classify_char(ch) height = round(font_pt * 100.0) - if ch == _FIXED_SPACE: + if ch == _FIXED_SPACE and not typed: return float(height // _LAYOUT_UNIT // 4 * _LAYOUT_UNIT) ratio, spacing = _scaling(ch, style) if ch in _HALF_EM_SPACES and not style.use_font_space: @@ -646,7 +655,9 @@ def hancom_line_starts( ``widths[k]`` is the width of line ``k`` in HWPUNIT (the last one repeats). A line takes characters while they fit (the last one without its 자간); the space right after a word hangs past the margin, and a further space - that starts at or past it begins the next line. With ``style.condense`` the + that starts at or past it begins the next line, as does a fixed-width + space that starts there right after a word; typed ideographic spaces all + hang (see ``_GLYPH_SPACE``). With ``style.condense`` the spaces after the line's first text may shrink by that share of a space without its 자간 to make room for a character; the spaces before it never do. Once the line's text reaches the margin (its 자간 included), its spaces @@ -679,13 +690,14 @@ def hancom_line_starts( fixed = None if advances is None else advances.get(end) advance = char_advance(ch, size, look) if fixed is None else fixed if ch in _HANGING_SPACES: - if used >= width and end > start and text[end - 1] in _HANGING_SPACES and not full: + if used >= width and end > start and not full and _spills(ch, text[end - 1]): spilled = True break if seen and used >= width and text[end - 1] not in _HANGING_SPACES: full = True # the text reached the margin: its spaces hang, the next word starts the next line used += advance - if seen and ch != _FIXED_SPACE: # the spaces before the line's first text never shrink + # the spaces before the line's first text never shrink, nor do fixed-width or typed ideographic ones + if seen and ch not in (_FIXED_SPACE, _GLYPH_SPACE): # a space of another size or style counts as its share of a space at *font_pt* pending += 1 if sizes is None and styles is None else ( char_advance(" ", size, _without_spacing(look)) / space) @@ -721,6 +733,16 @@ def hancom_line_starts( starts.append(start) +def _spills(ch: str, previous: str) -> bool: + """Whether the space *ch*, starting at or past the margin after *previous*, begins the next line: a + fixed-width space does even right after a word, a space only after another space (the first one after a + word hangs), and a typed ideographic space never does: however many follow each other, they hang.""" + + if ch == _GLYPH_SPACE: + return False + return ch == _FIXED_SPACE or previous in _HANGING_SPACES + + @lru_cache(maxsize=256) def _without_spacing(style: TextStyle) -> TextStyle: return replace(style, spacing=0.0, scripts=tuple((name, ratio, 0.0) for name, ratio, _ in style.scripts)) diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index 284ac425..1468ce81 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -146,7 +146,7 @@ from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any -from ..form_fit.measure import (char_advance, hancom_line_starts, indented_widths, paragraph_label, +from ..form_fit.measure import (_GLYPH_SPACE, char_advance, hancom_line_starts, indented_widths, paragraph_label, text_style_from_refs) from ..oxml._document_primitives import _remove_stale_paragraph_layout_cache from ..oxml.namespaces import HH, HP @@ -315,14 +315,16 @@ def _on(flags: dict[str, str], name: str) -> bool: def _t_text(text_element: Any) -> str: """The text of one ``hp:t``, with ``hp:lineBreak`` as a newline, ``hp:tab`` as a tab, ``hp:nbSpace`` as a - no-break space (U+00A0) and ``hp:fwSpace`` as a fixed-width one (U+3000), as python-hwpx reads them.""" + no-break space (U+00A0) and ``hp:fwSpace`` as a fixed-width one (U+3000), as python-hwpx reads them; an + ideographic space typed in the text is FormFit's ``_GLYPH_SPACE``, so it is not taken for a fixed-width one.""" parts = [text_element.text or ""] for child in text_element: name = _local(child) parts.append({"lineBreak": "\n", "tab": "\t", "nbSpace": "\u00a0", "fwSpace": "\u3000"}.get(name, "")) parts.append(child.tail or "") - return "".join(parts) + return "".join(part.replace("\u3000", _GLYPH_SPACE) if index % 2 == 0 else part + for index, part in enumerate(parts)) def _indented(line: float, shape: _Shape, style: Any) -> list[float]: diff --git a/tests/fixtures/hancom_saved/pages_typed_ideographic_spaces.hwpx b/tests/fixtures/hancom_saved/pages_typed_ideographic_spaces.hwpx new file mode 100644 index 00000000..e54a96da Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_typed_ideographic_spaces.hwpx differ diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 42e456a5..a5395122 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -26,6 +26,8 @@ "pages_fixed_width_spaces": 2, # rows of a syllable and a fixed-width space: a quarter em that hangs "pages_no_break_spaces": 2, # rows of "가나" and a no-break space: each is half an em and keeps the row # one word + "pages_typed_ideographic_spaces": 5, # cells of "가나다라마" and a U+3000 typed in the text, a full em + # that hangs however many follow, or fixed-width spaces "pages_empty_run_ending_after_a_larger_first_line": 1, # 20 pt on the first line, 10 pt text on the # last before an empty 14 pt run: 14 pt tall "pages_empty_run_ending_after_larger_text_amid": 1, # the same with the 20 pt run amid the 10 pt text @@ -395,6 +397,28 @@ def test_without_line_caches_formfit_breaks_the_lines_the_same(name: str) -> Non _assert_like_hancom(estimate_pages(_without_caches(data)), data, HANCOM_PAGES[name]) +def test_a_typed_ideographic_space_is_not_a_fixed_width_one() -> None: + # 맑은 고딕 10 pt cells of narrowing widths, laid out and saved by Hancom: rows of "가나다라마" each followed + # by one or two ideographic spaces typed in the text (U+3000), or by fixed-width spaces (hp:fwSpace, which + # python-hwpx reads as U+3000 too). The typed space is a full em, a line may start after it, and at a line + # end it hangs however many follow each other; a fixed-width space is a quarter em, and one starting at or + # past the margin begins the next line, even right after a word. + doc = HwpxDocument.open((FIXTURES / "pages_typed_ideographic_spaces.hwpx").read_bytes()) + cells = list(doc.oxml.sections[0].element.iter(f"{HP}tc")) + typed = 0 + for cell in cells: + paragraph = cell.find(f"{HP}subList/{HP}p") + runs = paragraph.findall(f"{HP}run") + ref = next(run.get("charPrIDRef") for run in runs if run.find(f"{HP}t") is not None) + style = page_layout.text_style_from_refs(doc.oxml, paragraph.get("paraPrIDRef"), [ref]) + segs = paragraph.findall(f"{HP}linesegarray/{HP}lineseg") + widths = [int(seg.get("horzsize")) for seg in segs] + starts = page_layout.hancom_line_starts(page_layout._run_text(runs), widths, 10, style) + assert starts == [int(seg.get("textpos")) for seg in segs], widths[0] + typed += "\u3000" in "".join(t.text or "" for t in paragraph.iter(f"{HP}t")) + assert (len(cells), typed) == (30, 22) + + def test_a_header_wrapped_square_with_room_beside_it_is_not_followed() -> None: # The exam header 3000 narrower: a line fits beside it, and the lines reaching it are not followed. out = io.BytesIO()