diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ade3b9..1e6e64d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,11 @@ 지원 밖(`a document without a header part (Contents/header.xml)`)으로 알린다. 줄 배치 정보 쓰기도 그런 문서에는 쓰지 않는다. - (실험) 줄 배치 정보 쓰기가 문단 모양이 한글·영문/숫자 자동 간격(`hh:autoSpacing`)을 켜고 한글 음절 바로 옆에 영문 글자나 숫자가 있는 문단을 건너뛴다. 한/글은 그 사이를 넓히는데 그 폭을 아직 재지 않아, 쓴 줄 나눔이 한/글과 다를 수 있었다. +- (실험) 줄 배치 정보 쓰기가 글 사이에 필드(하이퍼링크·누름틀·날짜·메일 머지)나 형광펜이 든 문단에도 쓴다. 필드의 시작·끝은 +- (실험) 줄 배치 정보 쓰기가 글 사이에 필드(하이퍼링크·누름틀·날짜·메일 머지)나 형광펜이 든 문단에도 쓴다(구역 첫 문단의 + 필드와 메모 필드는 한/글이 배치 정보 없이 저장해 건너뛴다). 필드의 시작·끝은 + 줄에 자리를 차지하지 않고 줄 위치(textpos)에서 하나에 8글자씩이며, 줄이 나뉘는 자리에 있으면 다음 줄이 그것으로 + 시작한다. 형광펜 표시는 자리도 줄 위치도 차지하지 않는다. 한/글이 그렇게 놓는다. 전에는 그런 문단을 건너뛰었다. - FormFit과 쪽 수 추정이 글꼴 폭 표에 없는 가나(히라가나·가타카나)를 그 글꼴의 한글 음절 폭으로 잡는다(함초롬바탕 972, 한컴 고딕 932). 한/글이 그렇게 놓는다. 전에는 전각(1 em)으로 셌다. - 쪽 수 추정(실험, `estimate_pages`)이 탭 정의를 한 번만 읽는다. 전에는 글자 모양을 볼 때마다 머리 정보의 탭 정의 전체를 diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index ca496637..22231422 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -16,7 +16,9 @@ Only a paragraph Hancom would lay out from its text alone gets a cache: a body paragraph of text runs in a section of one column where no object lets text run beside it. Tabs to the default tab stops, line breaks, controls that take no room on a line (a header, a footer, a page number, the section's own definitions in its -first paragraph) and a hanging bullet or number label in the paragraph's size are fine. Its runs may differ in +first paragraph), the start and end of a field (a hyperlink, a click-here field, a date, a mail merge field) anywhere in the text but +in a section's first paragraph, a +highlight and a hanging bullet or number label in the paragraph's size are fine. Its runs may differ in face, each character taking its own run's advance, and in size: each line is then as tall as its largest character, unless a larger one past the line's end may be the one that did not fit on it (Hancom writes the line as tall as that one), and has no tab; a larger empty run ending the paragraph makes its last line's text as tall. A @@ -115,6 +117,12 @@ #: The text children a cached paragraph may hold, as python-hwpx reads them into its text. With a cache Hancom #: draws a tab as wide as its ``hp:tab`` says, so a tab gets the width Hancom lays it out at too. _TEXT_MARKS = frozenset({"tab", "lineBreak", "nbSpace", "fwSpace"}) +#: Marks in the text that take neither room on a line nor a text position: a highlight's start and end. +_EMPTY_MARKS = frozenset({"markpenBegin", "markpenEnd"}) +#: The kinds of field whose start and end take no room on a line and eight characters each in a line's text position +#: wherever they stand (one standing where a line breaks starts the next line). A memo's field, or a field in a +#: section's first paragraph, Hancom was seen to save without a cache, so those are left for it. +_FIELD_KINDS = frozenset({"HYPERLINK", "CLICK_HERE", "DATE", "MAILMERGE"}) #: What Hancom writes on a tab that reaches a default tab stop: no leader, a left stop. _DEFAULT_TAB = {"leader": "0", "type": "1"} @@ -320,8 +328,8 @@ def lay_out(room: list[float]) -> list[int]: return None if mixed and _taller_past_a_break(text, sizes, starts, metrics): return None - if not _sure_of(text, starts, style, looks) or any(place in starts[1:] for place in controls): - return None # a line starting at a control: on which side Hancom starts it is not known + if not _sure_of(text, starts, style, looks): + return None if any(lay_out([room + shift for room in widths]) != starts for shift in (-_MARGIN, _MARGIN)): return None # a line break this close to a line's end may fall elsewhere in Hancom tabs = _tab_widths(text, starts, widths, size, style, looks) if "\t" in text else [] @@ -533,7 +541,8 @@ def _wraps_text(section: Any) -> bool: def _control_places(runs: list[Any], first: bool) -> list[int] | None: """Where in the paragraph's text each of its controls stands, or None when the paragraph holds anything but text and controls that take no room on a line: a header, a footer, a page number and the other page controls - (and in a section's first paragraph, the section's own definitions), all of them before the text.""" + (and in a section's first paragraph, the section's own definitions), all of them before the text, and the start + and end of a field anywhere.""" places: list[int] = [] length = 0 @@ -541,18 +550,29 @@ def _control_places(runs: list[Any], first: bool) -> list[int] | None: for child in run: name = _pages._local(child) if name == "t": - if any(_pages._local(mark) not in _TEXT_MARKS for mark in child): + if any(_pages._local(mark) not in _TEXT_MARKS | _EMPTY_MARKS for mark in child): return None if "\n" in (child.text or "") or any("\n" in (mark.tail or "") for mark in child): return None # a line break typed into the text, which Hancom writes as hp:lineBreak length += len(_pages._t_text(child)) elif (name == "secPr" and first or name == "ctrl" and _takes_no_room(child, first)) and not length: places.append(length) # before the text only: one among it was seen to break the line elsewhere + elif name == "ctrl" and not first and _field_mark(child): + places.append(length) else: return None return places +def _field_mark(ctrl: Any) -> bool: + """Whether *ctrl* holds only the start of a field of :data:`_FIELD_KINDS` or a field's end.""" + + marks = list(ctrl) + return bool(marks) and all( + _pages._local(mark) == "fieldEnd" or _pages._local(mark) == "fieldBegin" and mark.get("type") in _FIELD_KINDS + for mark in marks) + + def _takes_no_room(ctrl: Any, first: bool) -> bool: kinds = {_pages._local(child) for child in ctrl} return bool(kinds) and kinds <= (_PAGE_CONTROLS | ({"colPr"} if first else set())) diff --git a/tests/fixtures/hancom_saved/line_cache_fields.hwpx b/tests/fixtures/hancom_saved/line_cache_fields.hwpx new file mode 100644 index 00000000..8678a82a Binary files /dev/null and b/tests/fixtures/hancom_saved/line_cache_fields.hwpx differ diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 1753f833..f6a10523 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -18,7 +18,7 @@ HANCOM_LAID_OUT = ["line_cache_tabs.hwpx", "line_cache_line_breaks.hwpx", "line_cache_hanging_indents.hwpx", "line_cache_indents.hwpx", "line_cache_labels.hwpx", "line_cache_mixed_sizes.hwpx", "line_cache_taller_past_a_break.hwpx", "line_cache_table_cells.hwpx", - "line_cache_mixed_faces_tabs.hwpx", "line_cache_space_runs.hwpx", "line_cache_tab_punctuation.hwpx", "line_cache_script_faces.hwpx"] + "line_cache_mixed_faces_tabs.hwpx", "line_cache_space_runs.hwpx", "line_cache_tab_punctuation.hwpx", "line_cache_script_faces.hwpx", "line_cache_fields.hwpx"] def _body(document: HwpxDocument) -> list: @@ -444,3 +444,23 @@ def test_glyphs_in_a_shape_of_several_script_faces_get_a_cache() -> None: assert any(ch.isascii() and ch.isalnum() for ch in texts) and "※" in texts for mine, theirs in written: assert _segments(mine) == _segments(theirs) + + +def test_a_paragraph_holding_fields_or_a_highlight_gets_a_cache() -> None: + # A field's start and end take no room on a line and eight characters each in its text position, wherever + # they stand (one where a line breaks starts the next line); a highlight's marks take neither. + document = HwpxDocument.open(FIXTURES / "line_cache_fields.hwpx") + hancom = HwpxDocument.open(FIXTURES / "line_cache_fields.hwpx") + for paragraph in _body(document): + for cache in paragraph.findall(f"{HP}linesegarray"): + paragraph.remove(cache) + + write_line_caches(document) + + written = [(mine, theirs) for mine, theirs in zip(_body(document), _body(hancom)) if _segments(mine)] + with_fields = [mine for mine, _ in written if mine.find(f".//{HP}fieldBegin") is not None] + with_highlights = [mine for mine, _ in written if mine.find(f".//{HP}markpenBegin") is not None] + assert len(with_fields) >= 10 and with_highlights + assert any(len(_segments(mine)) > 1 for mine in with_fields) + for mine, theirs in written: + assert _segments(mine) == _segments(theirs)