diff --git a/django/utils/text.py b/django/utils/text.py
index 374fd78f927d..9560ebc67840 100644
--- a/django/utils/text.py
+++ b/django/utils/text.py
@@ -2,12 +2,20 @@
 import re
 import secrets
 import unicodedata
+from collections import deque
 from gzip import GzipFile
 from gzip import compress as gzip_compress
+from html import escape
+from html.parser import HTMLParser
 from io import BytesIO

 from django.core.exceptions import SuspiciousFileOperation
-from django.utils.functional import SimpleLazyObject, keep_lazy_text, lazy
+from django.utils.functional import (
+    SimpleLazyObject,
+    cached_property,
+    keep_lazy_text,
+    lazy,
+)
 from django.utils.regex_helper import _lazy_re_compile
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy, pgettext
@@ -80,6 +88,101 @@ def add_truncation_text(text, truncate=None):
     return f"{text}{truncate}"

+def calculate_truncate_chars_length(length, replacement):
+    truncate_len = length
+    for char in add_truncation_text("", replacement):
+        if not unicodedata.combining(char):
+            truncate_len -= 1
+            if truncate_len == 0:
+                break
+    return truncate_len
+
+
+class TruncateHTMLParser(HTMLParser):
+    class TruncationCompleted(Exception):
+        pass
+
+    def __init__(self, *, length, replacement, convert_charrefs=True):
+        super().__init__(convert_charrefs=convert_charrefs)
+        self.tags = deque()
+        self.output = ""
+        self.remaining = length
+        self.replacement = replacement
+
+    @cached_property
+    def void_elements(self):
+        from django.utils.html import VOID_ELEMENTS
+
+        return VOID_ELEMENTS
+
+    def handle_startendtag(self, tag, attrs):
+        self.handle_starttag(tag, attrs)
+        if tag not in self.void_elements:
+            self.handle_endtag(tag)
+
+    def handle_starttag(self, tag, attrs):
+        self.output += self.get_starttag_text()
+        if tag not in self.void_elements:
+            self.tags.appendleft(tag)
+
+    def handle_endtag(self, tag):
+        if tag not in self.void_elements:
+            self.output += f"</{tag}>"
+            try:
+                self.tags.remove(tag)
+            except ValueError:
+                pass
+
+    def handle_data(self, data):
+        data, output = self.process(data)
+        data_len = len(data)
+        if self.remaining < data_len:
+            self.remaining = 0
+            self.output += add_truncation_text(output, self.replacement)
+            raise self.TruncationCompleted
+        self.remaining -= data_len
+        self.output += output
+
+    def feed(self, data):
+        try:
+            super().feed(data)
+        except self.TruncationCompleted:
+            self.output += "".join([f"</{tag}>" for tag in self.tags])
+            self.tags.clear()
+            self.reset()
+        else:
+            # No data was handled.
+            self.reset()
+
+
+class TruncateCharsHTMLParser(TruncateHTMLParser):
+    def __init__(self, *, length, replacement, convert_charrefs=True):
+        self.length = length
+        self.processed_chars = 0
+        super().__init__(
+            length=calculate_truncate_chars_length(length, replacement),
+            replacement=replacement,
+            convert_charrefs=convert_charrefs,
+        )
+
+    def process(self, data):
+        self.processed_chars += len(data)
+        if (self.processed_chars == self.length) and (
+            len(self.output) + len(data) == len(self.rawdata)
+        ):
+            self.output += data
+            raise self.TruncationCompleted
+        output = escape("".join(data[: self.remaining]))
+        return data, output
+
+
+class TruncateWordsHTMLParser(TruncateHTMLParser):
+    def process(self, data):
+        data = re.split(r"(?<=\S)\s+(?=\S)", data)
+        output = escape(" ".join(data[: self.remaining]))
+        return data, output
+
+
 class Truncator(SimpleLazyObject):
     """
     An object used to truncate text, either by characters or words.
@@ -108,19 +211,16 @@ def chars(self, num, truncate=None, html=False):
             return ""
         text = unicodedata.normalize("NFC", self._wrapped)

-        # Calculate the length to truncate to (max length - end_text length)
-        truncate_len = length
-        for char in add_truncation_text("", truncate):
-            if not unicodedata.combining(char):
-                truncate_len -= 1
-                if truncate_len == 0:
-                    break
         if html:
-            return self._truncate_html(length, truncate, text, truncate_len, False)
-        return self._text_chars(length, truncate, text, truncate_len)
+            parser = TruncateCharsHTMLParser(length=length, replacement=truncate)
+            parser.feed(text)
+            parser.close()
+            return parser.output
+        return self._text_chars(length, truncate, text)

-    def _text_chars(self, length, truncate, text, truncate_len):
+    def _text_chars(self, length, truncate, text):
         """Truncate a string after a certain number of chars."""
+        truncate_len = calculate_truncate_chars_length(length, truncate)
         s_len = 0
         end_index = None
         for i, char in enumerate(text):
@@ -149,7 +249,10 @@ def words(self, num, truncate=None, html=False):
         if length <= 0:
             return ""
         if html:
-            return self._truncate_html(length, truncate, self._wrapped, length, True)
+            parser = TruncateWordsHTMLParser(length=length, replacement=truncate)
+            parser.feed(self._wrapped)
+            parser.close()
+            return parser.output
         return self._text_words(length, truncate)

     def _text_words(self, length, truncate):
@@ -164,94 +267,6 @@ def _text_words(self, length, truncate):
             return add_truncation_text(" ".join(words), truncate)
         return " ".join(words)

-    def _truncate_html(self, length, truncate, text, truncate_len, words):
-        """
-        Truncate HTML to a certain number of chars (not counting tags and
-        comments), or, if words is True, then to a certain number of words.
-        Close opened tags if they were correctly closed in the given HTML.
-
-        Preserve newlines in the HTML.
-        """
-        if words and length <= 0:
-            return ""
-
-        size_limited = False
-        if len(text) > self.MAX_LENGTH_HTML:
-            text = text[: self.MAX_LENGTH_HTML]
-            size_limited = True
-
-        html4_singlets = (
-            "br",
-            "col",
-            "link",
-            "base",
-            "img",
-            "param",
-            "area",
-            "hr",
-            "input",
-        )
-
-        # Count non-HTML chars/words and keep note of open tags
-        pos = 0
-        end_text_pos = 0
-        current_len = 0
-        open_tags = []
-
-        regex = re_words if words else re_chars
-
-        while current_len <= length:
-            m = regex.search(text, pos)
-            if not m:
-                # Checked through whole string
-                break
-            pos = m.end(0)
-            if m[1]:
-                # It's an actual non-HTML word or char
-                current_len += 1
-                if current_len == truncate_len:
-                    end_text_pos = pos
-                continue
-            # Check for tag
-            tag = re_tag.match(m[0])
-            if not tag or current_len >= truncate_len:
-                # Don't worry about non tags or tags after our truncate point
-                continue
-            closing_tag, tagname, self_closing = tag.groups()
-            # Element names are always case-insensitive
-            tagname = tagname.lower()
-            if self_closing or tagname in html4_singlets:
-                pass
-            elif closing_tag:
-                # Check for match in open tags list
-                try:
-                    i = open_tags.index(tagname)
-                except ValueError:
-                    pass
-                else:
-                    # SGML: An end tag closes, back to the matching start tag,
-                    # all unclosed intervening start tags with omitted end tags
-                    open_tags = open_tags[i + 1 :]
-            else:
-                # Add it to the start of the open tags list
-                open_tags.insert(0, tagname)
-
-        truncate_text = add_truncation_text("", truncate)
-
-        if current_len <= length:
-            if size_limited and truncate_text:
-                text += truncate_text
-            return text
-
-        out = text[:end_text_pos]
-        if truncate_text:
-            out += truncate_text
-        # Close any tags still open
-        for tag in open_tags:
-            out += "</%s>" % tag
-        # Return string
-        return out
-

 @keep_lazy_text
 def get_valid_filename(name):
diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt
index 701d68653252..aca1281a98a6 100644
--- a/docs/releases/5.1.txt
+++ b/docs/releases/5.1.txt
@@ -368,6 +368,11 @@ Miscellaneous
   :meth:`~django.test.SimpleTestCase.assertInHTML` now add ``": "`` to the
   ``msg_prefix``. This is consistent with the behavior of other assertions.

+* ``django.utils.text.Truncator`` used by :tfilter:`truncatechars_html` and
+  :tfilter:`truncatewords_html` template filters now uses
+  :py:class:`html.parser.HTMLParser` subclasses. This results in a more robust
+  and faster operation, but there may be small differences in the output.
+
 .. _deprecated-features-5.1:

 Features deprecated in 5.1

diff --git a/tests/template_tests/filter_tests/test_truncatewords_html.py b/tests/template_tests/filter_tests/test_truncatewords_html.py
index 32b7c81a7626..0cf41d83aeef 100644
--- a/tests/template_tests/filter_tests/test_truncatewords_html.py
+++ b/tests/template_tests/filter_tests/test_truncatewords_html.py
@@ -24,7 +24,7 @@ def test_truncate2(self):
             truncatewords_html(
                 '
one two - three
four five
', 4
             ),
-            '
one two - three …
',
+            '
one two - three
 …
',
         )

     def test_truncate3(self):
@@ -32,7 +32,7 @@ def test_truncate3(self):
             truncatewords_html(
                 '
one two - three
four five
', 5
             ),
-            '
one two - three
four …
',
+            '
one two - three
four …
',
         )

     def test_truncate4(self):
@@ -53,7 +53,7 @@ def test_truncate_complex(self):
             truncatewords_html(
                 "Buenos días! ¿Cómo está?", 3
             ),
-            "Buenos días! ¿Cómo …",
+            "Buenos días! ¿Cómo …",
         )

     def test_invalid_arg(self):
diff --git a/tests/utils_tests/test_text.py b/tests/utils_tests/test_text.py
index 6004712bf296..b38d8238c523 100644
--- a/tests/utils_tests/test_text.py
+++ b/tests/utils_tests/test_text.py
@@ -111,7 +111,7 @@ def test_truncate_chars_html(self):
             truncator.chars(46, html=True),
         )
         self.assertEqual(
-            '
The quick brown fox jumped over the lazy dog.'
+            '
The quick brown fox jumped over the lazy dog…'
             "
",
             truncator.chars(45, html=True),
         )
@@ -120,7 +120,7 @@ def test_truncate_chars_html(self):
             truncator.chars(10, html=True),
         )
         self.assertEqual(
-            "…",
+            '
…
',
             truncator.chars(1, html=True),
         )
         self.assertEqual("", truncator.chars(0, html=True))
@@ -142,18 +142,16 @@ def test_truncate_chars_html_size_limit(self):
         bigger_len = text.Truncator.MAX_LENGTH_HTML + 1
         valid_html = "
Joel is a slug
"  # 14 chars
         perf_test_values = [
-            ("", None),
-            ("
", "</p" + "\t" * 6 + "…"),
-            ("&" * bigger_len, "&" * 9 + "…"),
-            ("_X<<<<<<<<<<<>", None),
+            ("", ""),
+            ("
", "
"),
+            ("&" * bigger_len, ""),
+            ("_X<<<<<<<<<<<>", "_X<<<<<<<…"),
             (valid_html * bigger_len, "
Joel is a…
"),  # 10 chars
         ]
         for value, expected in perf_test_values:
             with self.subTest(value=value):
                 truncator = text.Truncator(value)
-                self.assertEqual(
-                    expected if expected else value, truncator.chars(10, html=True)
-                )
+                self.assertEqual(expected, truncator.chars(10, html=True))

     def test_truncate_chars_html_with_newline_inside_tag(self):
         truncator = text.Truncator(
@@ -181,7 +179,7 @@ def test_truncate_chars_html_with_void_elements(self):
             "
The
quick brown…", truncator.chars(16, html=True)
         )
         self.assertEqual("
The
q…", truncator.chars(6, html=True))
-        self.assertEqual("
The …", truncator.chars(5, html=True))
+        self.assertEqual("
The
…", truncator.chars(5, html=True))
         self.assertEqual("
The…", truncator.chars(4, html=True))
         self.assertEqual("
Th…", truncator.chars(3, html=True))

@@ -190,11 +188,19 @@ def test_truncate_chars_html_with_html_entities(self):
             "Buenos días! ¿Cómo está?"
         )
         self.assertEqual(
-            "Buenos días! ¿Cómo…",
+            "Buenos días! ¿Cómo está?",
             truncator.chars(40, html=True),
         )
+        self.assertEqual(
+            "Buenos días…",
+            truncator.chars(12, html=True),
+        )
+        self.assertEqual(
+            "Buenos días! ¿Cómo está…",
+            truncator.chars(24, html=True),
+        )
         truncator = text.Truncator("
I <3 python, what about you?
")
-        self.assertEqual("
I <3 python,…
", truncator.chars(16, html=True))
+        self.assertEqual("
I <3 python, wh…
", truncator.chars(16, html=True))

     def test_truncate_words(self):
         truncator = text.Truncator("The quick brown fox jumped over the lazy dog.")
@@ -242,7 +248,7 @@ def test_truncate_html_words(self):
             "
The  quick \t brown fox jumped over the lazy dog.
"
         )
         self.assertEqual(
-            "
The  quick \t brown fox…
",
+            "
The quick brown fox…
",
             truncator.words(4, html=True),
         )

@@ -277,7 +283,7 @@ def test_truncate_html_words(self):
             "Buenos días! ¿Cómo está?"
         )
         self.assertEqual(
-            "Buenos días! ¿Cómo…",
+            "Buenos días! ¿Cómo…",
             truncator.words(3, html=True),
         )
         truncator = text.Truncator("
I <3 python, what about you?
")
@@ -292,19 +298,17 @@ def test_truncate_words_html_size_limit(self):
         bigger_len = text.Truncator.MAX_LENGTH_HTML + 1
         valid_html = "
Joel is a slug
"  # 4 words
         perf_test_values = [
-            ("", None),
-            ("
", "</p" + "\t" * (max_len - 3) + "…"),
-            ("&" * max_len, None),  # no change
-            ("&" * bigger_len, "&" * max_len + "…"),
-            ("_X<<<<<<<<<<<>", None),
+            ("", ""),
+            ("
", "
"),
+            ("&" * max_len, ""),
+            ("&" * bigger_len, ""),
+            ("_X<<<<<<<<<<<>", "_X<<<<<<<<<<<>"),
             (valid_html * bigger_len, valid_html * 12 + "
Joel is…
"),  # 50 words
         ]
         for value, expected in perf_test_values:
             with self.subTest(value=value):
                 truncator = text.Truncator(value)
-                self.assertEqual(
-                    expected if expected else value, truncator.words(50, html=True)
-                )
+                self.assertEqual(expected, truncator.words(50, html=True))

     def test_wrap(self):
         digits = "1234 67 9"

Improve utils.text.Truncator &co to use a full HTML parser.
Description

(last modified by Carlton Gibson)
Original description:
I'm using Truncator.chars to truncate wikis, and it sometimes truncates in the middle of " entities, resulting in '
some text &qu
'
This is a limitation of the regex based implementation (which has had security issues, and presents an intractable problem).
Better to move to use a HTML parser, for Truncate, and strip_tags(), via html5lib and bleach.
Improve utils.text.Truncator &co to use a full HTML parser.
Description

(last modified by Carlton Gibson)
Original description:
I'm using Truncator.chars to truncate wikis, and it sometimes truncates in the middle of " entities, resulting in '
some text &qu
'
This is a limitation of the regex based implementation (which has had security issues, and presents an intractable problem).
Better to move to use a HTML parser, for Truncate, and strip_tags(), via html5lib and bleach.

['Hi Thomas. Any chance of an example string (hopefully minimal) that creates the behaviour so we can have a look?', 1565074082.0]
["I think now that the security release are out let's just add bleach as dependency on master and be done with it?", 1565076701.0]
["Here's an example \u200bhttps://repl.it/@tdhooper/Django-truncate-entities-bug", 1565079129.0]
['btw I confused truncator with strip_tags. So in this case the answer would be to rewrite the parser using html5lib, while split_tags would use bleach which in turn then uses html5lib as well.', 1565079313.0]
['Looks like it can be fixed with this regex change \u200bhttps://github.com/django/django/pull/11633/files', 1565085272.0]
['Example implemetation of _truncate_html() using html5lib, by Florian Apolloner', 1565151161.0]
["Right, good news is this isn't a regression from 7f65974f8219729c047fbbf8cd5cc9d80faefe77. The new example case fails on v2.2.3 &co. The suggestion for the regex change is in the part not changed as part of 7f65974f8219729c047fbbf8cd5cc9d80faefe77. (Which is why the new case fails, I suppose :) I don't want to accept a tweaking of the regex here. Rather, we should move to using html5lib as Florian suggests. Possibly this would entail small changes in behaviour around edge cases, to be called out in release notes, but would be a big win overall. This has previously been discussed by the Security Team as the required way forward. I've updated the title/description and will Accept accordingly. I've attached an initial WIP patch by Florian of an html5lib implementation of the core _truncate_html() method. An implementation of strip_tags() using bleach would go something like: bleach.clean(text, tags=[], strip=True, strip_comments=True) Thomas, would taking on making changes like these be something you'd be willing/keen to do? If so, I'm very happy to input to assist in any way. :)", 1565151467.0]
['Hi Carlton, that would be fun, but this is bigger than I have time for now. It looks like you all have it in hand.', 1565172776.0]
["Do we want to make both html5lib and bleach required dependencies of Django? html5lib latest release is now 20 months ago, and when I read issues like \u200bhttps://github.com/html5lib/html5lib-python/issues/419 without any maintainer feedback, I'm a bit worried. What about the security report workflow for those libs? What if a security issue is discovered in html5 lib and the maintainers are unresponsive? Sorry to sound a bit negative, but I think those questions must be asked.", 1565172804.0]
["Yep Claude, absolutely. I think there's two difficulties we could face: trying to successfully sanitize HTML with regexes. (Help) Make sure html5lib-python is maintained. The first of these is intractable. The second not. 🙂 I've put out some feelers to try and find out more. This is pressing for Python and pip now, not for us for a while yet. If we look at \u200bhttps://github.com/html5lib/html5lib-python/issues/361 it seems there's some money on the table from tidelift potentially. We COULD allocate some time in a pinch I think. AND it's just (even with the emphasis, cough) a wrapper around the underlying C library, so whilst 20 months seems a long time, I'm not sure the release cadence is really an issue. BUT, yes, absolutely. Let's hammer this out properly before we commit. 👍 I will open a mailing list thread when I know more.", 1565181330.0]
["AND it's just (even with the emphasis, cough) a wrapper around the underlying C library, so whilst 20 months seems a long time, I'm not sure the release cadence is really an issue. OK, that last one isn't at all true. (Looking at the source it's the entire implementation.) Update: I had \u200b`lxml` in mind.", 1565182199.0]
["To be clear, I'm also convinced parsing is more reliable than regexes. I just think we have to double-think before adding a dependency, because as the name implies, we depend on it and therefore we must be able to trust its maintainers. Some guarantees about the security process and serious bugs fixing should be obtained. Without that, we are just outsourcing problems.", 1565184992.0]
['@Claude: 💯👍 Totally agree.', 1565227033.0]
["Duplicate in #30700, with \u200bfailing test case provided. I've tried contacting maintainers of HTML5lib with no success. I've re-opened \u200bhttps://github.com/django/django/pull/11633 (original regex based suggestion) so we can at least assess it as a possible stop-gap.", 1565577102.0]
["Paging Jon, to ask his opinion on this. Hey Jon, I see you've made a number of PRs to both html5lib, and bleach. To me, at this point, html5lib essentially looks unmaintained. I don't have personal capacity to give to it, as cool as it is as a project. Arguably we (Fellows) could allocate it _some_ time, since we spend a fair bit already messing around with regexes but that would be small, and we couldn't take it on whole, so can I ask your thoughts? Is html5lib in trouble? If so, as a user, what are your plans, if any? And from that, what do you think about Django adopting it? What's the alternative? Thanks for the thought and insight.", 1566304019.0]
["To me, at this point, html5lib essentially looks unmaintained. I agree with this observation. The previous main maintainer looks to have stopped working on the project. Responses to issues and PRs have stopped. Is html5lib in trouble? If so, as a user, what are your plans, if any? And from that, what do you think about Django adopting it? What's the alternative? For my own projects, I'll probably continue using html5lib until its staleness creates an observable bug for me. I haven't hit that point yet. Bleach, on the other hand, looks like maintenance has slowed, but not stopped. I believe they have vendored html5lib to allow them to make changes internally. FWIW, I also still use Bleach. --- I'm not familiar with all the details of this ticket, but would the stdlib HTML parser be sufficient? \u200bhttps://docs.python.org/3/library/html.parser.html", 1566650557.0]
["Hi Jon, Thank you for the comments. I will email Will, the maintainer of Bleach, and ask his thoughts too. Bleach has slowed down, but that's because it's Stable/Mature now I would have thought. ...would the stdlib HTML parser be sufficient? Yes. Maybe. Ideally we just thought to bring in Bleach, and with it html5lib since, in theory, that's already working code. (Florian already had a Truncate prototype...) Anyhow... will follow-up.", 1566794092.0]
["Adding some detail after the last post, since you're looking at it David. There was a discussion (with various folks from html5lib, and Mozilla, and ...) about whether html5lib could be put on a better footing. I'm not sure how that panned out in the medium term. (I didn't check what the rhythm looks like now.) There was also talk about whether bleach (or an alternate) could build off html5ever which is the HTML parser from the Mozilla servo project. \u200bhttps://github.com/servo/html5ever \u200bhttps://github.com/SimonSapin/html5ever-python (CFFI bindings.) \u200bhttps://github.com/PyO3/setuptools-rust/tree/main/examples/html-py-ever (Py03 Bindings) That would be pretty cool, but it was clearly a lot of work, and then 2020 happened, so... The other candidate in this space is Matthias' html-sanitizer: \u200bhttps://github.com/matthiask/html-sanitizer — which is built on lxml. That's just to lay down the notes I had gathered. I'm not sure the way forward, but hopefully it's helpful. Very open to ideas though! Thanks for picking it up.", 1672741702.0]
['Hi all lxml is quite a heavy dependency. It works very well but you\'ll wait for the compilation a long time if you do not have wheels. (see \u200bhttps://pypi.org/project/lxml/#files) I think Python packaging is almost a non-issue these days except when it comes to transitive dependencies, and I wouldn\'t want to be in charge of specifying and updating the supported range of lxml versions. That being said, I encountered almost no breaking changes in lxml since \u200b~2009, I use lxml in almost all projects and I can heartily recommend it to anyone. I\'m sure that the regex-based solution has some problems; I\'m sorry to admit I haven\'t read the full thread but I just cannot imagine a situation where using |strip_tags without |safe would lead to a security issue, and why would you want to combine these? There\'s no point to mark a string as safe after stripping all tags. So it\'s only about the fact that the output sometimes isn\'t nice, something which may be fixed by converting as many entities to their unicode equivalents as possible and only truncating afterwards? Last but not least: I haven\'t benchmarked it ever, but I have the suspicion that running bleach or html-sanitizer during rendering may be wasteful in terms of CPU cycles. I only ever use the sanitizer when saving, never when rendering. |strip_tags is obviously applied when rendering and performs well enough in many situations. So, to me strip_tags is a clear case of \u200ba simple implementation with "worse is better" characteristics. I truly hope this is helpful and not just a cold shower (sorry for using "just" here) Thanks, Matthias', 1672742858.0]
['Hey Matthias — that\'s a very useful input. Thank you for your insight. So, to me strip_tags is a clear case of \u200ba simple implementation with "worse is better" characteristics. Let, me review what happened here tomorrow (it was a long while ago) but assuming it makes sense, wontfix + We\'re not accepting any complications to the algorithm — use ... if you need more sophistication may be the neatest way all round.', 1672748487.0]
["\u200bPR I was thinking about Jon's suggestion of using the HTMLParser from the standard library. Since the last comments on this ticket Adam Johnson has also written a blog post on Truncating HTML with Python's HTMLParser which helped inspire my PR, see \u200bblog post. (I'd cc Adam as I've mentioned his name, but not sure how to do that?!) While my PR still needs more work I thought it worth sharing as it may be helpful to Carlton when reviewing tomorrow.", 1672758237.0]
['Hi Thomas. Any chance of an example string (hopefully minimal) that creates the behaviour so we can have a look?', 1565074082.0]
["I think now that the security release are out let's just add bleach as dependency on master and be done with it?", 1565076701.0]
["Here's an example \u200bhttps://repl.it/@tdhooper/Django-truncate-entities-bug", 1565079129.0]
['btw I confused truncator with strip_tags. So in this case the answer would be to rewrite the parser using html5lib, while split_tags would use bleach which in turn then uses html5lib as well.', 1565079313.0]
['Looks like it can be fixed with this regex change \u200bhttps://github.com/django/django/pull/11633/files', 1565085272.0]
['Example implemetation of _truncate_html() using html5lib, by Florian Apolloner', 1565151161.0]
["Right, good news is this isn't a regression from 7f65974f8219729c047fbbf8cd5cc9d80faefe77. The new example case fails on v2.2.3 &co. The suggestion for the regex change is in the part not changed as part of 7f65974f8219729c047fbbf8cd5cc9d80faefe77. (Which is why the new case fails, I suppose :) I don't want to accept a tweaking of the regex here. Rather, we should move to using html5lib as Florian suggests. Possibly this would entail small changes in behaviour around edge cases, to be called out in release notes, but would be a big win overall. This has previously been discussed by the Security Team as the required way forward. I've updated the title/description and will Accept accordingly. I've attached an initial WIP patch by Florian of an html5lib implementation of the core _truncate_html() method. An implementation of strip_tags() using bleach would go something like: bleach.clean(text, tags=[], strip=True, strip_comments=True) Thomas, would taking on making changes like these be something you'd be willing/keen to do? If so, I'm very happy to input to assist in any way. :)", 1565151467.0]
['Hi Carlton, that would be fun, but this is bigger than I have time for now. It looks like you all have it in hand.', 1565172776.0]
["Do we want to make both html5lib and bleach required dependencies of Django? html5lib latest release is now 20 months ago, and when I read issues like \u200bhttps://github.com/html5lib/html5lib-python/issues/419 without any maintainer feedback, I'm a bit worried. What about the security report workflow for those libs? What if a security issue is discovered in html5 lib and the maintainers are unresponsive? Sorry to sound a bit negative, but I think those questions must be asked.", 1565172804.0]
["Yep Claude, absolutely. I think there's two difficulties we could face: trying to successfully sanitize HTML with regexes. (Help) Make sure html5lib-python is maintained. The first of these is intractable. The second not. 🙂 I've put out some feelers to try and find out more. This is pressing for Python and pip now, not for us for a while yet. If we look at \u200bhttps://github.com/html5lib/html5lib-python/issues/361 it seems there's some money on the table from tidelift potentially. We COULD allocate some time in a pinch I think. AND it's just (even with the emphasis, cough) a wrapper around the underlying C library, so whilst 20 months seems a long time, I'm not sure the release cadence is really an issue. BUT, yes, absolutely. Let's hammer this out properly before we commit. 👍 I will open a mailing list thread when I know more.", 1565181330.0]
["AND it's just (even with the emphasis, cough) a wrapper around the underlying C library, so whilst 20 months seems a long time, I'm not sure the release cadence is really an issue. OK, that last one isn't at all true. (Looking at the source it's the entire implementation.) Update: I had \u200b`lxml` in mind.", 1565182199.0]
["To be clear, I'm also convinced parsing is more reliable than regexes. I just think we have to double-think before adding a dependency, because as the name implies, we depend on it and therefore we must be able to trust its maintainers. Some guarantees about the security process and serious bugs fixing should be obtained. Without that, we are just outsourcing problems.", 1565184992.0]
['@Claude: 💯👍 Totally agree.', 1565227033.0]
["Duplicate in #30700, with \u200bfailing test case provided. I've tried contacting maintainers of HTML5lib with no success. I've re-opened \u200bhttps://github.com/django/django/pull/11633 (original regex based suggestion) so we can at least assess it as a possible stop-gap.", 1565577102.0]
["Paging Jon, to ask his opinion on this. Hey Jon, I see you've made a number of PRs to both html5lib, and bleach. To me, at this point, html5lib essentially looks unmaintained. I don't have personal capacity to give to it, as cool as it is as a project. Arguably we (Fellows) could allocate it _some_ time, since we spend a fair bit already messing around with regexes but that would be small, and we couldn't take it on whole, so can I ask your thoughts? Is html5lib in trouble? If so, as a user, what are your plans, if any? And from that, what do you think about Django adopting it? What's the alternative? Thanks for the thought and insight.", 1566304019.0]
["To me, at this point, html5lib essentially looks unmaintained. I agree with this observation. The previous main maintainer looks to have stopped working on the project. Responses to issues and PRs have stopped. Is html5lib in trouble? If so, as a user, what are your plans, if any? And from that, what do you think about Django adopting it? What's the alternative? For my own projects, I'll probably continue using html5lib until its staleness creates an observable bug for me. I haven't hit that point yet. Bleach, on the other hand, looks like maintenance has slowed, but not stopped. I believe they have vendored html5lib to allow them to make changes internally. FWIW, I also still use Bleach. --- I'm not familiar with all the details of this ticket, but would the stdlib HTML parser be sufficient? \u200bhttps://docs.python.org/3/library/html.parser.html", 1566650557.0]
["Hi Jon, Thank you for the comments. I will email Will, the maintainer of Bleach, and ask his thoughts too. Bleach has slowed down, but that's because it's Stable/Mature now I would have thought. ...would the stdlib HTML parser be sufficient? Yes. Maybe. Ideally we just thought to bring in Bleach, and with it html5lib since, in theory, that's already working code. (Florian already had a Truncate prototype...) Anyhow... will follow-up.", 1566794092.0]
["Adding some detail after the last post, since you're looking at it David. There was a discussion (with various folks from html5lib, and Mozilla, and ...) about whether html5lib could be put on a better footing. I'm not sure how that panned out in the medium term. (I didn't check what the rhythm looks like now.) There was also talk about whether bleach (or an alternate) could build off html5ever which is the HTML parser from the Mozilla servo project. \u200bhttps://github.com/servo/html5ever \u200bhttps://github.com/SimonSapin/html5ever-python (CFFI bindings.) \u200bhttps://github.com/PyO3/setuptools-rust/tree/main/examples/html-py-ever (Py03 Bindings) That would be pretty cool, but it was clearly a lot of work, and then 2020 happened, so... The other candidate in this space is Matthias' html-sanitizer: \u200bhttps://github.com/matthiask/html-sanitizer — which is built on lxml. That's just to lay down the notes I had gathered. I'm not sure the way forward, but hopefully it's helpful. Very open to ideas though! Thanks for picking it up.", 1672741702.0]
['Hi all lxml is quite a heavy dependency. It works very well but you\'ll wait for the compilation a long time if you do not have wheels. (see \u200bhttps://pypi.org/project/lxml/#files) I think Python packaging is almost a non-issue these days except when it comes to transitive dependencies, and I wouldn\'t want to be in charge of specifying and updating the supported range of lxml versions. That being said, I encountered almost no breaking changes in lxml since \u200b~2009, I use lxml in almost all projects and I can heartily recommend it to anyone. I\'m sure that the regex-based solution has some problems; I\'m sorry to admit I haven\'t read the full thread but I just cannot imagine a situation where using |strip_tags without |safe would lead to a security issue, and why would you want to combine these? There\'s no point to mark a string as safe after stripping all tags. So it\'s only about the fact that the output sometimes isn\'t nice, something which may be fixed by converting as many entities to their unicode equivalents as possible and only truncating afterwards? Last but not least: I haven\'t benchmarked it ever, but I have the suspicion that running bleach or html-sanitizer during rendering may be wasteful in terms of CPU cycles. I only ever use the sanitizer when saving, never when rendering. |strip_tags is obviously applied when rendering and performs well enough in many situations. So, to me strip_tags is a clear case of \u200ba simple implementation with "worse is better" characteristics. I truly hope this is helpful and not just a cold shower (sorry for using "just" here) Thanks, Matthias', 1672742858.0]
['Hey Matthias — that\'s a very useful input. Thank you for your insight. So, to me strip_tags is a clear case of \u200ba simple implementation with "worse is better" characteristics. Let, me review what happened here tomorrow (it was a long while ago) but assuming it makes sense, wontfix + We\'re not accepting any complications to the algorithm — use ... if you need more sophistication may be the neatest way all round.', 1672748487.0]
["\u200bPR I was thinking about Jon's suggestion of using the HTMLParser from the standard library. Since the last comments on this ticket Adam Johnson has also written a blog post on Truncating HTML with Python's HTMLParser which helped inspire my PR, see \u200bblog post. (I'd cc Adam as I've mentioned his name, but not sure how to do that?!) While my PR still needs more work I thought it worth sharing as it may be helpful to Carlton when reviewing tomorrow.", 1672758237.0]


diff --git a/documentation/source/user_section/command-line/deepseg/totalspineseg.rst b/documentation/source/user_section/command-line/deepseg/totalspineseg.rst
new file mode 100644
index 0000000000..2c5423a6d7
--- /dev/null
+++ b/documentation/source/user_section/command-line/deepseg/totalspineseg.rst
@@ -0,0 +1,11 @@
+
+
+totalspineseg
+=============
+
+.. argparse::
+   :ref: spinalcordtoolbox.scripts.sct_deepseg.get_parser
+   :prog: sct_deepseg -h
+   :markdownhelp:
+   :noepilog:
+
\ No newline at end of file
diff --git a/documentation/source/user_section/command-line/sct_deepseg.rst b/documentation/source/user_section/command-line/sct_deepseg.rst
index ef3a7dde11..f73532b201 100644
--- a/documentation/source/user_section/command-line/sct_deepseg.rst
+++ b/documentation/source/user_section/command-line/sct_deepseg.rst
@@ -118,19 +118,24 @@ Other structures
 .. |seg_spinal_rootlets_t2w| image:: https://raw.githubusercontent.com/spinalcordtoolbox/doc-figures/master/sct_deepseg/spinal_rootlets_t2.png
    :target: deepseg/seg_spinal_rootlets_t2w.html

-Rootlets segmentation can be performed by running the following sample command:
+.. |totalspineseg| image:: https://raw.githubusercontent.com/spinalcordtoolbox/doc-figures/master/sct_deepseg/totalspineseg.png
+   :target: deepseg/totalspineseg.html
+
+Multiple structures may be segmented by running the following sample command:

 .. code::

    sct_deepseg -task seg_spinal_rootlets_t2w -i input.nii.gz

+You can replace "``seg_spinal_rootlets_t2w``" with any of the task names in the table below to perform different tasks. Click on a task below for more information.
+

 .. list-table::
    :align: center
    :widths: 25 25 25

    * - |seg_spinal_rootlets_t2w| ``seg_spinal_rootlets_t2w``
-     -
+     - |totalspineseg| ``totalspineseg``
      -

 .. |canal_t2w| image:: https://raw.githubusercontent.com/spinalcordtoolbox/doc-figures/master/sct_deepseg/spinal_canal_t2.png
@@ -200,3 +205,4 @@ If you absolutely require these models, you can downgrade to version of SCT list
    deepseg/seg_tumor_t2
    deepseg/seg_spinal_rootlets_t2w
    deepseg/canal_t2w
+   deepseg/totalspineseg
diff --git a/requirements.txt b/requirements.txt
index 2d22937966..4b36236e24 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -59,6 +59,7 @@ requirements-parser
 scipy
 scikit-image
 scikit-learn
+totalspineseg
 xlwt
 tqdm
 transforms3d
diff --git a/spinalcordtoolbox/deepseg/inference.py b/spinalcordtoolbox/deepseg/inference.py
index e299883e70..c8478aa095 100644
--- a/spinalcordtoolbox/deepseg/inference.py
+++ b/spinalcordtoolbox/deepseg/inference.py
@@ -10,8 +10,11 @@
 import shutil
 import time
 from pathlib import Path
+import glob
+import multiprocessing as mp

 from ivadomed import inference as imed_inference
+from totalspineseg.inference import inference as tss_inference
 import nibabel as nib
 import numpy as np
 import torch
@@ -107,7 +110,11 @@ def segment_and_average_volumes(model_paths, input_filenames, options, use_gpu=F
 def segment_non_ivadomed(path_model, model_type, input_filenames, threshold, keep_largest, fill_holes_in_pred,
                          remove_small, use_gpu=False, remove_temp_files=True):
     # MONAI and NNUnet have similar structure, and so we use nnunet+inference functions with the same signature
-    if model_type == "monai":
+    # NB: For TotalSpineSeg, we don't need to create the network ourselves
+    if "totalspineseg" in path_model:
+        def create_net(pm, _): return pm  # just echo `path_model` back as 'net'
+        inference = segment_totalspineseg
+    elif model_type == "monai":
         create_net = ds_monai.create_nnunet_from_plans
         inference = segment_monai
     else:
@@ -323,3 +330,49 @@ def segment_nnunet(path_img, tmpdir, predictor, device: torch.device):
         fnames_out.append(fname_out)

     return fnames_out, targets
+
+
+def segment_totalspineseg(path_img, tmpdir, predictor, device):
+    # for totalspineseg, the 'predictor' is just the model path
+    path_model = predictor
+    # fetch the release subdirectory from the model path
+    installed_releases = sorted(
+        os.path.basename(release_path) for release_path in
+        glob.glob(os.path.join(path_model, 'nnUNet', 'results', 'r*'), recursive=True)
+    )
+    # There should always be a release subdirectory, hence the 'assert'
+    assert installed_releases, f"No 'nnUNet/results/rYYYYMMDD' subdirectory found in {path_model}"
+
+    # Copy the file to the temporary directory using shutil.copyfile
+    path_img_tmp = os.path.join(tmpdir, os.path.basename(path_img))
+    shutil.copyfile(path_img, path_img_tmp)
+    logger.info(f'Copied {path_img} to {path_img_tmp}')
+
+    # Create directory for nnUNet prediction
+    tmpdir_nnunet = os.path.join(tmpdir, 'nnUNet_prediction')
+    os.mkdir(tmpdir_nnunet)
+
+    # totalspineseg uses multiprocessing internally, so here we try to
+    # set multiprocessing method to 'spawn' to avoid deadlock issues
+    # - 'spawn' is already default on Windows/macOS, but 'fork' is default on Linux
+    # - 'spawn' is reliable though slower, but 'fork' causes intermittent stalling
+    mp.set_start_method("spawn", force=True)
+
+    tss_inference(
+        input_path=path_img_tmp,
+        output_path=tmpdir_nnunet,
+        data_path=path_model,
+        # totalspineseg requires explicitly specifying the release subdirectory
+        default_release=installed_releases[-1],  # use the most recent release
+        # totalspineseg expects the device type, not torch.device
+        device=device.type,
+        # Try to address stalling due to the use of concurrent.futures in totalspineseg
+        max_workers=1,
+        max_workers_nnunet=1,
+    )
+    fnames_out, targets = [], []
+    for output_dirname in ["step1_canal", "step1_cord", "step1_levels", "step1_output", "step2_output"]:
+        fnames_out.append(os.path.join(tmpdir_nnunet, output_dirname, os.path.basename(path_img)))
+        targets.append(f"_{output_dirname}")
+
+    return fnames_out, targets
diff --git a/spinalcordtoolbox/deepseg/models.py b/spinalcordtoolbox/deepseg/models.py
index baf7ea78d6..08dbee3656 100644
--- a/spinalcordtoolbox/deepseg/models.py
+++ b/spinalcordtoolbox/deepseg/models.py
@@ -14,10 +14,12 @@
 import shutil
 import glob
 from pathlib import Path
+from importlib.metadata import metadata

 from spinalcordtoolbox import download
-from spinalcordtoolbox.utils.sys import stylize, __deepseg_dir__
+from spinalcordtoolbox.utils.sys import stylize, __deepseg_dir__, LazyLoader

+tss_init = LazyLoader("tss_init", globals(), 'totalspineseg.init_inference')

 logger = logging.getLogger(__name__)

@@ -184,6 +186,17 @@
         "thr": None,  # Images are already binarized
         "default": False,
     },
+    "totalspineseg": {
+         # NB: Rather than hardcoding the URLs ourselves, use the URLs from the totalspineseg package.
+         # This means that when the totalspineseg package is updated, the URLs will be too, thus triggering
+         # a re-installation of the model URLs
+         "url": dict([meta.split(', ') for meta in metadata('totalspineseg').get_all('Project-URL')
+                      if meta.startswith('Dataset')]),
+         "description": "Instance segmentation of vertebrae, intervertebral discs (IVDs), spinal cord, and spinal canal on multi-contrasts MRI scans.",
+         "contrasts": ["any"],
+         "thr": None,  # Images are already binarized
+         "default": False,
+     },
 }

@@ -363,6 +376,13 @@
                              'false positives segmentations of other anatomical structures.',
          'url': 'https://github.com/ivadomed/model-canal-seg',
          'models': ['model_seg_canal_t2w']},
+    'totalspineseg':
+        {'description': 'Intervertebral discs labeling and vertebrae segmentation',
+         'long_description': 'TotalSpineSeg is a tool for automatic instance segmentation of all vertebrae, intervertebral discs (IVDs), '
+                             'spinal cord, and spinal canal in MRI images. It is robust to various MRI contrasts, acquisition orientations, '
+                             'and resolutions. The model used in TotalSpineSeg is based on nnU-Net as the backbone for training and inference.',
+         'url': 'https://github.com/neuropoly/totalspineseg',
+         'models': ['totalspineseg']},
 }

@@ -408,11 +428,17 @@ def install_model(name_model, custom_url=None):
     else:
         if not isinstance(url_field, dict):
             raise ValueError("Invalid url field in MODELS")
-        urls_used = {}
-        for seed_name, model_urls in url_field.items():
-            logger.info(f"\nInstalling '{seed_name}'...")
-            urls_used[seed_name] = download.install_data(model_urls,
-                                                         folder(os.path.join(name_model, seed_name)), keep=True)
+        # totalspineseg handles data downloading itself, so just pass the urls along
+        if name_model == 'totalspineseg':
+            tss_init.init_inference(data_path=Path(folder(name_model)), quiet=False, dict_urls=url_field,
+                                    store_export=False)  # Avoid having duplicate .zip files stored on disk
+            urls_used = url_field
+        else:
+            urls_used = {}
+            for seed_name, model_urls in url_field.items():
+                logger.info(f"\nInstalling '{seed_name}'...")
+                urls_used[seed_name] = download.install_data(model_urls,
+                                                             folder(os.path.join(name_model, seed_name)), keep=True)
     # Write `source.json` (for model provenance / updating)
     source_dict = {
         'model_name': name_model,
diff --git a/spinalcordtoolbox/reports/qc2.py b/spinalcordtoolbox/reports/qc2.py
index fd9b8cac2e..1c5ed23b5e 100644
--- a/spinalcordtoolbox/reports/qc2.py
+++ b/spinalcordtoolbox/reports/qc2.py
@@ -272,10 +272,17 @@ def sct_deepseg(
         dataset=dataset,
         subject=subject,
     ) as imgs_to_generate:
+        # Custom QC to handle multiclass segmentation outside the spinal cord
         if "seg_spinal_rootlets_t2w" in argv:
             sct_deepseg_spinal_rootlets_t2w(
-                imgs_to_generate, fname_input, fname_seg, fname_seg2, species)
-        if plane == 'Axial':
+                imgs_to_generate, fname_input, fname_seg, fname_seg2, species,
+                radius=(23, 23))
+        elif "totalspineseg" in argv:
+            sct_deepseg_spinal_rootlets_t2w(
+                imgs_to_generate, fname_input, fname_seg, fname_seg2, species,
+                radius=(40, 40), outline=False)
+        # Non-rootlets, axial/sagittal DeepSeg QC report
+        elif plane == 'Axial':
             sct_deepseg_axial(
                 imgs_to_generate, fname_input, fname_seg, fname_seg2, species)
         else:
@@ -385,6 +392,8 @@ def sct_deepseg_spinal_rootlets_t2w(
     fname_seg_sc: str,
     fname_seg_lesion: Optional[str],
     species: str,
+    radius: Sequence[int],
+    outline: bool = True
 ):
     """
     Generate a QC report for `sct_deepseg -task seg_spinal_rootlets_t2w`.
