diff --git a/README.md b/README.md index 4e4e619..847c7cb 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,14 @@ [![Code style: black][black-badge]][black-link] [![PyPI][pypi-badge]][pypi-link] +> [!NOTE] +> Currently, this is a *forked* version of `sphinx-external-toc` that implements: +> +> - Section numbering styles (e.g. numerical, roman (upper/lower), alphabetic (upper/lower)) per any level in the ToC by providing a new option `style` per subtree. +> - The option to restart the upper level section numbering for each subtree for the selected numbering style by providing a new option `restart_numbering` per subtree. + A sphinx extension that allows the documentation site-map (a.k.a Table of Contents) to be defined external to the documentation files. -As used by [Jupyter Book](https://jupyterbook.org)! +As used by default by [Jupyter Book](https://jupyterbook.org) (no need to manually add this extension to the extensions in `_config.yml` in a JupyterBook)! In normal Sphinx documentation, the documentation site-map is defined *via* a bottom-up approach - adding [`toctree` directives](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html#table-of-contents) within pages of the documentation. @@ -24,12 +30,25 @@ Add to your `conf.py`: ```python extensions = ["sphinx_external_toc"] +use_multitoc_numbering = True # optional, default: True external_toc_path = "_toc.yml" # optional, default: _toc.yml external_toc_exclude_missing = False # optional, default: False ``` Note the `external_toc_path` is always read as a Unix path, and can either be specified relative to the source directory (recommended) or as an absolute path. +### Jupyterbook configuration + +This extension is included in your jupyterbook configuration by default, so there's need to add it to the list of extensions. The other options can still be added: + +```yaml +use_multitoc_numbering: true # optional, default: true +external_toc_path: "_toc.yml" # optional, default: _toc.yml +external_toc_exclude_missing: False # optional, default: False +``` + +Note the `external_toc_path` is always read as a Unix path, and can either be specified relative to the source directory (recommended) or as an absolute path. + ### Basic Structure A minimal ToC defines the top level `root` key, for a single root document file: @@ -113,11 +132,27 @@ Each subtree can be configured with a number of options (see also [sphinx `toctr By default it is appended to the end of the document, but see also the `tableofcontents` directive for positioning of the ToC. - `maxdepth` (integer): A maximum nesting depth to use when showing the ToC within the document (default -1, meaning infinite). - `numbered` (boolean or integer): Automatically add numbers to all documents within a subtree (default `False`). - If set to `True`, all sub-trees will also be numbered based on nesting (e.g. with `1.1` or `1.1.1`), - or if set to an integer then the numbering will only be applied to that depth. + If set to `True`, all subtrees will also be numbered based on nesting (e.g. with `1.1` or `1.1.1`), + or if set to an integer then the numbering will only be applied until that depth. Warning: This can lead to unexpected results if not carefully managed, for example references created using `numref` may fail. Internally this options is always converted to an integer, with `True` -> `999` (effectively unlimited depth) and `False` -> `0` (no numbering). - `reversed` (boolean): If `True` then the entries in the subtree will be listed in reverse order (default `False`). This can be useful when using `glob` entries. - `titlesonly` (boolean): If `True` then only the first heading in the document will be shown in the ToC, not other headings of the same level (default `False`). +- `style` (string or list of strings): The section numbering style to use for this subtree (default `numerical`). + If a single string is given, this will be used for the top level of the subtree. + If a list of strings is given, then each entry will be used for the corresponding level of section numbering. + If styles are not given for all levels, then the remaining levels will be `numerical`. + If too many styles are given, the extra ones will be ignored. + The first time a style is used at the top level in a subtree, the numbering will start from 1, 'a', 'A', 'I' or 'i' depending on the style. + Subsequent times the same style is used at the top level in a subtree, the numbering will continue from the last number used for that style, unless `restart_numbering` is set to `True`. + Available styles: + - `numerical`: 1, 2, 3, ... + - `romanlower`: i, ii, iii, iv, v, ... + - `romanupper`: I, II, III, IV, V, ... + - `alphalower`: a, b, c, d, e, ..., aa, ab, ... + - `alphaupper`: A, B, C, D, E, ..., AA, AB, ... +- `restart_numbering` (boolean): If `True`, the numbering for the top level of this subtree will restart from 1 (or 'a', 'A', 'I' or 'i' depending on the style). If `False` the numbering for the top level of this subtree will continue from the last letter/number/symbol used in a previous subtree with the same style. The default value of this option is `not use_multitoc_numbering`. This means that: + - if `use_multitoc_numbering` is `True` (the default), the numbering for each part will continue from the last letter/number/symbol used in a previous part with the same style, unless `restart_numbering` is explicitly set to `True`. + - if `use_multitoc_numbering` is `False`, the numbering of each subtree will restart from 1 (or 'a', 'A', 'I' or 'i' depending on the style), unless `restart_numbering` is explicitly set to `False`. These options can be set at the level of the subtree: @@ -130,6 +165,8 @@ subtrees: numbered: True reversed: False titlesonly: True + style: [alphaupper, romanlower] + restart_numbering: True entries: - file: doc1 subtrees: @@ -149,6 +186,8 @@ options: numbered: True reversed: False titlesonly: True + style: [alphaupper, romanlower] + restart_numbering: True entries: - file: doc1 options: @@ -169,21 +208,14 @@ options: maxdepth: 1 numbered: True reversed: False + style: [alphaupper, romanlower] + restart_numbering: True entries: - file: doc1 entries: - file: doc2 ``` -:::{warning} -`numbered` should not generally be used as a default, since numbering cannot be changed by nested subtrees, and sphinx will log a warning. -::: - -:::{note} -By default, title numbering restarts for each subtree. -If you want want this numbering to be continuous, check-out the [sphinx-multitoc-numbering extension](https://github.com/executablebooks/sphinx-multitoc-numbering). -::: - ### Using different key-mappings For certain use-cases, it is helpful to map the `subtrees`/`entries` keys to mirror e.g. an output [LaTeX structure](https://www.overleaf.com/learn/latex/sections_and_chapters). @@ -424,13 +456,13 @@ meta: {} Questions / TODOs: -- Add additional top-level keys, e.g. `appendices` (see https://github.com/sphinx-doc/sphinx/issues/2502) and `bibliography` +- ~~Add additional top-level keys, e.g. `appendices` (see https://github.com/sphinx-doc/sphinx/issues/2502) and `bibliography`.~~ Can be replaced by setting the numbering style and (possibly) restarting the numbering. - Using `external_toc_exclude_missing` to exclude a certain file suffix: currently if you had files `doc.md` and `doc.rst`, and put `doc.md` in your ToC, it will add `doc.rst` to the excluded patterns but then, when looking for `doc.md`, will still select `doc.rst` (since it is first in `source_suffix`). Maybe open an issue on sphinx, that `doc2path` should respect exclude patterns. -- Integrate https://github.com/executablebooks/sphinx-multitoc-numbering into this extension? (or upstream PR) +- ~~Integrate https://github.com/executablebooks/sphinx-multitoc-numbering into this extension? (or upstream PR).~~ Included and enforced in this fork. - document suppressing warnings - test against orphan file - https://github.com/executablebooks/sphinx-book-theme/pull/304 diff --git a/docs/user_guide/sphinx.md b/docs/user_guide/sphinx.md index 0235d7f..1c91ece 100644 --- a/docs/user_guide/sphinx.md +++ b/docs/user_guide/sphinx.md @@ -157,15 +157,6 @@ entries: - file: doc2 ``` -:::{warning} -`numbered` should not generally be used as a default, since numbering cannot be changed by nested subtrees, and sphinx will log a warning. -::: - -:::{note} -By default, title numbering restarts for each subtree. -If you want want this numbering to be continuous, check-out the [sphinx-multitoc-numbering extension](https://github.com/executablebooks/sphinx-multitoc-numbering). -::: - ## Using different key-mappings For certain use-cases, it is helpful to map the `subtrees`/`entries` keys to mirror e.g. an output [LaTeX structure](https://www.overleaf.com/learn/latex/sections_and_chapters). diff --git a/pyproject.toml b/pyproject.toml index 6783f6f..b52f36a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "click>=7.1", "pyyaml", "sphinx>=5", + "sphinx-multitoc-numbering>=0.1.3" ] [project.urls] diff --git a/sphinx_external_toc/__init__.py b/sphinx_external_toc/__init__.py index 6bcffd4..1f9b460 100644 --- a/sphinx_external_toc/__init__.py +++ b/sphinx_external_toc/__init__.py @@ -1,15 +1,16 @@ """A sphinx extension that allows the project toctree to be defined in a single file.""" -__version__ = "1.0.1" - - from typing import TYPE_CHECKING if TYPE_CHECKING: from sphinx.application import Sphinx +__version__ = "1.1.0-dev" def setup(app: "Sphinx") -> dict: + + app.setup_extension("sphinx_multitoc_numbering") + """Initialize the Sphinx extension.""" from .events import ( InsertToctrees, @@ -18,6 +19,14 @@ def setup(app: "Sphinx") -> dict: ensure_index_file, parse_toc_to_env, ) + from .collectors import ( + disable_builtin_toctree_collector, + TocTreeCollectorWithStyles + ) + + # collectors + disable_builtin_toctree_collector(app) + app.add_env_collector(TocTreeCollectorWithStyles) # variables app.add_config_value("external_toc_path", "_toc.yml", "env") @@ -33,3 +42,4 @@ def setup(app: "Sphinx") -> dict: app.connect("build-finished", ensure_index_file) return {"version": __version__, "parallel_read_safe": True} + diff --git a/sphinx_external_toc/_compat.py b/sphinx_external_toc/_compat.py index c87fb83..f3ed887 100644 --- a/sphinx_external_toc/_compat.py +++ b/sphinx_external_toc/_compat.py @@ -147,3 +147,13 @@ def findall(node: Element): # findall replaces traverse in docutils v0.18 # note a difference is that findall is an iterator return getattr(node, "findall", node.traverse) + + +def validate_style(instance, attribute, value): + allowed = ["numerical", "romanupper", "romanlower", "alphaupper", "alphalower"] + if isinstance(value, list): + for v in value: + if v not in allowed: + raise ValueError(f"{attribute.name} must be one of {allowed}, not {v!r}") + elif value not in allowed: + raise ValueError(f"{attribute.name} must be one of {allowed}, not {value!r}") \ No newline at end of file diff --git a/sphinx_external_toc/api.py b/sphinx_external_toc/api.py index 3bd8155..cd71dc8 100644 --- a/sphinx_external_toc/api.py +++ b/sphinx_external_toc/api.py @@ -11,6 +11,7 @@ matches_re, optional, validate_fields, + validate_style, ) #: Pattern used to match URL items. @@ -61,6 +62,17 @@ class TocTree: ) reversed: bool = field(default=False, kw_only=True, validator=instance_of(bool)) titlesonly: bool = field(default=False, kw_only=True, validator=instance_of(bool)) + # Add extra field for style of toctree rendering + style: Union[List[str],str] = field( + default="numerical", + kw_only=True, + validator=validate_style + ) + # add extra field for restarting numbering for the set style + # Only allow True, False or None. None is the default value. + restart_numbering: Optional[bool] = field( + default=None, kw_only=True, validator=optional(instance_of(bool)) + ) def __post_init__(self): validate_fields(self) diff --git a/sphinx_external_toc/collectors.py b/sphinx_external_toc/collectors.py new file mode 100644 index 0000000..a56db94 --- /dev/null +++ b/sphinx_external_toc/collectors.py @@ -0,0 +1,188 @@ +import copy +from sphinx.environment.collectors.toctree import TocTreeCollector +import gc +from sphinx import addnodes as sphinxnodes +from docutils import nodes + +def disable_builtin_toctree_collector(app): + for obj in gc.get_objects(): + if not isinstance(obj, TocTreeCollector): + continue + # When running sphinx-autobuild, this function might be called multiple + # times. When the collector is already disabled `listener_ids` will be + # `None`, and thus we don't need to disable it again. + # + # Note that disabling an already disabled collector will fail. + if obj.listener_ids is None: + continue + obj.disable(app) + +class TocTreeCollectorWithStyles(TocTreeCollector): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.__numerical_count = 0 + self.__romanupper_count = 0 + self.__romanlower_count = 0 + self.__alphaupper_count = 0 + self.__alphalower_count = 0 + self.__map_old_to_new = {} + + def assign_section_numbers(self, env): + # First, call the original assign_section_numbers to get the default behavior + result = super().assign_section_numbers(env) # only needed to maintain functionality + + # Processing styles + for docname in env.numbered_toctrees: + doctree = env.get_doctree(docname) + for toctree in doctree.findall(sphinxnodes.toctree): + style = toctree.get("style", "numerical") + if not isinstance(style, list): + style = [style] + restart = toctree.get("restart_numbering", None) + continuous = env.app.config.use_multitoc_numbering + if restart is None: + restart = not continuous # set default behavior + if restart: + if style[0] == "numerical": + self.__numerical_count = 0 + elif style[0] == "romanupper": + self.__romanupper_count = 0 + elif style[0] == "romanlower": + self.__romanlower_count = 0 + elif style[0] == "alphaupper": + self.__alphaupper_count = 0 + elif style[0] == "alphalower": + self.__alphalower_count = 0 + # convert the section numbers to the new style + for _, ref in toctree["entries"]: + if "secnumber" in env.titles[ref]: + if style[0] == "numerical": + self.__numerical_count += 1 + if style[0] == "romanupper": + self.__romanupper_count += 1 + elif style[0] == "romanlower": + self.__romanlower_count += 1 + elif style[0] == "alphaupper": + self.__alphaupper_count += 1 + elif style[0] == "alphalower": + self.__alphalower_count += 1 + else: + pass + old_secnumber = copy.deepcopy(env.titles[ref]["secnumber"]) + new_secnumber = self.__renumber(env.titles[ref]["secnumber"],style) + env.titles[ref]["secnumber"] = copy.deepcopy(new_secnumber) + if ref in env.tocs: + self.__replace_toc(env, ref, env.tocs[ref],style) + + # STORE IN THE MAP + if isinstance(old_secnumber, list): + old_secnumber = old_secnumber[0] + if isinstance(new_secnumber, list): + new_secnumber = new_secnumber[0] + self.__map_old_to_new[old_secnumber] = new_secnumber + + # Now, replace the section numbers in env.toc_secnumbers + for docname in env.toc_secnumbers: + for anchorname, secnumber in env.toc_secnumbers[docname].items(): + if not secnumber: + continue + first_number = secnumber[0] + secnumber = (self.__map_old_to_new.get(first_number, first_number), *secnumber[1:]) + env.toc_secnumbers[docname][anchorname] = copy.deepcopy(secnumber) + + return result + + def __renumber(self, number_set,style_set): + if not number_set or not style_set: + return number_set + + if not isinstance(style_set, list): + style_set = [style_set] # if not multiple styles are given, convert to list + # for each style, convert the corresponding number, where only the first number + # is rebased, the rest are kept as is, but converted. + # convert the first number to the new style + if style_set[0] == "numerical": + number_set[0] = self.__numerical_count + if style_set[0] == "romanupper": + number_set[0] = self.__to_roman(self.__romanupper_count).upper() + elif style_set[0] == "romanlower": + number_set[0] = self.__to_roman(self.__romanlower_count).lower() + elif style_set[0] == "alphaupper": + number_set[0] = self.__to_alpha(self.__alphaupper_count).upper() + elif style_set[0] == "alphalower": + number_set[0] = self.__to_alpha(self.__alphalower_count).lower() + else: + pass + # convert the rest of the numbers to the corresponding styles + for i in range(1, min(len(number_set), len(style_set))): + if style_set[i] == "numerical" and isinstance(number_set[i], int): + continue # keep as is + if isinstance(number_set[i], str): + continue # skip non-numeric values, assuming those are already converted + if style_set[i] == "romanupper": + number_set[i] = self.__to_roman(int(number_set[i])).upper() + elif style_set[i] == "romanlower": + number_set[i] = self.__to_roman(int(number_set[i])).lower() + elif style_set[i] == "alphaupper": + number_set[i] = self.__to_alpha(int(number_set[i])).upper() + elif style_set[i] == "alphalower": + number_set[i] = self.__to_alpha(int(number_set[i])).lower() + else: + pass + + return number_set + + def __to_roman(self, n): + """Convert an integer to a Roman numeral.""" + val = [ + 1000, 900, 500, 400, + 100, 90, 50, 40, + 10, 9, 5, 4, + 1 + ] + syms = [ + "M", "CM", "D", "CD", + "C", "XC", "L", "XL", + "X", "IX", "V", "IV", + "I" + ] + roman_num = '' + i = 0 + while n > 0: + for _ in range(n // val[i]): + roman_num += syms[i] + n -= val[i] + i += 1 + return roman_num + + def __to_alpha(self, n): + """Convert an integer to an alphabetical representation (A, B, ..., Z, AA, AB, ...).""" + result = "" + while n > 0: + n -= 1 + result = chr(n % 26 + ord('A')) + result + n //= 26 + return result + + def __replace_toc(self, env, ref, node,style): + if isinstance(node, nodes.reference): + fixed_number = self.__renumber(node["secnumber"],style) + node["secnumber"] = fixed_number + env.toc_secnumbers[ref][node["anchorname"]] = fixed_number + + elif isinstance(node, sphinxnodes.toctree): + self.__fix_nested_toc(env, node, style) + + else: + for child in node.children: + self.__replace_toc(env, ref, child,style) + + def __fix_nested_toc(self, env, toctree, style): + for _, ref in toctree["entries"]: + if "secnumber" not in env.titles[ref]: + continue + new_secnumber = self.__renumber(env.titles[ref]["secnumber"],style) + env.titles[ref]["secnumber"] = copy.deepcopy(new_secnumber) + if ref in env.tocs: + self.__replace_toc(env, ref, env.tocs[ref],style) \ No newline at end of file diff --git a/sphinx_external_toc/events.py b/sphinx_external_toc/events.py index 8d468f4..aad996d 100644 --- a/sphinx_external_toc/events.py +++ b/sphinx_external_toc/events.py @@ -240,6 +240,8 @@ def insert_toctrees(app: Sphinx, doctree: nodes.document) -> None: else (999 if toctree.numbered is True else int(toctree.numbered)) ) subnode["titlesonly"] = toctree.titlesonly + subnode["style"] = toctree.style + subnode["restart_numbering"] = toctree.restart_numbering wrappernode = nodes.compound(classes=["toctree-wrapper"]) wrappernode.append(subnode) diff --git a/sphinx_external_toc/parsing.py b/sphinx_external_toc/parsing.py index 717b9fa..6af3791 100644 --- a/sphinx_external_toc/parsing.py +++ b/sphinx_external_toc/parsing.py @@ -23,6 +23,8 @@ "numbered", "reversed", "titlesonly", + "style", + "restart_numbering", )