']
+ # Markdown H2 headings so each shows in the "On this page" TOC; links stay
+ # raw HTML to resolve relative to index.html (headings can't sit in a
).
+ body_lines: list[str] = []
for heading, link_text, docname in entries:
- if link_text is None:
- html_lines.append(
- f'
')
- else:
- html_lines.append(f'
{heading}
')
- html_lines.append(f'
{link_text}
')
- html_lines.append("
")
- body = "\n".join(html_lines)
+ body_lines.append(f"## {heading}\n")
+ body_lines.append(
+ f'
\n')
+ body = "\n".join(body_lines).rstrip()
+
+ # Landing-page intro prose lives in a hand-editable Markdown file next to
+ # conf.py (docs_sphinx/index_intro.md), not inline here.
+ _intro_md = pathlib.Path(__file__).resolve().parent.parent / "index_intro.md"
+ try:
+ intro = _intro_md.read_text(encoding="utf-8").strip()
+ except OSError:
+ intro = ""
text = (
- "OpenCV modules\n"
- "==============\n\n"
+ "OpenCV documentation\n"
+ "====================\n\n"
"```{toctree}\n"
":hidden:\n"
":maxdepth: 1\n"
":titlesonly:\n\n"
f"{toctree}\n"
"```\n\n"
+ f"{intro}\n\n"
f"{body}\n"
)
try:
diff --git a/docs_sphinx/conf_helpers/patches.py b/docs_sphinx/conf_helpers/patches.py
index 3bc4ae303a..cdb109baa2 100644
--- a/docs_sphinx/conf_helpers/patches.py
+++ b/docs_sphinx/conf_helpers/patches.py
@@ -7,6 +7,8 @@
"""Runtime patches for Sphinx C++ domain and breathe; applied at import."""
from __future__ import annotations
+import re
+
def _patch_cpp_xref_resolver():
"""Work around Sphinx 8.1.x parentSymbol assert in _resolve_xref_inner."""
try:
@@ -169,3 +171,123 @@ def _silence_orphan_toctree_warning():
_silence_orphan_toctree_warning()
+
+
+def _patch_sidebar_section_root():
+ """Root the left sidebar at a page's own top-level section.
+
+ A page in two toctrees (e.g. a `cuda*` extra module also grouped under main
+ `cuda`) gets a last-wins parent from `_get_toctree_ancestors`, rooting its
+ sidebar at the foreign section. Re-pick the parent sharing the longest path
+ prefix (same section) so the full sibling list shows."""
+ try:
+ import pydata_sphinx_theme.toctree as _pt
+ from sphinx.environment.adapters.toctree import TocTree
+ except ImportError:
+ return
+
+ def _section_aware_ancestor(app, pagename, startdepth):
+ ti = app.env.toctree_includes
+ cand: dict[str, list[str]] = {}
+ for _p, _children in ti.items():
+ for _c in _children:
+ cand.setdefault(_c, []).append(_p)
+
+ def _shared(parent: str, child: str) -> int:
+ a, b, i = parent.split("/"), child.split("/"), 0
+ while i < len(a) and i < len(b) and a[i] == b[i]:
+ i += 1
+ return i
+
+ ancestors: list[str] = []
+ d = pagename
+ while d not in ancestors:
+ ps = cand.get(d)
+ if not ps:
+ break
+ ancestors.append(d)
+ d = max(ps, key=lambda p: _shared(p, d))
+ try:
+ out = ancestors[-startdepth]
+ except IndexError:
+ out = None
+ # Childless root => empty sidebar. Fall back to the dead-end ancestor `d`
+ # when it's a same-section page with children, else the section api_root.
+ if out is None or not ti.get(out):
+ _sec = pagename.split("/", 1)[0]
+ _base = pagename.rsplit("/", 1)[-1]
+ # Doxygen file/dir-reference pages are orphan utilities, not module
+ # content: leave None to suppress the sidebar rather than root at api_root.
+ if re.search(r"_8\w+$", _base) or _base.startswith("dir_"):
+ out = None
+ elif d != pagename and ti.get(d) and d.split("/", 1)[0] == _sec:
+ out = d
+ elif ti.get(_sec + "/api_root"):
+ out = _sec + "/api_root"
+ return out, TocTree(app.env)
+
+ _pt._get_ancestor_pagename = _section_aware_ancestor
+
+
+_patch_sidebar_section_root()
+
+
+def _patch_sphinx_toctree_ancestors():
+ """Fix which branch the collapsed startdepth=0 sidebar auto-expands.
+
+ Sphinx picks it via `_get_toctree_ancestors`, whose last-wins parent map
+ mis-picks for a page in two toctrees (e.g. a `cuda*` extra module also under
+ main `cuda`), so its section won't expand. Prefer the parent sharing the
+ longest path prefix (same section)."""
+ try:
+ import sphinx.environment.adapters.toctree as _st
+ except ImportError:
+ return
+
+ def _section_aware(toctree_includes, docname):
+ cand: dict[str, list[str]] = {}
+ for _p, _children in toctree_includes.items():
+ for _c in _children:
+ cand.setdefault(_c, []).append(_p)
+
+ def _shared(parent: str, child: str) -> int:
+ a, b, i = parent.split("/"), child.split("/"), 0
+ while i < len(a) and i < len(b) and a[i] == b[i]:
+ i += 1
+ return i
+
+ ancestors: list[str] = []
+ d = docname
+ while d not in ancestors:
+ ps = cand.get(d)
+ if not ps:
+ break
+ ancestors.append(d)
+ d = max(ps, key=lambda p: _shared(p, d))
+ return dict.fromkeys(ancestors).keys()
+
+ _st._get_toctree_ancestors = _section_aware
+
+
+_patch_sphinx_toctree_ancestors()
+
+
+def register_global_sidebar(app):
+ """Make the sidebar list ALL top-level sections (startdepth=0), current one
+ auto-expanded, instead of only the active section's subtree.
+
+ Wrap the theme's `generate_toctree_html` (its sole sidebar nav generator) to
+ force startdepth=0, connecting after the theme's html-page-context handler so
+ it keeps its own template and we flip only this arg. This makes
+ `_patch_sidebar_section_root` a no-op (its lookup only runs when startdepth != 0)."""
+ def _globalize(app, pagename, templatename, context, doctree):
+ gen = context.get("generate_toctree_html")
+ if not callable(gen):
+ return
+ def wrapped(kind, startdepth=0, show_nav_level=0, **kwargs):
+ # collapse=True: expand only the current branch. Without it,
+ # startdepth=0 renders the whole tree on every page (slow + bloated).
+ kwargs["collapse"] = True
+ return gen(kind, startdepth=0, show_nav_level=0, **kwargs)
+ context["generate_toctree_html"] = wrapped
+ app.connect("html-page-context", _globalize, priority=900)
diff --git a/docs_sphinx/conf_helpers/postprocess.py b/docs_sphinx/conf_helpers/postprocess.py
index 53fbae4e0b..43e1d9e911 100644
--- a/docs_sphinx/conf_helpers/postprocess.py
+++ b/docs_sphinx/conf_helpers/postprocess.py
@@ -9,7 +9,8 @@ from __future__ import annotations
import pathlib, re
from .state import (_doxy_page_to_local, _DOXY_ANCHOR_TO_MEMBER, DOXYGEN_BASE_URL,
- _LOCAL_CLASS_URL, _LOCAL_TYPEDEF_URL, _FILE_URL, _API_XML_DIR, DOC_ROOT)
+ _LOCAL_CLASS_URL, _LOCAL_TYPEDEF_URL, _FILE_URL, _API_XML_DIR, DOC_ROOT,
+ _CV_SYMBOL_URL)
def _doxy_parent_page(page: str, api_dir: pathlib.Path) -> str:
@@ -171,12 +172,14 @@ def _copy_js_tryit_files(out_dir: pathlib.Path) -> None:
dst = dest / src.name
if not dst.exists():
shutil.copy2(src, dst)
- # opencv.js from CMake (OPENCV_JS_PATH); bundle it alongside the Try-it pages.
+ # opencv.js from CMake (OPENCV_JS_PATH). Needed both alongside the Try-it
+ # pages (relative `src="opencv.js"`) AND at the doc-site root, where
+ # external/js_usage docs link https://docs.opencv.org/
/opencv.js.
opencv_js = os.environ.get("OPENCV_JS_PATH", "")
if opencv_js and pathlib.Path(opencv_js).is_file():
- dst = dest / "opencv.js"
- if not dst.exists():
- shutil.copy2(opencv_js, dst)
+ for dst in (dest / "opencv.js", dest.parent / "opencv.js"):
+ if not dst.exists():
+ shutil.copy2(opencv_js, dst)
# Extra assets referenced by Try-it pages but not in js_assets/.
_opencv_root = DOC_ROOT.parent
for _name, _src in {
@@ -328,6 +331,28 @@ _PYG_CPF_SPAN_RE = re.compile(
)
+# C++ free-function linkifier. The `(` lookahead means only call sites match,
+# never a like-named local (`log`, `min`, …); it also makes the pass idempotent
+# and class-safe (a wrapped/constructor name is followed by ``, not `(`).
+_PYG_CPP_PRE_RE = re.compile(
+ r'(?P)'
+ r'(?P.*?)(?P
)', re.DOTALL)
+_PYG_CALL_SPAN_RE = re.compile(
+ r'(?P
)(?P[A-Za-z_][A-Za-z0-9_]*)(?P)'
+ r'(?=\()'
+)
+# Python free-function calls. Requires a `cv.`/`cv2.` prefix (OpenCV's Python
+# API is always module-qualified) so bare builtins / other modules never link.
+_PYG_PY_PRE_RE = re.compile(
+ r'(?P'
+ r'
)(?P.*?)(?P
)', re.DOTALL)
+_PYG_PY_CALL_RE = re.compile(
+ r'(?P
cv2?\.)'
+ r'(?P)(?P[A-Za-z_][A-Za-z0-9_]*)(?P)'
+ r'(?=\()'
+)
+
+
def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
"""Walk every `.html` under `html_dir` and turn known identifier
tokens inside Pygments-rendered `` blocks into clickable
@@ -335,15 +360,73 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
don't accidentally repaint inline `` chips in
prose; the rule above already targets only Pygments span classes
that Pygments uses inside its `` output."""
- if not (_LOCAL_CLASS_URL or _LOCAL_TYPEDEF_URL or _FILE_URL):
+ if not (_LOCAL_CLASS_URL or _LOCAL_TYPEDEF_URL or _FILE_URL or _CV_SYMBOL_URL):
return
if not html_dir.is_dir():
return
import os
+ # basename -> path relative to the html root. Stored URLs are relative to
+ # main_modules/, so without re-pointing they 404 when linked from a page at
+ # a different depth (e.g. a tutorial referencing `core_basic.html#…`).
+ _skip_dirs = {"_static", "_sources", "_images", "_sphinx_design_static"}
+ _page_paths: dict[str, str] = {}
+ for _f in html_dir.rglob("*.html"):
+ _rel = _f.relative_to(html_dir)
+ if _rel.parts and _rel.parts[0] in _skip_dirs:
+ continue
+ _page_paths.setdefault(_f.name, _rel.as_posix())
+
+ def _rel_local(url: str, current_html: pathlib.Path) -> "str | None":
+ # Relativize a bare local Sphinx page URL to the current page. Returns
+ # None when the target page wasn't built (e.g. a struct documented
+ # inline, with no standalone page) so the caller drops the link instead
+ # of emitting a 404. Non-local URLs (http/already-relative) pass through.
+ page, sep, frag = url.partition("#")
+ if page.startswith(("http://", "https://", "../", "/")):
+ return url
+ tgt = _page_paths.get(page)
+ if not tgt:
+ return None
+ rel = os.path.relpath(html_dir / tgt, start=current_html.parent)
+ return rel + (sep + frag if sep else "")
+
def _resolve(name: str) -> str | None:
return _LOCAL_CLASS_URL.get(name) or _LOCAL_TYPEDEF_URL.get(name)
+ # Free functions only (classes/typedefs already get a local link via
+ # `_resolve`). Emits the docs.opencv.org URL; `_localize_doxygen_links`
+ # (run right after) rewrites it to the local Sphinx page when one exists.
+ def _resolve_fn(name: str) -> str | None:
+ if name in _LOCAL_CLASS_URL or name in _LOCAL_TYPEDEF_URL:
+ return None
+ return _CV_SYMBOL_URL.get(name)
+
+ def _wrap_call(m: "re.Match") -> str:
+ url = _resolve_fn(m.group("name"))
+ if not url:
+ return m.group(0)
+ return (f''
+ f'{m.group("span")}{m.group("name")}{m.group("end")}')
+
+ def _rewrite_cpp_calls(m: "re.Match") -> str:
+ return (m.group("open")
+ + _PYG_CALL_SPAN_RE.sub(_wrap_call, m.group("body"))
+ + m.group("close"))
+
+ def _wrap_py_call(m: "re.Match") -> str:
+ url = _resolve_fn(m.group("name"))
+ if not url:
+ return m.group(0)
+ return (m.group("pre")
+ + f''
+ + f'{m.group("span")}{m.group("name")}{m.group("end")}')
+
+ def _rewrite_py_calls(m: "re.Match") -> str:
+ return (m.group("open")
+ + _PYG_PY_CALL_RE.sub(_wrap_py_call, m.group("body"))
+ + m.group("close"))
+
# `…
` blocks only — keeps the substitution from touching
# inline `` runs that may appear in other contexts.
_PRE_BLOCK_RE = re.compile(r"(.*?)
", re.DOTALL)
@@ -361,11 +444,14 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
except ValueError:
return f"../../../doc/doxygen/html/{file_url}"
- def _wrap_span(m: re.Match) -> str:
+ def _wrap_span(m: re.Match, current_html: pathlib.Path) -> str:
name = m.group("name")
url = _resolve(name)
if not url:
return m.group(0)
+ url = _rel_local(url, current_html)
+ if url is None: # target page not built — don't emit a 404 link
+ return m.group(0)
return (f''
f'{m.group("prefix")}{name}{m.group("suffix")}')
@@ -408,13 +494,13 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
k = inner.find(" None:
continue
new_text = _PRE_BLOCK_RE.sub(
lambda m: _rewrite_pre(m, html), text)
+ # Function-call passes run after the class/typedef pass so constructors
+ # are already wrapped and skipped by the `(` lookahead.
+ new_text = _PYG_CPP_PRE_RE.sub(_rewrite_cpp_calls, new_text)
+ new_text = _PYG_PY_PRE_RE.sub(_rewrite_py_calls, new_text)
if new_text != text:
try:
html.write_text(new_text, encoding="utf-8")
@@ -501,6 +591,240 @@ def _linkify_inline_code(html_dir: pathlib.Path) -> None:
pass
+# Center the active nav entry in the scrollable left sidebar on load so a
+# module far down the list isn't below the fold. The theme has no such hook.
+_AUTOSCROLL_SNIPPET = (
+ '"
+)
+
+
+def _inject_sidebar_autoscroll(out_dir: pathlib.Path) -> None:
+ """Inject the sidebar auto-scroll script before ; idempotent."""
+ for html in out_dir.rglob("*.html"):
+ text = html.read_text(encoding="utf-8")
+ if "opencv-sidebar-autoscroll" in text or "" not in text:
+ continue
+ html.write_text(
+ text.replace("