diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d9a4ce9..19bca2ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,6 +130,9 @@ - 쪽 수 추정(실험, `estimate_pages`)이 탭 정의를 한 번만 읽는다. 전에는 글자 모양을 볼 때마다 머리 정보의 탭 정의 전체를 다시 읽어 큰 문서(185쪽)에서 6분 가까이 걸렸다(이제 약 2분). `write_line_caches`는 쪽 수 추정이 따르지 못하는 첫 구역에서 멈춘다(그런 문서에는 어차피 아무것도 쓰지 않는다). 그 구역 하나의 사유만 알린다. +- `doc.styles.replace_font`가 바뀐 글꼴을 쓰는 문단의 줄 배치 정보(`hp:linesegarray`)를 지운다. 한/글은 남은 정보의 + 줄 나눔을 그대로 따르므로, 전에는 새 글꼴이 옛 글꼴의 줄 폭에 맞춰 눌리거나 겹쳐 보일 수 있었다. + `doc.parts.set_paragraph_auto_spacing`도 값이 바뀌면 그 문단 모양을 쓰는 문단의 줄 배치 정보를 지운다. - FormFit과 쪽 수 추정이 한양 글꼴(한양신명조·한양중고딕·한양견고딕)의 굵은 글자를 em의 1/20만큼 넓게 잡는다. 한/글은 그 폭을 더한 뒤 배치 단위로 반올림한다(14pt "가"가 1,400이 아니라 1,472). 전에는 굵은 글자도 보통 폭으로 셌다. 다른 글꼴의 굵은 글자는 전처럼 보통 폭이다. `TextStyle.bold`가 글자 모양의 굵게를 담는다. diff --git a/src/hwpx/oxml/header_compat.py b/src/hwpx/oxml/header_compat.py index 97058ecb..c2caba27 100644 --- a/src/hwpx/oxml/header_compat.py +++ b/src/hwpx/oxml/header_compat.py @@ -304,6 +304,7 @@ def apply_paragraph_auto_spacing( ) auto_spacing_elements = [auto_spacing] + before = [dict(auto_spacing.attrib) for auto_spacing in auto_spacing_elements] for auto_spacing in auto_spacing_elements: if e_asian_eng is not None: auto_spacing.set("eAsianEng", _zero_one_bool_str(e_asian_eng)) @@ -315,3 +316,8 @@ def apply_paragraph_auto_spacing( auto_spacing.set("eAsianNum", "0") header.mark_dirty() + document = header.document + if document is not None and before != [dict(a.attrib) for a in auto_spacing_elements]: + # the gaps between Hangul and Latin or digits change, so do the cached line breaks + from .header_fonts import clear_line_caches + clear_line_caches(document, para_pr_ids={para_pr.get("id")}) diff --git a/src/hwpx/oxml/header_fonts.py b/src/hwpx/oxml/header_fonts.py index 038e8423..2c689332 100644 --- a/src/hwpx/oxml/header_fonts.py +++ b/src/hwpx/oxml/header_fonts.py @@ -35,6 +35,8 @@ _FONT_FACE_LANG_TO_REF, _HC, _HH, + _HP, + _clear_paragraph_layout_cache, _element_local_name, _normalize_font_langs, _validate_font_type, @@ -42,9 +44,10 @@ from .header import Font, parse_font if TYPE_CHECKING: + from .document_parts import HwpxOxmlDocument from .header_part import HwpxOxmlHeader -__all__ = ["border_fill_info", "font_face", "fonts", "replace_font"] +__all__ = ["border_fill_info", "clear_line_caches", "font_face", "fonts", "replace_font"] #: ``hh:borderFill`` side children, in ``BorderFillInfo`` field order. _BORDER_SIDES = ("leftBorder", "rightBorder", "topBorder", "bottomBorder", "diagonal") @@ -226,6 +229,11 @@ def replace_font( *dst_face* it is ````. *font_type* (``REP``/``TTF``/``HFT``), when given, sets the ``type`` of every font this call appends; fonts that already exist are not changed. + + Every paragraph with a run whose ``hh:charPr`` now names another face + loses its line layout cache (``hp:linesegarray``): the cached line + starts and widths belong to the old face, and Hancom keeps a cache's + line breaks, so the new face would be drawn squeezed into the old lines. """ src = (src_face or "").strip() @@ -259,6 +267,7 @@ def replace_font( None, ) + faces_before = _char_faces(header) changed: list[str] = [] declared: list[str] = [] repointed = 0 @@ -323,6 +332,11 @@ def replace_font( document = header.document if document is not None: document.invalidate_char_property_cache() + faces_after = _char_faces(header) + clear_line_caches( + document, + char_pr_ids={char_id for char_id, faces in faces_after.items() if faces_before.get(char_id) != faces}, + ) return FontReplaceReport( src_face=src, dst_face=dst, @@ -330,3 +344,50 @@ def replace_font( repointed=repointed, declared=tuple(declared), ) + + +def _char_faces(header: "HwpxOxmlHeader | None") -> dict[str | None, tuple[str | None, ...]]: + """The faces every ``hh:charPr`` names, one per ``hh:fontRef`` language, by charPr id.""" + + if header is None: + return {} + faces: dict[str | None, tuple[str | None, ...]] = {} + blocks = {lang: _fontface_block(header, lang) for lang in _FONT_FACE_LANG_TO_REF} + by_id = { + lang: {font.get("id"): font.get("face") for font in block.findall(f"{_HH}font")} + for lang, block in blocks.items() + if block is not None + } + for char_pr in header.element.iter(f"{_HH}charPr"): + font_ref = char_pr.find(f"{_HH}fontRef") + faces[char_pr.get("id")] = tuple( + None if font_ref is None else by_id.get(lang, {}).get(font_ref.get(attr)) + for lang, attr in _FONT_FACE_LANG_TO_REF.items() + ) + return faces + + +def clear_line_caches( + document: "HwpxOxmlDocument", + *, + char_pr_ids: Iterable[str | None] = (), + para_pr_ids: Iterable[str | None] = (), +) -> None: + """Drop the line caches (``hp:linesegarray``) of every section paragraph whose + ``paraPrIDRef`` is in *para_pr_ids* or that has a run whose ``charPrIDRef`` is + in *char_pr_ids*: an edit changed that shape in place, so the cached line + breaks no longer fit, and Hancom keeps a cache's line breaks. + """ + + char_ids, para_ids = set(char_pr_ids), set(para_pr_ids) + if not char_ids and not para_ids: + return + for section in document.sections: + removed = 0 + for paragraph in section.element.iter(f"{_HP}p"): + if paragraph.get("paraPrIDRef") in para_ids or any( + run.get("charPrIDRef") in char_ids for run in paragraph.findall(f"{_HP}run") + ): + removed += _clear_paragraph_layout_cache(paragraph) + if removed: + section.mark_dirty() diff --git a/tests/test_header_compat_authoring.py b/tests/test_header_compat_authoring.py index f736a98c..016cbf17 100644 --- a/tests/test_header_compat_authoring.py +++ b/tests/test_header_compat_authoring.py @@ -291,3 +291,29 @@ def test_real_corpus_document_options_round_trip_untouched(sample_path: Path) -> else [] ) assert [p.auto_spacing for p in props_before] == [p.auto_spacing for p in props_after] + + +def test_set_paragraph_auto_spacing_drops_the_line_caches_of_its_paragraphs() -> None: + """The Hangul-Latin gaps change, so the cached line breaks of the paragraphs + with that shape no longer fit; other paragraphs keep their caches, and a + call that changes nothing keeps them all.""" + + from lxml import etree + + hp = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" + document = HwpxDocument.new() + para_pr_id = document.oxml.headers[0].ensure_paragraph_format(alignment="LEFT") + edited = document.add_paragraph("한글 English 123").element + edited.set("paraPrIDRef", para_pr_id) + kept = document.add_paragraph("다른 문단").element + kept.set("paraPrIDRef", "0") + for paragraph in (edited, kept): + cache = etree.SubElement(paragraph, f"{hp}linesegarray") + etree.SubElement(cache, f"{hp}lineseg", {"textpos": "0", "vertsize": "1000"}) + + document.parts.set_paragraph_auto_spacing(para_pr_id, e_asian_eng=False, e_asian_num=False) + assert edited.find(f"{hp}linesegarray") is not None # already 0/0: nothing changed + document.parts.set_paragraph_auto_spacing(para_pr_id, e_asian_eng=True) + + assert edited.find(f"{hp}linesegarray") is None + assert kept.find(f"{hp}linesegarray") is not None diff --git a/tests/test_style_font_border_reads.py b/tests/test_style_font_border_reads.py index e7f1e797..6a71c8fa 100644 --- a/tests/test_style_font_border_reads.py +++ b/tests/test_style_font_border_reads.py @@ -777,3 +777,54 @@ def test_replace_font_rejects_an_unknown_font_type_before_mutating() -> None: assert caught.value.code == "style-font-type-invalid" assert etree.tostring(_header(doc)) == header_before + + +# --------------------------------------------------------------------------- +# replace_font and the paragraphs' line layout caches + +HP = "{http://www.hancom.co.kr/hwpml/2011/paragraph}" + + +def _cached_paragraph(doc: HwpxDocument, text: str, char_pr_id: str) -> etree._Element: + paragraph = doc.add_paragraph(text).element + for run in paragraph.findall(f"{HP}run"): + run.set("charPrIDRef", char_pr_id) + cache = etree.SubElement(paragraph, f"{HP}linesegarray") + etree.SubElement(cache, f"{HP}lineseg", { + "textpos": "0", "vertpos": "0", "vertsize": "1000", "textheight": "1000", + "baseline": "850", "spacing": "600", "horzpos": "0", "horzsize": "42520", "flags": "393216", + }) + return paragraph + + +def _texts_with_cache(doc: HwpxDocument) -> list[str]: + return [ + "".join(t.text or "" for t in p.iter(f"{HP}t")) + for section in doc.oxml.sections + for p in section.element.iter(f"{HP}p") + if p.find(f"{HP}linesegarray") is not None + ] + + +def test_replace_font_drops_the_line_caches_of_paragraphs_it_changes() -> None: + doc = _decor_doc() + _cached_paragraph(doc, "바뀌는 글꼴의 문단", "0") + _cached_paragraph(doc, "그대로인 글꼴의 문단", "1") + + doc.styles.replace_font(DECOR, DST) + + # the old face's line breaks would squeeze the new face; the other paragraph keeps its cache + assert _texts_with_cache(doc) == ["그대로인 글꼴의 문단"] + reopened = HwpxDocument.open(io.BytesIO(doc.to_bytes())) + assert _texts_with_cache(reopened) == ["그대로인 글꼴의 문단"] + + +def test_replace_font_in_other_blocks_only_keeps_unchanged_paragraph_caches() -> None: + doc = _decor_doc() + _cached_paragraph(doc, "가", "3") + _cached_paragraph(doc, "나", "0") + + doc.styles.replace_font(DECOR, DST, langs=["USER"]) + + # charPr 3 names no decorative face; charPr 0's user face moved to DST + assert _texts_with_cache(doc) == ["가"]