Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/syntax/optional.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ Adding `"smartquotes"` to `myst_enable_extensions` (in the {{ confpy }}) will au
- `'single quotes'`: 'single quotes'
- `"double quotes"`: "double quotes"

Quote characters follow the configured document language (such as Sphinx's `language` option or Docutils' `--language`, `--smart-quotes`, and `--smartquote-locales` settings), falling back to English typographic quotes when unsupported.

Adding `"replacements"` to `myst_enable_extensions` (in the {{ confpy }}) will automatically convert some common typographic texts:

text | converted
Expand Down
13 changes: 11 additions & 2 deletions myst_parser/parsers/docutils_.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@
SortFootnotes,
UnreferencedFootnotesDetector,
)
from myst_parser.parsers.mdit import create_md_parser, linkify_available
from myst_parser.parsers.mdit import (
create_md_parser,
document_smartquotes,
linkify_available,
)
from myst_parser.warnings_ import MystWarnings, create_warning


Expand Down Expand Up @@ -329,7 +333,12 @@ def parse(self, inputstring: str, document: nodes.document) -> None:
config = merge_file_level(config, topmatter, warning)

# parse content
parser = create_md_parser(config, DocutilsRenderer)
quotes = (
document_smartquotes(document)
if "smartquotes" in config.enable_extensions
else None
)
parser = create_md_parser(config, DocutilsRenderer, quotes=quotes)
parser.options["document"] = document
parser.render(inputstring)

Expand Down
107 changes: 104 additions & 3 deletions myst_parser/parsers/mdit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

from __future__ import annotations

from collections.abc import Callable
from collections.abc import Callable, Iterable, Sequence
from typing import TYPE_CHECKING

from markdown_it import MarkdownIt
from markdown_it.renderer import RendererProtocol
Expand All @@ -26,6 +27,90 @@

from myst_parser.config.main import MdParserConfig

if TYPE_CHECKING:
from docutils import nodes

DEFAULT_SMARTQUOTES: tuple[str, str, str, str] = (
"\u201c",
"\u201d",
"\u2018",
"\u2019",
)


def _coerce_quotes(quotes: Sequence[str]) -> tuple[str, str, str, str] | None:
"""Validate and normalize a 4-item quote sequence into a 4-tuple of strings."""
if len(quotes) != 4:
return None
return (quotes[0], quotes[1], quotes[2], quotes[3])


def resolve_smartquotes(
language_code: str | None = None,
*,
smart_quotes: bool | str = False,
smartquotes_locales: Iterable[tuple[str, Sequence[str]]] | None = None,
) -> tuple[str, str, str, str]:
"""Resolve localized smart quote characters for ``markdown-it-py``.

Follows Docutils' ``SmartQuotes`` locale lookup (``smartchars.quotes``,
``smartquotes_locales``, ``smart_quotes="alt"``, and BCP 47 subtag fallback
via ``normalize_language_tag``) without mutating global Docutils state.
Falls back to ``DEFAULT_SMARTQUOTES`` when ``language_code`` is unset or
has no matching quote definition.

:param language_code: Document language tag (e.g. ``"fr"``, ``"de_DE"``).
:param smart_quotes: Docutils ``smart_quotes`` setting; strings starting
with ``"alt"`` select the ``-x-altquot`` variant.
:param smartquotes_locales: Optional ``(language_tag, quotes)`` overrides
from Docutils' ``smartquotes_locales`` setting.
:return: A 4-tuple ``(open_double, close_double, open_single, close_single)``.
"""
if not language_code or not language_code.strip():
return DEFAULT_SMARTQUOTES

from docutils.utils import normalize_language_tag
from docutils.utils.smartquotes import smartchars

quotes_map: dict[str, Sequence[str]] = dict(smartchars.quotes)
if smartquotes_locales:
for locale_tag, locale_quotes in smartquotes_locales:
coerced = _coerce_quotes(locale_quotes)
if coerced is None or not locale_tag:
continue
normalized = normalize_language_tag(locale_tag)
if normalized and normalized[0]:
quotes_map[normalized[0]] = coerced

