File size: 22,438 Bytes
d2815e6
 
 
 
e75de29
d2815e6
 
 
 
 
e75de29
6938a44
 
e75de29
 
 
 
 
d2815e6
 
ad9385f
d2815e6
 
6938a44
 
d2815e6
 
ad9385f
 
d2815e6
e75de29
 
 
 
 
 
d2815e6
6938a44
 
 
d2815e6
75e2cc8
 
 
 
d2815e6
 
 
 
75e2cc8
 
e75de29
d2815e6
115fba1
d2815e6
 
 
75e2cc8
d2815e6
 
 
 
 
 
 
97b4c47
 
d2815e6
 
115fba1
 
 
 
 
 
 
 
d2815e6
 
 
 
 
 
 
97b4c47
 
d2815e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
583fb42
97b4c47
6938a44
 
 
 
 
 
 
ae56e3f
6938a44
 
 
 
 
60658eb
 
 
583fb42
97b4c47
583fb42
 
 
 
 
97b4c47
58965e9
 
1ec71a6
 
 
 
 
 
 
 
 
58965e9
 
 
1ec71a6
 
 
3e2cbc6
78add12
d2815e6
 
 
 
 
 
 
ae56e3f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d2815e6
 
 
97b4c47
d2815e6
97b4c47
d2815e6
583fb42
 
 
d9dc9ac
583fb42
 
 
97b4c47
 
 
583fb42
97b4c47
 
583fb42
60658eb
 
 
 
6938a44
583fb42
d2815e6
115fba1
 
d2815e6
e75de29
d2815e6
 
 
 
 
 
 
 
1ec71a6
58965e9
d2815e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3e2cbc6
 
 
583fb42
 
d2815e6
 
d9dc9ac
d2815e6
 
 
 
 
 
 
 
 
29956e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d2815e6
 
 
29956e0
d2815e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad9385f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d2815e6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e75de29
 
 
 
d2815e6
 
 
e75de29
 
d2815e6
 
 
ad9385f
 
d2815e6
ad9385f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d2815e6
 
 
e75de29
d2815e6
583fb42
d2815e6
 
 
 
 
 
 
6938a44
 
 
d2815e6
 
 
 
 
6938a44
 
d2815e6
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
"""
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>
"""