diff --git a/CHANGELOG.md b/CHANGELOG.md index be13a9b2..e8596fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,7 +84,12 @@ 줄은 문단 왼쪽 여백에서, 첫 줄은 들여쓰기만큼 더 들어가 시작한다(내어쓰기는 둘째 줄부터). - 탭의 앞뒤 어디서나 줄이 바뀔 수 있다. - 전에는 탭을 공백 크기 글자처럼 재서, 탭이 있는 문단의 줄 나눔이 한/글과 달랐다. - - 탭 정의에 자기 탭 위치나 자동 탭이 있는 문단은 전처럼 잰다. + - 탭 정의에 자기 탭 위치가 있으면 그 위치가 먼저다. 탭 뒤 글을 그 위치에 맞출 수 있는(탭 앞에서 시작하지도, 줄 끝을 + 넘지도 않는) 첫 위치로 간다. 왼쪽 위치는 글을 거기서 시작하고, 오른쪽은 거기서 끝내고, 가운데는 거기에 가운데를 + 맞추고, 소수점은 마지막 `.`을 거기에 둔다. 왼쪽 위치는 글이 줄 끝을 넘어도 그대로 가지만, 앞에서 지나친 위치가 있으면 + 글을 줄 끝에서 끝낸다. 줄 끝에 닿은 탭은 다음 줄로 간다. 오른쪽 끝 자동 탭은 줄 끝의 오른쪽 + 위치로 함께 센다. 그다음은 기본 탭 위치다. 맞출 수 없어 지나친 위치가 있는데 기본 탭 위치에서 글이 줄에 들어가지 + 않으면 탭은 폭이 없다. 내어쓰기 자동 탭은 둘째 줄이 시작하는 곳에 왼쪽 위치를 둔다. - 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로 재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다. - `.hwp`를 열 때 그림의 투명도와 반전을 한/글이 읽는 자리에서 읽는다. 한/글은 그림 레코드의 투명도를 그림 원래 크기 diff --git a/src/hwpx/form_fit/measure.py b/src/hwpx/form_fit/measure.py index ceba4a9c..8d8d77e4 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -28,7 +28,7 @@ import math import re import unicodedata -from collections.abc import Collection, Mapping, Sequence +from collections.abc import Callable, Collection, Mapping, Sequence from dataclasses import dataclass, field, replace from fractions import Fraction from functools import lru_cache @@ -1201,8 +1201,10 @@ class TextStyle: 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. 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`` + starting at ``margin_left`` and its indent or label room; ``tab_stop`` 0 lays a tab out as a + space-class glyph. ``tab_stops`` holds the paragraph's own stops, ``(position, kind)`` from that edge + in ascending order, which come first (see :func:`tab_reach`), and ``auto_tab_right`` a right-aligned + stop at the line's end after them; ``auto_tab_left`` a left one where a hanging indent's lines start. ``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 @@ -1227,6 +1229,9 @@ class TextStyle: head: float = 0.0 head_hangs: bool = False tab_stop: int = _DEFAULT_TAB_STOP + tab_stops: tuple[tuple[int, str], ...] = () + auto_tab_left: bool = False + auto_tab_right: bool = False def _line_origin(style: TextStyle, first: bool) -> float: @@ -1247,6 +1252,48 @@ def _tab_advance(x: float, stop: int) -> float: return (math.floor(x / stop) + 1) * stop - x +def tab_reach(x: float, right: float, style: TextStyle, after: Callable[[bool], float]) -> float | None: + """How far a tab at *x* reaches on a line ending at *right* (both from the column's left edge), or None + when it is laid out as a glyph. *after* gives the width of the text that follows the tab up to the next + tab or the end, or with True up to its last full stop (all of it when it has none). + + The paragraph's own stops come first, the first one past *x* that the text after the tab can take: a + left stop starts the text there, a right stop ends it there, a centre stop centres it there and a + decimal stop puts its last full stop there; a text so aligned does not pass the line's end (a left stop + does not hold its text back so, but past a stop already passed over), and a stop whose text would + have to start before *x* is passed over. The automatic tab at the end (``auto_tab_right``) is a right stop at the line's end among + them. Then the default stops; past a stop that could not take the text, a default stop that leaves the + text no room on the line gives the tab no width.""" + + stops = set(style.tab_stops) + if style.auto_tab_left and style.indent < 0: # where the lines of a hanging indent start + stops.add((style.margin_left - style.indent, "LEFT")) + if style.auto_tab_right: # a right stop at the line's end, in its place among the others + stops.add((int(right), "RIGHT")) + passed = False # a stop past x that could not take the text + for position, kind in sorted(stops): + if position <= x: + continue + if kind == "RIGHT": + start = position - after(False) + elif kind == "CENTER": + start = position - after(False) / 2 + elif kind == "DECIMAL": + start = position - after(True) + else: + start = position + if kind != "LEFT" or passed: + start = min(start, right - after(False)) + if start >= x: + return start - x + passed = True + if not style.tab_stop: + return None + reach = _tab_advance(x, style.tab_stop) + # past a stop that could not take the text, a default stop that leaves it no room gives the tab none + return 0.0 if passed and x + reach + after(False) > right else reach + + def indented_widths(line: float, style: TextStyle) -> tuple[float, float]: """The room of a paragraph's first line and of its other lines, *line* wide inside its margins: less the first-line indent (a hanging one comes off the other lines) and the label's room. With ``head_hangs`` the @@ -1507,8 +1554,10 @@ def hancom_line_starts( 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) + if fixed is None and ch == "\t" and (style.tab_stop or style.tab_stops or style.auto_tab_right): + fixed = tab_reach(origin + used, origin + width, style, + lambda point, at=end: _width_after_tab(text, at, font_pt, style, sizes, styles, + advances, point)) 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]) \ @@ -1529,8 +1578,8 @@ def hancom_line_starts( break shrink = (inner + pending) * space * style.condense / 100.0 last = fixed if fixed is not None else char_advance(ch, size, unspaced if styles is None else _without_spacing(look)) - if used + last - shrink > width and end > start: - break + if (used + last - shrink > width or ch == "\t" and used + last >= width) and end > start: + break # a tab reaching the margin itself no longer fits used += advance inner += pending pending = 0 @@ -1555,6 +1604,32 @@ def hancom_line_starts( starts.append(start) +def _width_after_tab( + text: str, + tab: int, + font_pt: float, + style: TextStyle, + sizes: Sequence[float] | None, + styles: Sequence[TextStyle] | None, + advances: Mapping[int, float] | None, + point: bool, +) -> float: + """The width of the text after the tab at *tab* up to the next tab or the end, or with *point* up to + its last full stop (all of it when it has none).""" + + stop = text.find("\t", tab + 1) + stop = len(text) if stop < 0 else stop + if point: + dot = text.rfind(".", tab + 1, stop) + stop = stop if dot < 0 else dot + width = 0.0 + for index in range(tab + 1, stop): + fixed = None if advances is None else advances.get(index) + width += fixed if fixed is not None else char_advance( + text[index], font_pt if sizes is None else sizes[index], style if styles is None else styles[index]) + return width + + 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 @@ -2161,13 +2236,12 @@ 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 + tab_stops = tuple(sorted( + (int(stop.pos), str(stop.type or "LEFT").upper()) for stop in getattr(tabs, "tab_stops", None) or ())) # 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 @@ -2188,7 +2262,9 @@ def text_style_from_refs( glyph_face=glyph_face, hangul_face=hangul_face, scripts=scripts, - tab_stop=tab_stop, + tab_stops=tab_stops, + auto_tab_left=bool(getattr(tabs, "auto_tab_left", False)), + auto_tab_right=bool(getattr(tabs, "auto_tab_right", False)), ) heading = paragraph_label(root, para_pr_id_ref, label) if heading is None: diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_own_automatic_right.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_own_automatic_right.hwpx new file mode 100644 index 00000000..8b386bc6 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_own_automatic_right.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_own_centre_then_default.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_own_centre_then_default.hwpx new file mode 100644 index 00000000..aa91d052 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_own_centre_then_default.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_own_decimal_passed_over.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_own_decimal_passed_over.hwpx new file mode 100644 index 00000000..e94f4a6c Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_own_decimal_passed_over.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_own_left_past_a_passed_stop.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_own_left_past_a_passed_stop.hwpx new file mode 100644 index 00000000..431689a0 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_own_left_past_a_passed_stop.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_own_left_past_the_line.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_own_left_past_the_line.hwpx new file mode 100644 index 00000000..729adf7a Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_own_left_past_the_line.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_own_right_no_room.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_own_right_no_room.hwpx new file mode 100644 index 00000000..0df9de0e Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_own_right_no_room.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_own_tab_at_the_margin.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_own_tab_at_the_margin.hwpx new file mode 100644 index 00000000..1a0cbe85 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_own_tab_at_the_margin.hwpx differ diff --git a/tests/test_form_fit_hancom_rules.py b/tests/test_form_fit_hancom_rules.py index 6dffd2d3..9d8ef963 100644 --- a/tests/test_form_fit_hancom_rules.py +++ b/tests/test_form_fit_hancom_rules.py @@ -40,6 +40,7 @@ indented_widths, label_room, resolve_slot_metrics, + tab_reach, text_style_from_refs, ) @@ -105,10 +106,45 @@ def test_a_tab_reaches_the_next_default_stop_from_the_columns_left_edge() -> Non # 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. + # Without default stops (and no stops of its own) a tab is a space-class glyph. assert hancom_line_starts("가가가\t가가", [8500], 10, replace(style, margin_left=1417, tab_stop=0)) == [0] +def test_a_tab_reaches_the_paragraphs_own_stops_first() -> None: + def after(point: bool) -> float: + return 600.0 if point else 1500.0 # "12.5": 600 up to its full stop, 1500 in all + + style = TextStyle(tab_stops=((10000, "LEFT"), (20000, "RIGHT"), (30000, "CENTER"), (35000, "DECIMAL"))) + assert tab_reach(3000, 42520, style, after) == 7000 # a left stop starts the text there + assert tab_reach(12000, 42520, style, after) == 6500 # a right stop ends it there + assert tab_reach(21000, 42520, style, after) == 8250 # a centre stop centres it there + assert tab_reach(31000, 42520, style, after) == 3400 # a decimal stop puts its full stop there + # Past the last stop the default stops. An aligned text does not pass the line's end; a left stop past + # it takes the tab there all the same. + assert tab_reach(36000, 42520, style, after) == 4000 + assert tab_reach(21000, 29000, style, after) == 6500 # the centre stop: "12.5" ends at 29000 + assert tab_reach(3000, 9000, style, after) == 7000 + # The automatic tab at the end is a right stop at the line's end among the others. + assert tab_reach(36000, 42520, replace(style, auto_tab_right=True), after) == 5020 + assert tab_reach(3000, 9000, replace(style, auto_tab_right=True), after) == 4500 + # A stop whose text would have to start before the tab is passed over; past one, a default stop that + # leaves the text no room on the line gives the tab none. + assert tab_reach(19000, 42520, style, after) == 10250 + assert tab_reach(34500, 35500, style, after) == 0 + # The automatic tab of a hanging indent stops where its lines start. + assert tab_reach(1000, 42520, TextStyle(indent=-2000, auto_tab_left=True), after) == 1000 + assert tab_reach(1000, 42520, TextStyle(indent=-2000), after) == 3000 + + +def test_a_tab_reaching_a_right_stop_keeps_the_text_after_it_on_the_line() -> None: + style = TextStyle(tab_stops=((8000, "RIGHT"),)) + # "가가" then a tab to a right stop at 8000: "나나" (2000) ends there, the line 8500 wide; on a line 7900 + # wide the stop is past its end and "나나" ends at the end. On one 3500 wide it no longer fits after "가가". + assert hancom_line_starts("가가\t나나", [8500], 10, style) == [0] + assert hancom_line_starts("가가\t나나", [7900], 10, style) == [0] + assert hancom_line_starts("가가\t나나", [3500], 10, style) == [0, 3] + + def test_each_character_can_take_its_own_size() -> None: style = TextStyle(break_non_latin_word="KEEP_WORD") diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index f3ac879d..b3601c67 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -457,6 +457,20 @@ # 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 + # Paragraphs with tab stops of their own (left, right, centre, decimal, some past the line's end, with the + # automatic tabs or not) after labels, numbers and words: a tab reaches the first stop past it whose text + # can be aligned there without starting before the tab or passing the line's end (a left stop past it is + # taken all the same), the automatic tab at the end being a right stop there; then the default stops, + # which give the tab no width past a stop it could not take when the text has no room after them. + "pages_tab_stops_own_centre_then_default": 1, + "pages_tab_stops_own_left_past_the_line": 1, + "pages_tab_stops_own_decimal_passed_over": 1, + "pages_tab_stops_own_automatic_right": 1, + "pages_tab_stops_own_right_no_room": 1, + # ... past a stop it could not take, a left stop past the line's end (or whose text would pass it) ends the + # text at the end; a tab at the line's end goes on to the next line + "pages_tab_stops_own_left_past_a_passed_stop": 1, + "pages_tab_stops_own_tab_at_the_margin": 1, "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 @@ -1621,7 +1635,7 @@ def test_a_table_placed_up_from_its_paragraph_in_a_cell_stands_at_the_paragraph_ 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. + # A paragraph whose tab definition sets a stop of its own takes it. doc = HwpxDocument.new() paragraph = doc.add_paragraph("가가가\t가가") doc.styles.apply_paragraph_format(paragraphs=[paragraph], tab_stops=[{"pos_mm": 50, "type": "RIGHT"}]) @@ -1630,7 +1644,7 @@ def test_the_estimate_reads_a_paragraphs_tab_definition() -> None: style = page_layout._Measure(root).style(element.get("paraPrIDRef"), ["0"]) - assert style.tab_stop == 0 + assert style.tab_stops == ((14173, "RIGHT"),) def test_the_lines_a_cell_splits_between_add_up_to_its_height() -> None: