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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@

### 고침

- FormFit과 쪽 수 추정이 탭을 한/글처럼 놓는다.
- 탭은 단(셀이면 셀 글 영역)의 왼쪽 끝에서 4000 HWPUNIT마다 있는 기본 탭 위치 가운데 지금 위치 다음 것까지 간다.
줄은 문단 왼쪽 여백에서, 첫 줄은 들여쓰기만큼 더 들어가 시작한다(내어쓰기는 둘째 줄부터).
- 탭의 앞뒤 어디서나 줄이 바뀔 수 있다.
- 전에는 탭을 공백 크기 글자처럼 재서, 탭이 있는 문단의 줄 나눔이 한/글과 달랐다.
- 탭 정의에 자기 탭 위치나 자동 탭이 있는 문단은 전처럼 잰다.
- 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로
재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다.
- `.hwp`를 열 때 그림의 투명도와 반전을 한/글이 읽는 자리에서 읽는다. 한/글은 그림 레코드의 투명도를 그림 원래 크기
뒤 마지막 바이트에서 읽고, 바이너리 항목 번호 뒤 바이트는 읽지 않는다(저장할 때 0으로 쓴다). 전에는 그 바이트를
투명도로 옮겨, 거기에 255가 든 문서의 그림이 HWPX에서 보이지 않았다. 반전(`hp:pic@reverse`)은 그림 개체 속성의
Expand Down
48 changes: 44 additions & 4 deletions src/hwpx/form_fit/measure.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@
# and rounded. A face or glyph listed nowhere falls back to the class averages,
# unrounded.
_LAYOUT_UNIT = 4
#: Hancom's default tab stops, this far apart (HWPUNIT) from the column's left edge (the section's
#: ``tabStopVal``).
_DEFAULT_TAB_STOP = 4000

_HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
_HH = "{http://www.hancom.co.kr/hwpml/2011/head}"
Expand Down Expand Up @@ -1196,7 +1199,10 @@ class TextStyle:
label takes before the text (see :func:`label_room`); it comes off the first
line, and with ``head_hangs`` (자동 내어 쓰기) off every line, the indent then
only off the others (see :func:`indented_widths`). ``space_before`` (HWPUNIT) is room above the first
line; the spacing after the paragraph takes no room in a cell. ``hangul_face``
line; the spacing after the paragraph takes no room in a cell. A tab reaches Hancom's next default tab
stop, every ``tab_stop`` HWPUNIT from the column's (a cell's: its text area's) left edge, the line
starting at ``margin_left`` and its indent or label room; ``tab_stop`` 0 (a paragraph whose tab
definition sets stops of its own or automatic tabs) lays a tab out as a space-class glyph. ``hangul_face``
and ``glyph_face`` name the faces whose design advances Hangul syllables
and the other glyphs take, laid out as Hancom rounds them (see
:func:`char_advance`); an empty or unlisted face, or a glyph it does not
Expand All @@ -1220,6 +1226,25 @@ class TextStyle:
scripts: tuple[tuple[str, float, float], ...] = ()
head: float = 0.0
head_hangs: bool = False
tab_stop: int = _DEFAULT_TAB_STOP


def _line_origin(style: TextStyle, first: bool) -> float:
"""Where a line starts from the column's left edge, the room :func:`indented_widths` takes off it before
its text: the left margin, then the first-line indent and the label's room (a hanging indent off the
other lines)."""

if style.head and style.head_hangs:
room = style.head if first else style.head - style.indent
else:
room = max(style.indent, 0) + style.head if first else max(-style.indent, 0)
return style.margin_left + room


def _tab_advance(x: float, stop: int) -> float:
"""How far a tab at *x* (from the column's left edge) reaches: to the next default tab stop past it."""

return (math.floor(x / stop) + 1) * stop - x


def indented_widths(line: float, style: TextStyle) -> tuple[float, float]:
Expand Down Expand Up @@ -1415,8 +1440,8 @@ def _hancom_break_opportunities(text: str, style: TextStyle) -> set[int]:
prev, cur = text[index - 1], text[index]
if cur in _HANGING_SPACES:
continue
if prev in _HANGING_SPACES:
opportunities.add(index)
if prev in _HANGING_SPACES or "\t" in (prev, cur): # a line may start after a space, or at either side
opportunities.add(index) # of a tab
continue
if classify_char(prev) in ("hangul", "wide") or classify_char(cur) in ("hangul", "wide"):
if style.break_non_latin_word == "KEEP_WORD":
Expand All @@ -1436,6 +1461,7 @@ def hancom_line_starts(
styles: Sequence[TextStyle] | None = None,
advances: Mapping[int, float] | None = None,
objects: Collection[int] = (),
continued: bool = False,
) -> list[int]:
"""Where Hancom starts each line of the one-line *text* (no newlines).

Expand All @@ -1461,6 +1487,9 @@ def hancom_line_starts(
such as an object set as a character, by their index; *objects* holds
the indexes of those that are objects: the two spaces right after one
hang past the margin, and a further space begins the next line as above.
A tab reaches the next default tab stop (``style.tab_stop``) from the
column's left edge; a line may start on either side of one. *continued*
text follows a line break, so its first line is not the paragraph's.
"""

