diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0b3974..07e78d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ 모든 중요한 변경 사항은 이 문서에 기록됩니다. 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)과 [Semantic Versioning](https://semver.org/lang/ko/)을 따릅니다. +## [Unreleased] + +### 추가 + +- (실험) `write_line_caches(doc)`가 줄 배치 정보를 쓰는 문단을 넓힌다. 이 함수를 부른 뒤 `.hwp`로 저장하면 아래 그림 + 문단도 `PARA_LINE_SEG`를 얻어, 이 레코드로 그림 자리를 찾는 다른 HWP 뷰어가 그림을 그린다. + - 글자처럼 놓인 그림 하나만 든 문단: 캡션이 있는 그림(위·아래 캡션은 캡션의 줄과 간격만큼 줄이 높고, 옆 캡션은 그림 + 높이 그대로), 부드러운 가장자리나 안쪽 그림자가 있는 그림, 줄 간격이 고정·최소·줄 사이인 문단(간격은 그림 줄 높이에서 + 센다), 글자를 줄의 위·가운데·아래에 놓는 문단. + - 글자처럼 놓인 그림 여럿만 든 문단: 단 폭에 드는 만큼 한 줄에 놓고, 줄마다 그 줄의 가장 큰 그림 높이로 쓴다. 줄 간격에 + 영향을 주는 그림이 있거나 줄 간격이 비율이 아닌 문단은 쓰지 않는다. + - 너비가 같은 보통 다단 구역: 글 문단과 그림 문단을 그 단의 폭으로 쓴다. 구역 중간에서 단이 바뀌면 문단이 놓인 단의 + 폭으로 쓴다(전에는 구역 첫 단의 폭으로 쟀다). 고른 배분·평행 다단과 너비가 다른 단은 쓰지 않는다. + - 글이 개체 옆으로 흐르는 구역: 전에는 구역 전체를 건너뛰었지만, 이제 그 개체를 품은 문단이 놓인 쪽과 다음 쪽만 뺀다. + - 빛남·바깥 그림자·반사처럼 그림 둘레에 자리를 차지하는 효과가 있는 그림과 그리기 개체의 그림자(`hp:shadow`)가 있는 + 그림, 줄 나눔이 확실하지 않은 캡션이 달린 그림은 쓰지 않고 한/글에 맡긴다. + ## [6.9.0] - 2026-10-23 쪽 수 추정과 FormFit의 글자 폭·줄 나눔을 한컴오피스 2024의 한/글에 맞춘 릴리스입니다. 한양·HCI·KoPub 글꼴과 diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 7e6137e1..7bec8c07 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -13,8 +13,11 @@ the baseline and the line spacing past it. The others come from :func:`hwpx.layout.pages.estimate_pages`. Hancom also draws a tab as wide as its ``hp:tab`` says once the paragraph has a cache, so each tab gets its width. -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, +Only a paragraph Hancom would lay out from its text alone gets a cache: a body paragraph of text runs on a page +where no object lets text run beside it, in one column or in newspaper columns of equal width (as wide as its +column; where the columns change inside a section, as wide as those it is laid out in). An object letting text run +beside it (square, tight or through wrapping) leaves the pages its paragraph is on, and the next one, for Hancom. +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), 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 @@ -26,13 +29,20 @@ writes for that (0, half or all of the text height). A table's caption above or below it takes its lines and its gap in the table's line, when the caption's paragraphs are laid out surely. -The cells of a table in such a section get caches the same way when every paragraph of the cell is one of -these: their lines go down from the top of the cell's text area, as wide as that area in whole layout units and -never narrower than Hancom's narrowest line. A paragraph holding nothing but a table set as a character gets its -one line, as tall as the table, and the table its rows' height (``hp:sz``), as Hancom saves them, when every -paragraph of its cells is laid out surely. A paragraph holding nothing but a plain picture set as a character (no -caption, shadow or effects, in a paragraph spaced by a percentage) gets its one line, as tall as the picture and -its outer margins or as the paragraph's characters when they are taller. +The cells of a table in such a paragraph, in one column, get caches the same way when every paragraph of the cell +is one of these: their lines go down from the top of the cell's text area, as wide as that area in whole layout +units and never narrower than Hancom's narrowest line. A paragraph in one column holding nothing but a table set as +a character gets its one line, as tall as the table, and the table its rows' height (``hp:sz``), as Hancom saves +them, when every paragraph of its cells is laid out surely. + +A paragraph holding nothing but a picture set as a character gets its one line, as tall as the picture with its +outer margins and a caption above or below it (the caption's lines and its gap; one beside it takes none), or as +the paragraph's characters when they are taller, its baseline where the paragraph sets its characters, spaced as +its characters are unless the picture affects the line spacing or the spacing is not a percentage. One holding +several pictures, none of them affecting the line spacing, in a paragraph spaced by a percentage, gets a line for +each row of pictures as wide as the column, each as tall as its tallest picture. A picture whose glow, outer +shadow or reflection takes room around it, or with a shadow written as a drawing's (``hp:shadow``), is left for +Hancom, as is a caption whose lines are less sure. Saved as ``.hwp``, the caches become the paragraphs' ``PARA_LINE_SEG`` records, which viewers other than Hancom Office may need to place a picture; Hancom lays out a paragraph without one when it opens the file. @@ -163,20 +173,26 @@ def write_line_caches(document: "HwpxDocument") -> LineCacheReport: index = 0 for section in root.sections: page = _pages._page(section.element) - # lines beside an object are narrower, and an object in a switch (a chart) was not checked: left for Hancom - beside = _wraps_text(section.element) or bool(_pages._switched(section.element)) + # an object in a switch (a chart) was not checked: left for Hancom + switched = bool(_pages._switched(section.element)) paragraphs = section.element.findall(f"{HP}p") + columns = _column_widths(section.element, page) lines = estimate.lines[index:index + len(paragraphs)] index += len(paragraphs) + # lines beside an object are narrower: the pages where one stands are left for Hancom + beside = _pages_beside(section.element, lines) if _wraps_text(section.element) else set() + sure = [not switched and beside is not None and width is not None + and not any(line.page in beside for line in placed) for width, placed in zip(columns, lines)] changed = False for number, (paragraph, placed) in enumerate(zip(paragraphs, lines)): if paragraph.find(f"{HP}linesegarray") is not None: kept += 1 continue - laid = None if page.columns != 1 or beside else ( - _lines(measure, paragraph, page.column_width, first=number == 0) - or _table_line(measure, paragraph, page.column_width) - or _picture_line(measure, paragraph, page.column_width)) + width = columns[number] + laid = None if not sure[number] else ( + _lines(measure, paragraph, width, first=number == 0) + or (_table_line(measure, paragraph, width) if width == page.text_width else None) # one column + or _picture_lines(measure, paragraph, width)) if laid is None or len(laid[0]) != len(placed): skipped += 1 continue @@ -186,58 +202,128 @@ def write_line_caches(document: "HwpxDocument") -> LineCacheReport: _append_cache(paragraph, segments, tabs) written += 1 changed = True - if page.columns == 1 and not beside: - for table in (child for paragraph in paragraphs for run in paragraph.findall(f"{HP}run") - for child in run if _pages._local(child) == "tbl"): - cells = _cell_caches(measure, table) - written += cells[0] - skipped += cells[1] - kept += cells[2] - changed = changed or bool(cells[0]) + for table in (child for number, paragraph in enumerate(paragraphs) + if sure[number] and columns[number] == page.text_width # in one column + for run in paragraph.findall(f"{HP}run") for child in run if _pages._local(child) == "tbl"): + cells = _cell_caches(measure, table) + written += cells[0] + skipped += cells[1] + kept += cells[2] + changed = changed or bool(cells[0]) if changed: section.mark_dirty() return LineCacheReport(written, skipped, kept) -def _picture_line(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[str, int]], list[int]] | None: - """The one line of a paragraph holding nothing but a picture set as a character, as Hancom writes it: as tall - as the picture and its outer margins, or as the paragraph's characters when they are taller, its baseline at - 85 % of that, spaced as the paragraph's characters are unless the picture affects the line spacing. Only a - plain picture: one with a caption, a shadow or effects, and a line spacing other than a percentage, are left - for Hancom. None for any other paragraph.""" +def _picture_lines(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[str, int]], list[int]] | None: + """The lines of a paragraph holding nothing but pictures set as characters, as Hancom writes them; None for + any other paragraph. A picture alone takes one line, as tall as the picture with its outer margins and its + caption above or below it (its lines and its gap), or as the paragraph's characters when they are taller, + spaced as the paragraph's characters are unless the picture affects the line spacing or the spacing is not a + percentage (then from the line's height). Several pictures go on lines as wide as the + column, each line as tall as its tallest picture: only plain ones that do not affect the line spacing, in a + paragraph spaced by a percentage. A picture with a shadow drawn as a shape's (``hp:shadow``), a glow, an + outer shadow or a reflection, which Hancom lays out up to a unit from the estimate, and a caption whose lines + are less sure, are left for Hancom.""" runs = paragraph.findall(f"{HP}run") - picture = _pages._object_alone(runs) - if picture is None or _pages._local(picture) != "pic": + children = [child for run in runs for child in run] + pictures = [child for child in children if _pages._local(child) == "pic"] + if not pictures or _pages._run_text(runs) or any( + _pages._local(child) not in ("t", "pic", "secPr", "ctrl") + or _pages._local(child) == "ctrl" and not _takes_no_room(child, True) for child in children): return None - if any(_pages._local(child) not in ("t", "pic", "secPr", "ctrl") for run in runs for child in run): + if any(picture.find(f"{HP}pos").get("treatAsChar") != "1" or not _plain_picture(measure, picture) + for picture in pictures): return None - if any(_pages._local(child) == "ctrl" and not _takes_no_room(child, True) for run in runs for child in run): + vertical = _vertical_align(measure, paragraph.get("paraPrIDRef")) + shape = measure.shape(paragraph.get("paraPrIDRef")) + if vertical not in _BASELINES or shape.font_line: return None - shadow = picture.find(f"{HP}shadow") - effects = picture.find(f"{HP}effects") - if picture.find(f"{HP}caption") is not None or (shadow is not None and shadow.get("type", "NONE") != "NONE") \ - or (effects is not None and len(effects)): + room = width - shape.left - shape.right + headed = measure.headed(paragraph) + if len(pictures) == 1: + picture = pictures[0] + size = max(measure.char_height(run.get("charPrIDRef")) for run in runs) + tall = max(_pages._drawn_height(picture, measure) + _pages._extent_margins(picture) + + sum(_pages._caption(measure, picture)), size) + return [{ + "textpos": 0, + "vertpos": 0, + "vertsize": tall, + "textheight": tall, + "baseline": _BASELINES[vertical](tall), + "spacing": _pages._object_pitch(shape, picture, tall, size) - tall, + "horzpos": shape.left, + "horzsize": room, + "flags": _ONE_SEGMENT | (_LABELLED if headed else 0), + }], [] + if headed or shape.kind != "PERCENT" or vertical != "BASELINE" or any( + _pages._local(child) != "t" for child in children if _pages._local(child) != "pic") or any( + picture.find(f"{HP}caption") is not None or picture.find(f"{HP}pos").get("affectLSpacing") == "1" + for picture in pictures): return None - if _vertical_align(measure, paragraph.get("paraPrIDRef")) != "BASELINE": + try: + text, sizes, _, placed, _ = _pages._inline_content(measure, paragraph, runs) + except _pages._Unsupported: return None - shape = measure.shape(paragraph.get("paraPrIDRef")) - if shape.kind != "PERCENT": + style = measure.style(paragraph.get("paraPrIDRef"), [run.get("charPrIDRef") for run in runs], paragraph) + widths = _pages._indented(room, shape, style) + + def lay_out(room: list[float]) -> list[int]: + return _pages._metric_starts(measure, text, room, sizes, style, placed) + + starts = lay_out(widths) + if any(lay_out([line + shift for line in widths]) != starts for shift in (-_MARGIN, _MARGIN)): + return None # a picture ending this close to a line's end may go on to the next line in Hancom + metrics = _pages._line_metrics(measure, text, widths, sizes, style, shape, placed) + if len(metrics) != len(starts): return None - size = max(measure.char_height(run.get("charPrIDRef")) for run in runs) - tall = max(_pages._drawn_height(picture, measure) + _pages._extent_margins(picture), size) - advance = _pages._object_pitch(shape, picture, tall, size) return [{ - "textpos": 0, + "textpos": _CONTROL_LENGTH * start, # each picture takes eight characters "vertpos": 0, - "vertsize": tall, - "textheight": tall, - "baseline": (tall * 85 + 50) // 100, - "spacing": advance - tall, + "vertsize": height, + "textheight": height, + "baseline": _BASELINES[vertical](height), + "spacing": advance - height, "horzpos": shape.left, - "horzsize": width - shape.left - shape.right, - "flags": _ONE_SEGMENT | (_LABELLED if measure.headed(paragraph) else 0), - }], [] + "horzsize": room, + "flags": _ONE_SEGMENT, + } for start, (height, advance) in zip(starts, metrics)], [] + + +def _plain_picture(measure: Any, picture: Any) -> bool: + """Whether *picture* takes the room Hancom lays out for it surely: no shadow drawn as a shape's, only effects + that take no room (a soft edge, an inner shadow), and a caption beside it or above or below it laid out surely + (see :func:`_caption_sure`).""" + + shadow = picture.find(f"{HP}shadow") + if shadow is not None and shadow.get("type", "NONE") != "NONE": + return False # Hancom does not count it on a picture's line, the estimate does + effects = picture.find(f"{HP}effects") + if effects is not None and any( + not (_pages._local(effect) == "softEdge" + or _pages._local(effect) == "shadow" and effect.get("style") == "INSIDE") for effect in effects): + return False + return _caption_sure(measure, picture) + + +def _column_widths(section: Any, page: Any) -> list[int | None]: + """The width of the column each body paragraph of *section* is laid out in, as the estimate lays the column + areas out (see ``pages._column_areas``); None in columns of unequal width, and in balanced or parallel ones + (whose lines the estimate places otherwise than Hancom at times).""" + + paragraphs = section.findall(f"{HP}p") + bounds = [(0, _pages._first_columns(section)), *_pages._column_areas(section)] + widths: list[int | None] = [] + settings = None + for number, (start, found) in enumerate(bounds): + end = bounds[number + 1][0] if number + 1 < len(bounds) else len(paragraphs) + settings = found if found is not None else settings + count, width, unequal, _ = _pages._column_layout(settings, page.text_width) + newspaper = count == 1 or settings.get("type", "NEWSPAPER") == "NEWSPAPER" + widths += [width if newspaper and not unequal else None] * (end - start) + return widths def _table_line(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[str, int]], list[int]] | None: @@ -578,6 +664,24 @@ def _wraps_text(section: Any) -> bool: return False +def _pages_beside(section: Any, lines: list[Any]) -> set[int] | None: + """The pages of *section* where text may run beside an object of it that is not set as a character (square, + tight or through wrapping): those its paragraph's lines are on, and the next one (it may reach past the + page's foot); None when one is in a header or a footer (on every page) or its paragraph has no line. *lines*: + the estimate's lines of each paragraph of the section.""" + + pages: set[int] = set() + for paragraph, placed in zip(section.findall(f"{HP}p"), lines): + for pos in paragraph.iter(f"{HP}pos"): + obj = pos.getparent() + if pos.get("treatAsChar") == "1" or obj.get("textWrap") not in ("SQUARE", "TIGHT", "THROUGH"): + continue + if not placed or any(_pages._local(above) in ("header", "footer") for above in obj.iterancestors()): + return None + pages.update(range(placed[0].page, placed[-1].page + 2)) + return pages + + 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 diff --git a/tests/fixtures/hancom_saved/line_cache_pictures_beside.hwpx b/tests/fixtures/hancom_saved/line_cache_pictures_beside.hwpx new file mode 100644 index 00000000..771f4672 Binary files /dev/null and b/tests/fixtures/hancom_saved/line_cache_pictures_beside.hwpx differ diff --git a/tests/fixtures/hancom_saved/line_cache_pictures_columns.hwpx b/tests/fixtures/hancom_saved/line_cache_pictures_columns.hwpx new file mode 100644 index 00000000..44ab507f Binary files /dev/null and b/tests/fixtures/hancom_saved/line_cache_pictures_columns.hwpx differ diff --git a/tests/fixtures/hancom_saved/line_cache_pictures_kinds.hwpx b/tests/fixtures/hancom_saved/line_cache_pictures_kinds.hwpx new file mode 100644 index 00000000..a4de3aea Binary files /dev/null and b/tests/fixtures/hancom_saved/line_cache_pictures_kinds.hwpx differ diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index 38d14c96..86b4b066 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -24,7 +24,9 @@ "pages_empty_numbered_paragraphs.hwpx", "line_cache_condensed_space_runs.hwpx", "line_cache_condensed_space_runs_at_line_ends.hwpx", "formfit_condense_space_runs_past_margin.hwpx", - "line_cache_taller_control_runs.hwpx", "line_cache_pictures_alone.hwpx"] + "line_cache_taller_control_runs.hwpx", "line_cache_pictures_alone.hwpx", + "line_cache_pictures_kinds.hwpx", "line_cache_pictures_columns.hwpx", + "line_cache_pictures_beside.hwpx"] def _body(document: HwpxDocument) -> list: @@ -634,25 +636,151 @@ def test_a_paragraph_holding_a_picture_gets_one_line_carried_into_an_hwp_save() assert _segments(_body(reopened)[-1]) == picture_line -@pytest.mark.parametrize("change", ["caption", "shadow", "fixed"]) -def test_a_picture_with_a_caption_a_shadow_or_a_fixed_spacing_is_left_to_hancom(change: str) -> None: +def _spaced(document: HwpxDocument, paragraph, kind: str, value: int) -> None: + hh = "{http://www.hancom.co.kr/hwpml/2011/head}" + document.styles.apply_paragraph_format(paragraphs=[paragraph], line_spacing_percent=161) + shape_id = paragraph.element.get("paraPrIDRef") + for shape in document.oxml.headers[0].element.iter(f"{hh}paraPr"): + if shape.get("id") == shape_id: + for spacing in shape.iter(f"{hh}lineSpacing"): + spacing.attrib.update({"type": kind, "value": str(value)}) + + +@pytest.mark.parametrize("change", ["empty caption", "shadow", "glow", "outer shadow", "reflection"]) +def test_a_picture_whose_room_is_less_sure_is_left_to_hancom(change: str) -> None: + # Hancom does not count a shadow written as a drawing's on a picture's line (the estimate does), and lays a glow, + # an outer shadow and a reflection out a unit from the estimate at some sizes: those pictures are left for it. + from hwpx.oxml import PictureGlow, PictureReflection, PictureShadow + + effects = {"glow": {"glow": PictureGlow(radius=2000)}, "outer shadow": {"shadow": PictureShadow(distance=0)}, + "reflection": {"reflection": PictureReflection()}} document = HwpxDocument.new() - picture = document.add_picture(_png(), "png", width_mm=40, height_mm=20) - element = _body(document)[-1].find(f".//{HP}pic") - if change == "caption": + picture = document.add_picture(_png(), "png", width_mm=40, height_mm=20, **effects.get(change, {})) + element = picture.element + if change == "empty caption": # a caption without a paragraph element.append(element.makeelement(f"{HP}caption", {"side": "BOTTOM", "gap": "850"})) elif change == "shadow": element.append(element.makeelement(f"{HP}shadow", {"type": "PARALLEL_RIGHTBOTTOM", "offsetY": "283"})) - else: - document.styles.apply_paragraph_format(paragraphs=[document.paragraphs[-1]], line_spacing_percent=160) - hh = "{http://www.hancom.co.kr/hwpml/2011/head}" - shape_id = _body(document)[-1].get("paraPrIDRef") - for shape in document.oxml.headers[0].element.iter(f"{hh}paraPr"): - if shape.get("id") == shape_id: - for spacing in shape.iter(f"{hh}lineSpacing"): - spacing.set("type", "FIXED") - assert picture is not None write_line_caches(document) assert not _segments(_body(document)[-1]) + + +@pytest.mark.parametrize(("side", "tall"), [("TOP", 5669 + 850 + 1000), ("BOTTOM", 5669 + 850 + 1000), + ("LEFT", 5669), ("RIGHT", 5669)]) +def test_a_picture_with_a_caption_holds_it_in_its_line(side: str, tall: int) -> None: + # Hancom makes a picture's line as tall as the picture, the gap and the caption's line for a caption above or + # below it, and as the picture alone for one beside it, however tall the caption runs. + document = HwpxDocument.new() + document.add_picture(_png(), "png", width_mm=40, height_mm=20).set_caption("그림 1", side=side) + + write_line_caches(document) + + assert [(s["vertsize"], s["textheight"], s["spacing"]) for s in _segments(_body(document)[-1])] == [ + (str(tall), str(tall), "600")] + + +@pytest.mark.parametrize(("kind", "value", "spacing"), [("FIXED", 2500, 2500 - 5669), ("AT_LEAST", 6000, 6000 - 5669), + ("AT_LEAST", 3000, 0), ("BETWEEN_LINES", 300, 300)]) +def test_a_picture_in_a_paragraph_not_spaced_by_a_percentage_is_spaced_from_its_line( + kind: str, value: int, spacing: int) -> None: + # Hancom spaces such a line from its own height, the picture's: a fixed spacing keeps the next line that far + # down even below the picture, at least one no less than the picture. + document = HwpxDocument.new() + document.add_picture(_png(), "png", width_mm=40, height_mm=20) + _spaced(document, document.paragraphs[-1], kind, value) + + write_line_caches(document) + + assert [(s["vertsize"], s["spacing"]) for s in _segments(_body(document)[-1])] == [("5669", str(spacing))] + + +def test_pictures_side_by_side_go_on_lines_as_wide_as_the_column() -> None: + # Hancom puts as many pictures on a line as fit it, each line as tall as its tallest picture, each picture + # eight characters along the line's text: three pictures 20,000 wide (two to a line of 42,520) take two lines. + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + image = document.media.add_image(_png(), "png") + for height in (3000, 5000, 1000): + paragraph.add_picture(image, width=20000, height=height) + + write_line_caches(document) + + assert [(s["textpos"], s["vertsize"], s["spacing"]) for s in _segments(_body(document)[-1])] == [ + ("0", "5000", "600"), ("16", "1000", "600")] + + +def test_pictures_side_by_side_affecting_the_line_spacing_are_left_to_hancom() -> None: + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + image = document.media.add_image(_png(), "png") + for _ in range(2): + paragraph.add_picture(image, width=8000, height=3000).element.find(f"{HP}pos").set("affectLSpacing", "1") + + write_line_caches(document) + + assert not _segments(_body(document)[-1]) + + +def test_paragraphs_in_newspaper_columns_are_laid_out_as_wide_as_their_column() -> None: + # Two columns 20,660 wide: text breaks into lines as wide as a column, and from a paragraph starting two columns + # inside the section (its column definition left for Hancom) the paragraphs after it are laid out in those. + document = HwpxDocument.new() + document.add_paragraph("한 단 " + "가나다라마바사 " * 8) + document.page.set_columns(2, same_gap=1200, paragraph=document.add_paragraph("두 단 시작")) + document.add_paragraph("두 단 " + "가나다라마바사 " * 8) + document.add_picture(_png(), "png", width_mm=40, height_mm=20) + + write_line_caches(document) + + one, start, two, picture = (_segments(p) for p in _body(document)[1:]) + assert not start + assert [s["horzsize"] for s in one] == ["42520"] * 2 + assert [s["horzsize"] for s in two] == ["20660"] * 4 + assert [(s["vertsize"], s["horzsize"]) for s in picture] == [("5669", "20660")] + + +@pytest.mark.parametrize("kind", ["BALANCED_NEWSPAPER", "PARALLEL"]) +def test_paragraphs_in_balanced_or_parallel_columns_are_left_to_hancom(kind: str) -> None: + document = HwpxDocument.new() + document.page.set_columns(2, col_type=kind) + document.add_paragraph("두 단 글") + + write_line_caches(document) + + assert not _segments(_body(document)[-1]) + + +def test_the_page_of_an_object_text_runs_beside_and_the_next_are_left_to_hancom() -> None: + # Text beside a square-wrapped picture breaks into shorter lines: the page holding the picture's paragraph and + # the one after it are left for Hancom, the pages after those laid out as any other. + document = HwpxDocument.new() + image = document.media.add_image(_png(), "png") + document.add_paragraph("감싸기 그림 문단").add_picture( + image, width=12000, height=12000, treat_as_char=False, text_wrap="SQUARE", + pos_overrides={"vertRelTo": "PARA", "horzRelTo": "COLUMN"}) + for _ in range(100): + document.add_paragraph("가나다라마바사 아자차카타파하 " * 4) + document.add_picture(_png(), "png", width_mm=40, height_mm=20) + + write_line_caches(document) + + pages = [lines[0].page for lines in estimate_pages(document.to_bytes()).lines] + assert pages[-1] >= 3 + for paragraph, page in zip(_body(document)[1:], pages[1:]): # the template's first paragraph has its own + assert bool(_segments(paragraph)) == (page > 1), page + + +def test_pictures_hancom_lays_out_otherwise_than_the_estimate_keep_no_cache() -> None: + # The fixture ends with a picture under a caption too long for its lines, one with a glow, one with an outer + # shadow and two pictures affecting the line spacing: those get none, every picture before them does. + document = HwpxDocument.open(FIXTURES / "line_cache_pictures_kinds.hwpx") + for paragraph in _body(document): + for cache in paragraph.findall(f"{HP}linesegarray"): + paragraph.remove(cache) + + write_line_caches(document) + + pictures = [p for p in _body(document) if p.find(f".//{HP}pic") is not None] + assert [bool(_segments(p)) for p in pictures] == [True] * (len(pictures) - 4) + [False] * 4