diff --git a/CHANGELOG.md b/CHANGELOG.md index e92eab04..be13a9b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,14 @@ ### 고침 +- FormFit과 쪽 수 추정이 탭을 한/글처럼 놓는다. + - 탭은 단(셀이면 셀 글 영역)의 왼쪽 끝에서 4000 HWPUNIT마다 있는 기본 탭 위치 가운데 지금 위치 다음 것까지 간다. + 줄은 문단 왼쪽 여백에서, 첫 줄은 들여쓰기만큼 더 들어가 시작한다(내어쓰기는 둘째 줄부터). + - 탭의 앞뒤 어디서나 줄이 바뀔 수 있다. + - 전에는 탭을 공백 크기 글자처럼 재서, 탭이 있는 문단의 줄 나눔이 한/글과 달랐다. + - 탭 정의에 자기 탭 위치나 자동 탭이 있는 문단은 전처럼 잰다. +- 쪽 수 추정에서 강제 줄 바꿈 다음 줄은 첫 줄이 아니다. 들여쓰기를 빼지 않고 다른 줄의 폭으로 잰다. 전에는 첫 줄 폭으로 + 재서, 들여쓴 문단에서 줄이 하나 더 생길 수 있었다. - `.hwp`를 열 때 그림의 투명도와 반전을 한/글이 읽는 자리에서 읽는다. 한/글은 그림 레코드의 투명도를 그림 원래 크기 뒤 마지막 바이트에서 읽고, 바이너리 항목 번호 뒤 바이트는 읽지 않는다(저장할 때 0으로 쓴다). 전에는 그 바이트를 투명도로 옮겨, 거기에 255가 든 문서의 그림이 HWPX에서 보이지 않았다. 반전(`hp:pic@reverse`)은 그림 개체 속성의 diff --git a/src/hwpx/form_fit/measure.py b/src/hwpx/form_fit/measure.py index b5e5d558..ceba4a9c 100644 --- a/src/hwpx/form_fit/measure.py +++ b/src/hwpx/form_fit/measure.py @@ -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}" @@ -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 @@ -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]: @@ -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": @@ -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). @@ -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) @@ -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]) \ @@ -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) @@ -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 @@ -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: diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index 3d87b2d6..63844fe3 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -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.""" @@ -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, @@ -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 diff --git a/tests/fixtures/hancom_saved/pages_line_breaks_mixed_sizes_indented.hwpx b/tests/fixtures/hancom_saved/pages_line_breaks_mixed_sizes_indented.hwpx new file mode 100644 index 00000000..00e58895 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_line_breaks_mixed_sizes_indented.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_after_hangul_and_indents.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_after_hangul_and_indents.hwpx new file mode 100644 index 00000000..d1f3d055 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_after_hangul_and_indents.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_mixed_faces.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_mixed_faces.hwpx new file mode 100644 index 00000000..3868985a Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_mixed_faces.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_mixed_sizes.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_mixed_sizes.hwpx new file mode 100644 index 00000000..c93529b7 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_mixed_sizes.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tab_stops_one_size.hwpx b/tests/fixtures/hancom_saved/pages_tab_stops_one_size.hwpx new file mode 100644 index 00000000..3839ec9a Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tab_stops_one_size.hwpx differ diff --git a/tests/fixtures/hancom_saved/pages_tabs_breaks_sizes_faces.hwpx b/tests/fixtures/hancom_saved/pages_tabs_breaks_sizes_faces.hwpx new file mode 100644 index 00000000..14e8bd30 Binary files /dev/null and b/tests/fixtures/hancom_saved/pages_tabs_breaks_sizes_faces.hwpx differ diff --git a/tests/test_form_fit_hancom_rules.py b/tests/test_form_fit_hancom_rules.py index 7d0fec45..6dffd2d3 100644 --- a/tests/test_form_fit_hancom_rules.py +++ b/tests/test_form_fit_hancom_rules.py @@ -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") diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 3c4c6fbd..f3ac879d 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -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 @@ -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