Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@

### 고침

- 쪽 수 추정(실험, `estimate_pages`)이 `hp:t` 글 안에 글자로 든 전각 공백(U+3000)을 고정폭 빈칸(`hp:fwSpace`)으로
보던 것을 고친다(python-hwpx는 둘을 모두 U+3000으로 읽는다). 글자 전각 공백은 글꼴의 폭(대개 글자 크기만큼)을
차지하고, 그 뒤에서 줄이 나뉘며, 줄 끝에서는 몇 개가 이어져도 모두 매달린다. 전에는 1/4 폭의 고정폭 빈칸으로
재어 줄을 늦게 나누었다.
- FormFit은 고정폭 빈칸이 낱말 바로 뒤라도 여백에서(또는 넘어서) 시작하면 다음 줄로 넘긴다. 전에는 낱말이
여백에 딱 맞을 때 그 뒤 고정폭 빈칸을 줄 끝에 매달았다.
- 쪽 수 추정(실험, `estimate_pages`)이 글 문단의 첫 줄에서 아래로 띄운 흐르는 위아래 표를 쪽 끝에서도 따른다.
- 그 줄이 쪽 바닥 위에 들어가지 않으면 줄과 표가 함께 다음 쪽으로 가고, 표는 거기서 띄운 만큼 아래에서 흐른다.
- 줄은 들어가는데 표 윗선이 바닥을 넘거나, 행 단위로 옮기는(TABLE) 표의 첫 행이 들어가지 않으면, 표만 다음 쪽 맨
Expand Down
38 changes: 30 additions & 8 deletions src/hwpx/form_fit/measure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
8 changes: 5 additions & 3 deletions src/hwpx/layout/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down
Binary file not shown.
24 changes: 24 additions & 0 deletions tests/test_layout_page_estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading