diff --git a/CHANGELOG.md b/CHANGELOG.md index f9fb09e7..d652ed1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,27 @@ - 쪽 수 추정이 문서 끝 미주를 따른다. 미주는 본문 마지막 줄 바닥 아래에 미주 영역의 위 간격·구분선·아래 간격을 두고 줄마다 이어지며 쪽 끝을 넘어간다. 각주가 자리를 차지한 쪽 뒤에서는 다음 쪽에서 시작한다. 전에는 미주가 있으면 추정하지 않았다. 구역 끝 미주와 여러 단 구역 뒤 미주는 아직 추정하지 않는다. -- (실험) `hwpx.layout.line_cache.write_line_caches(doc)`가 본문의 글만 든 문단에 한/글이 저장할 때 쓰는 줄 배치 정보 +- (실험) `hwpx.layout.line_cache.write_line_caches(doc)`가 글만 든 문단에 한/글이 저장할 때 쓰는 줄 배치 정보 (`hp:linesegarray`)를 쓴다. 줄 높이·글 높이·기준선·줄 간격은 한/글이 쓰는 값 그대로, 줄 시작·위치·폭은 쪽 수 추정이 - 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. 글자 크기와 모양(글꼴·장평·자간)이 하나이고 개체·사용자 탭 정의가 없는 문단만(내어 쓰는 번호·글머리표는 된다), 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역에서만 쓰고, 나머지 문단은 전처럼 - 한/글이 열 때 배치한다. 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 문단도 쓰지 않는다. 이미 배치 정보가 있는 문단은 그대로 두고, 쪽 수 추정이 문서를 따르지 못하면 아무것도 쓰지 않는다. - 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. 개체·사용자 탭 정의가 없는 문단만(run마다 글꼴·장평·자간이 달라도 된다)(머리말·꼬리말·쪽 번호처럼 줄에 자리를 차지하지 않는 컨트롤은 된다)(글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다)(내어 쓰는 번호·글머리표는 된다), 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역에서만 쓰고, 나머지 문단은 전처럼 - 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. 글자 모양(글꼴·장평·자간)이 하나이고 개체·사용자 탭 정의가 없는 문단만(글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다)(내어 쓰는 번호·글머리표는 된다), 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역에서만 쓰고(그 구역 표의 칸도 칸 안 문단이 모두 그런 문단이면 쓰고, 글자처럼 놓인 표만 든 문단에는 표 높이의 한 줄을 쓰며 표의 `hp:sz` 높이도 행 높이의 합으로 쓴다), 나머지 문단은 전처럼 - 한/글이 열 때 배치한다. 이미 배치 정보가 있는 문단은 그대로 두고, 쪽 수 추정이 문서를 따르지 못하면 아무것도 쓰지 않는다. + 놓는 대로이고, 탭에는 한/글이 배치한 폭을 쓴다. + - 쓰는 문단: 단이 하나이고 글이 개체 옆으로 흐르지 않는 구역의, 개체·사용자 탭 정의가 없는 문단. run마다 글꼴이 + 달라도 되고, 글자 크기가 섞이면 줄마다 가장 큰 글자의 높이로 쓴다. 내어 쓰는 번호·글머리표와 머리말·꼬리말· + 쪽 번호처럼 줄에 자리를 차지하지 않는 컨트롤은 된다. + - 그 구역 표의 칸도 칸 안 문단이 모두 그런 문단이면 쓴다. 글자처럼 놓인 표만 든 문단에는 표 높이의 한 줄을 쓰고, 표의 + `hp:sz` 높이도 행 높이의 합으로 쓴다. 표 위·아래 캡션은 그 줄 높이와 간격만큼 표의 줄에 더한다(옆 캡션은 더하지 + 않는다). + - 글자 크기보다 큰 빈 run으로 끝나는 문단은 마지막 줄의 글 높이도 그 크기로 쓴다. 기준선은 문단 모양의 세로 정렬대로 + 글꼴 기준이면 글 높이의 85 %, 위면 0, 가운데면 절반, 아래면 글 높이 그대로 쓴다. + - 한/글은 줄이 어디서 나뉘는지도 배치 정보를 따르므로, 줄 나눔이 덜 확실한 문단은 쓰지 않는다: 한컴오피스 2024 + 데스크톱에서 폭을 확인한 글꼴(함초롬·맑은 고딕·바탕·돋움·굴림·궁서·한컴 고딕·HY헤드라인M 등·휴먼·한양·HCI)이 + 아니거나, 굵은 글자가 한양 글꼴·HCI Hollyhock이거나(한/글은 한양 글꼴의 굵은 글자를 1/20 em 넓게 그린다), + 장평 50~200 %·자간 −30~30 %·최소 공백 50 % 밖이거나, 최소 공백이나 자간이 있는 문단에 묶음 빈칸이 있거나, 한글 음절과 + 확인한 기호 밖의 글자가 든 문단, 쪽 컨트롤이 글자 뒤에 있는 문단, 글자 폭 표에 없는 글자나 한 줄보다 긴 낱말이 든 + 문단, 줄 폭을 100 HWPUNIT + 좁히거나 넓히면 줄 나눔이 바뀌는 문단, 탭 앞 글이 탭 정지 위치에서 줄 처음이나 앞 탭 정지부터 센 글자 수 × 4 + HWPUNIT(배치 단위, 많아야 100) 안쪽인 문단. + - 나머지 문단과 이미 배치 정보가 있는 문단은 그대로 두어 한/글이 열 때 배치한다. 쪽 수 추정이 문서를 따르지 못하면 + 아무것도 쓰지 않는다. 기본 저장은 바뀌지 않는다. - 그림을 만들 때(`doc.add_picture`, `paragraph.add_picture`) 그림 효과와 테두리를 줄 수 있다. - `brightness`(밝기)·`contrast`(대비): -100~100. `effect`: `REAL_PIC`(원래 그림)·`GRAY_SCALE`(회색조)· diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 58932917..32fa587a 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -17,9 +17,12 @@ 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 -face, 장평 or 자간, 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. +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 +paragraph that sets its characters at the top, the centre or the bottom of their lines gets the baseline Hancom +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 @@ -28,8 +31,11 @@ paragraph of its cells is laid out surely. A paragraph is left for Hancom when its line breaks are less sure: a character no glyph table gives an advance -for, or a word too long for a line. 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. +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 +laid out as one. Nothing is written when the estimate does not follow the document. """ from __future__ import annotations @@ -40,6 +46,7 @@ from ..form_fit.measure import ( _FIXED_SPACE, _GLYPH_SPACE, + _GLYPHS, _HALF_EM_GLYPHS, _HALF_EM_SPACES, _design_units, @@ -54,7 +61,7 @@ ) from ..oxml.table_sizes import cell_margins_of, grid_widths_of from . import pages as _pages -from .pages import HP +from .pages import HH, HP if TYPE_CHECKING: from ..document import HwpxDocument @@ -72,6 +79,33 @@ _PAST_A_BREAK = 40 #: The line starts with the paragraph's bullet or number label. _LABELLED = 0x200000 +#: The baseline Hancom writes for a line of text *height* tall, by where its paragraph sets the characters in +#: their lines (``hh:align``'s ``vertical``). +_BASELINES = { + "BASELINE": lambda height: (height * 85 + 50) // 100, + "TOP": lambda height: 0, + "CENTER": lambda height: height // 2, + "BOTTOM": lambda height: height, +} +#: The faces whose glyphs Hancom Office 2024 was seen to lay out at the advances FormFit gives them, on the +#: desktop: a face Hancom may draw in another (one not installed, a name no table lists) is left for Hancom. +_SURE_FACES = frozenset({ + "함초롬바탕", "함초롬돋움", "맑은 고딕", "바탕", "바탕체", "돋움", "돋움체", "굴림", "굴림체", "궁서", "궁서체", + "한컴 고딕", "HY헤드라인M", "HY견고딕", "HY울릉도M", "HY견명조", "HY동녘M", "휴먼명조", "휴먼고딕", "휴먼옛체", + "한양중고딕", "한양신명조", "한양견고딕", "HCI Poppy", "HCI Hollyhock", +}) +#: The faces Hancom was seen to lay bold characters out in at their regular advances. The 한양 faces it draws a +#: twentieth of an em wider when bold, and some glyphs of HCI Hollyhock: bold characters in them are left. +_BOLD_SURE_FACES = _SURE_FACES - {"한양중고딕", "한양신명조", "한양견고딕", "HCI Hollyhock"} +#: The 장평 (%), 자간 (%) and 최소 공백 (%) Hancom was seen to lay lines out at as FormFit does, on the desktop. +_SURE_RATIOS = (50, 200) +_SURE_SPACINGS = (-30, 30) +_SURE_CONDENSE = 50 +#: The glyphs other than Hangul syllables whose advances were seen on the desktop in those faces. +_SURE_GLYPHS = frozenset(_GLYPHS) +#: How much narrower or wider (HWPUNIT) every line may be laid out and still break at the same places for its +#: paragraph to get a cache: a width a little off from Hancom's must not move a line break. +_MARGIN = 100 #: How many characters a control (a section or column definition) takes in a line's text position. _CONTROL_LENGTH = 8 #: The controls that take no room on a line: page headers and footers, page numbers and the page controls. @@ -159,12 +193,15 @@ def _table_line(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[str return None if not _cells_sure(measure, table): # the rows are as tall as their cells' lines: those must be sure too return None - if not _cells_sure(measure, table): # the rows are as tall as their cells' lines: those must be sure too + if _vertical_align(measure, paragraph.get("paraPrIDRef")) != "BASELINE": + return None + if not _caption_sure(measure, table): return None shape = measure.shape(paragraph.get("paraPrIDRef")) size = max(measure.char_height(run.get("charPrIDRef")) for run in runs) # spaced from its largest characters rows = _pages._inline_table_height(measure, table) - tall = rows + _pages._extent_margins(table) + # a caption above or below the table takes its lines and its gap in the line too, one beside it none + tall = rows + _pages._extent_margins(table) + sum(_pages._caption(measure, table)) advance = _pages._object_pitch(shape, table, tall, size) table.find(f"{HP}sz").set("height", str(rows)) room = width - shape.left - shape.right @@ -247,7 +284,8 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, return None headed = measure.headed(paragraph) shape = measure.shape(paragraph.get("paraPrIDRef")) - if shape.font_line: + vertical = _vertical_align(measure, paragraph.get("paraPrIDRef")) + if shape.font_line or vertical not in _BASELINES or not _bold_sure(measure, paragraph, runs): return None size, refs, sizes = _pages._text_size(measure, runs) end = _pages._end_size(measure, runs, sizes) @@ -264,10 +302,14 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, if mixed or looks is not None: if mixed and "\t" in text: # a tab among characters of several sizes: its width is less sure return None - starts = _pages._metric_starts(measure, text, widths, sizes, style, {}, looks) + def lay_out(room: list[float]) -> list[int]: + return _pages._metric_starts(measure, text, room, sizes, style, {}, looks) + starts = lay_out(widths) metrics = _pages._line_metrics(measure, text, widths, sizes, style, shape, {}, looks, end=end) else: - starts = measure.line_starts(text, widths, size, style) if text else [0] + def lay_out(room: list[float]) -> list[int]: + return measure.line_starts(text, room, size, style) if text else [0] + starts = lay_out(widths) metrics = ((size, _pages._pitch(shape.kind, shape.value, size)),) * len(starts) if len(metrics) != len(starts): return None @@ -275,6 +317,8 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, 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 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 [] if tabs is None: return None @@ -283,14 +327,18 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, ends = [*starts[1:], len(text)] for line, (start, (height, advance)) in enumerate(zip(starts, metrics)): drawn = [sizes[index] for index in range(start, ends[line]) if text[index] != "\n"] if mixed else [] + if end and line == len(starts) - 1: + drawn.append(end) # the paragraph's end, larger than its text: the last line's text is as tall text_height = max(drawn) if drawn else height # a line break larger than the text makes the line taller + if vertical == "CENTER" and text_height % 2: + return None # half an odd text height: which way Hancom rounds it was not seen takes_indent = (indented and (shape.indent > 0 if line == 0 else shape.indent < 0)) or (headed and line > 0) segments.append({ "textpos": _hancom_offset(text, start, controls) if line else 0, # the first line starts at 0 "vertpos": 0, "vertsize": height, "textheight": text_height, - "baseline": (text_height * 85 + 50) // 100, + "baseline": _BASELINES[vertical](text_height), "spacing": advance - height, "horzpos": shape.left, # a cell's lines in whole layout units, and never narrower than Hancom's narrowest line @@ -305,25 +353,80 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, def _sure_of(text: str, starts: list[int], style: Any, looks: list[Any] | None = None) -> bool: - """Whether the line breaks at *starts* can be trusted to be Hancom's: every character takes an advance a - glyph table gives (not a class average), and no line starts inside a word that did not fit on a line of its - own. Hancom takes the line breaks from a cache as written (a line short of a break is drawn with its text + """Whether the line breaks at *starts* can be trusted to be Hancom's: every character is set in a face of + :data:`_SURE_FACES` at 장평, 자간 and 최소 공백 within those seen on the desktop, takes an advance a glyph table gives (not a class + average) and is a Hangul syllable or one of :data:`_SURE_GLYPHS`, and no line starts inside a word that did not + fit on a line of its own. Hancom takes the line breaks from a cache as written (a line short of a break is drawn with its text pressed together), so a paragraph laid out less surely is left for Hancom.""" - if style is None: + if style is None or style.condense > _SURE_CONDENSE: return False + if " " in text and (style.condense or any( + look.spacing or any(spacing for _, _, spacing in look.scripts) for look in set(looks or [style]))): + return False # a no-break space among spaces that shrink, or with 자간 (it takes none), is left + for look in set(looks or [style]): + if any(not _SURE_RATIOS[0] <= ratio <= _SURE_RATIOS[1] or not _SURE_SPACINGS[0] <= spacing <= _SURE_SPACINGS[1] + for ratio, spacing in [(look.ratio, look.spacing), *((r, sp) for _, r, sp in look.scripts)]): + return False # 장평 and 자간 past those seen on the desktop + if look.hangul_face not in _SURE_FACES or look.glyph_face not in _SURE_FACES: + return False for ch, look in set(zip(text, looks or [style] * len(text))): if ch in "\t\n" or ch in _HALF_EM_SPACES or ch in _HALF_EM_GLYPHS or ch in (_FIXED_SPACE, _GLYPH_SPACE): continue if classify_char(ch) == "hangul": if _hangul_units(_laid_out_face(look.hangul_face), ch) is None: return False - elif _design_units(_laid_out_face(look.glyph_face), ch) is None: + elif ch not in _SURE_GLYPHS or _design_units(_laid_out_face(look.glyph_face), ch) is None: return False breaks = _hancom_break_opportunities(text, style) return all(start in breaks or text[start - 1] == "\n" for start in starts[1:]) +def _bold_sure(measure: Any, paragraph: Any, runs: list[Any]) -> bool: + """Whether every bold run of *paragraph* holding text is set in faces of :data:`_BOLD_SURE_FACES`.""" + + bold = getattr(measure, "_line_cache_bold", None) + if bold is None: + bold = {shape.get("id"): shape.find(f"{HH}bold") is not None for shape in measure._header.iter(f"{HH}charPr")} + measure._line_cache_bold = bold + for run in runs: + ref = run.get("charPrIDRef") + if not bold.get(ref) or not _pages._run_text([run]): + continue + style = measure.style(paragraph.get("paraPrIDRef"), [ref], paragraph) + if style is None or style.hangul_face not in _BOLD_SURE_FACES or style.glyph_face not in _BOLD_SURE_FACES: + return False + return True + + +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.""" + + aligned = getattr(measure, "_line_cache_vertical", None) + if aligned is None: + aligned = {} + for shape in measure._header.iter(f"{HH}paraPr"): + align = shape.find(f"{HH}align") + aligned[shape.get("id")] = "BASELINE" if align is None else align.get("vertical", "BASELINE") + measure._line_cache_vertical = aligned + return aligned.get(para_pr_id) + + +def _caption_sure(measure: Any, table: Any) -> bool: + """Whether *table*'s caption, if any, takes room Hancom lays out surely: none beside the table, and above or + below it only when every paragraph of the caption is one this module lays out.""" + + caption = table.find(f"{HP}caption") + if caption is None or caption.get("side") in ("LEFT", "RIGHT"): + return True + if caption.get("side") not in ("TOP", "BOTTOM"): + return False + width = int(caption.get("lastWidth", 0)) or int(table.find(f"{HP}sz").get("width", 0)) + paragraphs = caption.findall(f"{HP}subList/{HP}p") + return bool(paragraphs) and all(_lines(measure, p, width, first=False, cell=True) is not None for p in paragraphs) + + def _taller_past_a_break(text: str, sizes: list[int], starts: list[int], metrics: tuple[tuple[int, int], ...]) -> bool: """Whether a character past a line's end that may be the one that did not fit on it (the next line's first word @@ -359,19 +462,31 @@ def _tab_widths(text: str, starts: list[int], widths: list[float], size: int, st origin = _line_origin(style, logical == 0 and visual == 0) own_looks = None if looks is None else looks[logical:logical + len(own)] used = 0.0 + since = start # the first character after the line's start or the last tab stop reached: exact places for index in range(start, end): ch = text[index] if ch != "\t": used += char_advance(ch, points, style if looks is None else looks[index]) continue at = index - logical - reach = tab_reach(origin + used, origin + width, style, - lambda point, at=at: _width_after_tab(own, at, points, style, None, own_looks, None, - point)) + + def after(point: bool, at: int = at) -> float: + return _width_after_tab(own, at, points, style, None, own_looks, None, point) + + reach = tab_reach(origin + used, origin + width, style, after) if reach is None: return None + # text this close to a tab stop may reach another one in Hancom: a layout unit off for each character + # since the line's start or the last tab stop, at most _MARGIN + margin = min((index - since) * _LAYOUT_UNIT, _MARGIN) + for shift in (-margin, margin) if margin else (): + moved = tab_reach(origin + used + shift, origin + width, style, after) + if moved is None or abs(shift + moved - reach) > 0.5: + return None found.append(round(reach)) used += reach + if reach: + since = index + 1 return found @@ -389,7 +504,7 @@ 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).""" + (and in a section's first paragraph, the section's own definitions), all of them before the text.""" places: list[int] = [] length = 0 @@ -402,8 +517,8 @@ def _control_places(runs: list[Any], first: bool) -> list[int] | 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): - places.append(length) + 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 else: return None return places diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index d0d172be..9060eb8a 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -11,6 +11,7 @@ from hwpx.layout.line_cache import write_line_caches HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" +HH = "{http://www.hancom.co.kr/hwpml/2011/head}" FIXTURES = Path(__file__).parent / "fixtures" / "hancom_saved" # Hancom Office laid these documents out from python-hwpx's paragraphs without a cache and saved them. HANCOM_LAID_OUT = ["line_cache_tabs.hwpx", "line_cache_line_breaks.hwpx", "line_cache_hanging_indents.hwpx", @@ -79,6 +80,126 @@ def test_a_paragraph_holding_a_table_gets_one_line_as_tall_as_it() -> None: assert int(line["vertsize"]) == int(table.find(f"{HP}sz").get("height")) > 282 * 2 +@pytest.mark.parametrize(("side", "gap", "extra"), [("TOP", 850, 1000 + 850), ("BOTTOM", 566, 1000 + 566), + ("LEFT", 850, 0), ("RIGHT", 850, 0)]) +def test_a_table_with_a_caption_holds_it_in_its_line(side: str, gap: int, extra: int) -> None: + # A caption above or below the table takes its line (1000 for one line of 10 pt) and its gap in the table's + # line, as Hancom saves it; one beside the table takes none. + document = HwpxDocument.new() + table = document.add_table(2, 2) + table.set_caption("표 1", side=side, gap=gap) + + write_line_caches(document) + + [line] = _segments(_body(document)[-1]) + rows = int(_body(document)[-1].find(f"{HP}run/{HP}tbl/{HP}sz").get("height")) + assert int(line["vertsize"]) == rows + extra + + +@pytest.mark.parametrize(("vertical", "baseline"), [("BASELINE", "850"), ("TOP", "0"), ("CENTER", "500"), + ("BOTTOM", "1000")]) +def test_a_paragraph_gets_the_baseline_of_where_it_sets_its_characters(vertical: str, baseline: str) -> None: + # Hancom writes the baseline at 85 % of the text height for characters on their baseline, 0 at the top of + # their line, half of it at the centre and all of it at the bottom. + document = HwpxDocument.new() + paragraph = document.add_paragraph("가나다") + document.styles.apply_paragraph_format(paragraphs=[paragraph], alignment="LEFT") + shape_id = _body(document)[-1].get("paraPrIDRef") + shape = next(el for el in document.oxml.headers[0].element.iter(f"{HH}paraPr") if el.get("id") == shape_id) + shape.find(f"{HH}align").set("vertical", vertical) + + report = write_line_caches(document) + + assert report.written == 1 + assert _segments(_body(document)[-1])[0]["baseline"] == baseline + + +def test_a_larger_paragraph_end_makes_the_last_lines_text_as_tall() -> None: + # An empty run ending the paragraph, larger than its text, makes its last line and that line's text as tall. + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + paragraph.add_run("가나다", char_pr_id_ref=document.styles.ensure_run(size=10)) + paragraph.add_run("", char_pr_id_ref=document.styles.ensure_run(size=16)) + + report = write_line_caches(document) + + assert report.written == 1 + [line] = _segments(_body(document)[-1]) + assert (line["vertsize"], line["textheight"], line["baseline"]) == ("1600", "1600", "1360") + + +@pytest.mark.parametrize("style", [{"font": "HY신명조"}, {"font": "KoPub바탕체 Light"}, {"letter_spacing": -40}, + {"ratio": 210}, {"font": "한양신명조", "bold": True}, + {"font": "한양견고딕", "bold": True}]) +def test_a_paragraph_in_a_face_or_look_not_seen_on_the_desktop_is_left_for_hancom(style: dict) -> None: + # A face Hancom may draw in another one, or 장평 and 자간 past those seen on the desktop, are left for Hancom. + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + paragraph.add_run("가나다 " * 30, char_pr_id_ref=document.styles.ensure_run(**style)) + + report = write_line_caches(document) + + assert report.written == 0 and report.skipped == 1 + + +@pytest.mark.parametrize("style", [{"letter_spacing": -5}, {"ratio": 90}, {"ratio": 110, "letter_spacing": 5}, + {"font": "한양신명조"}, {"font": "맑은 고딕", "bold": True}, + {"font": "휴먼명조", "bold": True}]) +def test_a_paragraph_at_a_look_seen_on_the_desktop_gets_a_cache(style: dict) -> None: + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + paragraph.add_run("가나다 " * 5, char_pr_id_ref=document.styles.ensure_run(**style)) + + report = write_line_caches(document) + + assert report.written == 1 + + +def test_a_page_control_among_the_text_is_left_for_hancom() -> None: + # A page control among the text (not before it) was seen to break the line elsewhere than FormFit. + document = HwpxDocument.new() + document.add_paragraph("가나다 " * 40) + run = _body(document)[-1].find(f"{HP}run") + ctrl = run.makeelement(f"{HP}ctrl", {}) + ctrl.append(ctrl.makeelement(f"{HP}pageHiding", {"hideHeader": "0", "hideFooter": "0", "hideMasterPage": "0", + "hideBorder": "0", "hideFill": "0", "hidePageNum": "1"})) + text = run.find(f"{HP}t") + tail = run.makeelement(f"{HP}t", {}) + tail.text, text.text = text.text[1:], text.text[:1] + text.addnext(ctrl) + ctrl.addnext(tail) + + report = write_line_caches(document) + + assert report.written == 0 and report.skipped == 1 + + +@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 + # 자간 takes none of it: such paragraphs are left for Hancom. + document = HwpxDocument.new() + paragraph = document.add_paragraph("", include_run=False) + paragraph.add_run("가나 다라 마바", char_pr_id_ref=document.styles.ensure_run(letter_spacing=spacing)) + shape_id = _body(document)[-1].get("paraPrIDRef") + next(el for el in document.oxml.headers[0].element.iter(f"{HH}paraPr") if el.get("id") == shape_id).set( + "condense", condense) + + report = write_line_caches(document) + + assert report.written == written + + +def test_a_glyph_not_seen_on_the_desktop_is_left_for_hancom() -> None: + # "「" (U+FF62) has a table advance read from the font, not one seen on the desktop. + document = HwpxDocument.new() + document.add_paragraph("「가나다」") + + report = write_line_caches(document) + + assert report.written == 0 and report.skipped == 1 + + def _cell_paragraphs(document: HwpxDocument) -> list: return [p for section in document.oxml.sections for cell in section.element.iter(f"{HP}tc") for p in cell.findall(f"{HP}subList/{HP}p")] @@ -114,6 +235,31 @@ def test_a_tab_gets_the_width_hancom_lays_it_out_at() -> None: assert dict(tab.attrib) == {"width": "2056", "leader": "0", "type": "1"} +@pytest.mark.parametrize(("head", "written"), [("가" * 16, 1), ("가" * 16 + "!", 0)]) +def test_a_tab_close_to_a_tab_stop_is_left_for_hancom(head: str, written: int) -> None: + # Sixteen 가 take 15,552 (972 each), 448 short of the stop at 16,000: their tab reaches it surely. With a "!" (half + # an em) they take 16,052, 52 past it, closer than a layout unit for each of the 17 characters: text that much + # off from Hancom's would take the tab to another stop. + document = HwpxDocument.new() + document.add_paragraph(f"{head}\t내용") + + report = write_line_caches(document) + + assert report.written == written and report.skipped == 1 - written + + +def test_a_tab_right_after_a_tab_starts_at_its_stop_surely() -> None: + # The first tab ends at the stop at 4000, exactly where the second one starts: no character before the second + # tab could be off, so it reaches the stop at 8000 surely. + document = HwpxDocument.new() + document.add_paragraph("항목\t\t내용") + + report = write_line_caches(document) + + assert report.written == 1 + assert [tab.get("width") for tab in _body(document)[-1].iter(f"{HP}tab")] == ["2056", "4000"] + + def test_a_line_break_typed_into_the_text_is_left_for_hancom() -> None: # Hancom writes a line break as hp:lineBreak; a newline in hp:t's own text is left for it to lay out. document = HwpxDocument.new() @@ -136,6 +282,19 @@ def test_a_paragraph_laid_out_less_surely_is_left_for_hancom(text: str) -> None: assert report.written == 0 and report.skipped == 1 +@pytest.mark.parametrize(("right_mm", "written"), [(6.0, 1), (7.0, 0)]) +def test_a_line_ending_close_to_its_room_is_left_for_hancom(right_mm: float, written: int) -> None: + # Twelve words of 3,416 HWPUNIT (the last without its space) take 40,492 of the line. 7 mm to the right leaves + # it 44 more, and a width a little off from Hancom's could break the line elsewhere; 6 mm leaves 327. + document = HwpxDocument.new() + paragraph = document.add_paragraph("가나다 " * 20) + document.styles.apply_paragraph_format(paragraphs=[paragraph], indent_right_mm=right_mm) + + report = write_line_caches(document) + + assert report.written == written and report.skipped == 1 - written + + def test_a_paragraph_of_several_character_sizes_gets_a_cache() -> None: # Hancom makes each line as tall as its largest character and draws its text that high. document = HwpxDocument.open(FIXTURES / "line_cache_mixed_sizes.hwpx")