diff --git a/CHANGELOG.md b/CHANGELOG.md index 374133d6..4dbe3803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,10 @@ 빈칸이 여러 개 이어진 자리에서 줄이 한 글자 일찍 나뉘었다. - FormFit과 쪽 수 추정이 탭 바로 뒤의 닫는 문장 부호(`)`·`.`·`!`·`」` 등)로 줄을 시작할 수 있게 한다. 빈칸 뒤처럼 탭은 앞 줄에 남고 부호가 다음 줄을 시작한다. 한/글이 그렇게 놓는다. 전에는 탭까지 다음 줄로 보냈다. +- 쪽 수 추정(실험, `estimate_pages`)이 머리 정보 파트(`Contents/header.xml`)가 없는 문서에서 `IndexError`로 멈추던 것을 + 지원 밖(`a document without a header part (Contents/header.xml)`)으로 알린다. 줄 배치 정보 쓰기도 그런 문서에는 쓰지 않는다. +- (실험) 줄 배치 정보 쓰기가 문단 모양이 한글·영문/숫자 자동 간격(`hh:autoSpacing`)을 켜고 한글 음절 바로 옆에 영문 글자나 + 숫자가 있는 문단을 건너뛴다. 한/글은 그 사이를 넓히는데 그 폭을 아직 재지 않아, 쓴 줄 나눔이 한/글과 다를 수 있었다. - FormFit과 쪽 수 추정이 글꼴 폭 표에 없는 가나(히라가나·가타카나)를 그 글꼴의 한글 음절 폭으로 잡는다(함초롬바탕 972, 한컴 고딕 932). 한/글이 그렇게 놓는다. 전에는 전각(1 em)으로 셌다. - 쪽 수 추정(실험, `estimate_pages`)이 탭 정의를 한 번만 읽는다. 전에는 글자 모양을 볼 때마다 머리 정보의 탭 정의 전체를 diff --git a/src/hwpx/layout/line_cache.py b/src/hwpx/layout/line_cache.py index 44d0dd71..6eb9dd34 100644 --- a/src/hwpx/layout/line_cache.py +++ b/src/hwpx/layout/line_cache.py @@ -295,6 +295,8 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool, if headed and (_pages._head_size(measure, paragraph, sizes) or shape.indent): # a label of its own size return None text = _pages._run_text(runs) + if _auto_spaced(measure, paragraph.get("paraPrIDRef"), text): + return None # the gap Hancom adds between Hangul and Latin or digits was not measured style = measure.style(paragraph.get("paraPrIDRef"), refs, paragraph) if style is not None and style.head and not (headed and style.head_hangs): return None @@ -383,6 +385,28 @@ def _sure_of(text: str, starts: list[int], style: Any, looks: list[Any] | None = return all(start in breaks or text[start - 1] == "\n" for start in starts[1:]) +def _auto_spaced(measure: Any, para_pr_id: str | None, text: str) -> bool: + """Whether the paragraph shape turns on Hancom's automatic spacing between Hangul and Latin letters or + digits (``hh:autoSpacing``) and the text puts a Hangul syllable right next to one of those.""" + + spacing = getattr(measure, "_line_cache_auto_spacing", None) + if spacing is None: + spacing = {} + for shape in measure._header.iter(f"{HH}paraPr"): + auto = next(shape.iter(f"{HH}autoSpacing"), None) + if auto is not None: + spacing[shape.get("id")] = (auto.get("eAsianEng") in ("1", "true"), auto.get("eAsianNum") in ("1", "true")) + measure._line_cache_auto_spacing = spacing + latin, digits = spacing.get(para_pr_id, (False, False)) + if not latin and not digits: + return False + for before, after in zip(text, text[1:]): + for hangul, other in ((before, after), (after, before)): + if "가" <= hangul <= "힣" and other.isascii() and (latin and other.isalpha() or digits and other.isdigit()): + return True + return False + + 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`.""" diff --git a/src/hwpx/layout/pages.py b/src/hwpx/layout/pages.py index 9299b69b..30654bf6 100644 --- a/src/hwpx/layout/pages.py +++ b/src/hwpx/layout/pages.py @@ -282,6 +282,8 @@ def _estimate(document: Any, *, first_reason: bool = False) -> PageEstimate: one given) instead of laying every section out to name them all.""" root = getattr(_open(document), "_root") + if not root.headers: # the shapes every paragraph names live there + return PageEstimate(None, (), ("a document without a header part (Contents/header.xml)",)) measure = _Measure(root) layouts: list[_SectionLayout] = [] reasons: list[str] = [] diff --git a/tests/test_layout_line_cache.py b/tests/test_layout_line_cache.py index f2060c37..1b99fe75 100644 --- a/tests/test_layout_line_cache.py +++ b/tests/test_layout_line_cache.py @@ -403,3 +403,26 @@ def test_a_tab_among_runs_of_several_faces_gets_its_width() -> None: assert len(tabbed) >= 2 for mine, theirs in tabbed: assert [dict(tab.attrib) for tab in mine.iter(f"{HP}tab")] == [dict(tab.attrib) for tab in theirs.iter(f"{HP}tab")] + + +@pytest.mark.parametrize(("eng", "num", "text", "written"), [ + ("1", "0", "가나 ABC 다라마바사 " * 3, True), # Latin only beside spaces + ("1", "0", "가나ABC다라마바사 " * 3, False), # a Hangul syllable right next to a Latin letter + ("0", "1", "제15호에 따른 정의 " * 3, False), # ... and next to a digit + ("0", "0", "제15호에 따른 정의 " * 3, True), # automatic spacing off +]) +def test_a_paragraph_with_automatic_spacing_beside_hangul_is_left_for_hancom( + eng: str, num: str, text: str, written: bool) -> None: + # Hancom widens the gap between Hangul and Latin letters or digits when the paragraph shape turns that on; + # how much was not measured, so such a paragraph keeps no cache. + document = HwpxDocument.new() + paragraph = document.add_paragraph(text) + for shape in document.oxml.headers[0].element.iter(f"{HH}paraPr"): + if shape.get("id") == paragraph.element.get("paraPrIDRef"): + auto = next(shape.iter(f"{HH}autoSpacing")) + auto.set("eAsianEng", eng) + auto.set("eAsianNum", num) + + write_line_caches(document) + + assert bool(_segments(paragraph.element)) is written diff --git a/tests/test_layout_page_estimate.py b/tests/test_layout_page_estimate.py index 56b5b928..0a3b30d9 100644 --- a/tests/test_layout_page_estimate.py +++ b/tests/test_layout_page_estimate.py @@ -2277,3 +2277,32 @@ def test_footnotes_beneath_text_remain_unsupported_with_endnote_support(written: assert estimate.pages is None assert estimate.unsupported == ("section 0: footnotes beneath the text",) + + +def _without_header(data: bytes) -> bytes: + out = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(data)) as src, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as dst: + for item in src.infolist(): + if item.filename == "Contents/header.xml": + continue + payload = src.read(item.filename) + if item.filename == "Contents/content.hpf": + payload = re.sub(rb"]*href=\"Contents/header.xml\"[^>]*/>", b"", payload) + dst.writestr(item, payload) + return out.getvalue() + + +def test_a_document_without_a_header_part_is_left_unsupported() -> None: + # The paragraph and character shapes every paragraph names live in the header part; without it there is + # nothing to lay the text out by, so the estimate says so instead of failing. + document = HwpxDocument.new() + document.add_paragraph("머리 정보가 없는 문서") + data = _without_header(document.to_bytes()) + + estimate = estimate_pages(data) + + assert estimate.pages is None and estimate.lines == () + assert estimate.unsupported == ("a document without a header part (Contents/header.xml)",) + from hwpx.layout.line_cache import write_line_caches + report = write_line_caches(HwpxDocument.open(io.BytesIO(data))) + assert report.written == 0 and report.unsupported == estimate.unsupported