lang = language_code.strip()
if isinstance(smart_quotes, str) and smart_quotes.startswith("alt"):
if "-x-altquot" in lang:
lang = lang.replace("-x-altquot", "")
else:
lang = f"{lang}-x-altquot"

for tag in normalize_language_tag(lang):
if tag in quotes_map:
coerced = _coerce_quotes(quotes_map[tag])
if coerced is not None:
return coerced

return DEFAULT_SMARTQUOTES


def document_smartquotes(document: nodes.document, /) -> tuple[str, str, str, str]:
"""Resolve localized smart quote characters from a Docutils document.

:param document: The Docutils document whose runtime settings are read.
:return: A 4-tuple ``(open_double, close_double, open_single, close_single)``.
"""
settings = document.settings
return resolve_smartquotes(
getattr(settings, "language_code", None),
smart_quotes=getattr(settings, "smart_quotes", False),
smartquotes_locales=getattr(settings, "smartquotes_locales", None),
)


def linkify_available() -> bool:
"""Return whether the optional ``linkify-it-py`` dependency is installed."""
Expand All @@ -36,9 +121,20 @@ def linkify_available() -> bool:


def create_md_parser(
config: MdParserConfig, renderer: Callable[[MarkdownIt], RendererProtocol]
config: MdParserConfig,
renderer: Callable[[MarkdownIt], RendererProtocol],
*,
quotes: Sequence[str] | None = None,
) -> MarkdownIt:
"""Return a Markdown parser with the required MyST configuration."""
"""Return a Markdown parser with the required MyST configuration.

:param config: The MyST parser configuration.
:param renderer: Renderer factory passed to ``MarkdownIt``.
:param quotes: Optional 4-item sequence ``(open_double, close_double,
open_single, close_single)`` overriding ``markdown-it-py``'s default
typographic quote characters when ``smartquotes`` is enabled.
:return: Configured ``MarkdownIt`` parser instance.
"""

if config.commonmark_only:
# see https://spec.commonmark.org/
Expand Down Expand Up @@ -73,6 +169,11 @@ def create_md_parser(
if "smartquotes" in config.enable_extensions:
md.enable("smartquotes")
typographer = True
if quotes is not None:
coerced_quotes = _coerce_quotes(quotes)
if coerced_quotes is None:
raise ValueError("`quotes` must contain exactly 4 quote strings.")
md.options["quotes"] = coerced_quotes
if "replacements" in config.enable_extensions:
md.enable("replacements")
typographer = True
Expand Down
9 changes: 7 additions & 2 deletions myst_parser/parsers/sphinx_.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
ResolveSectionRefs,
SortFootnotes,
)
from myst_parser.parsers.mdit import create_md_parser
from myst_parser.parsers.mdit import create_md_parser, document_smartquotes
from myst_parser.warnings_ import create_warning

SPHINX_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -84,6 +84,11 @@ def parse(self, inputstring: str, document: nodes.document) -> None:
)
config = merge_file_level(config, topmatter, warning)

parser = create_md_parser(config, SphinxRenderer)
quotes = (
document_smartquotes(document)
if "smartquotes" in config.enable_extensions
else None
)
parser = create_md_parser(config, SphinxRenderer, quotes=quotes)
parser.options["document"] = document
parser.render(inputstring)
152 changes: 151 additions & 1 deletion tests/test_docutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
cli_xml,
to_html5_demo,
)
from myst_parser.parsers.mdit import create_md_parser
from myst_parser.parsers.mdit import (
DEFAULT_SMARTQUOTES,
create_md_parser,
resolve_smartquotes,
)


def test_attr_to_optparse_option():
Expand Down Expand Up @@ -1218,3 +1222,149 @@ def test_math_inline_double_latex_no_blank_lines():
)
assert "\\begin{align*}" in output
assert_no_blank_line_in_math_env(output)


