Simon Clematide commited on
Commit
115fba1
·
1 Parent(s): 693f03b

Add tree resize handle, pedagogical guides, and UI/comparison enhancements

Browse files
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, About, 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. Comparisons skip multiword-token ranges and empty-node IDs.
 
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, completion/error handling, and frame sizing.
 
 
 
 
 
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. Use the existing `.venv` when available;
 
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. The UI requests morphology-stripped parses.
 
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. Treebank descriptions
65
- and statistics also appear in `app.py`; update both locations when relevant.
 
 
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, multiword tokens,
89
- empty nodes, FEATS stripping, and request failures. Add focused regression tests
 
 
 
 
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
- Parse opens by default; Compare, About, and Treebank Guide provide additional views.
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
- .sentence-input textarea {
 
 
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
- # Universal Dependencies: Parser & Treebank Visualizer
89
- Parse one sentence or compare two models' POS tags and dependency relations.
90
- Each model learns from a different annotated treebank.
91
-
92
- Training size affects how many examples the parser can learn from, especially
93
- for rare constructions. Annotation quality and compliance with UD guidelines
94
- also matter. A model disagreement can reflect an error, ambiguity, or a
95
- difference in annotation practice; agreement alone does not establish correctness.
96
-
97
- The Treebank Guide describes training sizes and annotation provenance, with
98
- links to the documentation and corpus searches.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if res_a.get("error"):
172
- err_msg = f"<div style='color: #ef4444;'>Error: {res_a['error']}</div>"
 
 
 
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 Model Selected:</strong> "
196
- f"Both models are set to <strong>{m_name_esc}</strong> ({len(table_a)} tokens). "
197
- f"Select a different second model to inspect differences.</div>"
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
- table_b = conllu_to_table(conllu_b)
 
 
 
 
 
 
 
 
 
 
 
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 models produced matching POS tags and syntactic dependency arcs.</div>"
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=2, max_lines=2, elem_classes=["sentence-input"])
273
  with gr.Row():
274
  language = gr.Dropdown(label="Language", choices=["English", "German"], value="English", scale=1)
275
- primary = gr.Dropdown(label="Model" if not compare else "First model",
276
  choices=list(AVAILABLE_MODELS["English"]),
277
  value=list(AVAILABLE_MODELS["English"])[0], scale=2)
278
- secondary = gr.Dropdown(label="Second model", choices=list(AVAILABLE_MODELS["English"]),
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": True,
105
  "diff_summary": "Insufficient tree data to compare.",
106
  "diff_table": pd.DataFrame(),
107
  "discrepancy_count": 0,
108
  }
109
 
110
- # Match tokens by index
111
- min_len = min(len(sig_a), len(sig_b))
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  differences = []
113
 
114
- for i in range(min_len):
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
- "English-EWT (Web)": "english-ewt-ud-2.17-251125",
173
- "English-GUM (Multi-Genre)": "english-gum-ud-2.17-251125",
174
- "English-LinES (Literature / Manuals)": "english-lines-ud-2.17-251125"
175
  },
176
  "German": {
177
- "German-HDT (News)": "german-hdt-ud-2.17-251125",
178
- "German-GSD (News / Web)": "german-gsd-ud-2.17-251125"
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 model', exact:true}).click();
52
- await page.getByRole('option', {name:'English-EWT (Web)', exact:true}).click();
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 Model Selected');
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 model"]').value.includes('German-HDT'));
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="Model"]').value.includes('German-HDT'));
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 model"]').value.includes('German-HDT'));
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 actual tree marks (including tokens in the first text row)
315
- // so zoom and exports tightly crop to the content. Exclude redundant
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:first-child tspan, .sentnum path, .sentnum 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: hidden; }
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">