Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`가 글자 모양의 굵게를 담는다.
Expand Down
6 changes: 6 additions & 0 deletions src/hwpx/oxml/header_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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")})
63 changes: 62 additions & 1 deletion src/hwpx/oxml/header_fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,19 @@
_FONT_FACE_LANG_TO_REF,
_HC,
_HH,
_HP,
_clear_paragraph_layout_cache,
_element_local_name,
_normalize_font_langs,
_validate_font_type,
)
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")
Expand Down Expand Up @@ -226,6 +229,11 @@ def replace_font(
*dst_face* it is ``<hh:font id=… face=dst type="TTF" isEmbedded="0"/>``.
*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()
Expand Down Expand Up @@ -259,6 +267,7 @@ def replace_font(
None,
)

faces_before = _char_faces(header)
changed: list[str] = []
declared: list[str] = []
repointed = 0
Expand Down Expand Up @@ -323,10 +332,62 @@ 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,
langs=tuple(changed),
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()
26 changes: 26 additions & 0 deletions tests/test_header_compat_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 51 additions & 0 deletions tests/test_style_font_border_reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) == ["가"]
Loading