diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be2f4c2..471cabb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ ### 고침 +- 줄 캐시(`write_line_caches`)가 머리에 정의되지 않은 글머리표·번호를 가리키는 문단 모양(예: 글머리표 idRef 0)의 + 문단에는 캐시를 쓰지 않는다. 한/글은 정의가 없어도 글머리 자리를 잡아 첫 줄에 글자를 덜 넣는데, 전에는 글머리 없이 + 나눈 줄을 써서 한/글이 그 줄을 눌러 담을 수 있었다. +- 줄 캐시가 왼쪽이나 오른쪽 여백이 음수인 문단(줄이 단·칸 가장자리 밖으로 나가는 문단)에도 캐시를 쓰지 않는다. 한/글이 + 그런 줄을 어떤 폭으로 나누는지 확정하지 못했고, 한 글자 이상 다르게 나눈 문단이 있었다. - 쪽 수 추정이 표 칸 안에서 양쪽에 글 자리가 있는 어울림 개체 곁에 빈 줄만 오는 칸도 추정한다. 한/글처럼 빈 줄은 개체 곁에 한 높이 두 조각으로 둔다(곁에 글이 오면 지금처럼 지원 밖). 한/글이 저장한 줄 캐시가 있는 칸에서 그런 두 조각을 줄 둘로 세어 칸을 조각 수만큼 높게 잡던 것도 바로잡았다. diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index cd547d39..88299c89 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -52,7 +52,8 @@ for, a word too long for a line, a line break that moves when every line is laid out :data:`_MARGIN` narrower or wider (a line ending that close to its room may end elsewhere in Hancom), or a tab whose text before it is closer to a tab stop than a layout unit for each character since the line's start or the last tab stop (at most -:data:`_MARGIN`). A paragraph that has a cache keeps it. A document mixing paragraphs with and without a cache is +:data:`_MARGIN`), a paragraph shape naming a bullet or a number the header does not define (Hancom draws a label +there all the same), or one with a negative left or right margin. A paragraph that has a cache keeps it. A document mixing paragraphs with and without a cache is laid out as one. Nothing is written when the estimate does not follow the document. """ @@ -260,7 +261,8 @@ def _picture_lines(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[ return None vertical = _vertical_align(measure, paragraph.get("paraPrIDRef")) shape = measure.shape(paragraph.get("paraPrIDRef")) - if vertical not in _BASELINES or shape.font_line: + if vertical not in _BASELINES or shape.font_line or _unknown_label(measure, paragraph.get("paraPrIDRef")) \ + or shape.left < 0 or shape.right < 0: return None room = width - shape.left - shape.right headed = measure.headed(paragraph) @@ -463,6 +465,11 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, vertical = _vertical_align(measure, paragraph.get("paraPrIDRef")) if shape.font_line or vertical not in _BASELINES or not _shapes_sure(measure, paragraph, runs): return None + if _unknown_label(measure, paragraph.get("paraPrIDRef")): + return None # a bullet or number the header does not define: Hancom draws a label all the same + if shape.left < 0 or shape.right < 0: + return None # a margin reaching past the column's or the cell's edge: how wide Hancom lays the lines out + # then was not pinned down if measure.pictured(paragraph.get("paraPrIDRef")): return None # a picture bullet sized by its picture: its room was not measured size, refs, sizes = _pages._text_size(measure, runs) @@ -604,6 +611,25 @@ def _shapes_sure(measure: Any, paragraph: Any, runs: list[Any]) -> bool: return True +def _unknown_label(measure: Any, para_pr_id: str | None) -> bool: + """Whether a paragraph shape *para_pr_id* heads its paragraphs with a bullet or a number the header does not + define (``hh:heading`` naming no ``hh:bullet`` or ``hh:numbering``): Hancom still draws a label before their + text, of a room not known here.""" + + unknown = getattr(measure, "_line_cache_unknown_labels", None) + if unknown is None: + defined = {"BULLET": {item.get("id") for item in measure._header.iter(f"{HH}bullet")}, + "NUMBER": {item.get("id") for item in measure._header.iter(f"{HH}numbering")}} + unknown = set() + for shape in measure._header.iter(f"{HH}paraPr"): + heading = shape.find(f"{HH}heading") + if heading is not None and heading.get("type") in defined \ + and heading.get("idRef") not in defined[heading.get("type")]: + unknown.add(shape.get("id")) + measure._line_cache_unknown_labels = unknown + return para_pr_id in unknown + + def _vertical_align(measure: Any, para_pr_id: str | None) -> str | None: """Where a paragraph of the shape *para_pr_id* sets its characters in their lines (``hh:align``'s ``vertical``): BASELINE (the default), TOP, CENTER or BOTTOM; None for a shape not in the header.""" diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 61e7bc92..8a09d78a 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -356,6 +356,37 @@ def test_a_page_control_among_the_text_is_left_for_hancom() -> None: assert report.written == 0 and report.skipped == 1 +@pytest.mark.parametrize("kind", ["BULLET", "NUMBER"]) +def test_a_paragraph_headed_by_a_label_the_header_does_not_define_is_left_for_hancom(kind: str) -> None: + # A paragraph shape naming a bullet or a number the header lacks: Hancom still draws a label before the text + # (seen with a bullet: its first line holds fewer characters), of a room not known. + document = HwpxDocument.new() + document.add_paragraph("가나다 " * 40) + ref = _body(document)[-1].get("paraPrIDRef") + shape = next(s for s in document.oxml.headers[0].element.iter(f"{HH}paraPr") if s.get("id") == ref) + shape.find(f"{HH}heading").attrib.update({"type": kind, "idRef": "99"}) + + report = write_line_caches(document) + + assert report.written == 0 + + +@pytest.mark.parametrize("side", ["left", "right"]) +def test_a_paragraph_with_a_negative_margin_is_left_for_hancom(side: str) -> None: + # A margin reaching past the column's edge: Hancom lays such lines out at a width not pinned down (one 500 + # out on the right, or 339 out on the left in a cell, broke a character or more off FormFit's). + document = HwpxDocument.new() + document.add_paragraph("가나다 " * 40) + ref = _body(document)[-1].get("paraPrIDRef") + shape = next(s for s in document.oxml.headers[0].element.iter(f"{HH}paraPr") if s.get("id") == ref) + for margin in shape.iter("{http://www.hancom.co.kr/hwpml/2011/core}" + side): + margin.set("value", "-500") + + report = write_line_caches(document) + + assert report.written == 0 + + @pytest.mark.parametrize(("condense", "spacing", "written"), [("0", 0, 1), ("10", 0, 0), ("0", -10, 0)]) def test_a_no_break_space_among_spaces_that_shrink_is_left_for_hancom(condense: str, spacing: int, written: int) -> None: # A no-break space in a paragraph with 최소 공백 was seen to break a line elsewhere than FormFit, and one with