Simon Clematide
Enhance landing page design, deep linking, and interactive triggers
60658eb
Raw History Blame Contribute Delete
22.4 kB
"""
Renderer module that generates an HTML/iframe component embedding conllu.js
from simon-clematide/conllu.js.
Supports:
- Interactive dependency visualization with brat
- Download SVG and Download PNG buttons (from simon-clematide work journal)
- Clean, self-contained iframe to prevent any script collision
"""
import html
from pathlib import Path
from visualizer.ud_help import render_ud_help
from visualizer.grew_links import render_grew_word_links
PARSER_REVISION = "e46fe3cb6b1a02190c17c1b792f1f1ded42ddd2f"
RENDERER_REVISION = "e8fc85df554a50338b68d1920d7414980a5d0c11"
ASSET_ROOT = f"https://cdn.jsdelivr.net/gh/simon-clematide/conllu.js@{RENDERER_REVISION}/"
PARSER_URL = f"https://cdn.jsdelivr.net/gh/simon-clematide/conllu.js@{PARSER_REVISION}/conllu.js"
def build_visualizer_document(
conllu_text: str,
title: str = "",
show_help: bool = False,
model_id: str = None
) -> str:
"""
Returns the standalone HTML document that renders one CoNLL-U input.
The UI embeds it as iframe srcdoc; the exporter loads it in a headless browser.
"""
adapter_js = (
Path(__file__).with_name("embed.js").read_text(encoding="utf-8")
.replace("__PARSER_URL__", PARSER_URL)
.replace("__RENDERER_ROOT__", ASSET_ROOT)
)
escaped_conllu = html.escape(conllu_text)
title_escaped = html.escape(title)
word_links = render_grew_word_links(conllu_text, model_id, as_column=show_help)
help_html = render_ud_help(conllu_text, word_links) if show_help else ""
grew_html = "" if show_help else word_links
stylesheets = "\n".join(
f'<link rel="stylesheet" href="{ASSET_ROOT}{path}">'
for path in ("css/jquery-ui-redmond.css", "css/style-vis.css")
)
iframe_doc = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,400;0,600;0,700&display=swap">
""" + stylesheets + """
<style>
html { overflow: auto; }
body {
margin: 0;
padding: 10px;
font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #ffffff;
color: #1f2937;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.resize-handle {
position: fixed; right: 2px; bottom: 2px; z-index: 2000;
width: 24px; height: 24px; display: grid; place-items: center;
background: white; color: #526984; border: 1px solid #d1d5db;
border-radius: 4px; cursor: nwse-resize; touch-action: none;
}
.resize-handle:focus-visible { outline: 2px solid #526984; }
html:has(:fullscreen) .resize-handle { display: none; }
.title {
font-size: 13px;
font-weight: 600;
color: #374151;
}
.btn-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.btn {
background-color: #f3f4f6;
border: 1px solid #d1d5db;
color: #374151;
padding: 4px 10px;
font-size: 11px;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease;
}
.btn:hover {
background-color: #e5e7eb;
}
.view-controls { display: flex; justify-content: flex-end; gap: 6px; margin-bottom: 6px; flex-wrap: wrap; }
.btn-group [hidden] { display: none; }
.grew-words { margin-top: 10px; font-size: 12px; }
.grew-words summary { cursor: pointer; font-weight: 600; }
.grew-words p { color: #6b7280; margin: 6px 0; }
.grew-word-links { display: flex; flex-wrap: wrap; gap: 6px 12px; }
.grew-word-links a { color: #2563eb; }
.ud-help { margin-top: 10px; font-size: 12px; }
.ud-help summary { cursor: pointer; font-weight: 600; }
.ud-help-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px 24px; padding-top: 8px; }
.ud-help-columns .grew-words { margin-top: 0; }
.ud-help-columns > div { min-width: 0; overflow-wrap: anywhere; }
@media (max-width: 650px) { .ud-help-columns { grid-template-columns: 1fr; } }
.ud-help ul { padding-left: 18px; margin: 6px 0; }
.ud-help a { color: #2563eb; }
.analysis-footer { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px; margin: 8px 0 0; }
.analysis-hint { font-size: 12px; color: #1e3a8a; display: inline-flex; align-items: center; gap: 5px; }
.analysis-note { font-size: 11px; color: #6b7280; margin: 0; }
.icon-btn { width: 30px; height: 30px; padding: 5px; background: white; }
.icon-btn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; border: none; }
.btn:focus-visible { outline: 2px solid #526984; outline-offset: 2px; }
.btn:disabled { opacity: .4; cursor: default; }
#tree-view { background: white; }
#tree-view:fullscreen { display: flex; flex-direction: column; padding: 12px; box-sizing: border-box; }
#tree-view:fullscreen #vis { flex: 1; min-height: 0; overflow: auto; }
#vis svg { display: block; background: #ffffff; max-width: none; border-color: transparent; }
#vis rect.hover-head { fill: #d6b34a !important; stroke: #8a6c1f !important; }
#vis rect.hover-head-word { fill: #d6b34a !important; opacity: 1 !important; }
/* Keep hover emphasis geometrically stable; dim unrelated dependencies. */
#vis .arcs .highlight path { stroke-width: 1 !important; }
#vis .arcs .highlight text { stroke: none !important; stroke-width: 0 !important; }
#vis .span.highlight rect { stroke-width: .75px; }
#vis svg:has(.arcs .highlight) .arcs g[data-from]:not(.highlight) path {
stroke: #c4cbd3 !important;
marker-start: var(--inactive-marker-start, none) !important;
marker-end: var(--inactive-marker-end, none) !important;
}
#vis svg:has(.arcs .highlight) .span:not(.highlight) rect[data-span-id]:not(.hover-head) {
stroke: #c4cbd3 !important;
}
#vis svg:has(.arcs .highlight) .arcs g[data-from]:not(.highlight) text {
fill: #a3acb8 !important; stroke: none !important;
}
#vis .background0, #vis .background1 { fill: #ffffff; stroke: none; }
#vis .background1 { display: none !important; }
#vis {
overflow-x: auto;
min-height: 180px;
border: 1px solid #f3f4f6;
border-radius: 4px;
background: #ffffff;
}
#commentpopup {
font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
position: fixed;
top: 0;
left: 0;
display: none;
padding: 8px 12px;
background-color: #ffffff;
color: #1f2937;
border: 1px solid #d1d5db;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
font-size: 12px;
line-height: 1.4;
max-width: 320px;
z-index: 1000;
pointer-events: none;
}
#commentpopup .comment_text {
font-weight: 600;
font-size: 13px;
color: #111827;
margin-bottom: 2px;
}
#commentpopup .comment_type {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: #2563eb;
background-color: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 4px;
padding: 1px 5px;
margin-bottom: 4px;
}
#commentpopup .comment_mods {
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
#commentpopup .comment_mod_item {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
color: #374151;
background-color: #f3f4f6;
padding: 1px 4px;
border-radius: 3px;
display: inline-block;
}
#commentpopup hr {
border: 0;
border-top: 1px solid #e5e7eb;
margin: 6px 0;
}
#commentpopup .comment_notes {
font-size: 11px;
color: #6b7280;
margin-top: 2px;
}
</style>
</head>
<body>
<section id="tree-view" aria-label="Dependency tree">
<div class="header">
<span class="title">Parsing Output""" + (" of " + title_escaped if title else "") + """</span>
<div class="btn-group">
<div id="zoom-controls" class="btn-group" role="toolbar" aria-label="Tree view controls" hidden>
<button id="zoom-out" class="btn icon-btn" type="button" aria-label="Zoom out" title="Zoom out" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg></button>
<button id="zoom-in" class="btn icon-btn" type="button" aria-label="Zoom in" title="Zoom in" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M12 5v14"/></svg></button>
<button id="zoom-reset" class="btn icon-btn" type="button" aria-label="Reset zoom" title="Reset zoom to 140%" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10a8 8 0 1 1 1 8M4 4v6h6"/></svg></button>
<button id="fullscreen" class="btn icon-btn" type="button" aria-label="Enter fullscreen" title="Enter fullscreen" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3H3v6m12-6h6v6M3 15v6h6m12-6v6h-6"/></svg></button>
</div>
<button id="toggle-view-controls" class="btn icon-btn" type="button" aria-label="Show view controls" title="Show view controls" aria-expanded="false" aria-controls="zoom-controls" disabled><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2" fill="white"/><circle cx="15" cy="17" r="2" fill="white"/></svg></button>
<button id="download-svg" class="btn" type="button" disabled>⬇ Download SVG</button>
<button id="download-png" class="btn" type="button" disabled>⬇ Download PNG</button>
</div>
</div>
<div id="vis"></div>
<div class="analysis-footer">
<span class="analysis-hint">🎯 <strong>Head Hunt:</strong> Hover over words or POS tags to reveal syntactic heads.</span>
<span class="analysis-note">This syntactic analysis was automatically computed and can contain errors.</span>
</div>
""" + grew_html + help_html + """
</section>
<div id="resize-handle" class="resize-handle" role="button" tabindex="0"
aria-label="Resize tree panel" title="Drag to resize; arrow keys resize; double-click to fit content">◢</div>
<textarea id="input" style="display:none;">""" + escaped_conllu + """</textarea>
<p id="status" role="status">Loading visualization…</p>
<script type="text/javascript">
function getPreparedSVG() {
var container = document.getElementById('vis');
if (!container) return null;
var svg = container.querySelector('svg');
if (!svg) return null;
var clone = svg.cloneNode(true);
// Hover is a temporary view state, not part of the exported tree.
clone.querySelectorAll('.highlight, .hover-head, .hover-head-word').forEach(element => element.classList.remove('highlight', 'hover-head', 'hover-head-word'));
if (!clone.getAttribute('xmlns')) {
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
if (!clone.getAttribute('xmlns:xlink')) {
clone.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
}
var bbox = svg.getBBox ? svg.getBBox() : null;
var width = svg.width && svg.width.baseVal ? svg.width.baseVal.value : svg.clientWidth;
var height = svg.height && svg.height.baseVal ? svg.height.baseVal.value : svg.clientHeight;
if ((!width || width <= 1) && bbox && bbox.width > 0) {
width = bbox.x + bbox.width + 20;
}
if ((!height || height <= 1) && bbox && bbox.height > 0) {
height = bbox.y + bbox.height + 20;
}
width = Math.ceil(width || 800);
height = Math.ceil(height || 260);
// Downloads retain tight rendered tree content dimensions, avoiding excessive empty width.
width = Math.ceil(Number(svg.dataset.contentWidth) || Number(svg.dataset.nativeWidth) || width);
height = Math.ceil(Number(svg.dataset.contentHeight) || Number(svg.dataset.nativeHeight) || height);
clone.style.removeProperty('width');
clone.style.removeProperty('height');
clone.setAttribute('width', width);
clone.setAttribute('height', height);
clone.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
var bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bgRect.setAttribute('x', '0');
bgRect.setAttribute('y', '0');
bgRect.setAttribute('width', '100%');
bgRect.setAttribute('height', '100%');
bgRect.setAttribute('fill', '#ffffff');
clone.insertBefore(bgRect, clone.firstChild);
// Resolve the real brat and embedding CSS on a hover-free clone.
// Inline SVG presentation styles so external viewers need no CSS.
var staging = document.createElement('div');
staging.style.cssText = 'position:absolute;left:-100000px;top:0;opacity:0;pointer-events:none';
container.appendChild(staging);
staging.appendChild(clone);
var properties = [
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width',
'stroke-opacity', 'stroke-dasharray', 'stroke-dashoffset',
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit',
'opacity', 'display', 'visibility', 'filter',
'font-family', 'font-size', 'font-weight', 'font-style',
'font-variant', 'letter-spacing', 'word-spacing',
'text-anchor', 'dominant-baseline', 'baseline-shift',
'paint-order', 'marker-start', 'marker-mid', 'marker-end'
];
try {
[clone, ...clone.querySelectorAll('*')].forEach(function(element) {
var computed = getComputedStyle(element);
// Snapshot first, then write, to avoid inheritance changes
// while collecting values from the same element.
var values = properties.map(function(property) {
var value = computed.getPropertyValue(property);
// Keep marker/filter references local to the saved SVG.
value = value.replace(/url\\(["']?[^)"']*#([^)"']+)["']?\\)/g, 'url(#$1)');
return [property, value];
});
values.forEach(function(entry) {
if (entry[1]) element.style.setProperty(entry[0], entry[1]);
});
});
} finally {
clone.remove();
staging.remove();
}
clone.style.width = width + 'px';
clone.style.height = height + 'px';
clone.style.border = 'none';
var styleElem = document.createElementNS('http://www.w3.org/2000/svg', 'style');
styleElem.setAttribute('type', 'text/css');
styleElem.textContent = window.embeddedFontCSS || '';
clone.insertBefore(styleElem, clone.firstChild);
return {
width: width,
height: height,
xml: new XMLSerializer().serializeToString(clone)
};
}
function downloadSVG() {
var prep = getPreparedSVG();
if (!prep) return alert('No SVG visualization ready to download.');
var blob = new Blob([prep.xml], { type: 'image/svg+xml;charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'conllu-dependency-tree.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Resolves to a PNG data URL; used by the headless exporter.
function exportPNGDataURL(scale) {
return new Promise(function(resolve, reject) {
var prep = getPreparedSVG();
if (!prep) return reject(new Error('No visualization ready to export.'));
scale = scale || 2;
var canvas = document.createElement('canvas');
canvas.width = prep.width * scale;
canvas.height = prep.height * scale;
var ctx = canvas.getContext('2d');
var img = new Image();
img.onload = function() {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/png'));
};
img.onerror = function() { reject(new Error('PNG rasterization failed.')); };
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(prep.xml);
});
}
function downloadPNG() {
var prep = getPreparedSVG();
if (!prep) return alert('No visualization ready to download.');
var scale = 2;
var canvas = document.createElement('canvas');
canvas.width = prep.width * scale;
canvas.height = prep.height * scale;
var ctx = canvas.getContext('2d');
var blob = new Blob([prep.xml], { type: 'image/svg+xml;charset=utf-8' });
var url = URL.createObjectURL(blob);
var img = new Image();
img.onload = function() {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(url);
if (canvas.toBlob) {
canvas.toBlob(function(pngBlob) {
var pngUrl = URL.createObjectURL(pngBlob);
var a = document.createElement('a');
a.href = pngUrl;
a.download = 'conllu-dependency-tree.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(pngUrl);
}, 'image/png');
} else {
var pngUrl = canvas.toDataURL('image/png');
var a = document.createElement('a');
a.href = pngUrl;
a.download = 'conllu-dependency-tree.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
};
img.onerror = function() {
URL.revokeObjectURL(url);
document.getElementById('status').textContent = 'PNG export failed. Try SVG instead.';
};
img.src = url;
}
// The adapter consumes the pinned library directly, without Annodoc.
""" + adapter_js + """
</script>
</body>
</html>"""
return iframe_doc
def render_conllu_visualizer_html(
conllu_text: str,
vis_id: str = "vis-main",
title: str = "",
min_height: int = 280,
show_help: bool = False,
model_id: str = None
) -> str:
"""
Renders an HTML iframe hosting the conllu.js visualizer.
Using an iframe ensures zero CSS/JS namespace contamination with Gradio.
"""
if not conllu_text or not conllu_text.strip():
return "<div style='color: #6b7280; font-size: 13px; padding: 20px 0;'>No parse tree to display.</div>"
iframe_doc = build_visualizer_document(conllu_text, title=title, show_help=show_help, model_id=model_id)
title_escaped = html.escape(title)
# Embed safely in iframe using srcdoc
iframe_srcdoc = html.escape(iframe_doc, quote=True)
return (
f'<iframe title="{title_escaped or "Dependency tree"}" id="{html.escape(vis_id, quote=True)}" srcdoc="{iframe_srcdoc}" '
f'style="width: 100%; min-height: {min_height}px; border: 1px solid #e5e7eb; '
f'border-radius: 8px; background: #ffffff;" frameborder="0" allow="fullscreen" allowfullscreen></iframe>'
)
def render_side_by_side_visualizers_html(
conllu_a: str,
name_a: str,
conllu_b: str,
name_b: str,
model_id_a: str = None,
model_id_b: str = None
) -> str:
"""Renders two dependency trees stacked cleanly for visual comparison."""
if not conllu_a and not conllu_b:
return "<div style='color: #6b7280; font-size: 13px; padding: 20px 0;'>No parse trees generated yet.</div>"
html_a = render_conllu_visualizer_html(conllu_a, vis_id="vis-model-a", title=name_a, model_id=model_id_a) if conllu_a else ""
html_b = render_conllu_visualizer_html(conllu_b, vis_id="vis-model-b", title=name_b, model_id=model_id_b) if conllu_b else ""
return f"""
<div style="display: flex; flex-direction: column; gap: 16px;">
{html_a}
{html_b}
</div>
"""