Skip to content

Commit 4cb5e33

Browse files
committed
Merge feat/blog-image-hoist: 리스트 이미지 자식 전체 폭 렌더 [skip-notion]
2 parents dc2e52b + 06a21b9 commit 4cb5e33

2 files changed

Lines changed: 64 additions & 7 deletions

File tree

scripts/notion_to_md.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,15 @@ def block_to_markdown(block, slug, image_counter, item_num=1):
402402
def render_children():
403403
return "".join(block_to_markdown(c, slug, image_counter) for c in children)
404404

405+
def render_children_hoist_images():
406+
# 리스트 항목의 직속 이미지 자식은 들여쓰기하면 좁게 렌더되므로
407+
# 최상위(전체 폭) 블록으로 분리한다. 문서 순서대로 렌더해 카운터 일관성 유지.
408+
nested, hoisted = [], []
409+
for c in children:
410+
rendered = block_to_markdown(c, slug, image_counter)
411+
(hoisted if c["type"] == "image" else nested).append(rendered)
412+
return "".join(nested), "".join(hoisted)
413+
405414
if b_type == "table":
406415
if not children:
407416
return ""
@@ -464,7 +473,9 @@ def render_children():
464473
): # callout은 아래 elif에서 별도 처리 — 이 tuple 포함은 rich_text 추출용
465474
rich_text = block[b_type].get("rich_text", [])
466475
content = extract_text_from_rich_text(rich_text)
467-
child_md = render_children()
476+
# 리스트 항목은 render_children_hoist_images()로 별도 렌더 — 여기서 렌더하면
477+
# 이미지가 이중 렌더되고 image_counter가 두 번 증가한다.
478+
child_md = "" if b_type in ("bulleted_list_item", "numbered_list_item") else render_children()
468479
if b_type == "paragraph":
469480
# 단일 멀티라인 인라인 코드 (`...\n...`) → fenced code block
470481
# CommonMark 파서가 인라인 코드 내 \n을 공백으로 치환하므로 선변환 필요
@@ -486,13 +497,13 @@ def render_children():
486497
elif b_type == "bulleted_list_item":
487498
# "2. 내용" 같이 숫자+점으로 시작하면 GFM이 nested ordered list로 파싱 — 이스케이프
488499
safe = re.sub(r'^(\d+)\. ', r'\1\\. ', content)
489-
if child_md:
490-
return f"- {safe}\n{indent_md(child_md)}\n"
491-
return f"- {safe}\n\n"
500+
nested, hoisted = render_children_hoist_images()
501+
item = f"- {safe}\n{indent_md(nested)}\n" if nested else f"- {safe}\n\n"
502+
return item + hoisted
492503
elif b_type == "numbered_list_item":
493-
if child_md:
494-
return f"{item_num}. {content}\n{indent_md(child_md, ' ')}\n"
495-
return f"{item_num}. {content}\n\n"
504+
nested, hoisted = render_children_hoist_images()
505+
item = f"{item_num}. {content}\n{indent_md(nested, ' ')}\n" if nested else f"{item_num}. {content}\n\n"
506+
return item + hoisted
496507
elif b_type == "to_do":
497508
checked = "[x]" if block["to_do"]["checked"] else "[ ]"
498509
return f"- {checked} {content}\n\n" + child_md

scripts/tests/test_notion_to_md.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,52 @@ def test_multiple_shift_enters(self):
634634
assert "A \nB \nC" in out
635635

636636

637+
# ── 리스트 항목의 이미지 자식은 최상위(전체 폭)로 hoist ──────────────────────
638+
639+
class TestListItemImageHoisting:
640+
"""Notion에서 이미지가 불릿/번호 항목의 자식으로 들어가면 들여쓰기 없이
641+
최상위 블록(전체 폭)으로 렌더되어야 한다. 텍스트 자식은 기존대로 들여쓰기 유지."""
642+
643+
def _rt(self, text):
644+
return [{"plain_text": text, "annotations": {"code": False, "bold": False, "italic": False, "strikethrough": False}, "href": None}]
645+
646+
def _image_child(self, url="https://notion/x.png"):
647+
return {"type": "image", "id": "img-1", "image": {"file": {"url": url}}, "has_children": False}
648+
649+
def _list_block(self, b_type, text, children):
650+
return {"type": b_type, b_type: {"rich_text": self._rt(text)}, "_children": children, "has_children": bool(children)}
651+
652+
@patch.object(n, "download_image", return_value="/img/blog/x/img-00.png")
653+
def test_bulleted_image_child_hoisted(self, _dl):
654+
block = self._list_block("bulleted_list_item", "서버에 나타난다", [self._image_child()])
655+
out = n.block_to_markdown(block, "blog/x", [0])
656+
img_line = next(l for l in out.splitlines() if "![image]" in l)
657+
assert img_line == "![image](/img/blog/x/img-00.png)", f"이미지가 들여쓰기됨: {img_line!r}"
658+
assert "- 서버에 나타난다\n\n![image](/img/blog/x/img-00.png)" in out
659+
660+
@patch.object(n, "download_image", return_value="/img/blog/x/img-00.png")
661+
def test_numbered_image_child_hoisted(self, _dl):
662+
block = self._list_block("numbered_list_item", "결과", [self._image_child()])
663+
out = n.block_to_markdown(block, "blog/x", [0], 3)
664+
img_line = next(l for l in out.splitlines() if "![image]" in l)
665+
assert img_line == "![image](/img/blog/x/img-00.png)", f"이미지가 들여쓰기됨: {img_line!r}"
666+
assert "3. 결과\n\n![image](/img/blog/x/img-00.png)" in out
667+
668+
def test_non_image_child_still_indented(self):
669+
# 회귀: 텍스트 자식(중첩 불릿)은 여전히 2칸 들여쓰기 유지
670+
child = self._list_block("bulleted_list_item", "중첩 항목", [])
671+
block = self._list_block("bulleted_list_item", "부모", [child])
672+
out = n.block_to_markdown(block, "blog/x", [0])
673+
assert " - 중첩 항목" in out
674+
675+
@patch.object(n, "download_image", return_value="/img/blog/x/img-00.png")
676+
def test_image_counter_increments(self, _dl):
677+
block = self._list_block("bulleted_list_item", "설명", [self._image_child()])
678+
counter = [5]
679+
n.block_to_markdown(block, "blog/x", counter)
680+
assert counter[0] == 6
681+
682+
637683
# ── TC-14: toggle summary 인라인 마크다운 → HTML 변환 ──────────────────────────
638684

639685
class TestToggleSummaryInlineMarkdown:

0 commit comments

Comments
 (0)