breaks = _hancom_break_opportunities(text, style)
Expand All @@ -1471,12 +1500,15 @@ def hancom_line_starts(
start = 0
while True:
width = widths[min(len(starts) - 1, len(widths) - 1)]
origin = _line_origin(style, len(starts) == 1 and not continued)
end, used, inner, pending, seen, spilled, full = start, 0.0, 0, 0, False, False, False
while end < length:
ch = text[end]
size = font_pt if sizes is None else sizes[end]
look = style if styles is None else styles[end]
fixed = None if advances is None else advances.get(end)
if fixed is None and ch == "\t" and style.tab_stop:
fixed = _tab_advance(origin + used, style.tab_stop)
advance = char_advance(ch, size, look) if fixed is None else fixed
if ch in _HANGING_SPACES:
if used >= width and end > start and not full and _spills(ch, text[end - 1]) \
Expand Down Expand Up @@ -1550,7 +1582,7 @@ def _hancom_line_count(
total += 1
continue
widths = [first_width, rest_width] if index == 0 else [rest_width]
total += len(hancom_line_starts(line, widths, font_pt, style))
total += len(hancom_line_starts(line, widths, font_pt, style, continued=index > 0))
return max(total, 1)


Expand Down Expand Up @@ -2129,6 +2161,13 @@ def text_style_from_refs(
scripts=scripts,
)
breaks = getattr(prop, "break_setting", None)
tab_stop = _DEFAULT_TAB_STOP
try:
tabs = root.tab_property(getattr(prop, "tab_pr_id_ref", None))
except Exception: # pragma: no cover - defensive
tabs = None
if tabs is not None and (tabs.tab_stops or tabs.auto_tab_left or tabs.auto_tab_right):
tab_stop = 0 # stops of its own, or automatic tabs: not followed
# hp:case carries the HWPUNIT values Hancom lays out with; hp:default doubles them.
switch = getattr(prop, "version_switch", None)
case = getattr(switch, "case", None) if switch is not None else None
Expand All @@ -2149,6 +2188,7 @@ def text_style_from_refs(
glyph_face=glyph_face,
hangul_face=hangul_face,
scripts=scripts,
tab_stop=tab_stop,
)
heading = paragraph_label(root, para_pr_id_ref, label)
if heading is None:
Expand Down
16 changes: 11 additions & 5 deletions src/hwpx/layout/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,9 @@ def paragraph_property(self, para_pr_id: Any) -> Any:
self._paragraph_shapes = self._document.paragraph_properties
return HwpxOxmlHeader._lookup_by_id(self._paragraph_shapes, para_pr_id)

def tab_property(self, tab_pr_id: Any) -> Any:
return self._document.tab_property(tab_pr_id)


class _Measure:
"""Shapes and line breaking of one document."""
Expand Down Expand Up @@ -574,8 +577,9 @@ def lines(self, text: str, widths: list[float], size: int, style: Any) -> int:

if not text:
return 1
return sum(len(hancom_line_starts(line, widths, size / 100, style)) if line else 1
for line in text.split("\n"))
return sum(len(hancom_line_starts(line, widths if not index else widths[1:] or widths, size / 100, style,
continued=bool(index))) if line else 1
for index, line in enumerate(text.split("\n"))) # the lines after a line break are no first line

def line_starts(self, text: str, widths: list[float], size: int, style: Any,
sizes: list[int] | None = None, styles: list[Any] | None = None,
Expand All @@ -586,14 +590,16 @@ def line_starts(self, text: str, widths: list[float], size: int, style: Any,

starts: list[int] = []
base = 0
for line in text.split("\n"):
for number, line in enumerate(text.split("\n")):
points = None if sizes is None else [height / 100 for height in sizes[base:base + len(line)]]
looks = None if styles is None else styles[base:base + len(line)]
fixed = None if advances is None else {
index - base: width for index, width in advances.items() if base <= index < base + len(line)}
placed = {index - base for index in objects if base <= index < base + len(line)}
starts += [base + start for start in (hancom_line_starts(line, widths, size / 100, style, points, looks,
fixed, placed) if line else [0])]
room = widths if not number else widths[1:] or widths # the lines after a line break are no first line
starts += [base + start for start in (hancom_line_starts(line, room, size / 100, style, points, looks,
fixed, placed, continued=bool(number))
if line else [0])]
base += len(line) + 1
return starts

Expand Down
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
14 changes: 14 additions & 0 deletions tests/test_form_fit_hancom_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ def test_the_glyph_ending_a_line_takes_no_spacing() -> None:
assert hancom_line_starts("가나다라마바사", [8300], 10, TextStyle(break_non_latin_word="KEEP_WORD", spacing=20)) == [0]


def test_a_tab_reaches_the_next_default_stop_from_the_columns_left_edge() -> None:
# Stops every 4000 from the column's left edge: three syllables then a tab reach 4000, and "가가" ends at
# 6000; 1417 into the column (a left margin) the tab reaches 8000 instead, 6583 into the line, and "가가"
# no longer fits 8500 -- a line may start after a tab, so it goes on to the next line.
style = TextStyle()
assert hancom_line_starts("가가가\t가가", [8500], 10, style) == [0]
assert hancom_line_starts("가가가\t가가", [8500], 10, replace(style, margin_left=1417)) == [0, 4]
# A first-line indent moves the first line's text, not the lines after a line break.
assert hancom_line_starts("가가가\t가가", [8500], 10, replace(style, indent=1417)) == [0, 4]
assert hancom_line_starts("가가가\t가가", [8500], 10, replace(style, indent=1417), continued=True) == [0]
# A paragraph whose tab definition sets stops of its own keeps the tab as a space-class glyph.
assert hancom_line_starts("가가가\t가가", [8500], 10, replace(style, margin_left=1417, tab_stop=0)) == [0]


def test_each_character_can_take_its_own_size() -> None:
style = TextStyle(break_non_latin_word="KEEP_WORD")

Expand Down
25 changes: 25 additions & 0 deletions tests/test_layout_page_estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,18 @@
"pages_table_wider_than_the_line_then_a_click_here_field": 1,
"pages_index_mark_then_a_table_wider_than_the_line": 1, # an index mark
"pages_table_wider_than_the_line_then_an_index_mark": 1,
# Tabs: a tab reaches the next default tab stop, every 4000 from the column's left edge (the first line
# starting at the left margin and a first-line indent, a hanging indent off the other lines), and a line may
# start on either side of one. 90 paragraphs of 바탕 10 pt Hangul, a tab, then two-syllable words, after 0-9
# syllables and at left margins 0, 1417 and 2835 with indents 0, 850 and -850:
"pages_tab_stops_after_hangul_and_indents": 10,
"pages_tab_stops_one_size": 1, # labels, two tabs and text, at margins and indents
"pages_tab_stops_mixed_sizes": 2, # runs of 9 to 20 pt
"pages_tab_stops_mixed_faces": 1, # runs of six faces
# A line break: the line after it is no first line, its room the other lines' (a first-line indent not
# taken off it), in runs of several sizes
"pages_line_breaks_mixed_sizes_indented": 2,
"pages_tabs_breaks_sizes_faces": 1, # tabs, line breaks, sizes and faces together
"pages_picture_as_character_300_short_then_three_spaces": 1, # a picture 3000 tall: the same
"pages_table_cell_table_as_character_300_short_then_three_spaces": 1, # in a cell of a flowing table
"pages_table_as_character_then_a_line_break": 1, # a line break after it: an empty line below it
Expand Down Expand Up @@ -1608,6 +1620,19 @@ def test_a_table_placed_up_from_its_paragraph_in_a_cell_stands_at_the_paragraph_
assert estimate_pages(_without_caches(data)).unsupported == ("section 0: a nested table",)


def test_the_estimate_reads_a_paragraphs_tab_definition() -> None:
# A paragraph whose tab definition sets a stop of its own is not laid out on the default stops.
doc = HwpxDocument.new()
paragraph = doc.add_paragraph("가가가\t가가")
doc.styles.apply_paragraph_format(paragraphs=[paragraph], tab_stops=[{"pos_mm": 50, "type": "RIGHT"}])
root = HwpxDocument.open(io.BytesIO(doc.to_bytes()))._root
element = root.sections[0].element.findall(f"{HP}p")[-1]

style = page_layout._Measure(root).style(element.get("paraPrIDRef"), ["0"])

assert style.tab_stop == 0


def test_the_lines_a_cell_splits_between_add_up_to_its_height() -> None:
# A row of several paragraphs splits between their lines, one height and advance each: they add up to
# the height the cell stacks the paragraphs to, a paragraph mixing character sizes included (each line
Expand Down
Loading