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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@
줄은 문단 왼쪽 여백에서, 첫 줄은 들여쓰기만큼 더 들어가 시작한다(내어쓰기는 둘째 줄부터).
- 탭의 앞뒤 어디서나 줄이 바뀔 수 있다.
- 전에는 탭을 공백 크기 글자처럼 재서, 탭이 있는 문단의 줄 나눔이 한/글과 달랐다.
- 탭 정의에 자기 탭 위치나 자동 탭이 있는 문단은 전처럼 잰다.
- 탭 정의에 자기 탭 위치가 있으면 그 위치가 먼저다. 탭 뒤 글을 그 위치에 맞출 수 있는(탭 앞에서 시작하지도, 줄 끝을
넘지도 않는) 첫 위치로 간다. 왼쪽 위치는 글을 거기서 시작하고, 오른쪽은 거기서 끝내고, 가운데는 거기에 가운데를
맞추고, 소수점은 마지막 `.`을 거기에 둔다. 왼쪽 위치는 글이 줄 끝을 넘어도 그대로 가지만, 앞에서 지나친 위치가 있으면
글을 줄 끝에서 끝낸다. 줄 끝에 닿은 탭은 다음 줄로 간다. 오른쪽 끝 자동 탭은 줄 끝의 오른쪽
위치로 함께 센다. 그다음은 기본 탭 위치다. 맞출 수 없어 지나친 위치가 있는데 기본 탭 위치에서 글이 줄에 들어가지
않으면 탭은 폭이 없다. 내어쓰기 자동 탭은 둘째 줄이 시작하는 곳에 왼쪽 위치를 둔다.
- 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로
재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다.
- `.hwp`를 열 때 그림의 투명도와 반전을 한/글이 읽는 자리에서 읽는다. 한/글은 그림 레코드의 투명도를 그림 원래 크기
Expand Down
98 changes: 87 additions & 11 deletions src/hwpx/form_fit/measure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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]) \
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
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.
Binary file not shown.
38 changes: 37 additions & 1 deletion tests/test_form_fit_hancom_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
indented_widths,
label_room,
resolve_slot_metrics,
tab_reach,
text_style_from_refs,
)

Expand Down Expand Up @@ -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")

Expand Down
18 changes: 16 additions & 2 deletions tests/test_layout_page_estimate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}])
Expand All @@ -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:
Expand Down
Loading