Simon Clematide commited on
Commit ·
115fba1
1
Parent(s): 693f03b
Add tree resize handle, pedagogical guides, and UI/comparison enhancements
Browse files- AGENTS.md +58 -9
- README.md +12 -0
- app.py +72 -26
- core/conllu_utils.py +18 -15
- core/treebanks_data.py +5 -5
- tests/browser/README.md +6 -0
- tests/browser/build_preview.py +7 -0
- tests/browser/check_app.js +13 -6
- tests/browser/check_live.js +1 -1
- tests/browser/check_preview.js +47 -1
- tests/test_comparison.py +91 -0
- visualizer/embed.js +41 -5
- visualizer/template.py +11 -2
AGENTS.md
CHANGED
|
@@ -7,26 +7,34 @@ and German Universal Dependencies trees. Parsing uses the remote LINDAT UDPipe
|
|
| 7 |
service; no local model files are required.
|
| 8 |
|
| 9 |
- `app.py`: Gradio layout, styling, explanatory text, and event callbacks. The
|
| 10 |
-
tabs are Parse (default), Compare,
|
| 11 |
module constructs the interface; running it directly launches the app.
|
| 12 |
- `core/udpipe_client.py`: HTTP client, timeout and request-error handling, and
|
| 13 |
optional removal of the CoNLL-U FEATS column.
|
| 14 |
- `core/conllu_utils.py`: pandas token tables and comparisons of UPOS, HEAD,
|
| 15 |
-
and DEPREL. Comparisons skip multiword-token
|
|
|
|
| 16 |
- `core/treebanks_data.py`: treebank catalog and language-specific model labels
|
| 17 |
mapped to UDPipe model IDs.
|
| 18 |
- `visualizer/template.py`: HTML iframe generation, pinned CDN asset URLs,
|
| 19 |
and SVG/PNG download code.
|
| 20 |
- `visualizer/embed.js`: iframe initialization, direct conllu.js-to-brat
|
| 21 |
-
conversion, font loading,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
- `tests/browser/`: deployed-parser fixtures and Playwright CLI browser checks.
|
| 23 |
- `examples.json`: curated examples with `language`, `text`, and `phenomenon`.
|
| 24 |
- `README.md`: project introduction and Hugging Face Spaces deployment metadata.
|
| 25 |
- `requirements.txt`: runtime dependencies, including pinned Gradio 5.48.0.
|
|
|
|
| 26 |
|
| 27 |
## Setup and Commands
|
| 28 |
|
| 29 |
-
Run commands from the repository root
|
|
|
|
| 30 |
otherwise create one:
|
| 31 |
|
| 32 |
```sh
|
|
@@ -51,22 +59,46 @@ documentation unless explicitly asked.
|
|
| 51 |
Keep HTTP operations in the client, data processing in `core`, rendering in
|
| 52 |
`visualizer`, and UI wiring in `app.py`.
|
| 53 |
- Preserve the calm academic styling, English/German examples, and focus on
|
| 54 |
-
POS tags and dependency arcs.
|
|
|
|
| 55 |
Use Source Sans Pro for UI text; preserve Liberation Sans and PT Sans Caption
|
| 56 |
for dependency trees and keep tree backgrounds white.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
- Keep Gradio callback return values aligned with their declared outputs.
|
| 58 |
`run_single_or_compare` returns six outputs in this order: visualization,
|
| 59 |
primary token table, raw CoNLL-U, status, discrepancy-group visibility, and
|
| 60 |
discrepancy table. Check early-return branches when changing this callback.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
- Preserve tab-separated CoNLL-U columns and comments. The client currently
|
| 62 |
truncates input to the first line; this does not guarantee that UDPipe emits
|
| 63 |
only one sentence for multiple sentences on that line.
|
| 64 |
-
- Keep model labels/IDs and catalog entries consistent.
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
Catalog entries with `ModelID: None` are informational, not selectable models.
|
|
|
|
|
|
|
|
|
|
| 67 |
- Keep each visualization isolated in an iframe with a distinct outer ID.
|
| 68 |
Escape sentence data and titles inside the document, then escape the entire
|
| 69 |
document for `srcdoc`. Escape dynamic error text inserted into HTML as well.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
- The comparison renderer currently stacks trees vertically despite its
|
| 71 |
`render_side_by_side_visualizers_html` name. Inspect actual behavior before
|
| 72 |
relying on function names or docstrings.
|
|
@@ -83,10 +115,20 @@ For Python changes, run a syntax check in the activated environment:
|
|
| 83 |
python -m compileall -q app.py core visualizer
|
| 84 |
```
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
For client or comparison changes, use small CoNLL-U fixtures and mocked HTTP
|
| 87 |
responses to check the affected behavior. Useful cases include empty input,
|
| 88 |
-
identical trees, POS/arc differences, token-count mismatches,
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
when fixing substantive logic. Use `tests/browser/check_preview.js` and
|
| 91 |
`tests/browser/check_app.js` for rendering and Gradio integration regressions.
|
| 92 |
|
|
@@ -96,6 +138,13 @@ input, token tables, raw output, and discrepancy visibility as relevant. Check
|
|
| 96 |
rendered trees and SVG/PNG downloads in a browser; syntax checks cannot verify
|
| 97 |
the embedded JavaScript or iframe layout.
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
Live parsing requires network access to LINDAT. Visualization also loads assets
|
| 100 |
from jsDelivr and Google Fonts. Distinguish unavailable external services
|
| 101 |
from local failures, and report any validation that could not be completed.
|
|
|
|
| 7 |
service; no local model files are required.
|
| 8 |
|
| 9 |
- `app.py`: Gradio layout, styling, explanatory text, and event callbacks. The
|
| 10 |
+
tabs are About, Parse (default), Compare, and Treebank Guide. Importing this
|
| 11 |
module constructs the interface; running it directly launches the app.
|
| 12 |
- `core/udpipe_client.py`: HTTP client, timeout and request-error handling, and
|
| 13 |
optional removal of the CoNLL-U FEATS column.
|
| 14 |
- `core/conllu_utils.py`: pandas token tables and comparisons of UPOS, HEAD,
|
| 15 |
+
and DEPREL, plus a bounded LRU parse cache. Comparisons skip multiword-token
|
| 16 |
+
ranges and empty-node IDs.
|
| 17 |
- `core/treebanks_data.py`: treebank catalog and language-specific model labels
|
| 18 |
mapped to UDPipe model IDs.
|
| 19 |
- `visualizer/template.py`: HTML iframe generation, pinned CDN asset URLs,
|
| 20 |
and SVG/PNG download code.
|
| 21 |
- `visualizer/embed.js`: iframe initialization, direct conllu.js-to-brat
|
| 22 |
+
conversion, font loading, hover highlighting, zoom/fullscreen controls,
|
| 23 |
+
completion/error handling, and frame sizing.
|
| 24 |
+
- `visualizer/ud_help.py`: contextual links for UPOS, dependency relations,
|
| 25 |
+
and morphological features present in a parse.
|
| 26 |
+
- `visualizer/grew_links.py`: safely escaped exact-form corpus searches using
|
| 27 |
+
the selected treebank and the service's model metadata when present.
|
| 28 |
- `tests/browser/`: deployed-parser fixtures and Playwright CLI browser checks.
|
| 29 |
- `examples.json`: curated examples with `language`, `text`, and `phenomenon`.
|
| 30 |
- `README.md`: project introduction and Hugging Face Spaces deployment metadata.
|
| 31 |
- `requirements.txt`: runtime dependencies, including pinned Gradio 5.48.0.
|
| 32 |
+
The experimental screencast uses `gradio-screenrecorder==0.0.1`.
|
| 33 |
|
| 34 |
## Setup and Commands
|
| 35 |
|
| 36 |
+
Run commands from the repository root with Python 3.13, as documented in the
|
| 37 |
+
README and browser checks. Use the existing `.venv` when available;
|
| 38 |
otherwise create one:
|
| 39 |
|
| 40 |
```sh
|
|
|
|
| 59 |
Keep HTTP operations in the client, data processing in `core`, rendering in
|
| 60 |
`visualizer`, and UI wiring in `app.py`.
|
| 61 |
- Preserve the calm academic styling, English/German examples, and focus on
|
| 62 |
+
POS tags and dependency arcs. Morphology is stripped by default; the
|
| 63 |
+
Include morphology checkbox preserves FEATS when selected.
|
| 64 |
Use Source Sans Pro for UI text; preserve Liberation Sans and PT Sans Caption
|
| 65 |
for dependency trees and keep tree backgrounds white.
|
| 66 |
+
- The Sentence input in both Parse and Compare is intentionally prominent:
|
| 67 |
+
preserve its 24px font, 1.5 line height, and single-line layout. Gradio renders
|
| 68 |
+
a one-line Textbox as an `input`, so `.sentence-input` styling must cover both
|
| 69 |
+
`input` and `textarea`. Do not shrink its text when changing input height.
|
| 70 |
- Keep Gradio callback return values aligned with their declared outputs.
|
| 71 |
`run_single_or_compare` returns six outputs in this order: visualization,
|
| 72 |
primary token table, raw CoNLL-U, status, discrepancy-group visibility, and
|
| 73 |
discrepancy table. Check early-return branches when changing this callback.
|
| 74 |
+
- Parse and Compare have independent controls built by `build_parser` and
|
| 75 |
+
their own session caches. Example selection can change the language, model
|
| 76 |
+
choices, sentence, source description, and parse results in one callback;
|
| 77 |
+
`on_example_input` must return 14 outputs, including its no-op branch.
|
| 78 |
+
- Cache keys include model ID, normalized input text, and the morphology
|
| 79 |
+
stripping flag. Keep morphology-enabled and stripped results separate.
|
| 80 |
- Preserve tab-separated CoNLL-U columns and comments. The client currently
|
| 81 |
truncates input to the first line; this does not guarantee that UDPipe emits
|
| 82 |
only one sentence for multiple sentences on that line.
|
| 83 |
+
- Keep model labels/IDs and catalog entries consistent. The guide tables in
|
| 84 |
+
`app.py` derive their rows from `TREEBANK_CATALOG`; check accompanying prose
|
| 85 |
+
when changing the catalog. Model training/statistics use UD 2.17, while
|
| 86 |
+
Grew exploration uses the separate `GREW_UD_VERSION` (currently 2.18).
|
| 87 |
Catalog entries with `ModelID: None` are informational, not selectable models.
|
| 88 |
+
- Selectable parser names include both tooling and training treebank, e.g.
|
| 89 |
+
`UDPipe · english-ewt-ud-2.17-251125`. These names also label tree results and
|
| 90 |
+
comparison output; keep browser selectors aligned with UI label changes.
|
| 91 |
- Keep each visualization isolated in an iframe with a distinct outer ID.
|
| 92 |
Escape sentence data and titles inside the document, then escape the entire
|
| 93 |
document for `srcdoc`. Escape dynamic error text inserted into HTML as well.
|
| 94 |
+
- Each tree has a bottom-right resize handle for width and height, with arrow
|
| 95 |
+
keys for keyboard resizing and double-click to restore automatic fitting.
|
| 96 |
+
Preserve manual dimensions during zoom, help toggles, and fullscreen changes;
|
| 97 |
+
comparison trees resize independently. New parse results start with auto fit.
|
| 98 |
+
- Pin parser and renderer revisions separately in `visualizer/template.py`.
|
| 99 |
+
Preserve the adapter's trailing-newline normalization without changing raw
|
| 100 |
+
CoNLL-U or internal sentence boundaries. Downloads embed tree fonts and
|
| 101 |
+
remove temporary hover styling; check the exported files after CSS changes.
|
| 102 |
- The comparison renderer currently stacks trees vertically despite its
|
| 103 |
`render_side_by_side_visualizers_html` name. Inspect actual behavior before
|
| 104 |
relying on function names or docstrings.
|
|
|
|
| 115 |
python -m compileall -q app.py core visualizer
|
| 116 |
```
|
| 117 |
|
| 118 |
+
Run deterministic comparison and callback regressions without remote parsing:
|
| 119 |
+
|
| 120 |
+
```sh
|
| 121 |
+
python -m unittest discover -s tests -p 'test_*.py'
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
For client or comparison changes, use small CoNLL-U fixtures and mocked HTTP
|
| 125 |
responses to check the affected behavior. Useful cases include empty input,
|
| 126 |
+
identical trees, POS/arc differences, token-count mismatches, equal-length
|
| 127 |
+
tokenization differences, multiword tokens, empty nodes, multiple sentences,
|
| 128 |
+
FEATS stripping, cache separation, malformed/empty service responses, and
|
| 129 |
+
failure of either model request. Missing parse data must not establish tree
|
| 130 |
+
identity; token alignment must be checked before comparing arcs by position.
|
| 131 |
+
Add focused regression tests
|
| 132 |
when fixing substantive logic. Use `tests/browser/check_preview.js` and
|
| 133 |
`tests/browser/check_app.js` for rendering and Gradio integration regressions.
|
| 134 |
|
|
|
|
| 138 |
rendered trees and SVG/PNG downloads in a browser; syntax checks cannot verify
|
| 139 |
the embedded JavaScript or iframe layout.
|
| 140 |
|
| 141 |
+
Use `tests/browser/build_preview.py` with a static server on port 8765 for
|
| 142 |
+
renderer fixtures and `tests/browser/run_app.py` on port 7871 for deterministic
|
| 143 |
+
Gradio checks. The latter replaces only UDPipe responses; it does not validate
|
| 144 |
+
the HTTP client or live model availability. `check_live.js` exercises actual
|
| 145 |
+
parsing and downloads; `check_recorder.js` covers the experimental recorder.
|
| 146 |
+
Keep generated previews, screenshots, and downloads in ignored output paths.
|
| 147 |
+
|
| 148 |
Live parsing requires network access to LINDAT. Visualization also loads assets
|
| 149 |
from jsDelivr and Google Fonts. Distinguish unavailable external services
|
| 150 |
from local failures, and report any validation that could not be completed.
|
README.md
CHANGED
|
@@ -30,3 +30,15 @@ tree fonts. Asset-loading and parsing failures appear in the visualization area.
|
|
| 30 |
|
| 31 |
See [browser regression checks](tests/browser/README.md) for deterministic
|
| 32 |
fixtures, Gradio integration tests, and hosted verification steps.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
See [browser regression checks](tests/browser/README.md) for deterministic
|
| 32 |
fixtures, Gradio integration tests, and hosted verification steps.
|
| 33 |
+
|
| 34 |
+
## Interface Design
|
| 35 |
+
|
| 36 |
+
The Sentence input is the visual starting point in Parse and Compare. Its
|
| 37 |
+
single-line layout intentionally uses a prominent 24px font with 1.5 line
|
| 38 |
+
height for teaching and screencasts. Preserve this size when adjusting the
|
| 39 |
+
layout. Gradio can render textboxes as either `input` or `textarea` elements;
|
| 40 |
+
the sentence styling covers both.
|
| 41 |
+
|
| 42 |
+
Drag a tree panel's bottom-right handle to resize its width and height. Focus
|
| 43 |
+
the handle and use arrow keys to resize with the keyboard; double-click it to
|
| 44 |
+
restore automatic fitting. Each comparison tree can be resized independently.
|
app.py
CHANGED
|
@@ -3,7 +3,7 @@ Universal Dependencies Parser & Visualizer
|
|
| 3 |
Gradio Web Application following a calm, pedagogical design.
|
| 4 |
|
| 5 |
Tabs:
|
| 6 |
-
|
| 7 |
"""
|
| 8 |
|
| 9 |
import html
|
|
@@ -16,7 +16,7 @@ from gradio_screenrecorder import ScreenRecorder
|
|
| 16 |
import pandas as pd
|
| 17 |
|
| 18 |
from core.udpipe_client import UDPipeClient
|
| 19 |
-
from core.conllu_utils import conllu_to_table, compare_trees, BoundedParseCache
|
| 20 |
from core.treebanks_data import TREEBANK_CATALOG, AVAILABLE_MODELS
|
| 21 |
from visualizer.template import render_conllu_visualizer_html, render_side_by_side_visualizers_html
|
| 22 |
|
|
@@ -55,7 +55,9 @@ html, body, .gradio-container, button, input, select, textarea, .prose {
|
|
| 55 |
padding-right: 16px !important;
|
| 56 |
box-sizing: border-box !important;
|
| 57 |
}
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
font-size: 24px !important;
|
| 60 |
line-height: 1.5 !important;
|
| 61 |
}
|
|
@@ -85,17 +87,46 @@ html, body, .gradio-container, button, input, select, textarea, .prose {
|
|
| 85 |
"""
|
| 86 |
|
| 87 |
OVERVIEW_MD = """
|
| 88 |
-
#
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
"""
|
| 100 |
|
| 101 |
CONFORMANCE_DOC_MD = """
|
|
@@ -168,8 +199,11 @@ def run_single_or_compare(
|
|
| 168 |
|
| 169 |
strip_morphology = not include_morphology
|
| 170 |
res_a = client.parse_sentence(sentence, model_id_a, strip_morphology=strip_morphology, cache=cache)
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 173 |
return err_msg, pd.DataFrame(), "", "", gr.update(visible=False), pd.DataFrame()
|
| 174 |
|
| 175 |
conllu_a = res_a["conllu"]
|
|
@@ -192,9 +226,9 @@ def run_single_or_compare(
|
|
| 192 |
vis_html = render_conllu_visualizer_html(conllu_a, vis_id="vis-primary", title=primary_model_name, model_id=model_id_a)
|
| 193 |
m_name_esc = html.escape(primary_model_name)
|
| 194 |
status_html = (
|
| 195 |
-
f"<div class='diff-alert diff-alert-same'>✓ <strong>Same
|
| 196 |
-
f"Both
|
| 197 |
-
f"Select a different second
|
| 198 |
)
|
| 199 |
return (
|
| 200 |
vis_html,
|
|
@@ -209,7 +243,18 @@ def run_single_or_compare(
|
|
| 209 |
model_id_b = models_dict.get(secondary_model_name, list(models_dict.values())[0])
|
| 210 |
res_b = client.parse_sentence(sentence, model_id_b, strip_morphology=strip_morphology, cache=cache)
|
| 211 |
conllu_b = res_b.get("conllu", "")
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
# Diff trees
|
| 215 |
diff_info = compare_trees(conllu_a, primary_model_name, conllu_b, secondary_model_name)
|
|
@@ -223,7 +268,7 @@ def run_single_or_compare(
|
|
| 223 |
raw_conllu_combined = f"# === {primary_model_name} ===\n{conllu_a}\n\n# === {secondary_model_name} ===\n{conllu_b}"
|
| 224 |
|
| 225 |
if diff_info["identical"]:
|
| 226 |
-
status_html = f"<div class='diff-alert diff-alert-same'>✓ <strong>Identical Trees:</strong> Both
|
| 227 |
diff_visible = False
|
| 228 |
else:
|
| 229 |
diff_text = html.escape(diff_info["diff_summary"])
|
|
@@ -269,17 +314,18 @@ def build_parser(compare=False):
|
|
| 269 |
"""Build independent, sentence-first controls for each parsing mode."""
|
| 270 |
sentence = gr.Textbox(label="Sentence", value="They buy and sell books.",
|
| 271 |
placeholder="Enter an English or German sentence...",
|
| 272 |
-
lines=
|
| 273 |
with gr.Row():
|
| 274 |
language = gr.Dropdown(label="Language", choices=["English", "German"], value="English", scale=1)
|
| 275 |
-
primary = gr.Dropdown(label="
|
| 276 |
choices=list(AVAILABLE_MODELS["English"]),
|
| 277 |
value=list(AVAILABLE_MODELS["English"])[0], scale=2)
|
| 278 |
-
secondary = gr.Dropdown(label="Second
|
| 279 |
value=list(AVAILABLE_MODELS["English"])[1], visible=compare, scale=2)
|
| 280 |
with gr.Column(scale=1):
|
| 281 |
submit = gr.Button("Compare" if compare else "Parse", variant="primary")
|
| 282 |
include_morphology = gr.Checkbox(label="Include morphology (FEATS)", value=False)
|
|
|
|
| 283 |
with gr.Accordion("Examples", open=True):
|
| 284 |
selected_example = gr.State(0)
|
| 285 |
with gr.Row():
|
|
@@ -430,12 +476,12 @@ with gr.Blocks(**_blocks_kwargs) as demo:
|
|
| 430 |
)
|
| 431 |
|
| 432 |
with gr.Tabs(selected="tab_parser"):
|
|
|
|
|
|
|
| 433 |
with gr.Tab("Parse", id="tab_parser"):
|
| 434 |
build_parser()
|
| 435 |
with gr.Tab("Compare", id="tab_compare"):
|
| 436 |
build_parser(compare=True)
|
| 437 |
-
with gr.Tab("About", id="tab_overview"):
|
| 438 |
-
gr.Markdown(OVERVIEW_MD)
|
| 439 |
with gr.Tab("Treebank Guide", id="tab_guide"):
|
| 440 |
with gr.Accordion("Training size, quality and conformance", open=False):
|
| 441 |
# Remove duplicate heading since accordion provides the label
|
|
|
|
| 3 |
Gradio Web Application following a calm, pedagogical design.
|
| 4 |
|
| 5 |
Tabs:
|
| 6 |
+
Tabs are ordered About, Parse, Compare, Treebank Guide; Parse opens by default.
|
| 7 |
"""
|
| 8 |
|
| 9 |
import html
|
|
|
|
| 16 |
import pandas as pd
|
| 17 |
|
| 18 |
from core.udpipe_client import UDPipeClient
|
| 19 |
+
from core.conllu_utils import conllu_to_table, compare_trees, extract_tree_signature, BoundedParseCache
|
| 20 |
from core.treebanks_data import TREEBANK_CATALOG, AVAILABLE_MODELS
|
| 21 |
from visualizer.template import render_conllu_visualizer_html, render_side_by_side_visualizers_html
|
| 22 |
|
|
|
|
| 55 |
padding-right: 16px !important;
|
| 56 |
box-sizing: border-box !important;
|
| 57 |
}
|
| 58 |
+
/* Gradio uses input for one line and textarea for multiline textboxes.
|
| 59 |
+
Keep the sentence prominent in either form: 24px is intentional. */
|
| 60 |
+
.sentence-input input, .sentence-input textarea {
|
| 61 |
font-size: 24px !important;
|
| 62 |
line-height: 1.5 !important;
|
| 63 |
}
|
|
|
|
| 87 |
"""
|
| 88 |
|
| 89 |
OVERVIEW_MD = """
|
| 90 |
+
# Explore Linguistic Structures with Universal Dependencies
|
| 91 |
+
|
| 92 |
+
Use this tool to investigate how words work together in English and German
|
| 93 |
+
sentences. Start with a curated example or enter your own sentence, then follow
|
| 94 |
+
the dependency arcs to explore subjects, objects, modifiers, and clause structure.
|
| 95 |
+
|
| 96 |
+
- **Parse a sentence.** Choose a UDPipe parser to see its predicted word classes
|
| 97 |
+
and dependency relations. Each parser name identifies its training treebank
|
| 98 |
+
and model version. Enable **Include morphology (FEATS)** to explore grammatical
|
| 99 |
+
features such as case, number, and tense.
|
| 100 |
+
- **Explore heads interactively.** Hover over a word or its POS label to highlight
|
| 101 |
+
its syntactic head. Hover over dependency arcs to focus on a relation and its
|
| 102 |
+
connected words. Use this to ask which word a phrase depends on and how
|
| 103 |
+
modifiers and clauses attach.
|
| 104 |
+
- **Look up the categories in your analysis.** In Parse, the **Help** section
|
| 105 |
+
links the POS tags, dependency relations, and supported morphological features
|
| 106 |
+
that occur in the sentence to their Universal Dependencies documentation.
|
| 107 |
+
Read the definitions and examples, then return to your sentence to interpret
|
| 108 |
+
the labels. Language-specific relation subtypes link to their base relation
|
| 109 |
+
when no universal subtype page is available.
|
| 110 |
+
- **Inspect words in real treebanks.** Each word has a link to an exact-form
|
| 111 |
+
search in **Grew**, using the treebank associated with the selected parser.
|
| 112 |
+
Compare several corpus examples to discover recurring patterns, alternative
|
| 113 |
+
attachments, and counterexamples to your first interpretation.
|
| 114 |
+
- **Compare two analyses.** In Compare, choose two UDPipe parsers and inspect
|
| 115 |
+
their trees and the POS/dependency differences. Ask which analysis best fits
|
| 116 |
+
the sentence and the UD guidelines, and explain your reasoning.
|
| 117 |
+
|
| 118 |
+
Open **Annotations** for the token table or **CoNLL-U** for the raw analysis.
|
| 119 |
+
Zoom in on a tree, use fullscreen, or download it as SVG or PNG for discussion
|
| 120 |
+
and teaching. The **Treebank Guide** explains training sizes, text sources, and
|
| 121 |
+
annotation provenance, with links for further corpus exploration. Grew searches
|
| 122 |
+
use the configured exploration release, which can differ from the parser's
|
| 123 |
+
training release.
|
| 124 |
+
|
| 125 |
+
Treat each automatic analysis as a starting point for investigation: parsers
|
| 126 |
+
can make mistakes, and agreement does not establish correctness. A disagreement
|
| 127 |
+
can reveal ambiguity, a parsing error, or differences in annotation practice.
|
| 128 |
+
Use the highlighting, documentation, and corpus examples to test your ideas
|
| 129 |
+
about linguistic structure.
|
| 130 |
"""
|
| 131 |
|
| 132 |
CONFORMANCE_DOC_MD = """
|
|
|
|
| 199 |
|
| 200 |
strip_morphology = not include_morphology
|
| 201 |
res_a = client.parse_sentence(sentence, model_id_a, strip_morphology=strip_morphology, cache=cache)
|
| 202 |
+
error_a = res_a.get("error")
|
| 203 |
+
if not error_a and not extract_tree_signature(res_a.get("conllu", "")):
|
| 204 |
+
error_a = "The primary model returned no usable parse tokens."
|
| 205 |
+
if error_a:
|
| 206 |
+
err_msg = f"<div style='color: #ef4444;'>Error: {html.escape(str(error_a))}</div>"
|
| 207 |
return err_msg, pd.DataFrame(), "", "", gr.update(visible=False), pd.DataFrame()
|
| 208 |
|
| 209 |
conllu_a = res_a["conllu"]
|
|
|
|
| 226 |
vis_html = render_conllu_visualizer_html(conllu_a, vis_id="vis-primary", title=primary_model_name, model_id=model_id_a)
|
| 227 |
m_name_esc = html.escape(primary_model_name)
|
| 228 |
status_html = (
|
| 229 |
+
f"<div class='diff-alert diff-alert-same'>✓ <strong>Same UDPipe Parser Selected:</strong> "
|
| 230 |
+
f"Both parsers are set to <strong>{m_name_esc}</strong> ({len(table_a)} tokens). "
|
| 231 |
+
f"Select a different second parser to inspect differences.</div>"
|
| 232 |
)
|
| 233 |
return (
|
| 234 |
vis_html,
|
|
|
|
| 243 |
model_id_b = models_dict.get(secondary_model_name, list(models_dict.values())[0])
|
| 244 |
res_b = client.parse_sentence(sentence, model_id_b, strip_morphology=strip_morphology, cache=cache)
|
| 245 |
conllu_b = res_b.get("conllu", "")
|
| 246 |
+
error_b = res_b.get("error")
|
| 247 |
+
if not error_b and not extract_tree_signature(conllu_b):
|
| 248 |
+
error_b = "The secondary model returned no usable parse tokens."
|
| 249 |
+
if error_b:
|
| 250 |
+
vis_html = render_conllu_visualizer_html(
|
| 251 |
+
conllu_a, vis_id="vis-primary", title=primary_model_name, model_id=model_id_a
|
| 252 |
+
)
|
| 253 |
+
status_html = (
|
| 254 |
+
"<div class='diff-alert diff-alert-diff'><strong>Comparison unavailable:</strong> "
|
| 255 |
+
f"{html.escape(secondary_model_name)}: {html.escape(str(error_b))}</div>"
|
| 256 |
+
)
|
| 257 |
+
return vis_html, table_a, conllu_a, status_html, gr.update(visible=False), pd.DataFrame()
|
| 258 |
|
| 259 |
# Diff trees
|
| 260 |
diff_info = compare_trees(conllu_a, primary_model_name, conllu_b, secondary_model_name)
|
|
|
|
| 268 |
raw_conllu_combined = f"# === {primary_model_name} ===\n{conllu_a}\n\n# === {secondary_model_name} ===\n{conllu_b}"
|
| 269 |
|
| 270 |
if diff_info["identical"]:
|
| 271 |
+
status_html = f"<div class='diff-alert diff-alert-same'>✓ <strong>Identical Trees:</strong> Both UDPipe parsers produced matching POS tags and syntactic dependency arcs.</div>"
|
| 272 |
diff_visible = False
|
| 273 |
else:
|
| 274 |
diff_text = html.escape(diff_info["diff_summary"])
|
|
|
|
| 314 |
"""Build independent, sentence-first controls for each parsing mode."""
|
| 315 |
sentence = gr.Textbox(label="Sentence", value="They buy and sell books.",
|
| 316 |
placeholder="Enter an English or German sentence...",
|
| 317 |
+
lines=1, max_lines=1, elem_classes=["sentence-input"])
|
| 318 |
with gr.Row():
|
| 319 |
language = gr.Dropdown(label="Language", choices=["English", "German"], value="English", scale=1)
|
| 320 |
+
primary = gr.Dropdown(label="UDPipe parser" if not compare else "First UDPipe parser",
|
| 321 |
choices=list(AVAILABLE_MODELS["English"]),
|
| 322 |
value=list(AVAILABLE_MODELS["English"])[0], scale=2)
|
| 323 |
+
secondary = gr.Dropdown(label="Second UDPipe parser", choices=list(AVAILABLE_MODELS["English"]),
|
| 324 |
value=list(AVAILABLE_MODELS["English"])[1], visible=compare, scale=2)
|
| 325 |
with gr.Column(scale=1):
|
| 326 |
submit = gr.Button("Compare" if compare else "Parse", variant="primary")
|
| 327 |
include_morphology = gr.Checkbox(label="Include morphology (FEATS)", value=False)
|
| 328 |
+
gr.Markdown("Parser names show UDPipe and the model ID used in the raw CoNLL-U output, including the training treebank and UD release.")
|
| 329 |
with gr.Accordion("Examples", open=True):
|
| 330 |
selected_example = gr.State(0)
|
| 331 |
with gr.Row():
|
|
|
|
| 476 |
)
|
| 477 |
|
| 478 |
with gr.Tabs(selected="tab_parser"):
|
| 479 |
+
with gr.Tab("About", id="tab_overview"):
|
| 480 |
+
gr.Markdown(OVERVIEW_MD)
|
| 481 |
with gr.Tab("Parse", id="tab_parser"):
|
| 482 |
build_parser()
|
| 483 |
with gr.Tab("Compare", id="tab_compare"):
|
| 484 |
build_parser(compare=True)
|
|
|
|
|
|
|
| 485 |
with gr.Tab("Treebank Guide", id="tab_guide"):
|
| 486 |
with gr.Accordion("Training size, quality and conformance", open=False):
|
| 487 |
# Remove duplicate heading since accordion provides the label
|
core/conllu_utils.py
CHANGED
|
@@ -65,7 +65,7 @@ def conllu_to_table(conllu_text: str) -> pd.DataFrame:
|
|
| 65 |
return pd.DataFrame(rows)
|
| 66 |
|
| 67 |
|
| 68 |
-
def extract_tree_signature(conllu_text: str) -> List[Tuple[str, str, str, str]]:
|
| 69 |
"""
|
| 70 |
Extracts a list of (ID, Form, UPOS, Head, Deprel) tokens for exact difference comparison.
|
| 71 |
"""
|
|
@@ -101,17 +101,30 @@ def compare_trees(
|
|
| 101 |
|
| 102 |
if not sig_a or not sig_b:
|
| 103 |
return {
|
| 104 |
-
"identical":
|
| 105 |
"diff_summary": "Insufficient tree data to compare.",
|
| 106 |
"diff_table": pd.DataFrame(),
|
| 107 |
"discrepancy_count": 0,
|
| 108 |
}
|
| 109 |
|
| 110 |
-
#
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
differences = []
|
| 113 |
|
| 114 |
-
for i in range(
|
| 115 |
id_a, form_a, upos_a, head_a, rel_a = sig_a[i]
|
| 116 |
id_b, form_b, upos_b, head_b, rel_b = sig_b[i]
|
| 117 |
|
|
@@ -128,16 +141,6 @@ def compare_trees(
|
|
| 128 |
"Difference": "POS & Syntax" if (pos_diff and dep_diff) else ("POS" if pos_diff else "Syntax Arc"),
|
| 129 |
})
|
| 130 |
|
| 131 |
-
if len(sig_a) != len(sig_b):
|
| 132 |
-
differences.append({
|
| 133 |
-
"Token": "Tokenization",
|
| 134 |
-
f"{name_a} (UPOS)": f"{len(sig_a)} tokens",
|
| 135 |
-
f"{name_b} (UPOS)": f"{len(sig_b)} tokens",
|
| 136 |
-
f"{name_a} (Head → Deprel)": "-",
|
| 137 |
-
f"{name_b} (Head → Deprel)": "-",
|
| 138 |
-
"Difference": "Token Count Mismatch",
|
| 139 |
-
})
|
| 140 |
-
|
| 141 |
is_identical = len(differences) == 0
|
| 142 |
|
| 143 |
if is_identical:
|
|
|
|
| 65 |
return pd.DataFrame(rows)
|
| 66 |
|
| 67 |
|
| 68 |
+
def extract_tree_signature(conllu_text: str) -> List[Tuple[str, str, str, str, str]]:
|
| 69 |
"""
|
| 70 |
Extracts a list of (ID, Form, UPOS, Head, Deprel) tokens for exact difference comparison.
|
| 71 |
"""
|
|
|
|
| 101 |
|
| 102 |
if not sig_a or not sig_b:
|
| 103 |
return {
|
| 104 |
+
"identical": False,
|
| 105 |
"diff_summary": "Insufficient tree data to compare.",
|
| 106 |
"diff_table": pd.DataFrame(),
|
| 107 |
"discrepancy_count": 0,
|
| 108 |
}
|
| 109 |
|
| 110 |
+
# Head IDs only refer to corresponding words when tokenization aligns.
|
| 111 |
+
if [(token[0], token[1]) for token in sig_a] != [(token[0], token[1]) for token in sig_b]:
|
| 112 |
+
count_mismatch = len(sig_a) != len(sig_b)
|
| 113 |
+
return {
|
| 114 |
+
"identical": False,
|
| 115 |
+
"diff_summary": "Tokenization differs; POS tags and dependency arcs cannot be compared by position.",
|
| 116 |
+
"diff_table": pd.DataFrame([{
|
| 117 |
+
"Token": "Tokenization",
|
| 118 |
+
f"{name_a} (Tokens)": " | ".join(f"{token[0]}: {token[1]}" for token in sig_a),
|
| 119 |
+
f"{name_b} (Tokens)": " | ".join(f"{token[0]}: {token[1]}" for token in sig_b),
|
| 120 |
+
"Difference": "Token Count Mismatch" if count_mismatch else "Token Alignment Mismatch",
|
| 121 |
+
}]),
|
| 122 |
+
"discrepancy_count": 1,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
differences = []
|
| 126 |
|
| 127 |
+
for i in range(len(sig_a)):
|
| 128 |
id_a, form_a, upos_a, head_a, rel_a = sig_a[i]
|
| 129 |
id_b, form_b, upos_b, head_b, rel_b = sig_b[i]
|
| 130 |
|
|
|
|
| 141 |
"Difference": "POS & Syntax" if (pos_diff and dep_diff) else ("POS" if pos_diff else "Syntax Arc"),
|
| 142 |
})
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
is_identical = len(differences) == 0
|
| 145 |
|
| 146 |
if is_identical:
|
core/treebanks_data.py
CHANGED
|
@@ -169,13 +169,13 @@ TREEBANK_CATALOG = [
|
|
| 169 |
|
| 170 |
AVAILABLE_MODELS = {
|
| 171 |
"English": {
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
},
|
| 176 |
"German": {
|
| 177 |
-
"
|
| 178 |
-
"
|
| 179 |
}
|
| 180 |
}
|
| 181 |
|
|
|
|
| 169 |
|
| 170 |
AVAILABLE_MODELS = {
|
| 171 |
"English": {
|
| 172 |
+
"UDPipe · english-ewt-ud-2.17-251125": "english-ewt-ud-2.17-251125",
|
| 173 |
+
"UDPipe · english-gum-ud-2.17-251125": "english-gum-ud-2.17-251125",
|
| 174 |
+
"UDPipe · english-lines-ud-2.17-251125": "english-lines-ud-2.17-251125"
|
| 175 |
},
|
| 176 |
"German": {
|
| 177 |
+
"UDPipe · german-hdt-ud-2.17-251125": "german-hdt-ud-2.17-251125",
|
| 178 |
+
"UDPipe · german-gsd-ud-2.17-251125": "german-gsd-ud-2.17-251125"
|
| 179 |
}
|
| 180 |
}
|
| 181 |
|
tests/browser/README.md
CHANGED
|
@@ -34,9 +34,15 @@ errors when parser or renderer requests are blocked. Negative tests deliberately
|
|
| 34 |
produce failed-request console messages. Screenshots and downloads are saved in
|
| 35 |
the ignored `output/playwright/` directory.
|
| 36 |
|
|
|
|
|
|
|
|
|
|
| 37 |
`check_app.js` uses the real UI and callbacks, replacing only UDPipe responses.
|
| 38 |
It checks English comparison, single mode, German model updates/comparison,
|
| 39 |
repeated analysis, sample selection, and clearing stale trees after empty input.
|
|
|
|
|
|
|
|
|
|
| 40 |
The fixture server runs on port 7871; the static fixture server uses port 8765.
|
| 41 |
|
| 42 |
For live service verification, run `python app.py` and exercise the same modes
|
|
|
|
| 34 |
produce failed-request console messages. Screenshots and downloads are saved in
|
| 35 |
the ignored `output/playwright/` directory.
|
| 36 |
|
| 37 |
+
The preview checks also cover drag/keyboard panel resizing, retention of manual
|
| 38 |
+
height after zoom, independent comparison frames, and double-click fit reset.
|
| 39 |
+
|
| 40 |
`check_app.js` uses the real UI and callbacks, replacing only UDPipe responses.
|
| 41 |
It checks English comparison, single mode, German model updates/comparison,
|
| 42 |
repeated analysis, sample selection, and clearing stale trees after empty input.
|
| 43 |
+
It also verifies that both single-line Sentence inputs retain the intentional
|
| 44 |
+
24px font and 36px line height; changing Gradio's input element must not reduce
|
| 45 |
+
their visual prominence.
|
| 46 |
The fixture server runs on port 7871; the static fixture server uses port 8765.
|
| 47 |
|
| 48 |
For live service verification, run `python app.py` and exercise the same modes
|
tests/browser/build_preview.py
CHANGED
|
@@ -12,6 +12,11 @@ GERMAN = '# sentence-label German\n1\tBücher\tBuch\tNOUN\t_\t_\t2\tnsubj\t_\t_\
|
|
| 12 |
ESCAPING = '1\t</textarea></script>&\t_\tNOUN\t_\t_\t0\troot\t_\t_\n'
|
| 13 |
MULTIWORD = '1-2\tcan’t\t_\t_\t_\t_\t_\t_\t_\t_\n1\tca\tcan\tAUX\t_\t_\t3\taux\t_\t_\n2\tn’t\tnot\tPART\t_\t_\t3\tadvmod\t_\t_\n3\tgo\tgo\tVERB\t_\t_\t0\troot\t_\t_\n'
|
| 14 |
EMPTY = '1\tHello\thello\tINTJ\t_\t_\t0\troot\t_\t_\n1.1\tworld\tworld\tNOUN\t_\t_\t_\t_\t1:dep\t_\n'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
CROWDED = "\n".join(
|
| 16 |
"\t".join(map(str, [i, word, "_", pos, "_", "_", head, rel, "_", "_"]))
|
| 17 |
for i, (word, pos, head, rel) in enumerate([
|
|
@@ -30,6 +35,8 @@ def build():
|
|
| 30 |
preview += render_conllu_visualizer_html(MULTIWORD, 'multiword', 'Multiword')
|
| 31 |
preview += render_conllu_visualizer_html(EMPTY, 'empty', 'Empty node')
|
| 32 |
preview += render_conllu_visualizer_html(CROWDED, 'crowded', 'Crowded label markers', show_help=True)
|
|
|
|
|
|
|
| 33 |
output = ROOT / 'output' / 'playwright'
|
| 34 |
output.mkdir(parents=True, exist_ok=True)
|
| 35 |
(output / 'preview.html').write_text(preview)
|
|
|
|
| 12 |
ESCAPING = '1\t</textarea></script>&\t_\tNOUN\t_\t_\t0\troot\t_\t_\n'
|
| 13 |
MULTIWORD = '1-2\tcan’t\t_\t_\t_\t_\t_\t_\t_\t_\n1\tca\tcan\tAUX\t_\t_\t3\taux\t_\t_\n2\tn’t\tnot\tPART\t_\t_\t3\tadvmod\t_\t_\n3\tgo\tgo\tVERB\t_\t_\t0\troot\t_\t_\n'
|
| 14 |
EMPTY = '1\tHello\thello\tINTJ\t_\t_\t0\troot\t_\t_\n1.1\tworld\tworld\tNOUN\t_\t_\t_\t_\t1:dep\t_\n'
|
| 15 |
+
MULTISENTENCE = ENGLISH + '\n' + GERMAN
|
| 16 |
+
WRAPPED = '\n'.join(
|
| 17 |
+
f'{i}\tword{i}\t_\tNOUN\t_\t_\t{i - 1}\t{"root" if i == 1 else "conj"}\t_\t_'
|
| 18 |
+
for i in range(1, 31)
|
| 19 |
+
) + '\n'
|
| 20 |
CROWDED = "\n".join(
|
| 21 |
"\t".join(map(str, [i, word, "_", pos, "_", "_", head, rel, "_", "_"]))
|
| 22 |
for i, (word, pos, head, rel) in enumerate([
|
|
|
|
| 35 |
preview += render_conllu_visualizer_html(MULTIWORD, 'multiword', 'Multiword')
|
| 36 |
preview += render_conllu_visualizer_html(EMPTY, 'empty', 'Empty node')
|
| 37 |
preview += render_conllu_visualizer_html(CROWDED, 'crowded', 'Crowded label markers', show_help=True)
|
| 38 |
+
preview += render_conllu_visualizer_html(MULTISENTENCE, 'multisentence', 'Multiple sentences')
|
| 39 |
+
preview += '<div style="width:500px">' + render_conllu_visualizer_html(WRAPPED, 'wrapped', 'Wrapped sentence') + '</div>'
|
| 40 |
output = ROOT / 'output' / 'playwright'
|
| 41 |
output.mkdir(parents=True, exist_ok=True)
|
| 42 |
(output / 'preview.html').write_text(preview)
|
tests/browser/check_app.js
CHANGED
|
@@ -2,6 +2,12 @@ async (page) => {
|
|
| 2 |
await page.goto('http://127.0.0.1:7871');
|
| 3 |
const active = () => page.locator('[role="tabpanel"]:visible');
|
| 4 |
if (await page.getByRole('tab', {name:'Parse', exact:true}).getAttribute('aria-selected') !== 'true') throw Error('Parse must open first');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
const featsCheckbox = active().locator('input[type="checkbox"]');
|
| 6 |
if (!await featsCheckbox.count()) throw Error('Expected morphological features checkbox');
|
| 7 |
|
|
@@ -29,6 +35,7 @@ async (page) => {
|
|
| 29 |
if (queries.map(q=>q.word).join(',') !== 'books,buy,They' || queries.some(q=>q.corpus !== 'UD_English-EWT@2.18' || q.request !== 'pattern { X [form=' + JSON.stringify(q.word) + '] }')) throw Error('Incorrect word queries: ' + JSON.stringify(queries));
|
| 30 |
// 2. Compare mode
|
| 31 |
await page.getByRole('tab', {name:'Compare', exact:true}).click();
|
|
|
|
| 32 |
await analyze(2, 'They', 'Compare');
|
| 33 |
|
| 34 |
if (await active().locator('#vis-model-a').contentFrame().locator('.ud-help').count()) throw Error('Help should be limited to Parse');
|
|
@@ -48,18 +55,18 @@ async (page) => {
|
|
| 48 |
if (statusText.includes('**')) throw Error('Literal markdown markers (**) found in status HTML');
|
| 49 |
|
| 50 |
// 3. Same-model comparison
|
| 51 |
-
const modelsCount = await active().getByRole('listbox', {name:'Second
|
| 52 |
-
await page.getByRole('option', {name:'
|
| 53 |
await active().getByRole('button', {name:'Compare', exact:true}).click();
|
| 54 |
await page.waitForFunction(() => {
|
| 55 |
const alert = document.querySelector('[role="tabpanel"]:not([style*="display: none"]) .diff-alert-same');
|
| 56 |
-
return alert && alert.textContent.includes('Same
|
| 57 |
});
|
| 58 |
|
| 59 |
// 4. Language change to German in Compare
|
| 60 |
await active().getByRole('listbox', {name:'Language', exact:true}).click();
|
| 61 |
await page.getByRole('option', {name:'German', exact:true}).click();
|
| 62 |
-
await page.waitForFunction(() => document.querySelector('input[aria-label="First
|
| 63 |
|
| 64 |
// Verify sentence stays as is when changing language
|
| 65 |
const currentSentence = await active().getByRole('textbox', {name:'Sentence', exact:true}).inputValue();
|
|
@@ -87,12 +94,12 @@ async (page) => {
|
|
| 87 |
}
|
| 88 |
await active().getByRole('listbox', {name:'English Examples', exact:true}).click();
|
| 89 |
await page.getByRole('option', {name:'The dog that chased the cat barked loudly.', exact:true}).click();
|
| 90 |
-
await page.waitForFunction(() => Array.from(document.querySelectorAll('textarea')).some(el => el.value === 'The dog that chased the cat barked loudly.'));
|
| 91 |
|
| 92 |
if (await active().getByRole('button', {name:'Use example', exact:true}).count()) throw Error('Redundant example button');
|
| 93 |
await active().getByRole('listbox', {name:'German Examples', exact:true}).click();
|
| 94 |
await page.getByRole('option', {name:'Der Zug, der um acht Uhr ankommen sollte, hat Verspätung.', exact:true}).click();
|
| 95 |
-
await page.waitForFunction(() => document.querySelector('input[aria-label="
|
| 96 |
if (await active().getByRole('textbox', {name:'Sentence', exact:true}).inputValue() !== 'Der Zug, der um acht Uhr ankommen sollte, hat Verspätung.') throw Error('Cross-language example did not load sentence');
|
| 97 |
await active().getByText('Relative clause embedded in subject noun phrase', {exact:true}).waitFor();
|
| 98 |
await page.waitForFunction(() => document.querySelectorAll('[role="tabpanel"]:not([style*="display: none"]) iframe[id^="vis-"]').length === 1);
|
|
|
|
| 2 |
await page.goto('http://127.0.0.1:7871');
|
| 3 |
const active = () => page.locator('[role="tabpanel"]:visible');
|
| 4 |
if (await page.getByRole('tab', {name:'Parse', exact:true}).getAttribute('aria-selected') !== 'true') throw Error('Parse must open first');
|
| 5 |
+
async function checkSentenceStyle() {
|
| 6 |
+
const sentence = active().getByRole('textbox', {name:'Sentence', exact:true});
|
| 7 |
+
const style = await sentence.evaluate(el => ({fontSize:getComputedStyle(el).fontSize, lineHeight:getComputedStyle(el).lineHeight, tag:el.tagName}));
|
| 8 |
+
if (style.fontSize !== '24px' || style.lineHeight !== '36px' || style.tag !== 'INPUT') throw Error('Sentence must retain prominent single-line styling: ' + JSON.stringify(style));
|
| 9 |
+
}
|
| 10 |
+
await checkSentenceStyle();
|
| 11 |
const featsCheckbox = active().locator('input[type="checkbox"]');
|
| 12 |
if (!await featsCheckbox.count()) throw Error('Expected morphological features checkbox');
|
| 13 |
|
|
|
|
| 35 |
if (queries.map(q=>q.word).join(',') !== 'books,buy,They' || queries.some(q=>q.corpus !== 'UD_English-EWT@2.18' || q.request !== 'pattern { X [form=' + JSON.stringify(q.word) + '] }')) throw Error('Incorrect word queries: ' + JSON.stringify(queries));
|
| 36 |
// 2. Compare mode
|
| 37 |
await page.getByRole('tab', {name:'Compare', exact:true}).click();
|
| 38 |
+
await checkSentenceStyle();
|
| 39 |
await analyze(2, 'They', 'Compare');
|
| 40 |
|
| 41 |
if (await active().locator('#vis-model-a').contentFrame().locator('.ud-help').count()) throw Error('Help should be limited to Parse');
|
|
|
|
| 55 |
if (statusText.includes('**')) throw Error('Literal markdown markers (**) found in status HTML');
|
| 56 |
|
| 57 |
// 3. Same-model comparison
|
| 58 |
+
const modelsCount = await active().getByRole('listbox', {name:'Second UDPipe parser', exact:true}).click();
|
| 59 |
+
await page.getByRole('option', {name:'UDPipe · english-ewt-ud-2.17-251125', exact:true}).click();
|
| 60 |
await active().getByRole('button', {name:'Compare', exact:true}).click();
|
| 61 |
await page.waitForFunction(() => {
|
| 62 |
const alert = document.querySelector('[role="tabpanel"]:not([style*="display: none"]) .diff-alert-same');
|
| 63 |
+
return alert && alert.textContent.includes('Same UDPipe Parser Selected');
|
| 64 |
});
|
| 65 |
|
| 66 |
// 4. Language change to German in Compare
|
| 67 |
await active().getByRole('listbox', {name:'Language', exact:true}).click();
|
| 68 |
await page.getByRole('option', {name:'German', exact:true}).click();
|
| 69 |
+
await page.waitForFunction(() => document.querySelector('input[aria-label="First UDPipe parser"]').value.includes('german-hdt-ud-2.17-251125'));
|
| 70 |
|
| 71 |
// Verify sentence stays as is when changing language
|
| 72 |
const currentSentence = await active().getByRole('textbox', {name:'Sentence', exact:true}).inputValue();
|
|
|
|
| 94 |
}
|
| 95 |
await active().getByRole('listbox', {name:'English Examples', exact:true}).click();
|
| 96 |
await page.getByRole('option', {name:'The dog that chased the cat barked loudly.', exact:true}).click();
|
| 97 |
+
await page.waitForFunction(() => Array.from(document.querySelectorAll('.sentence-input input, .sentence-input textarea')).some(el => el.value === 'The dog that chased the cat barked loudly.'));
|
| 98 |
|
| 99 |
if (await active().getByRole('button', {name:'Use example', exact:true}).count()) throw Error('Redundant example button');
|
| 100 |
await active().getByRole('listbox', {name:'German Examples', exact:true}).click();
|
| 101 |
await page.getByRole('option', {name:'Der Zug, der um acht Uhr ankommen sollte, hat Verspätung.', exact:true}).click();
|
| 102 |
+
await page.waitForFunction(() => document.querySelector('input[aria-label="UDPipe parser"]').value.includes('german-hdt-ud-2.17-251125'));
|
| 103 |
if (await active().getByRole('textbox', {name:'Sentence', exact:true}).inputValue() !== 'Der Zug, der um acht Uhr ankommen sollte, hat Verspätung.') throw Error('Cross-language example did not load sentence');
|
| 104 |
await active().getByText('Relative clause embedded in subject noun phrase', {exact:true}).waitFor();
|
| 105 |
await page.waitForFunction(() => document.querySelectorAll('[role="tabpanel"]:not([style*="display: none"]) iframe[id^="vis-"]').length === 1);
|
tests/browser/check_live.js
CHANGED
|
@@ -43,7 +43,7 @@ async (page) => {
|
|
| 43 |
button = 'Compare';
|
| 44 |
await panel().getByRole('listbox', {name:'Language', exact:true}).click();
|
| 45 |
await app.getByRole('option', {name:'German', exact:true}).click();
|
| 46 |
-
await app.waitForFunction(() => document.querySelector('input[aria-label="First
|
| 47 |
await panel().getByRole('textbox', {name:'Sentence', exact:true}).fill('Bücher überzeugen.');
|
| 48 |
const german = await analyze(2, 'Bücher');
|
| 49 |
await panel().getByRole('textbox', {name:'Sentence', exact:true}).fill('Bücher überzeugen uns.');
|
|
|
|
| 43 |
button = 'Compare';
|
| 44 |
await panel().getByRole('listbox', {name:'Language', exact:true}).click();
|
| 45 |
await app.getByRole('option', {name:'German', exact:true}).click();
|
| 46 |
+
await app.waitForFunction(() => document.querySelector('input[aria-label="First UDPipe parser"]').value.includes('german-hdt-ud-2.17-251125'));
|
| 47 |
await panel().getByRole('textbox', {name:'Sentence', exact:true}).fill('Bücher überzeugen.');
|
| 48 |
const german = await analyze(2, 'Bücher');
|
| 49 |
await panel().getByRole('textbox', {name:'Sentence', exact:true}).fill('Bücher überzeugen uns.');
|
tests/browser/check_preview.js
CHANGED
|
@@ -16,9 +16,33 @@ async (page) => {
|
|
| 16 |
if (!frames[0].text.includes('nsubj') || !frames[0].text.includes('English')) throw Error('Missing English arc/label');
|
| 17 |
if (!frames[1].text.includes('Bücher')) throw Error('Missing Unicode text');
|
| 18 |
if (!frames[2].text.includes('</textarea></script>&')) throw Error('Escaping failed');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const overflow = await page.evaluate(() => Array.from(document.querySelectorAll('iframe'), frame => {
|
| 20 |
const doc = frame.contentDocument, vis = doc.getElementById('vis');
|
| 21 |
-
return {horizontal:vis.scrollWidth > vis.clientWidth + 1, vertical:doc.body.getBoundingClientRect().height > frame.clientHeight + 1};
|
| 22 |
}));
|
| 23 |
if (overflow.some(value => value.horizontal || value.vertical)) throw Error('Unexpected default scrollbars: ' + JSON.stringify(overflow));
|
| 24 |
const crowded = page.locator('#crowded').contentFrame();
|
|
@@ -153,6 +177,28 @@ async (page) => {
|
|
| 153 |
const download = await pending;
|
| 154 |
await download.saveAs('output/playwright/tree.' + extension);
|
| 155 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
const failures = [];
|
| 157 |
for (const pattern of ['**/conllu.js', '**/lib/brat/visualizer.js']) {
|
| 158 |
await page.route(pattern, route => route.abort());
|
|
|
|
| 16 |
if (!frames[0].text.includes('nsubj') || !frames[0].text.includes('English')) throw Error('Missing English arc/label');
|
| 17 |
if (!frames[1].text.includes('Bücher')) throw Error('Missing Unicode text');
|
| 18 |
if (!frames[2].text.includes('</textarea></script>&')) throw Error('Escaping failed');
|
| 19 |
+
for (const id of ['multisentence', 'wrapped']) {
|
| 20 |
+
const frame = page.locator('#' + id).contentFrame();
|
| 21 |
+
const rows = await frame.locator('#vis svg > g.text > text').evaluateAll(elements => elements.map(row => ({
|
| 22 |
+
text:row.textContent,
|
| 23 |
+
visible:getComputedStyle(row).display !== 'none' && getComputedStyle(row).visibility !== 'hidden' && row.getBoundingClientRect().height > 0
|
| 24 |
+
})));
|
| 25 |
+
if (!rows.length || rows.some(row => !row.visible)) throw Error('Missing word row in ' + id + ': ' + JSON.stringify(rows));
|
| 26 |
+
const lineCount = await frame.locator('#vis svg > g.text tspan').evaluateAll(words => new Set(words.map(word => Math.round(word.getBoundingClientRect().top))).size);
|
| 27 |
+
if (lineCount < 2) throw Error('Fixture did not exercise multiple word lines: ' + id);
|
| 28 |
+
const expected = id === 'multisentence' ? ['They', 'Bücher', 'überzeugen'] : ['word1', 'word15', 'word30'];
|
| 29 |
+
const visibleText = rows.map(row => row.text).join(' ');
|
| 30 |
+
if (expected.some(word => !visibleText.includes(word))) throw Error('Missing words in ' + id);
|
| 31 |
+
const exported = await frame.locator('body').evaluate(() => {
|
| 32 |
+
const prep = getPreparedSVG();
|
| 33 |
+
const svg = new DOMParser().parseFromString(prep.xml, 'image/svg+xml');
|
| 34 |
+
return Array.from(svg.querySelectorAll('g.text > text'), row => ({text:row.textContent, display:row.style.display}));
|
| 35 |
+
});
|
| 36 |
+
if (exported.some(row => row.display === 'none') || expected.some(word => !exported.some(row => row.text.includes(word)))) throw Error('Missing exported words in ' + id);
|
| 37 |
+
await page.waitForFunction(id => {
|
| 38 |
+
const iframe = document.getElementById(id);
|
| 39 |
+
return iframe.clientHeight >= iframe.contentDocument.body.getBoundingClientRect().height;
|
| 40 |
+
}, id);
|
| 41 |
+
await frame.locator('#vis').screenshot({path:'output/playwright/' + id + '.png'});
|
| 42 |
+
}
|
| 43 |
const overflow = await page.evaluate(() => Array.from(document.querySelectorAll('iframe'), frame => {
|
| 44 |
const doc = frame.contentDocument, vis = doc.getElementById('vis');
|
| 45 |
+
return {horizontal:frame.id !== 'wrapped' && vis.scrollWidth > vis.clientWidth + 1, vertical:doc.body.getBoundingClientRect().height > frame.clientHeight + 1};
|
| 46 |
}));
|
| 47 |
if (overflow.some(value => value.horizontal || value.vertical)) throw Error('Unexpected default scrollbars: ' + JSON.stringify(overflow));
|
| 48 |
const crowded = page.locator('#crowded').contentFrame();
|
|
|
|
| 177 |
const download = await pending;
|
| 178 |
await download.saveAs('output/playwright/tree.' + extension);
|
| 179 |
}
|
| 180 |
+
const panel = page.locator('#vis-model-a');
|
| 181 |
+
const resizeHandle = tree.getByRole('button', {name:'Resize tree panel', exact:true});
|
| 182 |
+
const initialPanel = await panel.boundingBox();
|
| 183 |
+
const otherPanel = await page.locator('#vis-model-b').boundingBox();
|
| 184 |
+
const grip = await resizeHandle.boundingBox();
|
| 185 |
+
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
|
| 186 |
+
await page.mouse.down();
|
| 187 |
+
await page.mouse.move(grip.x + grip.width / 2 - 100, grip.y + grip.height / 2 + 80, {steps:8});
|
| 188 |
+
await page.mouse.up();
|
| 189 |
+
const draggedPanel = await panel.boundingBox();
|
| 190 |
+
if (Math.abs(draggedPanel.width - (initialPanel.width - 100)) > 3 || Math.abs(draggedPanel.height - (initialPanel.height + 80)) > 3) throw Error('Panel drag resize failed');
|
| 191 |
+
await resizeHandle.focus();
|
| 192 |
+
await resizeHandle.press('ArrowDown');
|
| 193 |
+
await tree.getByRole('button', {name:'Show view controls'}).click();
|
| 194 |
+
await tree.getByRole('button', {name:'Zoom in', exact:true}).click();
|
| 195 |
+
const retainedPanel = await panel.boundingBox();
|
| 196 |
+
if (Math.abs(retainedPanel.height - (draggedPanel.height + 20)) > 3) throw Error('Auto sizing overrode manual height');
|
| 197 |
+
const untouchedPanel = await page.locator('#vis-model-b').boundingBox();
|
| 198 |
+
if (Math.abs(untouchedPanel.height - otherPanel.height) > 3 || Math.abs(untouchedPanel.width - otherPanel.width) > 3) throw Error('Resizing changed the other tree');
|
| 199 |
+
await resizeHandle.dblclick();
|
| 200 |
+
const resetPanel = await panel.boundingBox();
|
| 201 |
+
if (Math.abs(resetPanel.width - initialPanel.width) > 3) throw Error('Panel fit reset failed');
|
| 202 |
const failures = [];
|
| 203 |
for (const pattern of ['**/conllu.js', '**/lib/brat/visualizer.js']) {
|
| 204 |
await page.route(pattern, route => route.abort());
|
tests/test_comparison.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic comparison and callback regressions; no remote parsing."""
|
| 2 |
+
import unittest
|
| 3 |
+
from unittest.mock import patch
|
| 4 |
+
|
| 5 |
+
import app
|
| 6 |
+
from core.conllu_utils import compare_trees
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def token(form="word", token_id="1", upos="NOUN", head="0", relation="root"):
|
| 10 |
+
return f"{token_id}\t{form}\t_\t{upos}\t_\t_\t{head}\t{relation}\t_\t_\n"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ComparisonTests(unittest.TestCase):
|
| 14 |
+
def compare(self, a, b):
|
| 15 |
+
return compare_trees(a, "A", b, "B")
|
| 16 |
+
|
| 17 |
+
def test_identical_and_annotation_differences(self):
|
| 18 |
+
a = token()
|
| 19 |
+
self.assertTrue(self.compare(a, a)["identical"])
|
| 20 |
+
for b, expected in ((token(upos="VERB"), "POS"),
|
| 21 |
+
(token(head="2", relation="obj"), "Syntax Arc"),
|
| 22 |
+
(token(upos="VERB", relation="obj"), "POS & Syntax")):
|
| 23 |
+
with self.subTest(expected=expected):
|
| 24 |
+
result = self.compare(a, b)
|
| 25 |
+
self.assertFalse(result["identical"])
|
| 26 |
+
self.assertEqual(result["diff_table"].iloc[0]["Difference"], expected)
|
| 27 |
+
|
| 28 |
+
def test_missing_data_never_establishes_identity(self):
|
| 29 |
+
for a, b in ((token(), ""), ("", token()), ("", ""), (token(), "# comment\n")):
|
| 30 |
+
with self.subTest(a=a, b=b):
|
| 31 |
+
self.assertFalse(self.compare(a, b)["identical"])
|
| 32 |
+
|
| 33 |
+
def test_tokenization_mismatch_prevents_arc_comparison(self):
|
| 34 |
+
for b, expected in ((token("other", upos="VERB"), "Token Alignment Mismatch"),
|
| 35 |
+
(token(token_id="2"), "Token Alignment Mismatch"),
|
| 36 |
+
(token() + token("extra", "2"), "Token Count Mismatch")):
|
| 37 |
+
with self.subTest(expected=expected):
|
| 38 |
+
result = self.compare(token(), b)
|
| 39 |
+
self.assertFalse(result["identical"])
|
| 40 |
+
self.assertEqual(result["discrepancy_count"], 1)
|
| 41 |
+
self.assertEqual(result["diff_table"].iloc[0]["Difference"], expected)
|
| 42 |
+
self.assertIn("cannot be compared", result["diff_summary"])
|
| 43 |
+
|
| 44 |
+
def test_multiword_headers_and_empty_nodes_are_skipped(self):
|
| 45 |
+
extra = token("combined", "1-2") + token("empty", "1.1")
|
| 46 |
+
self.assertTrue(self.compare(token(), extra + token())["identical"])
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class CallbackTests(unittest.TestCase):
|
| 50 |
+
def run_callback(self, responses):
|
| 51 |
+
with patch.object(app.client, "parse_sentence", side_effect=responses):
|
| 52 |
+
result = app.run_single_or_compare(
|
| 53 |
+
"English", "word", "UDPipe · english-ewt-ud-2.17-251125", True, "UDPipe · english-gum-ud-2.17-251125"
|
| 54 |
+
)
|
| 55 |
+
self.assertEqual(len(result), 6)
|
| 56 |
+
return result
|
| 57 |
+
|
| 58 |
+
def test_secondary_failure_preserves_primary_and_clears_differences(self):
|
| 59 |
+
for response in ({"conllu": "", "error": "Timeout <unsafe>"},
|
| 60 |
+
{"conllu": "", "error": None},
|
| 61 |
+
{"conllu": "# comment\n", "error": None}):
|
| 62 |
+
with self.subTest(response=response):
|
| 63 |
+
result = self.run_callback([{"conllu": token(), "error": None}, response])
|
| 64 |
+
self.assertIn("<iframe", result[0])
|
| 65 |
+
self.assertEqual(result[2], token())
|
| 66 |
+
self.assertIn("Comparison unavailable", result[3])
|
| 67 |
+
self.assertNotIn("Identical Trees", result[3])
|
| 68 |
+
self.assertNotIn("<unsafe>", result[3])
|
| 69 |
+
self.assertFalse(result[4]["visible"])
|
| 70 |
+
self.assertTrue(result[5].empty)
|
| 71 |
+
|
| 72 |
+
def test_primary_failure_clears_outputs(self):
|
| 73 |
+
for response in ({"conllu": "", "error": "Timeout <unsafe>"},
|
| 74 |
+
{"conllu": "", "error": None}):
|
| 75 |
+
result = self.run_callback([response])
|
| 76 |
+
self.assertIn("Error:", result[0])
|
| 77 |
+
self.assertNotIn("<unsafe>", result[0])
|
| 78 |
+
self.assertTrue(result[1].empty)
|
| 79 |
+
self.assertEqual(result[2:4], ("", ""))
|
| 80 |
+
self.assertFalse(result[4]["visible"])
|
| 81 |
+
self.assertTrue(result[5].empty)
|
| 82 |
+
|
| 83 |
+
def test_successful_comparison_status(self):
|
| 84 |
+
for b, identical in ((token(), True), (token("different"), False), (token(upos="VERB"), False)):
|
| 85 |
+
result = self.run_callback([{"conllu": token(), "error": None}, {"conllu": b, "error": None}])
|
| 86 |
+
self.assertEqual("Identical Trees" in result[3], identical)
|
| 87 |
+
self.assertEqual(result[4]["visible"], not identical)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if __name__ == "__main__":
|
| 91 |
+
unittest.main()
|
visualizer/embed.js
CHANGED
|
@@ -5,6 +5,43 @@
|
|
| 5 |
const diagnostics = window.visualizerDiagnostics = {
|
| 6 |
parserURL: '__PARSER_URL__', stage: 'loading', messages: []
|
| 7 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
function fail(error) {
|
| 9 |
diagnostics.stage = 'error';
|
| 10 |
diagnostics.error = error.message;
|
|
@@ -311,17 +348,16 @@
|
|
| 311 |
const width = svg.width.baseVal.value, height = svg.height.baseVal.value + padding.y * 2 + 2;
|
| 312 |
svg.dataset.nativeWidth = width;
|
| 313 |
svg.dataset.nativeHeight = height;
|
| 314 |
-
// Measure
|
| 315 |
-
// so zoom and exports
|
| 316 |
-
// multiword text lines and background boxes.
|
| 317 |
const bounds = svg.getBoundingClientRect();
|
| 318 |
-
const marks = Array.from(svg.querySelectorAll('.span rect, .span path, .span text, .arcs path, .arcs text, svg > g.text > text
|
| 319 |
const contentWidth = Math.min(width, Math.max(...marks.map(mark => mark.getBoundingClientRect().right - bounds.left)) + 12);
|
| 320 |
svg.dataset.contentWidth = contentWidth;
|
| 321 |
svg.dataset.contentHeight = height;
|
| 322 |
svg.setAttribute('viewBox', '0 0 ' + contentWidth + ' ' + height);
|
| 323 |
const resize = () => {
|
| 324 |
-
if (window.frameElement && !document.fullscreenElement) {
|
| 325 |
window.frameElement.style.height = Math.ceil(document.body.getBoundingClientRect().height + 2) + 'px';
|
| 326 |
}
|
| 327 |
};
|
|
|
|
| 5 |
const diagnostics = window.visualizerDiagnostics = {
|
| 6 |
parserURL: '__PARSER_URL__', stage: 'loading', messages: []
|
| 7 |
};
|
| 8 |
+
let manualSize = false;
|
| 9 |
+
const frame = window.frameElement;
|
| 10 |
+
const handle = document.getElementById('resize-handle');
|
| 11 |
+
function sizePanel(width, height) {
|
| 12 |
+
if (!frame) return;
|
| 13 |
+
manualSize = true;
|
| 14 |
+
const available = frame.parentElement.clientWidth;
|
| 15 |
+
frame.style.width = Math.min(available, Math.max(Math.min(320, available), width)) + 'px';
|
| 16 |
+
frame.style.height = Math.max(parseFloat(getComputedStyle(frame).minHeight) || 280, height) + 'px';
|
| 17 |
+
}
|
| 18 |
+
let drag = null;
|
| 19 |
+
handle.addEventListener('pointerdown', event => {
|
| 20 |
+
if (!frame || event.button !== 0) return;
|
| 21 |
+
event.preventDefault();
|
| 22 |
+
const box = frame.getBoundingClientRect();
|
| 23 |
+
drag = {x:event.screenX, y:event.screenY, width:box.width, height:box.height};
|
| 24 |
+
handle.setPointerCapture(event.pointerId);
|
| 25 |
+
});
|
| 26 |
+
handle.addEventListener('pointermove', event => {
|
| 27 |
+
if (drag) sizePanel(drag.width + event.screenX - drag.x, drag.height + event.screenY - drag.y);
|
| 28 |
+
});
|
| 29 |
+
handle.addEventListener('pointerup', () => { drag = null; });
|
| 30 |
+
handle.addEventListener('pointercancel', () => { drag = null; });
|
| 31 |
+
handle.addEventListener('keydown', event => {
|
| 32 |
+
if (!frame || !['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
| 33 |
+
event.preventDefault();
|
| 34 |
+
const box = frame.getBoundingClientRect();
|
| 35 |
+
sizePanel(box.width + (event.key === 'ArrowRight' ? 20 : event.key === 'ArrowLeft' ? -20 : 0),
|
| 36 |
+
box.height + (event.key === 'ArrowDown' ? 20 : event.key === 'ArrowUp' ? -20 : 0));
|
| 37 |
+
});
|
| 38 |
+
handle.addEventListener('dblclick', () => {
|
| 39 |
+
manualSize = false;
|
| 40 |
+
if (frame) {
|
| 41 |
+
frame.style.width = '100%';
|
| 42 |
+
frame.style.height = Math.ceil(document.body.getBoundingClientRect().height + 2) + 'px';
|
| 43 |
+
}
|
| 44 |
+
});
|
| 45 |
function fail(error) {
|
| 46 |
diagnostics.stage = 'error';
|
| 47 |
diagnostics.error = error.message;
|
|
|
|
| 348 |
const width = svg.width.baseVal.value, height = svg.height.baseVal.value + padding.y * 2 + 2;
|
| 349 |
svg.dataset.nativeWidth = width;
|
| 350 |
svg.dataset.nativeHeight = height;
|
| 351 |
+
// Measure tree marks and words on every rendered row, including wrapped
|
| 352 |
+
// lines and later sentences, so zoom and exports retain all text.
|
|
|
|
| 353 |
const bounds = svg.getBoundingClientRect();
|
| 354 |
+
const marks = Array.from(svg.querySelectorAll('.span rect, .span path, .span text, .arcs path, .arcs text, svg > g.text > text tspan, .sentnum path, .sentnum text'));
|
| 355 |
const contentWidth = Math.min(width, Math.max(...marks.map(mark => mark.getBoundingClientRect().right - bounds.left)) + 12);
|
| 356 |
svg.dataset.contentWidth = contentWidth;
|
| 357 |
svg.dataset.contentHeight = height;
|
| 358 |
svg.setAttribute('viewBox', '0 0 ' + contentWidth + ' ' + height);
|
| 359 |
const resize = () => {
|
| 360 |
+
if (window.frameElement && !manualSize && !document.fullscreenElement) {
|
| 361 |
window.frameElement.style.height = Math.ceil(document.body.getBoundingClientRect().height + 2) + 'px';
|
| 362 |
}
|
| 363 |
};
|
visualizer/template.py
CHANGED
|
@@ -56,7 +56,7 @@ def render_conllu_visualizer_html(
|
|
| 56 |
""" + stylesheets + """
|
| 57 |
|
| 58 |
<style>
|
| 59 |
-
html { overflow:
|
| 60 |
body {
|
| 61 |
margin: 0;
|
| 62 |
padding: 10px;
|
|
@@ -72,6 +72,14 @@ def render_conllu_visualizer_html(
|
|
| 72 |
gap: 8px;
|
| 73 |
margin-bottom: 8px;
|
| 74 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
.title {
|
| 76 |
font-size: 13px;
|
| 77 |
font-weight: 600;
|
|
@@ -140,7 +148,6 @@ def render_conllu_visualizer_html(
|
|
| 140 |
}
|
| 141 |
#vis .background0, #vis .background1 { fill: #ffffff; stroke: none; }
|
| 142 |
#vis .background1 { display: none !important; }
|
| 143 |
-
#vis svg > g.text > text:nth-of-type(n+2) { display: none !important; }
|
| 144 |
#vis {
|
| 145 |
overflow-x: auto;
|
| 146 |
min-height: 180px;
|
|
@@ -233,6 +240,8 @@ def render_conllu_visualizer_html(
|
|
| 233 |
""" + grew_html + help_html + """
|
| 234 |
</section>
|
| 235 |
|
|
|
|
|
|
|
| 236 |
<textarea id="input" style="display:none;">""" + escaped_conllu + """</textarea>
|
| 237 |
<p id="status" role="status">Loading visualization…</p>
|
| 238 |
<script type="text/javascript">
|
|
|
|
| 56 |
""" + stylesheets + """
|
| 57 |
|
| 58 |
<style>
|
| 59 |
+
html { overflow: auto; }
|
| 60 |
body {
|
| 61 |
margin: 0;
|
| 62 |
padding: 10px;
|
|
|
|
| 72 |
gap: 8px;
|
| 73 |
margin-bottom: 8px;
|
| 74 |
}
|
| 75 |
+
.resize-handle {
|
| 76 |
+
position: fixed; right: 2px; bottom: 2px; z-index: 2000;
|
| 77 |
+
width: 24px; height: 24px; display: grid; place-items: center;
|
| 78 |
+
background: white; color: #526984; border: 1px solid #d1d5db;
|
| 79 |
+
border-radius: 4px; cursor: nwse-resize; touch-action: none;
|
| 80 |
+
}
|
| 81 |
+
.resize-handle:focus-visible { outline: 2px solid #526984; }
|
| 82 |
+
html:has(:fullscreen) .resize-handle { display: none; }
|
| 83 |
.title {
|
| 84 |
font-size: 13px;
|
| 85 |
font-weight: 600;
|
|
|
|
| 148 |
}
|
| 149 |
#vis .background0, #vis .background1 { fill: #ffffff; stroke: none; }
|
| 150 |
#vis .background1 { display: none !important; }
|
|
|
|
| 151 |
#vis {
|
| 152 |
overflow-x: auto;
|
| 153 |
min-height: 180px;
|
|
|
|
| 240 |
""" + grew_html + help_html + """
|
| 241 |
</section>
|
| 242 |
|
| 243 |
+
<div id="resize-handle" class="resize-handle" role="button" tabindex="0"
|
| 244 |
+
aria-label="Resize tree panel" title="Drag to resize; arrow keys resize; double-click to fit content">◢</div>
|
| 245 |
<textarea id="input" style="display:none;">""" + escaped_conllu + """</textarea>
|
| 246 |
<p id="status" role="status">Loading visualization…</p>
|
| 247 |
<script type="text/javascript">
|