From 9fbd719048c656ce2ab73c8d7dc470be292b534a Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Sat, 27 Jun 2026 08:15:39 +0900 Subject: [PATCH] feat(notion-writer): parse standalone images to image blocks Co-Authored-By: Claude Opus 4.8 (1M context) --- .../code/scripts/notion_writer.py | 21 ++++++++++++++++ .../code/scripts/test_parser.py | 25 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/custom-skills/32-notion-writer/code/scripts/notion_writer.py b/custom-skills/32-notion-writer/code/scripts/notion_writer.py index e423adf..e72b2ce 100644 --- a/custom-skills/32-notion-writer/code/scripts/notion_writer.py +++ b/custom-skills/32-notion-writer/code/scripts/notion_writer.py @@ -56,6 +56,7 @@ def format_id_with_dashes(raw_id: str) -> str: TABLE_SEPARATOR_RE = re.compile(r'^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$') +IMAGE_RE = re.compile(r'^\s*!\[([^\]]*)\]\(([^)\s]+)\)\s*$') def _is_table_row(line: str) -> bool: @@ -210,6 +211,13 @@ def _parse_lines(lines: List[str]) -> List[Dict[str, Any]]: blocks.append(create_column_list_block(column_blocks)) continue + image_match = IMAGE_RE.match(line) + if image_match: + blocks.append(create_image_block( + image_match.group(1), image_match.group(2))) + i += 1 + continue + if _is_table_row(line) and i + 1 < len(lines) and TABLE_SEPARATOR_RE.match(lines[i + 1]): header_cells = _split_table_row(line) i += 2 @@ -507,6 +515,19 @@ def create_divider_block() -> Dict[str, Any]: } +def create_image_block(alt: str, target: str) -> Dict[str, Any]: + """Image block in external shape. Local targets are converted to + file_upload shape later by ntn_files.materialize_local_media.""" + block: Dict[str, Any] = { + "object": "block", + "type": "image", + "image": {"type": "external", "external": {"url": target}}, + } + if alt: + block["image"]["caption"] = parse_rich_text(alt) + return block + + def create_table_block(header_cells: List[str], body_rows: List[List[str]]) -> Dict[str, Any]: """Build a Notion `table` block with header + body rows. diff --git a/custom-skills/32-notion-writer/code/scripts/test_parser.py b/custom-skills/32-notion-writer/code/scripts/test_parser.py index 19fc9d4..80d661d 100644 --- a/custom-skills/32-notion-writer/code/scripts/test_parser.py +++ b/custom-skills/32-notion-writer/code/scripts/test_parser.py @@ -413,6 +413,28 @@ def test_no_literal_markers_leak(): _assert("bold" in joined and "link" in joined, "visible words preserved") +def test_image_remote_external(): + blocks = markdown_to_notion_blocks("![a chart](https://ex.com/c.png)") + assert len(blocks) == 1 + b = blocks[0] + assert b["type"] == "image" + assert b["image"]["type"] == "external" + assert b["image"]["external"]["url"] == "https://ex.com/c.png" + assert b["image"]["caption"][0]["text"]["content"] == "a chart" + + +def test_image_local_external_shape_preupload(): + blocks = markdown_to_notion_blocks("![local](./pics/x.png)") + assert len(blocks) == 1 + assert blocks[0]["image"]["external"]["url"] == "./pics/x.png" + + +def test_image_only_when_standalone(): + # An inline bang-bracket inside prose is NOT an image block. + blocks = markdown_to_notion_blocks("see ![x](y.png) inline") + assert blocks[0]["type"] == "paragraph" + + def run_all(): tests = [ test_rich_text_plain, @@ -447,6 +469,9 @@ def run_all(): test_rich_text_relative_link_becomes_plain, test_rich_text_absolute_link_preserved, test_no_literal_markers_leak, + test_image_remote_external, + test_image_local_external_shape_preupload, + test_image_only_when_standalone, ] for t in tests: print(f"\n{t.__name__}")