Download visualizer/template.py from simon-clmtd/universal-dependencies-visualizer: direct link, hf CLI and curl.
- Browser
- Download file 22.4 kB
-
https://huggingface.co/spaces/simon-clmtd/universal-dependencies-visualizer/resolve/main/visualizer/template.py
- Command line
-
hf download hf://spaces/simon-clmtd/universal-dependencies-visualizer/visualizer/template.py
-
curl -L -o template.py https://huggingface.co/spaces/simon-clmtd/universal-dependencies-visualizer/resolve/main/visualizer/template.py
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> | |
| """ | |