Skip to content

Commit 8a1c7c7

Browse files
committed
fix(tree_renderer): embed PageIndex page images in the summary (#166)
Long-doc (PageIndex) images are extracted to wiki/sources/images/<doc>/ and referenced in the per-page JSON, but render_summary_md never read them, so they were invisible in the rendered summary a human actually opens. Pass the per-page list through from _write_long_doc_artifacts, build a page -> image-path map, and embed each node's page-range figures inline with paths relative to the summary's own directory (../sources/images/...). Already-emitted paths are tracked so a figure spanning several sibling nodes is shown only once.
1 parent ff54396 commit 8a1c7c7

3 files changed

Lines changed: 152 additions & 11 deletions

File tree

‎openkb/indexer.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,10 @@ def _write_long_doc_artifacts(
148148
summaries_dir.mkdir(parents=True, exist_ok=True)
149149
summary_path = summaries_dir / f"{doc_name}.md"
150150
summary_path.write_text(
151-
render_summary_md(tree, doc_name, doc_id, description=description), encoding="utf-8"
151+
render_summary_md(
152+
tree, doc_name, doc_id, description=description, pages=pages
153+
),
154+
encoding="utf-8",
152155
)
153156
return summary_path
154157

‎openkb/tree_renderer.py‎

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,62 @@ def _yaml_frontmatter(source_name: str, doc_id: str, description: str = "") -> s
1515
return "---\n" + "\n".join(lines) + "\n---\n"
1616

1717

18-
def _render_nodes_summary(nodes: list[dict], depth: int) -> str:
19-
"""Recursively render nodes for the *summary* view (summaries only)."""
18+
def _image_per_page(pages: list[dict] | None) -> dict[int, list[str]]:
19+
"""Map 1-based page number -> list of wiki-root-relative image paths.
20+
21+
``pages`` is the per-page list written to ``wiki/sources/<doc>.json`` (each
22+
item has a 1-based ``page`` and an ``images`` list of ``{"path": ...}``
23+
dicts whose paths are wiki-root-relative like
24+
``sources/images/<doc>/p1_img1.png``). Returns a dict keyed by page number
25+
for O(1) lookup while rendering nodes.
26+
"""
27+
if not pages:
28+
return {}
29+
per_page: dict[int, list[str]] = {}
30+
for item in pages:
31+
page = item.get("page")
32+
if not isinstance(page, int) or page < 1:
33+
continue
34+
paths = [
35+
img["path"]
36+
for img in item.get("images", [])
37+
if isinstance(img, dict) and isinstance(img.get("path"), str)
38+
]
39+
if paths:
40+
per_page.setdefault(page, []).extend(paths)
41+
return per_page
42+
43+
44+
def _summary_relative_path(wiki_root_path: str) -> str:
45+
"""Rewrite a wiki-root-relative image path for a page under ``wiki/summaries/``.
46+
47+
Image paths in the per-page JSON are wiki-root-relative
48+
(``sources/images/<doc>/file.png``). The summary lives one directory deeper
49+
(``wiki/summaries/<doc>.md``), so the path that resolves for Obsidian /
50+
GitHub is ``../`` + the wiki-root-relative path.
51+
"""
52+
return f"../{wiki_root_path}" if wiki_root_path else ""
53+
54+
55+
def _render_nodes_summary(
56+
nodes: list[dict],
57+
depth: int,
58+
per_page_images: dict[int, list[str]] | None = None,
59+
emitted: set[str] | None = None,
60+
) -> str:
61+
"""Recursively render nodes for the *summary* view (summaries only).
62+
63+
When ``per_page_images`` is provided, each node's page range embeds the
64+
images extracted from those pages (as ``![...](../sources/images/...)``
65+
links), skirting the PageIndex private-cache refs that are stripped from
66+
node text. ``emitted`` tracks already-rendered paths so a figure spanning
67+
pages covered by several sibling nodes is only shown once.
68+
"""
69+
if per_page_images is None:
70+
per_page_images = {}
71+
if emitted is None:
72+
emitted = set()
73+
2074
lines: list[str] = []
2175
heading_prefix = "#" * min(depth, 6)
2276
for node in nodes:
@@ -27,22 +81,43 @@ def _render_nodes_summary(nodes: list[dict], depth: int) -> str:
2781
children = node.get("nodes", [])
2882

2983
lines.append(f"{heading_prefix} {title} (pages {start}–{end})\n")
84+
85+
# Embed figures for the node's page range. Node indices are 0-based
86+
# page indices; the per-page image map is keyed by 1-based page number.
87+
node_images: list[str] = []
88+
if isinstance(start, int) and isinstance(end, int):
89+
lo, hi = start + 1, end + 1
90+
for page_num in range(lo, hi + 1):
91+
for path in per_page_images.get(page_num, []):
92+
if path not in emitted:
93+
emitted.add(path)
94+
node_images.append(_summary_relative_path(path))
95+
for img_path in node_images:
96+
lines.append(f"![image]({img_path})\n")
97+
3098
if summary:
3199
lines.append(f"Summary: {summary}\n")
32100
if children:
33-
lines.append(_render_nodes_summary(children, depth + 1))
101+
lines.append(_render_nodes_summary(children, depth + 1, per_page_images, emitted))
34102

35103
return "\n".join(lines)
36104

37105

38-
def render_summary_md(tree: dict, source_name: str, doc_id: str, description: str = "") -> str:
106+
def render_summary_md(
107+
tree: dict,
108+
source_name: str,
109+
doc_id: str,
110+
description: str = "",
111+
pages: list[dict] | None = None,
112+
) -> str:
39113
"""Render the summary Markdown page for a PageIndex tree.
40114
41-
Renders each node as a heading with page range and its summary text.
42-
Includes a YAML frontmatter block with ``type: "Summary"`` and an
43-
optional ``description`` field.
115+
Renders each node as a heading with page range and its summary text, and
116+
embeds the page images (when ``pages`` is supplied). Includes a YAML
117+
frontmatter block with ``type: "Summary"`` and an optional ``description``
118+
field.
44119
"""
45-
frontmatter = _yaml_frontmatter(source_name, doc_id, description)
120+
frontmatter_block = _yaml_frontmatter(source_name, doc_id, description)
46121
structure = tree.get("structure", [])
47-
body = _render_nodes_summary(structure, depth=1)
48-
return frontmatter + "\n" + body
122+
body = _render_nodes_summary(structure, depth=1, per_page_images=_image_per_page(pages))
123+
return frontmatter_block + "\n" + body

‎tests/test_tree_renderer.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,66 @@ def test_summary_full_text_quoted_yaml_safe():
6262
fm = yaml.safe_load(md.split("---")[1])
6363
assert fm["full_text"] == "sources/weird: name.json"
6464
assert fm["type"] == "Summary"
65+
66+
67+
# ---------------------------------------------------------------------------
68+
# render_summary_md with pages (issue #166: long-doc images never surface)
69+
# ---------------------------------------------------------------------------
70+
71+
72+
def test_images_from_page_range_are_embedded():
73+
# Node covers pages 1-2 (1-based); the per-page JSON lists an image on
74+
# each. Both must appear in the summary with paths relative to
75+
# wiki/summaries/ (../sources/images/...).
76+
tree = {"structure": [{"title": "Intro", "start_index": 0, "end_index": 1, "summary": "s"}]}
77+
pages = [
78+
{
79+
"page": 1,
80+
"content": "a",
81+
"images": [{"path": "sources/images/doc/p1_img1.png"}],
82+
},
83+
{
84+
"page": 2,
85+
"content": "b",
86+
"images": [{"path": "sources/images/doc/p2_img1.png"}],
87+
},
88+
]
89+
md = render_summary_md(tree, "doc", "doc-1", pages=pages)
90+
assert "![image](../sources/images/doc/p1_img1.png)" in md
91+
assert "![image](../sources/images/doc/p2_img1.png)" in md
92+
93+
94+
def test_no_images_rendered_without_pages():
95+
# Regression: without the pages argument the summary is unchanged.
96+
tree = {"structure": [{"title": "Intro", "start_index": 0, "end_index": 1, "summary": "s"}]}
97+
md = render_summary_md(tree, "doc", "doc-1")
98+
assert "![image]" not in md
99+
100+
101+
def test_figure_spanning_sibling_nodes_is_not_duplicated():
102+
# A parent and child both cover page 1 (with an image): the figure must be
103+
# embedded once (in the first node that reaches it), not repeated.
104+
tree = {
105+
"structure": [
106+
{
107+
"title": "Parent",
108+
"start_index": 0,
109+
"end_index": 2,
110+
"summary": "p",
111+
"nodes": [
112+
{"title": "Child", "start_index": 0, "end_index": 1, "summary": "c"},
113+
],
114+
}
115+
]
116+
}
117+
pages = [{"page": 1, "content": "a", "images": [{"path": "sources/images/doc/p1_img1.png"}]}]
118+
md = render_summary_md(tree, "doc", "doc-1", pages=pages)
119+
assert md.count("![image](../sources/images/doc/p1_img1.png)") == 1
120+
121+
122+
def test_image_outside_node_range_is_not_embedded():
123+
tree = {"structure": [{"title": "Intro", "start_index": 0, "end_index": 0, "summary": "s"}]}
124+
pages = [{"page": 5, "content": "a", "images": [{"path": "sources/images/doc/p5.png"}]}]
125+
md = render_summary_md(tree, "doc", "doc-1", pages=pages)
126+
assert "![image]" not in md
127+

0 commit comments

Comments
 (0)