diff --git a/Doc/library/html.rst b/Doc/library/html.rst
index 65c49a4107a0487..0cfb5f8974a57b4 100644
--- a/Doc/library/html.rst
+++ b/Doc/library/html.rst
@@ -34,6 +34,25 @@ This module defines utilities to manipulate HTML.
.. versionadded:: 3.4
+
+.. function:: htmlcharrefreplace_errors(exception)
+
+ Implements the ``htmlcharrefreplace`` error handling (for encoding only):
+ the unencodable character is replaced by the corresponding HTML named
+ character reference from :data:`html.entities.codepoint2name`, or by a
+ numeric character reference if there is no name for it.
+
+ This error handler is not registered by default, you should register it
+ with :func:`codecs.register_error`::
+
+ >>> import codecs, html
+ >>> codecs.register_error('htmlcharrefreplace',
+ ... html.htmlcharrefreplace_errors)
+ >>> '∀ x∈ℜ'.encode('ascii', 'htmlcharrefreplace')
+ b'∀ x∈ℜ'
+
+ .. versionadded:: next
+
--------------
Submodules in the ``html`` package are:
diff --git a/Lib/html/__init__.py b/Lib/html/__init__.py
index 1543460ca33b0ae..64bbaa44adbcf22 100644
--- a/Lib/html/__init__.py
+++ b/Lib/html/__init__.py
@@ -3,10 +3,10 @@
"""
import re as _re
-from html.entities import html5 as _html5
+from html.entities import codepoint2name as _codepoint2name, html5 as _html5
-__all__ = ['escape', 'unescape']
+__all__ = ['escape', 'unescape', 'htmlcharrefreplace_errors']
def escape(s, quote=True):
@@ -130,3 +130,20 @@ def unescape(s):
if '&' not in s:
return s
return _charref.sub(_replace_charref, s)
+
+
+def htmlcharrefreplace_errors(exception):
+ """Implements the 'htmlcharrefreplace' error handling.
+
+ Replaces an unencodable character with the corresponding HTML named
+ character reference, or with a numeric character reference if there
+ is no name for it.
+ """
+ if not isinstance(exception, UnicodeEncodeError):
+ raise exception
+ replace = []
+ for c in exception.object[exception.start:exception.end]:
+ n = ord(c)
+ name = _codepoint2name.get(n)
+ replace.append(f'&{name};' if name is not None else f'{n};')
+ return ''.join(replace), exception.end
diff --git a/Lib/test/test_html.py b/Lib/test/test_html.py
index 839e0a47a8499cc..4ca02f6fd61617e 100644
--- a/Lib/test/test_html.py
+++ b/Lib/test/test_html.py
@@ -2,6 +2,7 @@
Tests for the html module functions.
"""
+import codecs
import html
import unittest
@@ -98,6 +99,27 @@ def check_num(num, expected):
'ÉricÉric&alphacentauriαcentauri')
check('&co;', '&co;')
+ def test_htmlcharrefreplace_errors(self):
+ codecs.register_error('htmlcharrefreplace',
+ html.htmlcharrefreplace_errors)
+ self.assertEqual('\u2200 x\u2208\u211c'.encode('ascii',
+ 'htmlcharrefreplace'),
+ b'∀ x∈ℜ')
+ # Characters without a name are replaced with a numeric reference.
+ self.assertEqual('[$\xa5\u20a3\u20ac\U0001d56b]'.encode(
+ 'latin1', 'htmlcharrefreplace'),
+ b'[$\xa5₣€𝕫]')
+ # Surrogates have no name either.
+ self.assertEqual('\udcff'.encode('ascii', 'htmlcharrefreplace'),
+ b'')
+
+ def test_htmlcharrefreplace_errors_bad_exception(self):
+ with self.assertRaises(UnicodeDecodeError):
+ html.htmlcharrefreplace_errors(
+ UnicodeDecodeError('ascii', b'\xff', 0, 1, 'ordinal'))
+ with self.assertRaises(TypeError):
+ html.htmlcharrefreplace_errors(TypeError('spam'))
+
if __name__ == '__main__':
unittest.main()
diff --git a/Misc/NEWS.d/next/Library/2026-08-05-13-05-00.gh-issue-63866.htmlCR.rst b/Misc/NEWS.d/next/Library/2026-08-05-13-05-00.gh-issue-63866.htmlCR.rst
new file mode 100644
index 000000000000000..6a3e2047f56fc10
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-05-13-05-00.gh-issue-63866.htmlCR.rst
@@ -0,0 +1,4 @@
+Add :func:`html.htmlcharrefreplace_errors` which implements the
+``htmlcharrefreplace`` error handler: an unencodable character is replaced
+with the corresponding HTML named or numeric character reference. It is not
+registered by default.