Skip to content

Commit 936e447

Browse files
authored
Merge pull request #1845 from hafezparast/fix/maysam-mermaid-svg-text-1043
fix: preserve mermaid diagram text from SVGs during scraping (#1043)
2 parents 5e56e34 + 2fd0f4c commit 936e447

2 files changed

Lines changed: 253 additions & 0 deletions

File tree

crawl4ai/content_scraping_strategy.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,30 @@ def _scrap(
721721
elif content_element is None:
722722
content_element = body
723723

724+
# Replace mermaid SVGs with text before they get stripped
725+
for svg in body.xpath('.//svg[starts-with(@id, "mermaid-")]'):
726+
try:
727+
diagram_type = svg.get("aria-roledescription", "diagram")
728+
# Extract text from node/edge labels
729+
labels = []
730+
seen = set()
731+
for el in svg.cssselect(".nodeLabel, .label span, .edgeLabel span"):
732+
text = el.text_content().strip()
733+
if text and text not in seen:
734+
seen.add(text)
735+
labels.append(text)
736+
if labels:
737+
# Build a pre block so it survives markdown conversion
738+
placeholder = lhtml.Element("pre")
739+
code = etree.SubElement(placeholder, "code")
740+
code.set("class", "language-mermaid")
741+
code.text = f"%% {diagram_type} diagram\n" + "\n".join(labels)
742+
parent = svg.getparent()
743+
if parent is not None:
744+
parent.replace(svg, placeholder)
745+
except Exception:
746+
pass
747+
724748
# Remove script and style tags
725749
for tag in ["style", "link", "meta", "noscript"]:
726750
for element in body.xpath(f".//{tag}"):
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""
2+
Tests for issue #1043: Missing Mermaid Flowcharts
3+
4+
Verifies that mermaid SVG diagrams are preserved as text content
5+
during HTML scraping, rather than being stripped entirely.
6+
"""
7+
8+
import pytest
9+
from lxml import html as lhtml
10+
from crawl4ai.content_scraping_strategy import LXMLWebScrapingStrategy
11+
12+
13+
@pytest.fixture
14+
def strategy():
15+
return LXMLWebScrapingStrategy()
16+
17+
18+
def _make_html(body_content: str) -> str:
19+
return f"<html><body>{body_content}</body></html>"
20+
21+
22+
# -- Mermaid SVG detection and replacement --
23+
24+
FLOWCHART_SVG = """
25+
<div>
26+
<p>Before diagram</p>
27+
<svg id="mermaid-abc123" aria-roledescription="flowchart-v2" xmlns="http://www.w3.org/2000/svg">
28+
<g class="node"><foreignObject><div><span class="nodeLabel">Start</span></div></foreignObject></g>
29+
<g class="node"><foreignObject><div><span class="nodeLabel">Process Data</span></div></foreignObject></g>
30+
<g class="node"><foreignObject><div><span class="nodeLabel">End</span></div></foreignObject></g>
31+
<g class="edgeLabel"><foreignObject><div><span>yes</span></div></foreignObject></g>
32+
</svg>
33+
<p>After diagram</p>
34+
</div>
35+
"""
36+
37+
CLASS_DIAGRAM_SVG = """
38+
<div>
39+
<svg id="mermaid-def456" aria-roledescription="class" xmlns="http://www.w3.org/2000/svg">
40+
<g class="node"><foreignObject><div><span class="nodeLabel">MyClass</span></div></foreignObject></g>
41+
<g class="node"><foreignObject><div><span class="nodeLabel">+method() : void</span></div></foreignObject></g>
42+
<g class="node"><foreignObject><div><span class="nodeLabel">-field : int</span></div></foreignObject></g>
43+
</svg>
44+
</div>
45+
"""
46+
47+
SEQUENCE_SVG = """
48+
<div>
49+
<svg id="mermaid-seq789" aria-roledescription="sequence" xmlns="http://www.w3.org/2000/svg">
50+
<g class="label"><foreignObject><div><span>Alice</span></div></foreignObject></g>
51+
<g class="label"><foreignObject><div><span>Bob</span></div></foreignObject></g>
52+
<g class="edgeLabel"><foreignObject><div><span>Hello</span></div></foreignObject></g>
53+
</svg>
54+
</div>
55+
"""
56+
57+
58+
class TestMermaidSVGDetection:
59+
"""Test that mermaid SVGs are detected by their id prefix."""
60+
61+
def test_flowchart_svg_detected(self, strategy):
62+
html = _make_html(FLOWCHART_SVG)
63+
result = strategy._scrap("http://test.com", html)
64+
assert result is not None
65+
cleaned = result.get("cleaned_html", "")
66+
assert "Start" in cleaned
67+
assert "Process Data" in cleaned
68+
69+
def test_non_mermaid_svg_not_affected(self, strategy):
70+
"""Regular SVGs without mermaid id should be unaffected."""
71+
html = _make_html("""
72+
<div>
73+
<svg id="logo" xmlns="http://www.w3.org/2000/svg">
74+
<text>Logo Text</text>
75+
</svg>
76+
<p>Content here</p>
77+
</div>
78+
""")
79+
result = strategy._scrap("http://test.com", html)
80+
assert result is not None
81+
82+
def test_mermaid_svg_replaced_with_pre_code(self, strategy):
83+
"""Mermaid SVG should be replaced with pre/code block."""
84+
html = _make_html(FLOWCHART_SVG)
85+
result = strategy._scrap("http://test.com", html)
86+
cleaned = result.get("cleaned_html", "")
87+
assert "language-mermaid" in cleaned or "mermaid" in cleaned.lower()
88+
89+
90+
class TestMermaidTextExtraction:
91+
"""Test that text content is correctly extracted from mermaid SVGs."""
92+
93+
def test_node_labels_extracted(self, strategy):
94+
html = _make_html(FLOWCHART_SVG)
95+
result = strategy._scrap("http://test.com", html)
96+
cleaned = result.get("cleaned_html", "")
97+
assert "Start" in cleaned
98+
assert "Process Data" in cleaned
99+
assert "End" in cleaned
100+
101+
def test_edge_labels_extracted(self, strategy):
102+
html = _make_html(FLOWCHART_SVG)
103+
result = strategy._scrap("http://test.com", html)
104+
cleaned = result.get("cleaned_html", "")
105+
assert "yes" in cleaned
106+
107+
def test_class_diagram_labels_extracted(self, strategy):
108+
html = _make_html(CLASS_DIAGRAM_SVG)
109+
result = strategy._scrap("http://test.com", html)
110+
cleaned = result.get("cleaned_html", "")
111+
assert "MyClass" in cleaned
112+
assert "+method() : void" in cleaned
113+
114+
def test_sequence_diagram_labels_extracted(self, strategy):
115+
html = _make_html(SEQUENCE_SVG)
116+
result = strategy._scrap("http://test.com", html)
117+
cleaned = result.get("cleaned_html", "")
118+
assert "Alice" in cleaned
119+
assert "Bob" in cleaned
120+
121+
def test_duplicate_labels_deduplicated(self, strategy):
122+
"""Same label appearing multiple times should only appear once."""
123+
html = _make_html("""
124+
<div>
125+
<svg id="mermaid-dup" aria-roledescription="flowchart-v2" xmlns="http://www.w3.org/2000/svg">
126+
<g class="node"><foreignObject><div><span class="nodeLabel">Repeated</span></div></foreignObject></g>
127+
<g class="node"><foreignObject><div><span class="nodeLabel">Repeated</span></div></foreignObject></g>
128+
<g class="node"><foreignObject><div><span class="nodeLabel">Unique</span></div></foreignObject></g>
129+
</svg>
130+
</div>
131+
""")
132+
result = strategy._scrap("http://test.com", html)
133+
cleaned = result.get("cleaned_html", "")
134+
# Should have Repeated once, not twice
135+
assert cleaned.count("Repeated") == 1
136+
assert "Unique" in cleaned
137+
138+
139+
class TestMermaidDiagramType:
140+
"""Test that diagram type is preserved."""
141+
142+
def test_flowchart_type_preserved(self, strategy):
143+
html = _make_html(FLOWCHART_SVG)
144+
result = strategy._scrap("http://test.com", html)
145+
cleaned = result.get("cleaned_html", "")
146+
assert "flowchart" in cleaned.lower()
147+
148+
def test_class_type_preserved(self, strategy):
149+
html = _make_html(CLASS_DIAGRAM_SVG)
150+
result = strategy._scrap("http://test.com", html)
151+
cleaned = result.get("cleaned_html", "")
152+
assert "class" in cleaned.lower()
153+
154+
def test_sequence_type_preserved(self, strategy):
155+
html = _make_html(SEQUENCE_SVG)
156+
result = strategy._scrap("http://test.com", html)
157+
cleaned = result.get("cleaned_html", "")
158+
assert "sequence" in cleaned.lower()
159+
160+
161+
class TestMermaidSurroundingContent:
162+
"""Test that surrounding content is preserved."""
163+
164+
def test_text_before_diagram_preserved(self, strategy):
165+
html = _make_html(FLOWCHART_SVG)
166+
result = strategy._scrap("http://test.com", html)
167+
cleaned = result.get("cleaned_html", "")
168+
assert "Before diagram" in cleaned
169+
170+
def test_text_after_diagram_preserved(self, strategy):
171+
html = _make_html(FLOWCHART_SVG)
172+
result = strategy._scrap("http://test.com", html)
173+
cleaned = result.get("cleaned_html", "")
174+
assert "After diagram" in cleaned
175+
176+
177+
class TestMermaidEdgeCases:
178+
"""Test edge cases for mermaid SVG handling."""
179+
180+
def test_empty_mermaid_svg(self, strategy):
181+
"""SVG with no text content should be handled gracefully."""
182+
html = _make_html("""
183+
<div>
184+
<svg id="mermaid-empty" aria-roledescription="flowchart-v2" xmlns="http://www.w3.org/2000/svg">
185+
<rect width="100" height="100"/>
186+
</svg>
187+
<p>Content</p>
188+
</div>
189+
""")
190+
result = strategy._scrap("http://test.com", html)
191+
assert result is not None
192+
cleaned = result.get("cleaned_html", "")
193+
assert "Content" in cleaned
194+
195+
def test_multiple_mermaid_svgs(self, strategy):
196+
"""Multiple mermaid diagrams on one page."""
197+
html = _make_html(FLOWCHART_SVG + CLASS_DIAGRAM_SVG)
198+
result = strategy._scrap("http://test.com", html)
199+
cleaned = result.get("cleaned_html", "")
200+
assert "Start" in cleaned
201+
assert "MyClass" in cleaned
202+
203+
def test_mermaid_svg_no_aria(self, strategy):
204+
"""Mermaid SVG without aria-roledescription should use 'diagram' fallback."""
205+
html = _make_html("""
206+
<div>
207+
<svg id="mermaid-noaria" xmlns="http://www.w3.org/2000/svg">
208+
<g class="node"><foreignObject><div><span class="nodeLabel">Node A</span></div></foreignObject></g>
209+
</svg>
210+
</div>
211+
""")
212+
result = strategy._scrap("http://test.com", html)
213+
cleaned = result.get("cleaned_html", "")
214+
assert "Node A" in cleaned
215+
assert "diagram" in cleaned.lower()
216+
217+
def test_mermaid_svg_malformed_no_crash(self, strategy):
218+
"""Malformed SVG should not crash the scraper."""
219+
html = _make_html("""
220+
<div>
221+
<svg id="mermaid-bad" xmlns="http://www.w3.org/2000/svg">
222+
</svg>
223+
<p>Still works</p>
224+
</div>
225+
""")
226+
result = strategy._scrap("http://test.com", html)
227+
assert result is not None
228+
cleaned = result.get("cleaned_html", "")
229+
assert "Still works" in cleaned

0 commit comments

Comments
 (0)