@pytest.mark.parametrize(
("language_code", "smart_quotes", "locales", "expected"),
[
pytest.param(None, False, None, DEFAULT_SMARTQUOTES, id="unset"),
pytest.param("", False, None, DEFAULT_SMARTQUOTES, id="empty"),
pytest.param(" ", False, None, DEFAULT_SMARTQUOTES, id="whitespace"),
pytest.param("en", False, None, DEFAULT_SMARTQUOTES, id="english"),
pytest.param(
"fr",
False,
None,
("\u00ab\u00a0", "\u00a0\u00bb", "\u201c", "\u201d"),
id="french",
),
pytest.param(
"fr",
"alt",
None,
("\u00ab\u202f", "\u202f\u00bb", "\u201c", "\u201d"),
id="french-alt",
),
pytest.param(
"fr-x-altquot",
"alt",
None,
("\u00ab\u00a0", "\u00a0\u00bb", "\u201c", "\u201d"),
id="french-alt-toggle",
),
pytest.param(
"fr_CH",
False,
None,
("\u00ab", "\u00bb", "\u2039", "\u203a"),
id="swiss-french",
),
pytest.param(
"de_AT",
False,
None,
("\u201e", "\u201c", "\u201a", "\u2018"),
id="austrian-german-fallback",
),
pytest.param(
"zh_TW",
False,
None,
("\u300c", "\u300d", "\u300e", "\u300f"),
id="traditional-chinese",
),
pytest.param(
"tlh",
False,
None,
DEFAULT_SMARTQUOTES,
id="unsupported-fallback",
),
pytest.param(
"fr_FR",
False,
[("fr_FR", ("<<", ">>", "<", ">")), ("", "abcd"), ("de", "bad")],
("<<", ">>", "<", ">"),
id="custom-locale-override",
),
],
)
def test_resolve_smartquotes(language_code, smart_quotes, locales, expected):
"""``resolve_smartquotes`` honors Docutils locale rules without mutating globals (#806)."""
from docutils.utils.smartquotes import smartchars

snapshot = dict(smartchars.quotes)
assert (
resolve_smartquotes(
language_code,
smart_quotes=smart_quotes,
smartquotes_locales=locales,
)
== expected
)
assert smartchars.quotes == snapshot


def test_create_md_parser_invalid_quotes():
"""``create_md_parser`` rejects quote overrides that do not specify 4 entries."""
with pytest.raises(ValueError, match="exactly 4 quote strings"):
create_md_parser(
MdParserConfig(enable_extensions={"smartquotes"}),
DocutilsRenderer,
quotes=("<<", ">>"),
)


@pytest.mark.parametrize(
("source", "settings", "expected"),
[
pytest.param(
'Test\'s "double" and \'single\' and "*emph*"',
{
"language_code": "fr",
"myst_enable_extensions": ["smartquotes"],
},
"Test\u2019s \u00ab\u00a0double\u00a0\u00bb and \u201csingle\u201d and \u00ab\u00a0emph\u00a0\u00bb",
id="fr-default",
),
pytest.param(
'"Test"',
{
"language_code": "fr",
"smart_quotes": "alt",
"myst_enable_extensions": ["smartquotes"],
},
"\u00ab\u202fTest\u202f\u00bb",
id="fr-alt",
),
pytest.param(
"---\nmyst:\n enable_extensions: [smartquotes]\n---\n\"Hallo\" und 'Welt'",
{
"language_code": "de",
},
"\u201eHallo\u201c und \u201aWelt\u2018",
id="de-topmatter",
),
pytest.param(
"\"Custom\" and 'quote'",
{
"language_code": "de",
"smartquotes_locales": [("de", ("[", "]", "{", "}"))],
"myst_enable_extensions": ["smartquotes"],
},
"[Custom] and {quote}",
id="custom-locales",
),
],
)
def test_docutils_smartquotes_localized(source, settings, expected):
"""Docutils ``Parser`` localizes MyST ``smartquotes`` using document settings (#806)."""
doctree = publish_doctree(
source=source,
parser=Parser(),
settings_overrides={
"warning_stream": io.StringIO(),
**settings,
},
)
assert doctree.astext() == expected
Loading