@@ -400,8 +409,6 @@ def sct_deepseg_spinal_rootlets_t2w(
     img_seg_sc = Image(fname_seg_sc).change_orientation('SAL')
     img_seg_lesion = Image(fname_seg_lesion).change_orientation('SAL') if fname_seg_lesion else None

-    # Rootlets need a larger "base" radius as they exist outside the SC
-    radius = (23, 23)
     # The radius size is suited to the species-specific resolutions. But, since we plan to skip
     # resampling, we need to instead adjust the crop radius to suit the *actual* resolution.
     p_original = img_seg_sc.dim[5]  # dim[0:3] => shape, dim[4:7] => pixdim, so dim[5] == pixdim[1]
@@ -459,8 +466,9 @@ def sct_deepseg_spinal_rootlets_t2w(
                   alpha=1.0,
                   interpolation='none',
                   aspect=1.0)
-        # linewidth 0.5 is too thick, 0.25 is too thin
-        plot_outlines(img, ax=ax, facecolor='none', edgecolor='black', linewidth=0.3)
+        if outline:
+            # linewidth 0.5 is too thick, 0.25 is too thin
+            plot_outlines(img, ax=ax, facecolor='none', edgecolor='black', linewidth=0.3)
         add_segmentation_labels(ax, img, colors=colormaps[i].colors, radius=tuple(r*scale for r in radius))

     ax.get_xaxis().set_visible(False)
@@ -798,8 +806,9 @@ def add_segmentation_labels(ax: mpl_axes.Axes, seg_mosaic: np.ndarray, colors: l
     # Fetch mosaic shape properties
     bbox = [2*radius[0], 2*radius[1]]
     grid_shape = [s // bb for s, bb in zip(seg_mosaic.shape, bbox)]
-    # Fetch set of labels in the mosaic
-    labels = [v for v in np.unique(seg_mosaic) if v]
+    # Fetch set of labels in the mosaic (including labels in between min/max)
+    labels = [float(val) for val in range(int(np.unique(seg_mosaic).min()),
+                                          int(np.unique(seg_mosaic).max())+1)]
     # Iterate over each sub-array in the mosaic
     for row in range(grid_shape[0]):
         for col in range(grid_shape[1]):
@@ -810,8 +819,9 @@ def add_segmentation_labels(ax: mpl_axes.Axes, seg_mosaic: np.ndarray, colors: l
             # Check for nonzero labels, then draw text for each label found
             labels_in_arr = [v for v in np.unique(arr) if v]
             for idx_pos, l_arr in enumerate(labels_in_arr, start=1):
+                lr_shift = -4 * (len(str(int(l_arr))) - 1)
                 y, x = (extents[0].stop - 6*idx_pos + 3,  # Shift each subsequent label up in case there are >1
-                        extents[1].stop - 6)
+                        extents[1].stop - 6 + lr_shift)   # Shift labels left if double/triple digit
                 color = colors[0] if len(colors) == 1 else colors[labels.index(l_arr)]
                 ax.text(x, y, str(int(l_arr)), color=color, size=4).set_path_effects([
                     mpl_patheffects.Stroke(linewidth=1, foreground='black'),
@@ -916,7 +926,8 @@ def assign_label_colors_by_groups(labels):
     should each be assigned their own distinct colormap, as to group them semantically.
     """
     # Arrange colormaps for max contrast between colormaps, and max contrast between colors in colormaps
-    distinct_colormaps = ['Blues', 'Reds', 'Greens', 'Oranges', 'Purples']
+    # Put reds first because SC labels tend to be ~1, so they will usually be first in a list of labels
+    distinct_colormaps = ['Reds', 'Blues', 'Greens', 'Oranges', 'Purples']
     colormap_sampling = [0.25, 0.5, 0.75, 0.5]  # light -> medium -> dark -> medium -> (repeat)

     # Split labels into subgroups --> we split the groups wherever the difference between labels is > 1
diff --git a/spinalcordtoolbox/scripts/sct_deepseg.py b/spinalcordtoolbox/scripts/sct_deepseg.py
index a958ba1ee3..5a66a19d3a 100755
--- a/spinalcordtoolbox/scripts/sct_deepseg.py
+++ b/spinalcordtoolbox/scripts/sct_deepseg.py
@@ -356,9 +356,14 @@ def main(argv: Sequence[str]):

     if arguments.qc is not None:
         # Models can have multiple input images -- create 1 QC report per input image.
-        # Models can also either have 1 OR 2 outputs per input, so we may need to split output_filenames into 2 lists
         if len(output_filenames) == len(input_filenames):
             iterator = zip(input_filenames, output_filenames, [None] * len(input_filenames))
+        # Special case: totalspineseg which outputs 5 files per 1 input file
+        # Just use the 5th image ([4]) which represents the step2 output
+        elif arguments.task[0] == 'totalspineseg':
+            assert len(output_filenames) == 5 * len(input_filenames)
+            iterator = zip(input_filenames, output_filenames[4::5], [None] * len(input_filenames))
+        # Other models typically have 2 outputs per input (e.g. SC + lesion), so use both segs
         else:
             assert len(output_filenames) == 2 * len(input_filenames)
             iterator = zip(input_filenames, output_filenames[0::2], output_filenames[1::2])
@@ -378,11 +383,14 @@ def main(argv: Sequence[str]):
                 plane=arguments.qc_plane,
             )

+    images = [arguments.i[0]]
+    im_types = ['anat']
+    opacities = ['']
     for output_filename in output_filenames:
-        img_kind = check_image_kind(Image(output_filename))
-        display_viewer_syntax([arguments.i[0], output_filename],
-                              im_types=['anat', img_kind],
-                              opacities=['', '0.7'], verbose=verbose)
+        images.append(output_filename)
+        im_types.append(check_image_kind(Image(output_filename)))
+        opacities.append('0.7')
+    display_viewer_syntax(images, im_types=im_types, opacities=opacities, verbose=verbose)

 if __name__ == "__main__":

diff --git a/testing/cli/test_sct_deepseg.py b/testing/cli/test_sct_deepseg.py
index b6f4e9e8fb..a012f959f9 100644
--- a/testing/cli/test_sct_deepseg.py
+++ b/testing/cli/test_sct_deepseg.py
@@ -158,6 +158,12 @@ def test_segment_nifti_softseg_error_with_fill_holes(tmp_path):
      ["_GM_seg", "_WM_seg"],
      'seg_mouse_gm_wm_t1w',
      0.5),
+    (sct_test_path('t2', 't2.nii.gz'),
+     [None, None, None, None, None],
+     't2_deepseg.nii.gz',
+     ["_step1_canal", "_step1_cord", "_step1_levels", "_step1_output", "_step2_output"],
+     'totalspineseg',
+     0),
 ])
 def test_segment_nifti_multiclass(fname_image, fnames_seg_manual, fname_out, suffixes, task, thr,
                                   tmp_path):

Unify FSLeyes commands for multiclass DeepSeg models
FSLeyes commands outputted by `sct_deepseg -i t2.nii.gz -task seg_sc_lesion_t2w_sci` differ from the FSLeyes command in [our tutorial](https://github.com/spinalcordtoolbox/sct_tutorial_data/blob/8b91e72c6575cbf40c5a66898fe3a13d9c26790c/single_subject/batch_single_subject.sh#L128):

<img width="1406" alt="Screenshot 2024-11-12 at 11 16 44 AM" src="https://github.com/user-attachments/assets/5204f40c-6d93-4147-adc1-252e99ff292b">

We could consider unifying the commands. I personally like the command from our tutorial, which opens both spinal cord and lesion segmentation in a single FSLeyes window:

```console
fsleyes t2.nii.gz -cm greyscale t2_sc_seg.nii.gz -cm red -a 70.0 t2_lesion_seg.nii.gz -cm blue-lightblue -a 70.0 &
```


diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index a0f5cf0..98b8ee3 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -12,11 +12,13 @@
         "editorconfig.editorconfig",
         "ms-azuretools.vscode-docker",
         "ms-python.python",
-        "streetsidesoftware.code-spell-checker"
+        "streetsidesoftware.code-spell-checker",
+        "ryanluker.vscode-coverage-gutters"
       ],
       "python": {
         "defaultInterpreterPath": "/usr/local/bin/python"
       }
     }
-  }
+  },
+  "postCreateCommand": "pip install -e .[tests]"
 }
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d0207af..6d5a6c7 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,6 +4,7 @@
   },
   "cSpell.words": [
     "ADDNUM",
+    "addopts",
     "ADDSYSTEM",
     "AGRC",
     "atch",
@@ -13,11 +14,13 @@
     "charliermarsh",
     "choco",
     "cloudrun",
+    "Cntry",
     "codecov",
     "dotenv",
     "forcelist",
     "FULLADD",
     "geocoder",
+    "geocodes",
     "geosearch",
     "Geospatial",
     "getenv",
@@ -32,7 +35,10 @@
     "jsonpify",
     "Knative",
     "Lehi",
+    "makepoint",
+    "minversion",
     "mkcert",
+    "norecursedirs",
     "nort",
     "oauth2accesstoken",
     "opensgid",
@@ -41,8 +47,11 @@
     "pytest",
     "PYTHONUNBUFFERED",
     "Referer",
+    "ryanluker",
+    "setsrid",
     "simplejson",
     "STREETNAME",
+    "testpaths",
     "UGRC",
     "UICLSID",
     "usaddress",
@@ -56,5 +65,7 @@
   "editor.rulers": [120],
   "files.insertFinalNewline": true,
   "python.testing.pytestArgs": ["--no-cov"],
-  "python.testing.pytestEnabled": true
+  "python.testing.pytestEnabled": true,
+  "coverage-gutters.showGutterCoverage": false,
+  "coverage-gutters.showLineCoverage": true
 }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c142f3a..d834405 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,48 @@
 # Changelog

+## [1.5.5-4](https://github.com/agrc/masquerade/compare/v1.5.5-3...v1.5.5-4) (2024-12-06)
+
+
+### Bug Fixes
+
+* handle bad json parameters ([7bed818](https://github.com/agrc/masquerade/commit/7bed81881dab1db8f1f30990b626abcf648ef526))
+
+## [1.5.5-3](https://github.com/agrc/masquerade/compare/v1.5.5-2...v1.5.5-3) (2024-12-06)
+
+
+### Features
+
+* replace address grid with city/county name for geocoding results ([f7fb731](https://github.com/agrc/masquerade/commit/f7fb7317cd39cd60b8c8389b761056a47e0c2e90)), closes [#182](https://github.com/agrc/masquerade/issues/182)
+
+
+### Bug Fixes
+
+* fix duplicate "COUNTY" in reverse geocode results ([c78ae46](https://github.com/agrc/masquerade/commit/c78ae46b70e2227a306e024c16a9753722f0ce0e))
+
+## [1.5.5-2](https://github.com/agrc/masquerade/compare/v1.5.5-1...v1.5.5-2) (2024-12-06)
+
+
+### Bug Fixes
+
+* add types and return consistent type ([76ca7f0](https://github.com/agrc/masquerade/commit/76ca7f0e3fb89feeb2642fe21ed9973f5d1c7757))
+* escape returned input parameters ([f329207](https://github.com/agrc/masquerade/commit/f32920764492b37f0c63242acd543ca70b5b9291))
+* escape while preserving numbers ([7bb2170](https://github.com/agrc/masquerade/commit/7bb21705053ec1bbd5724ab6f7860e754d79d535))
+* use flask logger rather than print statements ([440a783](https://github.com/agrc/masquerade/commit/440a783bbaa1721281ab4970e96ae5a0a67aa2c8))
+* use parameterized query for user inputs ([1ace326](https://github.com/agrc/masquerade/commit/1ace326423fd940a9c5149bf96c9271285f30425))
+
+## [1.5.5-1](https://github.com/agrc/masquerade/compare/v1.5.4...v1.5.5-1) (2024-12-06)
+
+
+### Features
+
+* add reverse geocoding endpoint ([e4f4844](https://github.com/agrc/masquerade/commit/e4f484438d02ecb15ee37f7836107c7a69a9428e)), closes [#169](https://github.com/agrc/masquerade/issues/169)
+
+
+### Dependencies
+
+* bump deps ([1ce5264](https://github.com/agrc/masquerade/commit/1ce526411e4f7f2791a813734f1ff621a086cb5b))
+* python 3.10 -> 3.12 and other bumps ([dcbcbb0](https://github.com/agrc/masquerade/commit/dcbcbb03ace591e156ebb453ded235c77f98dacb))
+
 ## [1.5.5-0](https://github.com/agrc/masquerade/compare/v1.5.4...v1.5.5-0) (2024-11-27)

diff --git a/Dockerfile b/Dockerfile
index ba4ca62..1b0ab29 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,13 +1,11 @@
-FROM python:3.10 AS base
+FROM python:3.12 AS base

 FROM base AS dev_container

 RUN apt install -y git

-COPY . .
-
-RUN pip install -e ".[tests]"
+EXPOSE 5000

 FROM base AS prod
diff --git a/README.md b/README.md
index 336e0a0..159d818 100644
--- a/README.md
+++ b/README.md
@@ -32,9 +32,9 @@ _Give Masquerade's awesome searching capabilities a try in this [simple web app]

 ✅ Geocode Addresses

-✅ Suggest
+✅ Reverse Geocode

-Reverse Geocode has not been implemented. [Let us know](https://github.com/agrc/masquerade/issues/new) if you are interested in this feature!
+✅ Suggest

 [Esri REST API Reference](https://developers.arcgis.com/rest/services-reference/enterprise/geocode-service.htm)

diff --git a/pyproject.toml b/pyproject.toml
index 53f2cb5..c2bd2a7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,8 +1,6 @@
 [tool.ruff]
 line-length = 120
 ignore = ["E501"]
-[tool.black]
-line-length = 120
 [tool.pytest.ini_options]
 minversion = "6.0"
 testpaths = ["tests"]
diff --git a/setup.py b/setup.py
index 5d127b5..454c08e 100644
--- a/setup.py
+++ b/setup.py
@@ -4,6 +4,7 @@
 setup.py
 A module that installs masquerade as a module
 """
+
 from glob import glob
 from os.path import basename, splitext

@@ -11,7 +12,7 @@

 setup(
     name="masquerade",
-    version="1.5.5-0",
+    version="1.5.5-4",
     license="MIT",
     description="A proxy service that creates an Esri locator from UGRC data and web services.",
     author="UGRC",
@@ -33,28 +34,27 @@
     },
     keywords=["gis"],
     install_requires=[
-        "agrc-sweeper==1.4.*",
-        "flask-cors>=4.0,<5.1",
+        "ugrc-sweeper==2.*",
+        "flask-cors==5.*",
         "Flask-JSON==0.4.*",
-        "flask==3.0.*",
-        "psycopg_pool>=3.1,<3.3",
-        "psycopg[binary]>=3.1,<3.3",
-        "python-dotenv==1.0.*",
-        "requests>=2.32.3,<2.33",
-        "tenacity>=8.2,<9.1",
+        "flask==3.*",
+        "psycopg_pool==3.*",
+        "psycopg[binary]==3.*",
+        "pyproj==3.*",
+        "python-dotenv==1.*",
         #: flask uses this by default if installed
         #: this handles decimals as returned from open sgid data better than the default json library
-        "simplejson==3.19.*",
+        "simplejson==3.*",
+        "tenacity==9.*",
     ],
     extras_require={
         "tests": [
-            "black==24.*",
             "callee==0.3.*",
             "pytest-cov==6.*",
             "pytest-instafail==0.5.*",
-            "pytest-watch==4.2.*",
+            "pytest-watch==4.*",
             "pytest==8.*",
-            "requests-mock==1.12.*",
+            "requests-mock==1.*",
             "ruff==0.*",
         ]
     },
diff --git a/src/masquerade/main.py b/src/masquerade/main.py
index 6226252..04b01cf 100644
--- a/src/masquerade/main.py
+++ b/src/masquerade/main.py
@@ -13,10 +13,11 @@
 from flask.logging import create_logger
 from flask_cors import CORS
 from flask_json import FlaskJSON, as_json_p
+from pyproj import CRS, Transformer
 from requests.models import HTTPError

 from .providers import open_sgid, web_api
-from .utils import WGS84, cleanse_text, get_out_spatial_reference, get_request_param
+from .utils import WGS84, cleanse_text, escape_while_preserving_numbers, get_out_spatial_reference, get_request_param

 load_dotenv()

@@ -126,7 +127,7 @@ def geocode_base():
                 "required": True,
             },
         ],
-        "capabilities": ",".join(["Geocode", "Suggest"]),
+        "capabilities": ",".join(["Geocode", "ReverseGeocode", "Suggest"]),
         "countries": ["US"],
         "currentVersion": f"{SERVER_VERSION_MAJOR}.{SERVER_VERSION_MINOR}{SERVER_VERSION_PATCH}",
         "locatorProperties": {
@@ -230,7 +231,10 @@ def geocode_addresses():

     request_wkid, out_spatial_reference = get_out_spatial_reference(request)

-    addresses = json.loads(get_request_param(request, "addresses"))
+    try:
+        addresses = json.loads(get_request_param(request, "addresses"))
+    except json.JSONDecodeError:
+        return {"error": "addresses param is not valid JSON"}, 400

     locations = []

@@ -292,6 +296,42 @@ def geocode_addresses():
     }

+@app.route(f"{GEOCODE_SERVER_ROUTE}/reverseGeocode", methods=["GET", "POST"])
+@as_json_p
+def reverse_geocode():
+    """reverse geocode a point"""
+
+    request_wkid, out_spatial_reference = get_out_spatial_reference(request)
+
+    try:
+        location = json.loads(get_request_param(request, "location"))
+    except json.JSONDecodeError:
+        return {"error": "location param is not valid JSON"}, 400
+
+    if location["spatialReference"]["wkid"] != out_spatial_reference:
+        from_crs = CRS.from_epsg(location["spatialReference"]["wkid"])
+        to_crs = CRS.from_epsg(out_spatial_reference)
+        transformer = Transformer.from_crs(from_crs, to_crs, always_xy=True)
+        x, y = transformer.transform(location["x"], location["y"])
+    else:
+        x, y = location["x"], location["y"]
+
+    result = web_api.reverse_geocode(x, y, out_spatial_reference, location["x"], location["y"])
+    escaped_result = {key: escape_while_preserving_numbers(value) for key, value in result.items()}
+
+    return {
+        "address": escaped_result,
+        "location": {
+            "x": escape_while_preserving_numbers(x),
+            "y": escape_while_preserving_numbers(y),
+            "spatialReference": {
+                "wkid": escape_while_preserving_numbers(request_wkid),
+                "latestWkid": escape_while_preserving_numbers(out_spatial_reference),
+            },
+        },
+    }
+
+
 @app.route(f"{GEOCODE_SERVER_ROUTE}/<path:path>", methods=["HEAD"])
 def geocode_head(path):
     """handle head requests from Pro"""
diff --git a/src/masquerade/providers/open_sgid.py b/src/masquerade/providers/open_sgid.py
index be1cfa4..83d954c 100644
--- a/src/masquerade/providers/open_sgid.py
+++ b/src/masquerade/providers/open_sgid.py
@@ -3,6 +3,7 @@
 """
 A provider that queries data in Open SGID
 """
+
 import re

 from psycopg_pool import ConnectionPool
@@ -47,7 +48,7 @@
     normalize_direction_infos.append((re.compile(rf'^(\d+) ({"|".join(permutations)})( |$)'), direction[0]))
 has_prefix_direction = re.compile(r"^\d+ (n|s|e|w)")

-pool = ConnectionPool(f"dbname={DATABASE} user={AGRC} password={AGRC} host={HOST}")
+pool = ConnectionPool(f"dbname={DATABASE} user={AGRC} password={AGRC} host={HOST}", open=True)

 class DatabaseConnection:
@@ -57,11 +58,11 @@ class DatabaseConnection:
         stop=stop_after_attempt(RETRY_ATTEMPTS),
         wait=wait_exponential(multiplier=0.5, min=RETRY_WAIT_MIN, max=RETRY_WAIT_MAX),
     )
-    def query(self, query):
+    def query(self, query, params=None):
         """get records from the database"""
         with pool.connection() as connection:
             with connection.cursor() as cursor:
-                cursor.execute(query)
+                cursor.execute(query, params)

                 return cursor.fetchall()

@@ -389,3 +390,32 @@ class NoTableFoundException(Exception):

     def __init__(self, table_name):
         super().__init__(f"No table found: {table_name}")
+
+
+def get_boundary_value(x: float, y: float, spatial_reference: int, table_name: str, field_name: str) -> str | None:
+    """return the value of a given field for a point within a specified boundary table"""
+    query = f"""
+        select {field_name} from {table_name}
+        where st_contains(shape, st_transform(st_setsrid(st_makepoint(%s, %s), %s), 26912))
+    """
+    records = database.query(query, (x, y, spatial_reference))
+
+    if len(records) > 0:
+        return records[0][0]
+
+    return None
+
+
+def get_zip(x: float, y: float, spatial_reference: int) -> str | None:
+    """return the zip code for a given point"""
+    return get_boundary_value(x, y, spatial_reference, "opensgid.boundaries.zip_code_areas", "zip5")
+
+
+def get_city(x: float, y: float, spatial_reference: int) -> str | None:
+    """return the city for a given point"""
+    return get_boundary_value(x, y, spatial_reference, "opensgid.boundaries.municipal_boundaries", "name")
+
+
+def get_county(x: float, y: float, spatial_reference: int) -> str | None:
+    """return the county for a given point"""
+    return get_boundary_value(x, y, spatial_reference, "opensgid.boundaries.county_boundaries", "name")
diff --git a/src/masquerade/providers/web_api.py b/src/masquerade/providers/web_api.py
index 18bb635..e3e5e8e 100644
--- a/src/masquerade/providers/web_api.py
+++ b/src/masquerade/providers/web_api.py
@@ -6,16 +6,21 @@
 This module shares a fair amount of code with this one:
 https://github.com/agrc/geocoding-toolbox/blob/master/src/agrcgeocoding/geocode.py
 """
+
 import os
 import re

 import requests
+from flask import current_app
 from requests.adapters import HTTPAdapter
 from sweeper.address_parser import Address
 from urllib3.util.retry import Retry

-WEB_API_URL = "https://api.mapserv.utah.gov/api/v1/geocode"
+from . import open_sgid
+
+BASE_URL = "https://api.mapserv.utah.gov/api/v1"
 MIN_SCORE_FOR_BATCH = 70
+HEADERS = {"Referer": "https://masquerade.ugrc.utah.gov"}

 def get_candidates_from_single_line(single_line_address, out_spatial_reference, max_locations):
@@ -32,7 +37,7 @@ def get_candidates_from_single_line(single_line_address, out_spatial_reference,
     if not zone or not parsed_address.normalized:
         return []

-    return make_request(parsed_address.normalized, zone, out_spatial_reference, max_locations)
+    return make_geocode_request(parsed_address.normalized, zone, out_spatial_reference, max_locations)

 ALLOWABLE_CHARS = re.compile("[^a-zA-Z0-9]")
@@ -90,7 +95,7 @@ def _get_retry_session():
 session = _get_retry_session()

-def make_request(address, zone, out_spatial_reference, max_locations):
+def make_geocode_request(address: str, zone: str, out_spatial_reference: int, max_locations: int) -> list:
     """makes a request to the web api geocoding service"""
     parameters = {
         "apiKey": os.getenv("WEB_API_KEY"),
@@ -98,10 +103,9 @@ def make_request(address, zone, out_spatial_reference, max_locations):
         "suggest": max_locations,
     }

-    headers = {"Referer": "https://masquerade.ugrc.utah.gov"}
-    url = f"{WEB_API_URL}/{_cleanse_street(address)}/{_cleanse_zone(zone)}"
+    url = f"{BASE_URL}/geocode/{_cleanse_street(address)}/{_cleanse_zone(zone)}"

-    response = session.get(url, params=parameters, headers=headers, timeout=10)
+    response = session.get(url, params=parameters, headers=HEADERS, timeout=10)

     if response.status_code == 404 and "no address candidates found" in response.text.lower():
         return []
@@ -110,15 +114,15 @@ def make_request(address, zone, out_spatial_reference, max_locations):
         try:
             result = response.json()["result"]
         except Exception:
-            print(f"Error parsing result: {response.text}")
+            current_app.warning(f"Error parsing web api geocoding result: {response.text}")
             return []

         if "score" in result:
             if result["score"] == 100 or max_locations == 1 and result["score"] >= MIN_SCORE_FOR_BATCH:
-                return [etl_candidate(result)]
+                return [etl_candidate(result, out_spatial_reference)]

         if "candidates" in result:
-            return [etl_candidate(candidate) for candidate in result["candidates"]]
+            return [etl_candidate(candidate, out_spatial_reference) for candidate in result["candidates"]]

         return []

@@ -130,7 +134,7 @@ def make_request(address, zone, out_spatial_reference, max_locations):
 def get_candidate_from_parts(address, zone, out_spatial_reference):
     """gets a single candidate from address & zone input"""

-    candidates = make_request(address, zone, out_spatial_reference, 1)
+    candidates = make_geocode_request(address, zone, out_spatial_reference, 1)

     if len(candidates) > 0:
         return candidates[0]
@@ -149,7 +153,7 @@ def get_candidate_from_single_line(single_line_address, out_spatial_reference):
     return None

-def etl_candidate(ugrc_candidate):
+def etl_candidate(ugrc_candidate: dict, spatial_reference: int) -> dict:
     """translates an UGRC Web API candidate to an Esri locator candidate"""
     address = ugrc_candidate["address"] if "address" in ugrc_candidate else ugrc_candidate["matchAddress"]
     try:
@@ -157,6 +161,20 @@ def etl_candidate(ugrc_candidate):
     except KeyError:
         standardized_address = None

+    match_city = address.split(",")[-1].strip()
+    city_or_county = open_sgid.get_city(
+        ugrc_candidate["location"]["x"], ugrc_candidate["location"]["y"], spatial_reference
+    )
+    if not city_or_county:
+        city_or_county = open_sgid.get_county(
+            ugrc_candidate["location"]["x"], ugrc_candidate["location"]["y"], spatial_reference
+        )
+        if city_or_county:
+            city_or_county = f"{city_or_county} COUNTY"
+
+    if city_or_county and match_city.upper() != city_or_county.upper():
+        address = address.replace(match_city, city_or_county.upper())
+
     return {
         "address": address,
         "attributes": {
@@ -170,3 +188,70 @@ def etl_candidate(ugrc_candidate):
         "location": ugrc_candidate["location"],
         "score": ugrc_candidate["score"],
     }
+
+
+def reverse_geocode(x: float, y: float, spatial_reference: int, input_x: float, input_y: float) -> dict:
+    """reverse geocodes a point using web api supplemented by open sgid queries"""
+
+    city = open_sgid.get_city(x, y, spatial_reference)
+    city = city.upper() if city else None
+    county = open_sgid.get_county(x, y, spatial_reference)
+    if county:
+        county = f"{county} COUNTY"
+    zip_code = open_sgid.get_zip(x, y, spatial_reference)
+
+    #: example esri result: https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode?location=%7B%22spatialReference%22%3A%7B%22wkid%22%3A102100%7D%2C%22x%22%3A-12452539.51148021%2C%22y%22%3A4947846.054923615%7D&f=json
+    result = {
+        "Match_addr": "",
+        "LongLabel": "",
+        "ShortLabel": "",
+        "Addr_type": "",
+        "Type": "",  # unused by masquerade
+        "AddNum": "",
+        "Address": "",
+        "Block": "",  # unused by masquerade
+        "Sector": "",  # unused by masquerade
+        "Neighborhood": "",  # unused by masquerade
+        "District": "",  # unused by masquerade
+        "City": city or "",
+        "MetroArea": "",  # unused by masquerade
+        "Subregion": county,
+        "Region": "UTAH",
+        "RegionAbbr": "UT",
+        "Territory": "",  # unused by masquerade
+        "Postal": zip_code or "",
+        "PostalExt": "",  # unused by masquerade
+        "CntryName": "UNITED STATES",
+        "CountryCode": "USA",
+        "X": x,
+        "Y": y,
+        "InputX": input_x,
+        "InputY": input_y,
+    }
+
+    parameters = {
+        "apiKey": os.getenv("WEB_API_KEY"),
+        "spatialReference": spatial_reference,
+    }
+    url = f"{BASE_URL}/geocode/reverse/{x}/{y}"
+    response = session.get(url, params=parameters, headers=HEADERS, timeout=10)
+
+    if response.status_code == 200 and response.ok:
+        try:
+            api_result = response.json()["result"]
+        except Exception:
+            current_app.warning(f"Error parsing web api reverse geocode result: {response.text}")
+            return result
+
+        street = api_result["address"]["street"]
+        address_type = api_result["address"]["addressType"]
+        match_address = f"{street}, {city or county}, UTAH, {zip_code}"
+
+        result["Match_addr"] = match_address
+        result["LongLabel"] = f"{match_address}, USA"
+        result["ShortLabel"] = street
+        result["Addr_type"] = address_type
+        result["AddNum"] = street.split(" ")[0]
+        result["Address"] = street
+
+    return result
diff --git a/src/masquerade/utils.py b/src/masquerade/utils.py
index 10ee1bd..c1d587e 100644
--- a/src/masquerade/utils.py
+++ b/src/masquerade/utils.py
@@ -3,8 +3,11 @@
 """
 Utility functions
 """
+
 import json

+from markupsafe import escape
+
 WGS84 = 4326
 WEB_MERCATOR = 3857
 OLD_WEB_MERCATOR = 102100
@@ -48,3 +51,11 @@ def get_out_spatial_reference(incoming_request):
         request_wkid,
         WEB_MERCATOR if request_wkid == OLD_WEB_MERCATOR else request_wkid,
     )
+
+
+def escape_while_preserving_numbers(value: int | float | str) -> int | float | str:
+    """escape a value while preserving numbers"""
+    if isinstance(value, (int, float)):
+        return value
+
+    return escape(value)

diff --git a/tests/test_masquerade.py b/tests/test_masquerade.py
index fb2b05d..4ae875a 100644
--- a/tests/test_masquerade.py
+++ b/tests/test_masquerade.py
@@ -35,6 +35,13 @@ def test_base_geocode_route(test_client):
     assert b"locatorProperties" in response.data

+def test_geocode_bad_request(test_client):
+    response = test_client.get(f"{GEOCODE_SERVER_ROUTE}/geocodeAddresses", query_string={"addresses": "not a list"})
+
+    assert response.status_code == 400
+    assert b"not valid JSON" in response.data
+
+
 @mock.patch("masquerade.main.open_sgid.get_candidate_from_magic_key")
 def test_find_candidates(get_candidate_mock, test_client):
     get_candidate_mock.return_value = "blah"
@@ -185,3 +192,44 @@ def test_can_handle_output_sr_in_numeric_form(test_client):
     response_json = json.loads(response.data)

     assert response_json["spatialReference"]["latestWkid"] == 4326
+
+
+@mock.patch("masquerade.main.web_api.reverse_geocode")
+def test_reverse_geocode(reverse_geocode_mock, test_client):
+    reverse_geocode_mock.return_value = {
+        "address": "123 Main St",
+    }
+    response = test_client.get(
+        f"{GEOCODE_SERVER_ROUTE}/reverseGeocode",
+        query_string={
+            "location": dumps({"spatialReference": {"wkid": 102100}, "x": -12448301.645792466, "y": 4947055.905820554})
+        },
+    )
+
+    assert response.status_code == 200
+
+    response_json = json.loads(response.data)
+
+    assert response_json["address"]["address"] == "123 Main St"
+    assert response_json["location"]["spatialReference"]["wkid"] == 4326
+
+
+@mock.patch("masquerade.main.web_api.reverse_geocode")
+def test_reverse_geocode_output_spatial_reference(reverse_geocode_mock, test_client):
+    reverse_geocode_mock.return_value = {
+        "address": "123 Main St",
+    }
+    response = test_client.get(
+        f"{GEOCODE_SERVER_ROUTE}/reverseGeocode",
+        query_string={
+            "location": dumps({"spatialReference": {"wkid": 102100}, "x": -12448301.645792466, "y": 4947055.905820554}),
+            "outSR": 26912,
+        },
+    )
+
+    assert response.status_code == 200
+
+    response_json = json.loads(response.data)
+
+    assert response_json["address"]["address"] == "123 Main St"
+    assert response_json["location"]["spatialReference"]["wkid"] == 26912
diff --git a/tests/test_open_sgid.py b/tests/test_open_sgid.py
index 6e39ad1..e31b90c 100644
--- a/tests/test_open_sgid.py
+++ b/tests/test_open_sgid.py
@@ -12,6 +12,7 @@
     POINT,
     FullTextTable,
     NoTableFoundException,
+    get_boundary_value,
     get_candidate_from_magic_key,
     get_suggestions,
     get_table_from_table_name,
@@ -56,3 +57,18 @@ def test_full_text_table():
     table = FullTextTable("table_name", "search_field", POINT)

     assert "%hello%" in table.get_suggest_query("hello", 10)
+
+
+def test_get_boundary_value():
+    mock_db = mock.Mock()
+    mock_db.query.return_value = [("boundary_value",)]
+
+    with mock.patch("masquerade.providers.open_sgid.database", new=mock_db):
+        value = get_boundary_value(1, 2, 4326, "table_name", "field_name")
+        assert value == "boundary_value"
+
+    mock_db.query.return_value = []
+
+    with mock.patch("masquerade.providers.open_sgid.database", new=mock_db):
+        value = get_boundary_value(1, 2, 4326, "table_name", "field_name")
+        assert value is None
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f76c6f5..05debef 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,6 +1,8 @@
 from unittest.mock import MagicMock

-from masquerade.utils import cleanse_text, get_out_spatial_reference
+from markupsafe import Markup
+
+from masquerade.utils import cleanse_text, escape_while_preserving_numbers, get_out_spatial_reference

 def test_removes_spaces():
@@ -49,3 +51,19 @@ def test_get_out_spatial_reference_post_request():
     request.form = {"outSR": 3857}

     assert get_out_spatial_reference(request) == (3857, 3857)
+
+
+def test_escape_while_preserving_numbers_with_int():
+    assert escape_while_preserving_numbers(123) == 123
+
+
+def test_escape_while_preserving_numbers_with_float():
+    assert escape_while_preserving_numbers(123.45) == 123.45
+
+
+def test_escape_while_preserving_numbers_with_string():
+    assert escape_while_preserving_numbers("

Upgrade Python Version
              **thought**: It looks like 3.10 is the next python to EOL. Can this run on python 3.12 or 3.13 which are still getting support and not just security fixes?

https://devguide.python.org/versions/

_Originally posted by @steveoh in https://github.com/agrc/masquerade/pull/219#discussion_r1868240956_

Reverse Geocoding
Currently, this is not implemented. Short term, we should probably remove it from this list of capabilities: https://github.com/agrc/masquerade/blob/c9a9bffd73d11834b86c2cfe4856f589eab5c799/src/masquerade/main.py#L132

And then in the long term, we should probably implement it. @mhchou tried to use it today for something.
Translate address grids to actual cities as returned by the web api
Masquerade could be updated to fix incorrect cities returned by the web API service. It would take a secondary request(s) to OpenSGID to get the city/county.

Ref: https://github.com/agrc/api.mapserv.utah.gov/issues/245

South Jordan is attempting to use Masquerade within their CityWorks instance and this came up again. Just another data point for motivation to get it done.