From 50a574cfbf25279476ab280fc9da08d1fe4992e6 Mon Sep 17 00:00:00 2001 From: Ivo Leist Date: Sat, 3 Oct 2026 21:44:38 +0200 Subject: [PATCH 01/14] Add datatype sniffers for page.xml; alto; abbyy.xml and hOCR --- .../config/sample/datatypes_conf.xml.sample | 12 +- lib/galaxy/datatypes/text.py | 25 +++ lib/galaxy/datatypes/xml.py | 60 +++++++ test/unit/data/datatypes/test_ocr.py | 149 ++++++++++++++++++ 4 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 test/unit/data/datatypes/test_ocr.py diff --git a/lib/galaxy/config/sample/datatypes_conf.xml.sample b/lib/galaxy/config/sample/datatypes_conf.xml.sample index 014f69024d6..c08ac7dc765 100644 --- a/lib/galaxy/config/sample/datatypes_conf.xml.sample +++ b/lib/galaxy/config/sample/datatypes_conf.xml.sample @@ -16,10 +16,10 @@ - - - - + + + + @@ -1395,6 +1395,10 @@ + + + + diff --git a/lib/galaxy/datatypes/text.py b/lib/galaxy/datatypes/text.py index 84798642c00..e027ce0f366 100644 --- a/lib/galaxy/datatypes/text.py +++ b/lib/galaxy/datatypes/text.py @@ -8,6 +8,7 @@ import re import shlex import subprocess import tempfile +from html.parser import HTMLParser from typing import ( IO, Optional, @@ -83,6 +84,30 @@ class Html(Text): return False +class _HocrParser(HTMLParser): + """Recognize an hOCR page class without matching comments or script text.""" + + def __init__(self): + super().__init__() + self.has_page = False + + def handle_starttag(self, tag, attrs): + for name, value in attrs: + if name == "class" and value and "ocr_page" in value.split(): + self.has_page = True + + +class Hocr(Html): + """HTML or XHTML containing hOCR recognition data.""" + + file_ext = "hocr" + + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + parser = _HocrParser() + parser.feed(file_prefix.string_io().read()) + return parser.has_page + + @build_sniff_from_prefix class Json(Text): edam_format = "format_3464" diff --git a/lib/galaxy/datatypes/xml.py b/lib/galaxy/datatypes/xml.py index d8fc33ca089..296775f27b1 100644 --- a/lib/galaxy/datatypes/xml.py +++ b/lib/galaxy/datatypes/xml.py @@ -4,6 +4,7 @@ XML format classes import logging import re +from xml.parsers import expat from galaxy import util from galaxy.datatypes.dataproviders.dataset import DatasetDataProvider @@ -88,6 +89,65 @@ class GenericXml(data.Text): return XMLDataProvider(dataset_source, **settings) +class _XmlRootFound(Exception): + """Stop parsing as soon as the root start tag has been read.""" + + +def _sniff_xml_root(file_prefix: FilePrefix, root: str, namespace_pattern: str) -> bool: + # Parse only the root, so truncated prefixes and large OCR documents work. + # Expat does not fetch external entities without an external entity handler. + parser = expat.ParserCreate(namespace_separator="}") + matches = False + + def start_element(name, attrs): + nonlocal matches + namespace, _, local_name = name.rpartition("}") + matches = local_name == root and re.fullmatch(namespace_pattern, namespace) is not None + raise _XmlRootFound + + parser.StartElementHandler = start_element + try: + parser.Parse(file_prefix.contents_header_bytes, False) + except _XmlRootFound: + return matches + except expat.ExpatError: + pass + return False + + +class PageXml(GenericXml): + """PAGE XML layout and text recognition data.""" + + file_ext = "page.xml" + + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + return _sniff_xml_root( + file_prefix, "PcGts", r"http://schema\.primaresearch\.org/PAGE/gts/pagecontent/\d{4}-\d{2}-\d{2}" + ) + + +class Alto(GenericXml): + """ALTO layout and text recognition data, including the original v1 namespace.""" + + file_ext = "alto" + + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + return _sniff_xml_root( + file_prefix, "alto", r"(?:http://www\.loc\.gov/standards/alto/ns-v\d+#|http://schema\.ccs-gmbh\.com/ALTO)" + ) + + +class AbbyyXml(GenericXml): + """ABBYY FineReader XML recognition data.""" + + file_ext = "abbyy.xml" + + def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + return _sniff_xml_root( + file_prefix, "document", r"http://www\.abbyy\.com/FineReader_xml/FineReader\d+-schema-v\d+\.xml" + ) + + @disable_parent_class_sniffing class MEMEXml(GenericXml): """MEME XML Output data""" diff --git a/test/unit/data/datatypes/test_ocr.py b/test/unit/data/datatypes/test_ocr.py new file mode 100644 index 00000000000..9efe89e3d67 --- /dev/null +++ b/test/unit/data/datatypes/test_ocr.py @@ -0,0 +1,149 @@ +import pytest + +from galaxy.datatypes.registry import example_datatype_registry_for_sample +from galaxy.datatypes.sniff import guess_ext +from galaxy.datatypes.text import ( + Hocr, + Html, +) +from galaxy.datatypes.xml import ( + AbbyyXml, + Alto, + GenericXml, + PageXml, +) + +XML_FORMATS = [ + ( + PageXml, + "PcGts", + "http://schema.primaresearch.org/PAGE/gts/pagecontent/2019-07-15", + ), + ( + PageXml, + "PcGts", + "http://schema.primaresearch.org/PAGE/gts/pagecontent/2013-07-15", + ), + (Alto, "alto", "http://www.loc.gov/standards/alto/ns-v4#"), + (Alto, "alto", "http://www.loc.gov/standards/alto/ns-v3#"), + (Alto, "alto", "http://www.loc.gov/standards/alto/ns-v2#"), + (Alto, "alto", "http://schema.ccs-gmbh.com/ALTO"), + ( + AbbyyXml, + "document", + "http://www.abbyy.com/FineReader_xml/FineReader10-schema-v1.xml", + ), + ( + AbbyyXml, + "document", + "http://www.abbyy.com/FineReader_xml/FineReader6-schema-v1.xml", + ), +] + + +def sniff_content(tmp_path, datatype, content): + path = tmp_path / "input" + path.write_text(content, encoding="utf-8") + return datatype().sniff(str(path)) + + +@pytest.mark.parametrize("datatype,root,namespace", XML_FORMATS) +@pytest.mark.parametrize("prefixed", [False, True]) +@pytest.mark.parametrize("prolog", ["", '\ufeff\n\n']) +def test_xml_ocr_sniff(tmp_path, datatype, root, namespace, prefixed, prolog): + tag = f"ocr:{root}" if prefixed else root + xmlns = "xmlns:ocr" if prefixed else "xmlns" + # A prefix may end inside the document; only the root start tag is needed. + content = f"{prolog}<{tag}\n {xmlns}='{namespace}'>\n", + '<{root} xmlns="urn:unrelated"/>', + '', + '<{root} xmlns="{namespace}"/>', + '', + '<{root} xmlns="{namespace}.invalid"/>', + '<{root} xmlns="{namespace}"', + 'plain text <{root} xmlns="{namespace}"/>', + ], +) +def test_xml_ocr_rejects_false_positives(tmp_path, datatype, root, namespace, template): + assert not sniff_content(tmp_path, datatype, template.format(root=root, namespace=namespace)) + + +@pytest.mark.parametrize( + "content,expected", + [ + ( + '
', + True, + ), + ( + '
', + True, + ), + ("
", True), + ('', True), + ("
", True), + ("

ocr_page

", False), + ('', False), + ( + "", + False, + ), + ('
', False), + ('
', False), + ('', False), + ('
<{root} xmlns="{namespace}"/>') + assert isinstance(registry.get_datatype_by_extension(datatype.file_ext), datatype) + assert isinstance(registry.get_datatype_by_extension(datatype.file_ext), GenericXml) + assert registry.get_mimetype_by_extension(datatype.file_ext) == "application/xml" + assert guess_ext(str(path), registry.sniff_order) == datatype.file_ext + + +@pytest.mark.parametrize("prolog", ["", '']) +def test_hocr_registry(tmp_path, registry, prolog): + path = tmp_path / "unknown.dat" + path.write_text(f'{prolog}
') + assert isinstance(registry.get_datatype_by_extension("hocr"), Hocr) + assert isinstance(registry.get_datatype_by_extension("hocr"), Html) + assert registry.get_mimetype_by_extension("hocr") == "text/html" + assert guess_ext(str(path), registry.sniff_order) == "hocr" + + +@pytest.mark.parametrize( + "content,extension", + [ + ('', "xml"), + ("ordinary HTML", "html"), + ("", "txt"), + ], +) +def test_generic_fallback(tmp_path, registry, content, extension): + path = tmp_path / "unknown.dat" + path.write_text(content) + assert guess_ext(str(path), registry.sniff_order) == extension From 97f1bb23a241a99f5001b124cafdc8a4738f20f7 Mon Sep 17 00:00:00 2001 From: Ivo Leist Date: Sat, 3 Oct 2026 22:12:08 +0200 Subject: [PATCH 02/14] Add OCR datatype test fixtures --- .../datatypes/test/ocr_sample.abbyy.xml | 1260 +++++++++++++++++ lib/galaxy/datatypes/test/ocr_sample.alto | 20 + lib/galaxy/datatypes/test/ocr_sample.hocr | 116 ++ lib/galaxy/datatypes/test/ocr_sample.page.xml | 32 + .../datatypes/test/ocr_samples.README.md | 22 + test/unit/data/datatypes/test_ocr.py | 28 +- 6 files changed, 1477 insertions(+), 1 deletion(-) create mode 100644 lib/galaxy/datatypes/test/ocr_sample.abbyy.xml create mode 100644 lib/galaxy/datatypes/test/ocr_sample.alto create mode 100644 lib/galaxy/datatypes/test/ocr_sample.hocr create mode 100644 lib/galaxy/datatypes/test/ocr_sample.page.xml create mode 100644 lib/galaxy/datatypes/test/ocr_samples.README.md diff --git a/lib/galaxy/datatypes/test/ocr_sample.abbyy.xml b/lib/galaxy/datatypes/test/ocr_sample.abbyy.xml new file mode 100644 index 00000000000..a4e6906f703 --- /dev/null +++ b/lib/galaxy/datatypes/test/ocr_sample.abbyy.xml @@ -0,0 +1,1260 @@ + + + + + + + +1 +2 + + + + + + +m +i +c +h + +i +n + +H +a +r +n +i +s +c +h +. + +E +s + +h +a +n +d +e +l +t + +s +i +c +h + +a +l +s +o + +n +i +c +h +t + +u +m + +e +i +n +e +n + +K +a +m +p +f + +a +u +f + +L +e +b +e +n + +u +n +d + +T +o +d + +— + +m +a +n + +w +i +l +l + +m +i +c +h + +w +i +e + +e +i +n + +s +c +h +w +a +c +h +e +s + +M +ä +d +c +h +e +n + +e +n +t +f +i +l +z +e +n +? +" + + +„ +S +o + +s +c +h +e +i +n +t + +e +s + +i +m + +P +l +a +n +e + +d +e +r + +V +e +r +s +c +h +w +ö +r +e +r + +z +u + +l +i +e +g +e +n +. +" + +„ +O +h + +— + +w +e +n +n + +D +u +b +o +i +s + +h +i +e +r + +w +ä +r +e +. + +J +a +, + +j +a + +— + +d +i +e +s +e +s + +S +p +a +n +i +e +n + +m +a +c +h +t + +s +i +c +h + +i +m +m +e +r + +n +o +c +h + +s +e +h +r + +b +r +e +i +t +, + +e +s + +a +h +n +t + +n +i +c +h +t +, + +d +a +ß + +e +s + +i +n + +d +e +n + +l +e +t +z +t +e +n + +Z +ü +g +e +n + +l +i +e +g +t +. + +W +e +i +ß +t + +D +u +, + +d +' +A +r +g +e +n +s +o +n +, + +d +a +ß + +i +c +h + +s +t +ü +n +d +l +i +c +h + +d +e +n + +A +b +s +c +h +l +u +ß + +d +e +r + +Q +u +a +d +r +u +p +e +l +- +A +l +l +i +a +n +z + +z +w +i +s +c +h +e +n + +d +e +m + +K +a +i +s +e +r + +v +o +n + +D +e +u +t +s +c +h +l +a +n +d +, + +d +e +m + +K +ö +n +i +g +e + +v +o +n + +E +n +g +l +a +n +d +, + +H +o +l +l +a +n +d + +u +n +d + +u +n +s + +e +r +w +a +r +t +e +? +" + + +„ +I +c +h + +w +e +i +ß + +e +s +, + +M +o +n +s +e +i +g +n +e +u +r +, + +d +e +n +n + +d +e +r + +H +e +r +r + +A +b +b +ö + +D +u +b +o +i +s + +i +s +t + +e +i +n + +t +h +ä +t +i +g +e +r + +F +r +e +u +n +d +. +" + + +„ +H +a +! + +h +a +! + +h +a +! + +D +u + +u +n +d + +d +e +r + +A +b +b +e +, + +I +h +r + +B +e +i +d +e + +k +ö +n +n +t + +E +u +c +h + +n +u +n + +n +i +m +m +e +r +m +e +h +r + +l +e +i +d +e +n +, + +w +e +n +n + +I +h +r + +a +u +c +h + +n +o +c +h + +s +o + +f +r +e +u +n +d +l +i +c +h + +t +h +u +t +, + +j +a +, + +j +a + +— + +d +a +s + +i +s +t + +d +i +e + +L +i +e +b +e + +— +" + +„ +M +o +n +s +e +i +g +n +e +u +r +-- +“ + + +■ + +„ +O +h +o +, + +s +p +i +e +l +e + +n +i +c +h +t + +d +e +n + +F +r +o +m +m +e +n +. + +D +u + +u +n +d + +d +e +r + +H +e +r +r + +A +b +b +ä +, + +I +h +r + +s +e +i +d + +B +e +i +d +e + +a +u +f + +e +i +n + +u +n +d + +d +a +s +s +e +l +b +e + +W +i +l +d + +g +e +¬ + +s +t +o +ß +e +n + +— + +e +s + +i +s +t + +j +a + +w +e +l +t +b +e +k +a +n +n +t +. + +A +p +r +o +p +o +s + +i +c +h + +m +u +ß + +D +i +r + + +d +a +s + +e +r +z +ä +h +l +e +n +-- +" + + +d +' +A +r +g +e +n +s +o +n + +s +c +h +ü +t +t +e +l +t +e + +ä +r +g +e +r +l +i +c +h + +d +e +n + +K +o +p +f +. + + +„ +S +i +e + +h +a +b +e +n + +m +i +r + +d +i +e +s +e + +L +i +e +b +l +i +n +g +s +a +n +e +c +d +o +t +e + +s +c +h +o +n + +o +f +t + +g +e +n +u +g + +e +r +z +ä +h +l +t +. +" + + +„ +S +e +i + +n +i +c +h +t + +ä +r +g +e +r +l +i +c +h +, + +W +o +h +e +r +, +" + +r +i +e +f + +d +e +r + +R +e +g +e +n +t +. + +„ +E +s + +i +s +t + +n +i +c +h +t + +h +ü +b +s +c +h + +v +o +n + +d +e +r + +l +e +i +c +h +t +f +e +r +t +i +g +e +n + +T +e +n +c +i +n +, + +d +a +ß + +s +i +e + +s +o + +g +e +g +e +n + +D +i +c +h + +g +e +h +a +n +d +e +l +t + +h +a +t +. + +H +a +! + +h +a +! + +h +a +! + +a +b +e +r + +v +e +r +g +i +ß + +s +i +e + +— + +w +i +e + +k +a +n +n +s +t + +D +u + +n +o +c +h + +d +a +r +ü +b +e +r + +e +m +p +f +i +n +d +l +i +c +h + +s +e +i +n +? + +Z +u +e +r +s +t + +i +s +t + +F +r +a +u + + + + + + + + + + + + + + + + diff --git a/lib/galaxy/datatypes/test/ocr_sample.alto b/lib/galaxy/datatypes/test/ocr_sample.alto new file mode 100644 index 00000000000..0b7d49b546d --- /dev/null +++ b/lib/galaxy/datatypes/test/ocr_sample.alto @@ -0,0 +1,20 @@ + + + + pixel + sample.png + + + + + + + + + + + + + + + diff --git a/lib/galaxy/datatypes/test/ocr_sample.hocr b/lib/galaxy/datatypes/test/ocr_sample.hocr new file mode 100644 index 00000000000..64be752daed --- /dev/null +++ b/lib/galaxy/datatypes/test/ocr_sample.hocr @@ -0,0 +1,116 @@ + + + + + + + + + + + +
+
+

+ 1 Down the Rabbit-Hole + +

+
+
+

+ Alice was beginning to get very tired of sitting by her sister on the bank, + + and of having nothing to do: once or twice she had peeped into the book her + + sister was reading, but it had no pictures or conversations in it, ‘and what is + + the use of a book,’ thought Alice ‘Without pictures or conversation?’ + +

+ +

+ So she was considering in her own mind (as well as she could, for the hot + + day made her feel very sleepy and stupid), whether the pleasure of making a + + daisy-chain would be worth the trouble of getting up and picking the daisies, + + when suddenly a White Rabbit with pink eyes ran close by her. + +

+ +

+ There was nothing so VERY remarkable in that; nor did Alice think it so + + VERY much out of the way to hear the Rabbit say to itself, ‘Oh dear! Oh + + dear! I shall be late!’ (when she thought it over afterwards, it occurred to + + her that she ought to have wondered at this, but at the time it all seemed + + quite natural); but when the Rabbit actually TOOK A WATCH OUT OF + + ITS WAISTCOAT— POCKET, and looked at it, and then hurried on, Alice + + started to her feet, for it flashed across her mind that she had never before + + seen a rabbit with either a waistcoat-pocket, or a watch to take out of it, and + + burning with curiosity, she ran across the field after it, and fortunately was + + just in time to see it pop down a large rabbit-hole under the hedge. + +

+ +

+ In another moment down went Alice after it, never once considering how + + in the world she was to get out again. + +

+ +

+ The rabbit-hole went straight on like a tunnel for some way, and then + + dipped suddenly down, so suddenly that Alice had not a moment to think + + about stopping herself before she found herself falling down a very deep well. + +

+ +

+ Either the well was very deep, or she fell very slowly, for she had plenty + + of time as she went down to look about her and to wonder what was going + + to happen next. First, she tried to look down and make out what she was + + coming to, but it was too dark to see anything; then she looked at the sides + + of the well, and noticed that they were filled with cupboards and book- + + shelves; here and there she saw maps and pictures hung upon pegs. She took + + down a jar from one of the shelves as she passed; it was labelled ‘ORANGE + + MARMALADE’, but to her great disappointment it was empty: she did not + + like to drop the jar for fear of killing somebody, so managed to put it into + + one of the cupboards as she fell past it. + +

+ +

+ ‘Well!’ thought Alice to herself, ‘after such a fall as this, I shall think + + nothing of tumbling down stairs! How brave they’ll all think me at home! + + Why, I wouldn’t say anything about it, even if I fell off the top of the house!’ + +

+
+
+ + diff --git a/lib/galaxy/datatypes/test/ocr_sample.page.xml b/lib/galaxy/datatypes/test/ocr_sample.page.xml new file mode 100644 index 00000000000..dfd1bf30d05 --- /dev/null +++ b/lib/galaxy/datatypes/test/ocr_sample.page.xml @@ -0,0 +1,32 @@ + + + + Galaxy datatype test fixture + 2026-10-03T00:00:00Z + 2026-10-03T00:00:00Z + + + + + + + + + + + + + + + Hello + + + + Galaxy + + Hello Galaxy + + Hello Galaxy + + + diff --git a/lib/galaxy/datatypes/test/ocr_samples.README.md b/lib/galaxy/datatypes/test/ocr_samples.README.md new file mode 100644 index 00000000000..503731929e0 --- /dev/null +++ b/lib/galaxy/datatypes/test/ocr_samples.README.md @@ -0,0 +1,22 @@ +# OCR datatype fixtures + +These files exercise direct sniffing and detection through the sample datatype +registry in `test/unit/data/datatypes/test_ocr.py`. They can also be uploaded to +Galaxy with the datatype set to Auto-detect. No accompanying page image is +needed for datatype detection. Tests use the checked-in files without network access. + +| File | Expected datatype | Origin | +| --- | --- | --- | +| `ocr_sample.page.xml` | `page.xml` | Hand-authored PAGE 2019 example with metadata, reading order, a region, a baseline, words, and recognized text. | +| `ocr_sample.alto` | `alto` | Hand-authored ALTO v4 example with image metadata, page layout, a text block, a line, words, spacing, and confidence scores. | +| `ocr_sample.hocr` | `hocr` | Unmodified hocr-tools Tesseract 3.03 output for an Alice in Wonderland page; XHTML doctype, OCR metadata, layout, words, and confidence scores. | +| `ocr_sample.abbyy.xml` | `abbyy.xml` | Unmodified Calamari OCR test page from Hiltl's Die Bank des Verderbens; FineReader v10 namespace, layout, formatting, and character confidence scores. Includes the original UTF-8 BOM and CRLF line endings. | + +External samples were downloaded on 2026-10-03: + +- hOCR: https://raw.githubusercontent.com/ocropus/hocr-tools/master/test/testdata/tess.hocr +- ABBYY: https://raw.githubusercontent.com/Calamari-OCR/calamari/master/calamari_ocr/test/data/hiltl_die_bank_des_verderbens_abbyyxml/bsb11015645_00020.abbyy.xml + +The inline examples in the test module retain coverage for malformed input, +namespace variants, and false positives. The PAGE and ALTO fixtures are synthetic +examples, not output captured from an OCR engine. diff --git a/test/unit/data/datatypes/test_ocr.py b/test/unit/data/datatypes/test_ocr.py index 9efe89e3d67..81edc92d2a5 100644 --- a/test/unit/data/datatypes/test_ocr.py +++ b/test/unit/data/datatypes/test_ocr.py @@ -1,7 +1,10 @@ import pytest from galaxy.datatypes.registry import example_datatype_registry_for_sample -from galaxy.datatypes.sniff import guess_ext +from galaxy.datatypes.sniff import ( + get_test_fname, + guess_ext, +) from galaxy.datatypes.text import ( Hocr, Html, @@ -147,3 +150,26 @@ def test_generic_fallback(tmp_path, registry, content, extension): path = tmp_path / "unknown.dat" path.write_text(content) assert guess_ext(str(path), registry.sniff_order) == extension + + +@pytest.mark.parametrize( + "filename,datatype", + [ + ("ocr_sample.page.xml", PageXml), + ("ocr_sample.alto", Alto), + ("ocr_sample.abbyy.xml", AbbyyXml), + ("ocr_sample.hocr", Hocr), + ], +) +def test_ocr_fixtures(tmp_path, registry, filename, datatype): + source = get_test_fname(filename) + assert datatype().sniff(source) + for other in (PageXml, Alto, AbbyyXml, Hocr): + if other is not datatype: + assert not other().sniff(source) + + # Preserve BOMs and line endings, but remove the filename's format hint. + path = tmp_path / "unknown.dat" + with open(source, "rb") as fixture: + path.write_bytes(fixture.read()) + assert guess_ext(str(path), registry.sniff_order) == datatype.file_ext From 0b7b0fcdfafdf4350b81f59d02bad88195c6bfb1 Mon Sep 17 00:00:00 2001 From: Ivo Leist Date: Mon, 5 Oct 2026 18:08:59 +0200 Subject: [PATCH 03/14] Remove OCR datatype fixtures README --- .../datatypes/test/ocr_samples.README.md | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 lib/galaxy/datatypes/test/ocr_samples.README.md diff --git a/lib/galaxy/datatypes/test/ocr_samples.README.md b/lib/galaxy/datatypes/test/ocr_samples.README.md deleted file mode 100644 index 503731929e0..00000000000 --- a/lib/galaxy/datatypes/test/ocr_samples.README.md +++ /dev/null @@ -1,22 +0,0 @@ -# OCR datatype fixtures - -These files exercise direct sniffing and detection through the sample datatype -registry in `test/unit/data/datatypes/test_ocr.py`. They can also be uploaded to -Galaxy with the datatype set to Auto-detect. No accompanying page image is -needed for datatype detection. Tests use the checked-in files without network access. - -| File | Expected datatype | Origin | -| --- | --- | --- | -| `ocr_sample.page.xml` | `page.xml` | Hand-authored PAGE 2019 example with metadata, reading order, a region, a baseline, words, and recognized text. | -| `ocr_sample.alto` | `alto` | Hand-authored ALTO v4 example with image metadata, page layout, a text block, a line, words, spacing, and confidence scores. | -| `ocr_sample.hocr` | `hocr` | Unmodified hocr-tools Tesseract 3.03 output for an Alice in Wonderland page; XHTML doctype, OCR metadata, layout, words, and confidence scores. | -| `ocr_sample.abbyy.xml` | `abbyy.xml` | Unmodified Calamari OCR test page from Hiltl's Die Bank des Verderbens; FineReader v10 namespace, layout, formatting, and character confidence scores. Includes the original UTF-8 BOM and CRLF line endings. | - -External samples were downloaded on 2026-10-03: - -- hOCR: https://raw.githubusercontent.com/ocropus/hocr-tools/master/test/testdata/tess.hocr -- ABBYY: https://raw.githubusercontent.com/Calamari-OCR/calamari/master/calamari_ocr/test/data/hiltl_die_bank_des_verderbens_abbyyxml/bsb11015645_00020.abbyy.xml - -The inline examples in the test module retain coverage for malformed input, -namespace variants, and false positives. The PAGE and ALTO fixtures are synthetic -examples, not output captured from an OCR engine. From 2055d6e9ef11204ad6e3a57953347253f71b5df0 Mon Sep 17 00:00:00 2001 From: Ivo Leist Date: Mon, 5 Oct 2026 22:04:35 +0200 Subject: [PATCH 04/14] Fix mypy type checking for OCR sniffer test --- test/unit/data/datatypes/test_ocr.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/unit/data/datatypes/test_ocr.py b/test/unit/data/datatypes/test_ocr.py index 81edc92d2a5..25ccc0d53b1 100644 --- a/test/unit/data/datatypes/test_ocr.py +++ b/test/unit/data/datatypes/test_ocr.py @@ -2,6 +2,7 @@ import pytest from galaxy.datatypes.registry import example_datatype_registry_for_sample from galaxy.datatypes.sniff import ( + FilePrefix, get_test_fname, guess_ext, ) @@ -163,10 +164,11 @@ def test_generic_fallback(tmp_path, registry, content, extension): ) def test_ocr_fixtures(tmp_path, registry, filename, datatype): source = get_test_fname(filename) - assert datatype().sniff(source) + file_prefix = FilePrefix(source) + assert datatype().sniff_prefix(file_prefix) for other in (PageXml, Alto, AbbyyXml, Hocr): if other is not datatype: - assert not other().sniff(source) + assert not other().sniff_prefix(file_prefix) # Preserve BOMs and line endings, but remove the filename's format hint. path = tmp_path / "unknown.dat" From d1123316e028bdf9488f93ad95da06d5ddb0e6ef Mon Sep 17 00:00:00 2001 From: Ivo Leist Date: Tue, 6 Oct 2026 09:18:30 +0200 Subject: [PATCH 05/14] Retrigger CI From fc9b3883fd0e5437f19fcfd48fad0e338da7b492 Mon Sep 17 00:00:00 2001 From: Ivo Leist Date: Tue, 6 Oct 2026 09:58:50 +0200 Subject: [PATCH 06/14] Update TensorBoard tool XML to use biocontainers and improve help texts --- tools/interactive/interactivetool_tensorboard.xml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tools/interactive/interactivetool_tensorboard.xml b/tools/interactive/interactivetool_tensorboard.xml index 9c83d9649dd..f4eef5f1c99 100644 --- a/tools/interactive/interactivetool_tensorboard.xml +++ b/tools/interactive/interactivetool_tensorboard.xml @@ -1,11 +1,13 @@ - + Visualize and explore tensorboard logs from machine/deep learning experiments + 2.21.0 + 0 6006 - quay.io/galaxy/tensorboard:2.21.0 + quay.io/biocontainers/tensorboard:@TOOL_VERSION@ @@ -53,7 +55,7 @@ - + @@ -65,8 +67,9 @@ You can provide either a single TensorBoard event file or a list collection of TensorBoard event files. - For list collections, each collection element is presented as a separate - TensorBoard run. Galaxy collection element identifiers are used as the run names. + For list collections, each collection element is loaded as a separate set of TensorBoard logs. + The Galaxy collection element identifier is used as the corresponding name in TensorBoard, + allowing logs from multiple training runs to be compared. After starting the tool, open TensorBoard from Galaxy's active interactive tools. The available dashboards depend on the data recorded in your event logs. From 28be3e5ae90bfd84c0f049f435f0a5348d1068a9 Mon Sep 17 00:00:00 2001 From: Alireza Heidari <8046843+itisAliRH@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:13:33 +0200 Subject: [PATCH 07/14] Fix GTN webhook tool links with formatted tool names GTN renders tool titles through markdown, so names like **Filter** or `Bowtie2` become nested or inside the data-tool span. Clicking that inner text resolved the parent instead of the span and opened tool_id=undefined. Read the tool id from the element the listener is bound to, which also stops leaking tool_id as a global. --- config/plugins/webhooks/gtn/script.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/config/plugins/webhooks/gtn/script.js b/config/plugins/webhooks/gtn/script.js index f3a3c03d215..53648f7fe32 100644 --- a/config/plugins/webhooks/gtn/script.js +++ b/config/plugins/webhooks/gtn/script.js @@ -138,15 +138,9 @@ // Buttonify gtnToolElements.forEach(function (el) { - el.addEventListener("click", function (e) { - let target = e.target; - - // Sometimes we get the i or the strong, not the parent. - if (e.target.tagName.toLowerCase() !== "span" && e.target.tagName.toLowerCase() !== "a") { - target = e.target.parentElement; - } - - tool_id = target.dataset.tool; + el.addEventListener("click", function () { + // Tool titles can render as nested markup (e.g. ), so read the bound element. + const tool_id = el.dataset.tool; if (tool_id === "upload1" || tool_id === "upload") { document.getElementById("tool-panel-upload-button").click(); From 626cdbdc7722b1dcd80b8985824efeb9415b1133 Mon Sep 17 00:00:00 2001 From: Alireza Heidari <8046843+itisAliRH@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:13:52 +0200 Subject: [PATCH 08/14] Read the TRS URL from the bound GTN workflow element The workflow click handler only climbed one level from the click target, so any nested markup inside a data-workflow button would launch trs_url=undefined. Use the element the listener is bound to, matching the tool handler, and stop leaking trs_url as a global. --- config/plugins/webhooks/gtn/script.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/config/plugins/webhooks/gtn/script.js b/config/plugins/webhooks/gtn/script.js index 53648f7fe32..eb8752d87ae 100644 --- a/config/plugins/webhooks/gtn/script.js +++ b/config/plugins/webhooks/gtn/script.js @@ -157,15 +157,9 @@ // Buttonify gtnWorkflowElements.forEach(function (el) { - el.addEventListener("click", (e) => { - let target = e.target; - - // Sometimes we get the i or the strong, not the parent. - if (e.target.tagName.toLowerCase() !== "span" && e.target.tagName.toLowerCase() !== "a") { - target = e.target.parentElement; - } - - trs_url = target.dataset.workflow; + el.addEventListener("click", () => { + // The click target can be nested markup inside the button, so read the bound element. + const trs_url = el.dataset.workflow; Galaxy.router.push({ path: `/workflows/trs_import?trs_url=${encodeURIComponent(trs_url)}&run_form=true`, }); From 4b6832e2ec3127af4242dd64d655bf48e19ca9d0 Mon Sep 17 00:00:00 2001 From: Alireza Heidari <8046843+itisAliRH@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:00:31 +0200 Subject: [PATCH 09/14] Open external links from the GTN webhook iframe in a new tab Sites such as Zenodo send X-Frame-Options and render as a broken page when a tutorial link is followed inside the proxied GTN iframe. Links that leave the /training-material/ path, including links back to the Galaxy instance, now open in a new tab; tool and workflow links keep their existing handling. --- config/plugins/webhooks/gtn/script.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/config/plugins/webhooks/gtn/script.js b/config/plugins/webhooks/gtn/script.js index f3a3c03d215..1c6953cffa8 100644 --- a/config/plugins/webhooks/gtn/script.js +++ b/config/plugins/webhooks/gtn/script.js @@ -126,6 +126,26 @@ if (!gtnEmbed.contentDocument) { return; } + // Sites like Zenodo refuse to be framed, so links leaving the GTN open in a new tab. + gtnEmbed.contentDocument.addEventListener("click", (e) => { + const link = e.target.closest("a[href]"); + if (!link || link.closest("[data-tool],[data-workflow]")) { + return; + } + let url; + try { + url = new URL(link.getAttribute("href"), link.baseURI); + } catch (err) { + return; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return; + } + if (url.origin !== window.location.origin || !url.pathname.startsWith("/training-material/")) { + link.setAttribute("target", "_blank"); + link.relList.add("noopener", "noreferrer"); + } + }); // Add the class to the entire GTN page document .getElementById("gtn-embed") From 0fe0a3c1622e86f9b9e6da9e0e5d4d1e92637eb5 Mon Sep 17 00:00:00 2001 From: Alireza Heidari <8046843+itisAliRH@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:00:45 +0200 Subject: [PATCH 10/14] Make GTN webhook tool and workflow buttons keyboard accessible The GTN renders tool and workflow buttons as spans, so inside the Galaxy webhook they could only be activated with a mouse. Give non-link buttons role="button" and tabindex="0" unless already set, and run the same action on Enter or Space, preventing the page scroll on Space. --- config/plugins/webhooks/gtn/script.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/config/plugins/webhooks/gtn/script.js b/config/plugins/webhooks/gtn/script.js index eb8752d87ae..1f9c649d31f 100644 --- a/config/plugins/webhooks/gtn/script.js +++ b/config/plugins/webhooks/gtn/script.js @@ -16,6 +16,26 @@ document.getElementById("gtn-container").style.visibility = "visible"; } + function buttonify(el, activate) { + el.addEventListener("click", activate); + if (el.tagName.toLowerCase() === "a") { + return; + } + // GTN renders tool and workflow buttons as spans, which are not focusable or keyboard operable on their own. + if (!el.hasAttribute("role")) { + el.setAttribute("role", "button"); + } + if (!el.hasAttribute("tabindex")) { + el.setAttribute("tabindex", "0"); + } + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + activate(); + } + }); + } + function getIframeUrl() { let loc; try { @@ -138,7 +158,7 @@ // Buttonify gtnToolElements.forEach(function (el) { - el.addEventListener("click", function () { + buttonify(el, function () { // Tool titles can render as nested markup (e.g. ), so read the bound element. const tool_id = el.dataset.tool; @@ -157,7 +177,7 @@ // Buttonify gtnWorkflowElements.forEach(function (el) { - el.addEventListener("click", () => { + buttonify(el, () => { // The click target can be nested markup inside the button, so read the bound element. const trs_url = el.dataset.workflow; Galaxy.router.push({ From d5a9bf4805d6d7a2d4e6146daa236ba0b661c14d Mon Sep 17 00:00:00 2001 From: John Chilton Date: Mon, 28 Sep 2026 15:34:34 -0400 Subject: [PATCH 11/14] Tool Shed: validate self-service registration The /api_internal/register endpoint only rejected the reserved name "repos" before creating an account. It did not check the username format or, critically, whether the username was already taken - the Tool Shed User.username column has no unique constraint (unlike Galaxy's), and UserManager.create only guards against duplicate emails. Because push authorization keys on the username string (allow_push in a repository's hgrc, checked by can_push), a second account registered with an existing user's public name gained that user's push access to their repositories. Route registration through ensure_valid_new_user, split out of api_create_user, so the self-service path runs the same reserved-name, email, password and username format+uniqueness checks the admin create path already used. Co-Authored-By: Claude Opus 4.8 --- lib/tool_shed/managers/users.py | 12 +++++---- .../test/functional/test_shed_users.py | 25 +++++++++++++++++++ lib/tool_shed/webapp/api2/users.py | 6 +++-- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/lib/tool_shed/managers/users.py b/lib/tool_shed/managers/users.py index 070c3dcb50f..26bf04cada9 100644 --- a/lib/tool_shed/managers/users.py +++ b/lib/tool_shed/managers/users.py @@ -40,13 +40,15 @@ def create_user(app: ToolShedApp, email: str, username: str, password: str) -> U return user +def ensure_valid_new_user(trans: ProvidesUserContext, email: str, username: str, password: str) -> None: + """Raise RequestParameterInvalidException unless a new account may use these values.""" + if message := _validate(trans, email=email, password=password, confirm=password, username=username): + raise RequestParameterInvalidException(message) + + def api_create_user(trans: ProvidesUserContext, request: CreateUserRequest) -> ApiUser: app = trans.app - message = _validate( - trans, email=request.email, password=request.password, confirm=request.password, username=request.username - ) - if message: - raise RequestParameterInvalidException(message) + ensure_valid_new_user(trans, email=request.email, username=request.username, password=request.password) user = create_user(app, request.email, request.username, request.password) return get_api_user(app, user) diff --git a/lib/tool_shed/test/functional/test_shed_users.py b/lib/tool_shed/test/functional/test_shed_users.py index 3ec51f1f25d..e24715f08d5 100644 --- a/lib/tool_shed/test/functional/test_shed_users.py +++ b/lib/tool_shed/test/functional/test_shed_users.py @@ -95,5 +95,30 @@ class TestShedUsersApi(ShedApiTestCase): assert new_api_key != another_new_api_key assert new_api_key != api_key + def test_register(self): + email = "testregister@bx.psu.edu" + password = "mycoolpassword123" + response = self._register(email=email, username=email_to_username(email), password=password) + api_asserts.assert_status_code_is_ok(response) + self._verify_username_password(email, password) + + def test_register_rejects_invalid_username(self): + response = self._register(email="testregisterbadname@bx.psu.edu", username="Not Valid") + api_asserts.assert_status_code_is(response, 400) + + def test_register_rejects_taken_username(self): + email = "testregistertaken@bx.psu.edu" + ensure_user_with_email(self.admin_api_interactor, email, "mycoolpassword123") + response = self._register(email="testregistertakenother@bx.psu.edu", username=email_to_username(email)) + api_asserts.assert_status_code_is(response, 400) + + def test_register_rejects_invalid_email(self): + response = self._register(email="not an email", username="testregisterbademail") + api_asserts.assert_status_code_is(response, 400) + + def _register(self, email: str, username: str, password: str = "mycoolpassword123"): + url = urljoin(self.url, "api_internal/register") + return post(url, json={"email": email, "username": username, "password": password, "bear_field": ""}) + def _verify_username_password(self, email: str, password: str) -> str: return self.api_interactor.create_api_key(email, password) diff --git a/lib/tool_shed/webapp/api2/users.py b/lib/tool_shed/webapp/api2/users.py index cbb20205b6d..45778604718 100644 --- a/lib/tool_shed/webapp/api2/users.py +++ b/lib/tool_shed/webapp/api2/users.py @@ -30,6 +30,7 @@ from galaxy.webapps.base.webapp import create_new_session from tool_shed.context import SessionRequestContext from tool_shed.managers.users import ( api_create_user, + ensure_valid_new_user, get_api_user, index, ) @@ -221,8 +222,9 @@ class FastAPIUsers: raise RequestParameterInvalidException(message) username = register_request.username - if username == "repos": - raise RequestParameterInvalidException("Cannot create a user with the username 'repos'") + ensure_valid_new_user( + trans, email=register_request.email, username=username, password=register_request.password + ) self.user_manager.create(email=register_request.email, username=username, password=register_request.password) if self.app.config.user_activation_on: is_activation_sent = self.user_manager.send_activation_email(trans, register_request.email, username) From 75f2a7523bae875e9d57b7cda2b095460d4bdffc Mon Sep 17 00:00:00 2001 From: John Chilton Date: Wed, 7 Oct 2026 10:23:59 -0400 Subject: [PATCH 12/14] Tool Shed: make galaxy_user.username unique Registration now checks that a username is free, but the check and the insert are separate, so two concurrent registrations could still both pass. Back the check with a unique index, as Galaxy did for its own galaxy_user.username (d619fdfa6168). No data fix: existing duplicates were removed by hand on the Tool Shed instances we run. Co-Authored-By: Claude Opus 5.5 --- lib/tool_shed/webapp/model/__init__.py | 2 +- ...181af_username_column_unique_constraint.py | 38 +++++++++++++++++++ test/unit/tool_shed/model/test_user.py | 13 +++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 lib/tool_shed/webapp/model/migrations/alembic/versions/3b0a43f181af_username_column_unique_constraint.py create mode 100644 test/unit/tool_shed/model/test_user.py diff --git a/lib/tool_shed/webapp/model/__init__.py b/lib/tool_shed/webapp/model/__init__.py index 55e9b71e67e..9c90bf78c58 100644 --- a/lib/tool_shed/webapp/model/__init__.py +++ b/lib/tool_shed/webapp/model/__init__.py @@ -112,7 +112,7 @@ class User(Base, Dictifiable): create_time: Mapped[Optional[datetime]] = mapped_column(DateTime, default=now) update_time: Mapped[Optional[datetime]] = mapped_column(DateTime, default=now, onupdate=now) email: Mapped[str] = mapped_column(TrimmedString(255), nullable=False) - username: Mapped[Optional[str]] = mapped_column(String(255), index=True) + username: Mapped[Optional[str]] = mapped_column(String(255), index=True, unique=True) password: Mapped[str] = mapped_column(TrimmedString(40), nullable=False) external: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) new_repo_alert: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) diff --git a/lib/tool_shed/webapp/model/migrations/alembic/versions/3b0a43f181af_username_column_unique_constraint.py b/lib/tool_shed/webapp/model/migrations/alembic/versions/3b0a43f181af_username_column_unique_constraint.py new file mode 100644 index 00000000000..ebda5131a2f --- /dev/null +++ b/lib/tool_shed/webapp/model/migrations/alembic/versions/3b0a43f181af_username_column_unique_constraint.py @@ -0,0 +1,38 @@ +"""Username column unique constraint + +Revision ID: 3b0a43f181af +Revises: 1b5bf427db25 +Create Date: 2026-10-07 12:00:00.000000 + +""" + +from galaxy.model.database_object_names import build_index_name +from galaxy.model.migrations.util import ( + create_index, + drop_index, + index_exists, + transaction, +) + +# revision identifiers, used by Alembic. +revision = "3b0a43f181af" +down_revision = "1b5bf427db25" +branch_labels = None +depends_on = None + +table_name = "galaxy_user" +column_name = "username" +index_name = build_index_name(table_name, [column_name]) + + +def upgrade() -> None: + with transaction(): + if index_exists(index_name, table_name, True): + drop_index(index_name, table_name) + create_index(index_name, table_name, [column_name], unique=True) + + +def downgrade() -> None: + with transaction(): + drop_index(index_name, table_name) + create_index(index_name, table_name, [column_name]) diff --git a/test/unit/tool_shed/model/test_user.py b/test/unit/tool_shed/model/test_user.py new file mode 100644 index 00000000000..5898914f325 --- /dev/null +++ b/test/unit/tool_shed/model/test_user.py @@ -0,0 +1,13 @@ +import pytest +from sqlalchemy.exc import IntegrityError + +from tool_shed.webapp.model import User +from .._util import TestToolShedApp + + +def test_username_is_unique(shed_app: TestToolShedApp): + session = shed_app.model.context + session.add(User(email="a@galaxyproject.org", username="taken", password="x")) + session.add(User(email="b@galaxyproject.org", username="taken", password="x")) + with pytest.raises(IntegrityError): + session.flush() From 42acf5401ad586ea46c54297534cef630cd0bb99 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Wed, 7 Oct 2026 16:35:53 +0200 Subject: [PATCH 13/14] Skip HTML parsing in hOCR sniffer when ocr_page is absent Every text upload that reaches the hOCR sniffer had its 1 MB prefix run through HTMLParser. A substring check rejects non-hOCR content first. --- lib/galaxy/datatypes/text.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/galaxy/datatypes/text.py b/lib/galaxy/datatypes/text.py index e027ce0f366..76ff5364d0e 100644 --- a/lib/galaxy/datatypes/text.py +++ b/lib/galaxy/datatypes/text.py @@ -103,6 +103,8 @@ class Hocr(Html): file_ext = "hocr" def sniff_prefix(self, file_prefix: FilePrefix) -> bool: + if not file_prefix.search_str("ocr_page"): + return False parser = _HocrParser() parser.feed(file_prefix.string_io().read()) return parser.has_page From 5f561d7b4e8ba8bfbc3eab43fee226e5bb58ec21 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Wed, 7 Oct 2026 16:35:53 +0200 Subject: [PATCH 14/14] Trim ABBYY XML test fixture to a single block The sniffer only reads the root element; keep the BOM, declaration, root namespace and one page block. --- .../datatypes/test/ocr_sample.abbyy.xml | 1247 ----------------- 1 file changed, 1247 deletions(-) diff --git a/lib/galaxy/datatypes/test/ocr_sample.abbyy.xml b/lib/galaxy/datatypes/test/ocr_sample.abbyy.xml index a4e6906f703..65bcb9e21d9 100644 --- a/lib/galaxy/datatypes/test/ocr_sample.abbyy.xml +++ b/lib/galaxy/datatypes/test/ocr_sample.abbyy.xml @@ -9,1252 +9,5 @@ 2 - - - - -m -i -c -h - -i -n - -H -a -r -n -i -s -c -h -. - -E -s - -h -a -n -d -e -l -t - -s -i -c -h - -a -l -s -o - -n -i -c -h -t - -u -m - -e -i -n -e -n - -K -a -m -p -f - -a -u -f - -L -e -b -e -n - -u -n -d - -T -o -d - -— - -m -a -n - -w -i -l -l - -m -i -c -h - -w -i -e - -e -i -n - -s -c -h -w -a -c -h -e -s - -M -ä -d -c -h -e -n - -e -n -t -f -i -l -z -e -n -? -" - - -„ -S -o - -s -c -h -e -i -n -t - -e -s - -i -m - -P -l -a -n -e - -d -e -r - -V -e -r -s -c -h -w -ö -r -e -r - -z -u - -l -i -e -g -e -n -. -" - -„ -O -h - -— - -w -e -n -n - -D -u -b -o -i -s - -h -i -e -r - -w -ä -r -e -. - -J -a -, - -j -a - -— - -d -i -e -s -e -s - -S -p -a -n -i -e -n - -m -a -c -h -t - -s -i -c -h - -i -m -m -e -r - -n -o -c -h - -s -e -h -r - -b -r -e -i -t -, - -e -s - -a -h -n -t - -n -i -c -h -t -, - -d -a -ß - -e -s - -i -n - -d -e -n - -l -e -t -z -t -e -n - -Z -ü -g -e -n - -l -i -e -g -t -. - -W -e -i -ß -t - -D -u -, - -d -' -A -r -g -e -n -s -o -n -, - -d -a -ß - -i -c -h - -s -t -ü -n -d -l -i -c -h - -d -e -n - -A -b -s -c -h -l -u -ß - -d -e -r - -Q -u -a -d -r -u -p -e -l -- -A -l -l -i -a -n -z - -z -w -i -s -c -h -e -n - -d -e -m - -K -a -i -s -e -r - -v -o -n - -D -e -u -t -s -c -h -l -a -n -d -, - -d -e -m - -K -ö -n -i -g -e - -v -o -n - -E -n -g -l -a -n -d -, - -H -o -l -l -a -n -d - -u -n -d - -u -n -s - -e -r -w -a -r -t -e -? -" - - -„ -I -c -h - -w -e -i -ß - -e -s -, - -M -o -n -s -e -i -g -n -e -u -r -, - -d -e -n -n - -d -e -r - -H -e -r -r - -A -b -b -ö - -D -u -b -o -i -s - -i -s -t - -e -i -n - -t -h -ä -t -i -g -e -r - -F -r -e -u -n -d -. -" - - -„ -H -a -! - -h -a -! - -h -a -! - -D -u - -u -n -d - -d -e -r - -A -b -b -e -, - -I -h -r - -B -e -i -d -e - -k -ö -n -n -t - -E -u -c -h - -n -u -n - -n -i -m -m -e -r -m -e -h -r - -l -e -i -d -e -n -, - -w -e -n -n - -I -h -r - -a -u -c -h - -n -o -c -h - -s -o - -f -r -e -u -n -d -l -i -c -h - -t -h -u -t -, - -j -a -, - -j -a - -— - -d -a -s - -i -s -t - -d -i -e - -L -i -e -b -e - -— -" - -„ -M -o -n -s -e -i -g -n -e -u -r --- -“ - - -■ - -„ -O -h -o -, - -s -p -i -e -l -e - -n -i -c -h -t - -d -e -n - -F -r -o -m -m -e -n -. - -D -u - -u -n -d - -d -e -r - -H -e -r -r - -A -b -b -ä -, - -I -h -r - -s -e -i -d - -B -e -i -d -e - -a -u -f - -e -i -n - -u -n -d - -d -a -s -s -e -l -b -e - -W -i -l -d - -g -e -¬ - -s -t -o -ß -e -n - -— - -e -s - -i -s -t - -j -a - -w -e -l -t -b -e -k -a -n -n -t -. - -A -p -r -o -p -o -s - -i -c -h - -m -u -ß - -D -i -r - - -d -a -s - -e -r -z -ä -h -l -e -n --- -" - - -d -' -A -r -g -e -n -s -o -n - -s -c -h -ü -t -t -e -l -t -e - -ä -r -g -e -r -l -i -c -h - -d -e -n - -K -o -p -f -. - - -„ -S -i -e - -h -a -b -e -n - -m -i -r - -d -i -e -s -e - -L -i -e -b -l -i -n -g -s -a -n -e -c -d -o -t -e - -s -c -h -o -n - -o -f -t - -g -e -n -u -g - -e -r -z -ä -h -l -t -. -" - - -„ -S -e -i - -n -i -c -h -t - -ä -r -g -e -r -l -i -c -h -, - -W -o -h -e -r -, -" - -r -i -e -f - -d -e -r - -R -e -g -e -n -t -. - -„ -E -s - -i -s -t - -n -i -c -h -t - -h -ü -b -s -c -h - -v -o -n - -d -e -r - -l -e -i -c -h -t -f -e -r -t -i -g -e -n - -T -e -n -c -i -n -, - -d -a -ß - -s -i -e - -s -o - -g -e -g -e -n - -D -i -c -h - -g -e -h -a -n -d -e -l -t - -h -a -t -. - -H -a -! - -h -a -! - -h -a -! - -a -b -e -r - -v -e -r -g -i -ß - -s -i -e - -— - -w -i -e - -k -a -n -n -s -t - -D -u - -n -o -c -h - -d -a -r -ü -b -e -r - -e -m -p -f -i -n -d -l -i -c -h - -s -e -i -n -? - -Z -u -e -r -s -t - -i -s -t - -F -r -a -u - - - - - - - - - - - - - -