Merge pull request #29288 from abhishek-gola:doc_v3

Documentation fixes, Added How to use pre-built opencv doc #29288

closes: https://github.com/opencv/opencv/issues/29263

co-authored by: @kirtijindal14 @Akansha-977 
### Pull Request Readiness Checklist

See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request

- [x] I agree to contribute to the project under Apache 2 License.
- [x] To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV
- [x] The PR is proposed to the proper branch
- [ ] There is a reference to the original bug report and related work
- [ ] There is accuracy test, performance test and test data in opencv_extra repository, if applicable
      Patch to opencv_extra has the same branch name.
- [x] The feature is well documented and sample code can be built with the project CMake
This commit is contained in:
Abhishek Gola
2026-07-09 20:01:43 +05:30
committed by GitHub
parent 112959651a
commit aa1b8a2a21
31 changed files with 2174 additions and 137 deletions

View File

@@ -199,7 +199,7 @@ set(_SPHINX_WARNINGS "${CMAKE_CURRENT_BINARY_DIR}/sphinx-warnings.log")
set(_OPENCV_JS "${CMAKE_CURRENT_BINARY_DIR}/opencv.js")
if(NOT EXISTS "${_OPENCV_JS}")
message(STATUS "docs_sphinx: downloading opencv.js (one-time)")
file(DOWNLOAD "https://docs.opencv.org/5.x/opencv.js" "${_OPENCV_JS}"
file(DOWNLOAD "https://docs.opencv.org/5.x/js_tutorials/opencv.js" "${_OPENCV_JS}"
SHOW_PROGRESS STATUS _DL_STATUS)
list(GET _DL_STATUS 0 _DL_OK)
if(NOT _DL_OK EQUAL 0)

View File

@@ -67,6 +67,15 @@ html[data-theme="dark"] .bd-article-container li > a:hover {
}
.bd-content a > code, .bd-content code > a { color: inherit; }
/* Re-assert link colour for xref links inside code — the `color: inherit`
above would otherwise render them in the plain code colour. */
.bd-content code > a.reference,
.bd-content pre a.reference {
color: var(--pst-color-link, #0969da) !important;
}
.bd-content code > a.reference span,
.bd-content pre a.reference span { color: inherit !important; }
.bd-content a.opencv-enum-link,
.bd-content code.opencv-enum-sig > a,
.bd-content .opencv-enum-clickable a,
@@ -320,6 +329,45 @@ html[data-theme="dark"] table.opencv-meta-table td:last-child { border-right: no
.bd-header button.theme-switch-button:hover,
.bd-header .navbar-icon-links a.nav-link:hover { color: var(--opencv-accent); }
/* Override the theme's col-lg-3 (25%) width on __start, which would wrap the
version switcher below the logo; size to content and keep the row unwrapped. */
.bd-header .navbar-header-items__start { width: auto; }
.bd-header .navbar-header-items__start,
.bd-header .navbar-header-items__end,
.bd-header .navbar-header-items__center,
.bd-header .navbar-nav { flex-wrap: nowrap; }
/* Below 1200px: collapse only the nav links into the drawer; keep search,
theme toggle and GitHub icon in the header (search shrinks to its icon). */
@media (max-width: 1199.98px) {
.bd-header .navbar-header-items { display: flex !important; flex-grow: 1; }
.bd-header .navbar-header-items__center { display: none !important; }
.bd-header .navbar-header-items__end { margin-left: auto; }
.bd-header button.primary-toggle { display: flex !important; }
.bd-sidebar-primary .sidebar-header-items { display: flex !important; }
.bd-sidebar-primary .sidebar-header-items__end { display: none !important; }
.search-button-field > :not(svg) { display: none !important; }
/* Hide the desktop "Collapse Sidebar" toggle; also keeps it out of the
mobile drawer, which reuses this content. */
.bd-sidebar-primary .pst-sidebar-collapse { display: none !important; }
}
/* 960-1200px: header has folded into the hamburger, so extend the theme's own
<960px off-canvas drawer up to the header breakpoint instead of showing a
persistent column (which would mix header links + nav + collapse toggle). */
@media (min-width: 960px) and (max-width: 1199.98px) {
.bd-sidebar-primary {
position: fixed; top: 0; left: 0; z-index: 1055;
height: 100vh; max-height: 100vh; width: 75%; max-width: 350px;
margin-left: -75%; visibility: hidden; border: 0; flex-grow: 0.75;
}
}
/* On small screens drop the wordmark + subtitle, keep just the logo mark. */
@media (max-width: 768px) {
.bd-header .navbar-brand.logo .logo__textwrap { display: none; }
}
/* --- Version switcher dropdown (navbar) -------------------------------- *
* Sits in the `navbar_start` slot right after `navbar-logo`. The custom
* template `_templates/opencv-version-switcher.html` renders a plain
@@ -384,9 +432,14 @@ html[data-theme="dark"] .opencv-version-switcher #opencv-version-select option {
the content and the (often wide) diagrams get more room. Desktop only; the
mobile off-canvas drawer keeps the theme's own sizing. */
@media (min-width: 960px) {
.bd-sidebar-primary { width: 16rem; flex: 0 0 16rem; }
/* flex:0 0 auto (not a rigid 16rem basis) so the theme's collapse rule
(.pst-squeeze{width:4rem}) can still shrink it when collapsed. */
.bd-sidebar-primary { width: 16rem; flex: 0 0 auto; }
}
/* Collapsed: drop the divider so no leftover vertical border remains. */
.bd-sidebar-primary.pst-squeeze { border-right: none; }
.bd-sidebar-primary nav.bd-links { margin-right: 0 !important; }
.bd-sidebar-primary nav.bd-docs-nav p.bd-links__title {
font-size: 0.9rem !important;
@@ -422,6 +475,10 @@ html[data-theme="dark"] .opencv-version-switcher #opencv-version-select option {
}
html[data-theme="dark"] .bd-sidebar-primary nav.bd-links .current > a { color: #539bf5 !important; }
/* Hide the site footer (Sphinx/PyData credit); the prev/next nav
(.prev-next-footer) is a separate element and stays. */
.bd-footer { display: none !important; }
/* --- Code blocks ------------------------------------------------------- */
div.highlight {
position: relative;
@@ -662,6 +719,17 @@ html[data-theme="dark"] section[id$="-documentation"] > section:hover {
border-color: #58a6ff;
box-shadow: none;
}
/* Landing page id "opencv-documentation" matches the module card selectors
above; undo the boxing so the landing sections stay flat. */
#opencv-documentation > section,
html[data-theme="dark"] #opencv-documentation > section {
border: none;
background: none;
box-shadow: none;
margin: 0;
}
#opencv-documentation > section > h2 { margin-bottom: 0.25rem; }
#opencv-documentation > section > ul { margin-top: 0; }
section[id$="-documentation"] > section > h3 {
display: flex;
@@ -1781,3 +1849,21 @@ html[data-theme="dark"] section[id$="-documentation"] code.opencv-param-name {
border: 1px solid #444c56 !important;
color: #adbac7 !important;
}
/* Breathe (namespace pages) renders param names as plain <strong>; box them
to match the code.opencv-param-name chips used on other function pages. */
dl.field-list dd ul.simple > li > p > strong:first-child {
font-family: var(--pst-font-family-monospace, monospace);
font-weight: 400;
background-color: #eff1f3;
border: 1px solid #d0d7de;
border-radius: 4px;
padding: 0.1em 0.4em;
font-size: 0.85em;
color: #24292f;
white-space: nowrap;
}
html[data-theme="dark"] dl.field-list dd ul.simple > li > p > strong:first-child {
background-color: #2d333b;
border: 1px solid #444c56;
color: #adbac7;
}

View File

@@ -12,7 +12,12 @@
// SearchBox lives in search.js; guard so a load failure can't abort this
// script (which would leave the modal trigger unwired).
var searchBox;
try { searchBox = new SearchBox("searchBox", "{{ _search }}", '.html'); } catch (e) {}
try {
searchBox = new SearchBox("searchBox", "{{ _search }}", '.html');
// Short debounce: the top-N renderer below makes each search cheap enough
// for live-feeling results.
if (searchBox) searchBox.keyTimeoutLength = 120;
} catch (e) {}
// Open/close the centered modal. Defined early so the Ctrl+K handler can use
// them, and kept independent of the Doxygen backend so opening always works.
@@ -40,6 +45,22 @@
}, true);
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".bd-links__title").forEach(function (el) {
if (/section navigation/i.test(el.textContent)) el.textContent = "Navigation Bar";
});
// The theme's hamburger only opens the drawer dialog; add toggle-to-close
// and backdrop-click-to-close.
(function () {
var dlg = document.getElementById("pst-primary-sidebar-modal");
var tog = document.querySelector(".primary-toggle");
if (!dlg || !tog) return;
tog.addEventListener("click", function (e) {
if (dlg.open) { e.preventDefault(); e.stopImmediatePropagation(); dlg.close(); }
}, true); // capture: pre-empt the theme's open-only handler when already open
dlg.addEventListener("click", function (e) { if (e.target === dlg) dlg.close(); });
})();
// The theme renders navbar_end twice (desktop header + mobile drawer), so
// the search component appears twice → duplicate #opencvSearchTrigger /
// #opencvSearchOverlay IDs. Keep ONE overlay; wire EVERY trigger to it
@@ -81,6 +102,54 @@
init_search();
searchBox.OnSelectItem(0); // default to "All" on every page load
// Fast results: render only the top matches from the in-memory index,
// instead of Doxygen building/toggling all ~4000 rows every keystroke.
try { createResults = function () {}; } catch (e) {}
if (typeof searchResults !== "undefined" && searchResults) {
searchResults.Search = function (q) {
q = (q || "").replace(/^ +| +$/g, "").toLowerCase();
var box = document.getElementById("SRResults");
if (!box) return true;
box.innerHTML = "";
var data = (typeof searchData !== "undefined") ? searchData : [];
var rp = searchBox.resultsPath, shown = 0, MAX = 40;
function cls(el, c) { el.setAttribute("class", c); el.setAttribute("className", c); }
for (var i = 0; i < data.length && shown < MAX; i++) {
var el = data[i];
if ((el[1][0] || "").replace(/<[^>]*>/g, "").toLowerCase().indexOf(q) !== 0) continue;
var r = document.createElement("div");
r.id = "SR_" + el[0]; cls(r, "SRResult"); r.style.display = "block";
var e = document.createElement("div"); cls(e, "SREntry");
var a = document.createElement("a"); a.id = "Item" + shown; cls(a, "SRSymbol");
a.innerHTML = el[1][0]; e.appendChild(a);
if (el[1].length === 2) {
a.setAttribute("href", rp + el[1][1][0]);
a.setAttribute("onclick", "searchBox.CloseResultsWindow()");
a.setAttribute("target", el[1][1][1] ? "_parent" : "_blank");
var sp = document.createElement("span"); cls(sp, "SRScope");
sp.innerHTML = el[1][1][2] || ""; e.appendChild(sp);
} else {
a.setAttribute("href", 'javascript:searchResults.Toggle("SR_' + el[0] + '")');
var chn = document.createElement("div"); cls(chn, "SRChildren");
for (var c = 0; c < el[1].length - 1; c++) {
var ca = document.createElement("a"); cls(ca, "SRScope");
ca.setAttribute("href", rp + el[1][c + 1][0]);
ca.setAttribute("onclick", "searchBox.CloseResultsWindow()");
ca.setAttribute("target", el[1][c + 1][1] ? "_parent" : "_blank");
ca.innerHTML = el[1][c + 1][2] || ""; chn.appendChild(ca);
}
e.appendChild(chn);
}
r.appendChild(e); box.appendChild(r); shown++;
}
var nm = document.getElementById("NoMatches"); if (nm) nm.style.display = shown ? "none" : "block";
var ld = document.getElementById("Loading"); if (ld) ld.style.display = "none";
var sg = document.getElementById("Searching"); if (sg) sg.style.display = "none";
searchResults.lastMatchCount = shown;
return true;
};
}
var sphinxRoot = "{{ '../' * (pagename or '').count('/') }}";
// Doxygen result href -> Sphinx page, or "" if that page is not in our build.
function resolveSphinx(href) {
@@ -106,13 +175,16 @@
if (sphinxPath) {
e.preventDefault();
e.stopPropagation();
// Doxygen #autotoc_md anchors don't exist in Sphinx; rebuild from heading text.
var frag = "";
if (/#autotoc_md/.test(href)) {
var slug = (a.textContent || "").trim().toLowerCase()
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
if (slug) frag = "#" + slug;
}
// Doxygen anchors (#ga…, #autotoc_md) don't exist in Sphinx, so
// rebuild the fragment slug from the result text: tutorials use the
// full heading; symbols use the name with scope + "(args)" stripped
// -> e.g. "cv::Canny(...)" -> #canny.
var t = (a.textContent || "").trim();
var slug = /#autotoc_md/.test(href)
? t.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
: t.replace(/.*::/, "").replace(/\s*\(.*$/, "").toLowerCase()
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
var frag = slug ? "#" + slug : "";
window.location.href = sphinxRoot + sphinxPath + frag;
} else if (href.indexOf("doc/doxygen/html/") >= 0) {
e.preventDefault();

View File

@@ -0,0 +1,14 @@
{# Right-sidebar "Resources" box (landing page). Mirrors page-toc.html classes
so it matches "On this page"; `reference external` gets the theme's arrow. #}
<div class="page-toc tocsection onthispage">
<i class="fa-solid fa-link"></i> {{ _('Resources') }}
</div>
<nav class="page-toc" aria-label="{{ _('Resources') }}">
<ul class="visible nav section-nav flex-column">
<li class="toc-h2 nav-item toc-entry"><a class="reference external nav-link" href="https://github.com/opencv/opencv/wiki">OpenCV Wiki</a></li>
<li class="toc-h2 nav-item toc-entry"><a class="reference external nav-link" href="https://github.com/opencv/opencv/wiki/OpenCV-5">What's new in 5.0</a></li>
<li class="toc-h2 nav-item toc-entry"><a class="reference external nav-link" href="https://forum.opencv.org">Forum</a></li>
<li class="toc-h2 nav-item toc-entry"><a class="reference external nav-link" href="https://stackoverflow.com/questions/tagged/opencv">Stack Overflow</a></li>
<li class="toc-h2 nav-item toc-entry"><a class="reference external nav-link" href="https://github.com/opencv/opencv/issues">Issue tracker</a></li>
</ul>
</nav>

View File

@@ -169,9 +169,12 @@ html_theme_options = {
"show_toc_level": 2,
"navigation_with_keys": True,
"show_prev_next": True,
"show_nav_level": 2,
# 1, not 2: the theme force-opens every <details> up to this level and
# modules render at toctree-l1, so 2 would expand all of them; 1 opens only
# the current module's branch. Render depth is unaffected (navigation_depth).
"show_nav_level": 1,
"navigation_depth": 4,
"secondary_sidebar_items": {"**": ["page-toc"], "index": []},
"secondary_sidebar_items": {"**": ["page-toc"], "index": ["page-toc", "resources"]},
"back_to_top_button": True,
"show_version_warning_banner": False,
"icon_links": [{"name": "GitHub",
@@ -200,4 +203,5 @@ if not _in_source_tree(SPHINX_INPUT_ROOT):
def setup(app):
app.connect("source-read", _source_read)
app.connect("build-finished", _inline_coll_graphs_on_finish)
conf_helpers.patches.register_global_sidebar(app)
return {"parallel_read_safe": True, "parallel_write_safe": True}

View File

@@ -109,6 +109,98 @@ for _m in CONTRIB_MODULES:
_IMAGE_INDEX.setdefault(_img.name,
f"contrib_modules/{_rel}")
# Tutorials can reference images that live only under samples/ (+ apps/), which
# Doxygen's IMAGE_PATH covered but the tutorial-only index misses (renders
# broken). Index+stage ONLY sample images a tutorial actually references and
# that a tutorial `images/` dir doesn't already provide, to avoid copying the
# whole samples image set. Staged under sample_pics/ like api_pics above.
_referenced_images: set[str] = set()
for _md in (DOC_ROOT / "tutorials").rglob("*.markdown"):
try:
_txt = _md.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
for _m in re.finditer(r'!\[[^\]]*\]\((?:[^)\s]*?/)?images/([^)\s]+)\)', _txt):
_referenced_images.add(pathlib.Path(_m.group(1)).name)
_missing_images = {n for n in _referenced_images if n not in _IMAGE_INDEX}
if _missing_images:
_sample_pics = SPHINX_INPUT_ROOT / "sample_pics"
_stage_samples = SPHINX_INPUT_ROOT != DOC_ROOT
for _base in (OPENCV_ROOT / "samples", OPENCV_ROOT / "apps"):
if not _base.is_dir() or not _missing_images:
continue
for _img in _base.rglob("*"):
if (_img.name in _missing_images and _img.is_file()
and _img.suffix.lower() in _IMAGE_EXTS):
_IMAGE_INDEX[_img.name] = f"sample_pics/{_img.name}"
_missing_images.discard(_img.name) # first match wins; stop looking
if _stage_samples:
_sample_pics.mkdir(parents=True, exist_ok=True)
_link = _sample_pics / _img.name
if not _link.exists():
try:
_os.symlink(_img, _link)
except (OSError, NotImplementedError):
try:
_shutil.copy2(_img, _link)
except OSError:
pass
if not _missing_images:
break
# Hand-authored dnn topic; content lives in the dnn module's own doc dir.
_DNN_ENGINE_SELECTION_MD = (
OPENCV_ROOT / "modules" / "dnn" / "doc" / "dnn_engine.markdown").read_text(
encoding="utf-8")
def _add_dnn_engine_selection_topic(out_dir: pathlib.Path) -> None:
"""Add the 'DNN Engine Selection' page as a dnn-module topic.
Must run after stub generation (its stale-file sweep) and before the anchor
scan, so the new {#anchor} + @subpage get picked up."""
dnn = out_dir / "dnn.md"
if not dnn.is_file():
return
(out_dir / "dnn_engine_selection.md").write_text(
_DNN_ENGINE_SELECTION_MD, encoding="utf-8")
text = dnn.read_text(encoding="utf-8")
if "api_dnn_engine_selection" in text:
return
new = re.sub(
r"(## Topics\n\n(?:- @subpage [^\n]*\n)+)",
lambda m: m.group(1) + "- @subpage api_dnn_engine_selection\n",
text, count=1)
if new != text:
dnn.write_text(new, encoding="utf-8")
# Hand-authored standalone HAL page; content lives in core's own doc dir.
_HAL_MD = (
OPENCV_ROOT / "modules" / "core" / "doc" / "hal.markdown").read_text(
encoding="utf-8")
def _add_hal_page(out_dir: pathlib.Path) -> None:
"""Write the HAL page and add a 'Learn about HAL' link on the api_root page.
Call after stub generation and before the anchor scan."""
api_root = out_dir / "api_root.markdown"
if not api_root.is_file():
return
(out_dir / "hal.md").write_text(_HAL_MD, encoding="utf-8")
text = api_root.read_text(encoding="utf-8")
if "](hal.md)" in text:
return
text = re.sub(r"(```\{toctree\}\n.*?\n)(```\n)", r"\1hal\n\2",
text, count=1, flags=re.S)
text = text.rstrip() + (
"\n\n## Learn about HAL\n\n"
"OpenCV ships a Hardware Acceleration Layer that lets hardware vendors "
"inject tuned, silicon-specific implementations behind a stable C "
"interface. See [OpenCV Hardware Acceleration Layer (HAL)](hal.md).\n")
api_root.write_text(text, encoding="utf-8")
if API_MODULES:
_api_pics = SPHINX_INPUT_ROOT / "api_pics"
_stage_pics = SPHINX_INPUT_ROOT != DOC_ROOT
@@ -146,6 +238,8 @@ if API_MODULES:
_generate_api_stubs(_main_api, _API_XML_DIR, SPHINX_INPUT_ROOT / "main_modules",
root_anchor="api_root", root_title="Main modules",
extra_groups=_main_orphans)
_add_dnn_engine_selection_topic(SPHINX_INPUT_ROOT / "main_modules")
_add_hal_page(SPHINX_INPUT_ROOT / "main_modules")
_scan_internal(SPHINX_INPUT_ROOT / "main_modules")
if _extra_api or _extra_orphans:
_generate_api_stubs(_extra_api, _API_XML_DIR, SPHINX_INPUT_ROOT / "extra_modules",
@@ -176,6 +270,11 @@ def _write_root_index() -> None:
entries.append((heading, link_text, docname))
add("Introduction", "Introduction", "intro", "intro" in _ANCHOR_TO_DOC)
# Its own landing section rather than a bullet in the intro's Usage list.
_prebuilt = _ANCHOR_TO_DOC.get("tutorial_using_prebuilt_binaries")
add("How to use pre-built OpenCV binaries",
"Using OpenCV pre-built binaries in your own projects",
_prebuilt or "", bool(_prebuilt))
add("OpenCV Tutorials", "OpenCV tutorials", "tutorials/tutorials")
add("Python Tutorials", "OpenCV-Python tutorials",
"py_tutorials/py_tutorials", bool(PY_DOC_MODULES))
@@ -194,27 +293,33 @@ def _write_root_index() -> None:
toctree = "\n".join(
f"{heading} <{docname}>" for heading, _link, docname in entries)
# Body: raw HTML so links resolve correctly relative to index.html.
html_lines = ['<div class="ocv-landing">']
# 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 <div>).
body_lines: list[str] = []
for heading, link_text, docname in entries:
if link_text is None:
html_lines.append(
f'<h2><a href="{docname}.html">{heading}</a></h2>')
else:
html_lines.append(f'<h2>{heading}</h2>')
html_lines.append(f'<p><a href="{docname}.html">{link_text}</a></p>')
html_lines.append("</div>")
body = "\n".join(html_lines)
body_lines.append(f"## {heading}\n")
body_lines.append(
f'<ul><li><a href="{docname}.html">{link_text or heading}</a></li></ul>\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:

View File

@@ -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)

View File

@@ -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/<ver>/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 `</a>`, not `(`).
_PYG_CPP_PRE_RE = re.compile(
r'(?P<open><div class="highlight-cpp[^"]*"><div class="highlight"><pre>)'
r'(?P<body>.*?)(?P<close></pre>)', re.DOTALL)
_PYG_CALL_SPAN_RE = re.compile(
r'(?P<span><span class="n">)(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?P<end></span>)'
r'(?=<span class="p">\()'
)
# 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<open><div class="highlight-(?:python|py|pycon|ipython3?|default)[^"]*">'
r'<div class="highlight"><pre>)(?P<body>.*?)(?P<close></pre>)', re.DOTALL)
_PYG_PY_CALL_RE = re.compile(
r'(?P<pre><span class="n">cv2?</span><span class="o">\.</span>)'
r'(?P<span><span class="n">)(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?P<end></span>)'
r'(?=<span class="p">\()'
)
def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
"""Walk every `.html` under `html_dir` and turn known identifier
tokens inside Pygments-rendered `<pre>` blocks into clickable
@@ -335,15 +360,73 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
don't accidentally repaint inline `<code class="n">` chips in
prose; the rule above already targets only Pygments span classes
that Pygments uses inside its `<pre>` 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'<a class="reference external" href="{url}">'
f'{m.group("span")}{m.group("name")}{m.group("end")}</a>')
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'<a class="reference external" href="{url}">'
+ f'{m.group("span")}{m.group("name")}{m.group("end")}</a>')
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"))
# `<pre>…</pre>` blocks only — keeps the substitution from touching
# inline `<span class="n">` runs that may appear in other contexts.
_PRE_BLOCK_RE = re.compile(r"<pre>(.*?)</pre>", 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'<a class="reference internal" href="{url}">'
f'{m.group("prefix")}{name}{m.group("suffix")}</a>')
@@ -408,13 +494,13 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
k = inner.find("<a ", i)
if k < 0:
seg = inner[i:]
seg = _PYG_IDENT_SPAN_RE.sub(_wrap_span, seg)
seg = _PYG_IDENT_SPAN_RE.sub(lambda mm: _wrap_span(mm, current_html), seg)
seg = _PYG_CPF_SPAN_RE.sub(
lambda mm: _wrap_cpf(mm, current_html), seg)
out.append(seg)
break
seg = inner[i:k]
seg = _PYG_IDENT_SPAN_RE.sub(_wrap_span, seg)
seg = _PYG_IDENT_SPAN_RE.sub(lambda mm: _wrap_span(mm, current_html), seg)
seg = _PYG_CPF_SPAN_RE.sub(
lambda mm: _wrap_cpf(mm, current_html), seg)
out.append(seg)
@@ -430,6 +516,10 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> 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 = (
'<script id="opencv-sidebar-autoscroll">'
"document.addEventListener('DOMContentLoaded',function(){"
"var s=document.querySelector('.bd-sidebar-primary');if(!s)return;"
"var a=s.querySelectorAll('a.current'),t=a[a.length-1];if(!t)return;" # deepest = current page
"var sr=s.getBoundingClientRect(),tr=t.getBoundingClientRect();"
"s.scrollTop+=(tr.top-sr.top)-(s.clientHeight-tr.height)/2;});"
"</script>"
)
def _inject_sidebar_autoscroll(out_dir: pathlib.Path) -> None:
"""Inject the sidebar auto-scroll script before </body>; idempotent."""
for html in out_dir.rglob("*.html"):
text = html.read_text(encoding="utf-8")
if "opencv-sidebar-autoscroll" in text or "</body>" not in text:
continue
html.write_text(
text.replace("</body>", _AUTOSCROLL_SNIPPET + "</body>", 1),
encoding="utf-8")
_TOC_NAV_RE = re.compile(r'id="pst-page-toc-nav".*?</nav>', re.S)
_TOC_HREF_RE = re.compile(r'href="#([^"]+)"')
_ANY_ID_RE = re.compile(r'id="([^"]+)"')
_DETAIL_SEC_RE = re.compile(r'(<section id="detailed-description"[^>]*>)')
def _repair_dangling_toc_anchors(out_dir: pathlib.Path) -> None:
"""Stub a missing #anchor for any secondary-TOC link with no target element.
A dangling anchor makes the theme scroll-spy throw, which aborts init and
kills the collapse-sidebar button on that page. Idempotent."""
for html in out_dir.rglob("*.html"):
text = html.read_text(encoding="utf-8")
nav = _TOC_NAV_RE.search(text)
if not nav:
continue
ids = set(_ANY_ID_RE.findall(text))
missing = [a for a in _TOC_HREF_RE.findall(nav.group(0)) if a not in ids]
if not missing:
continue
stubs = "".join(f'<span id="{a}"></span>' for a in missing)
new = _DETAIL_SEC_RE.sub(lambda m: m.group(1) + stubs, text, count=1)
if new != text:
html.write_text(new, encoding="utf-8")
def _redirect_orphan_duplicates(app, out_dir: pathlib.Path) -> None:
"""An orphaned main_modules/* page that duplicates an in-nav extra_modules
page redirects to that twin, which has proper section nav. Idempotent."""
ti = app.env.toctree_includes
seen, stack = set(), [app.env.config.root_doc]
while stack:
d = stack.pop()
if d in seen:
continue
seen.add(d)
stack.extend(ti.get(d, []))
for doc in set(app.env.all_docs):
if not doc.startswith("main_modules/") or doc in seen:
continue
base = doc.split("/", 1)[1]
if "extra_modules/" + base not in seen:
continue
html = out_dir / (doc + ".html")
if not html.is_file():
continue
text = html.read_text(encoding="utf-8")
if "opencv-dup-redirect" in text:
continue
target = f"../extra_modules/{base}.html"
snippet = (
f'<link rel="canonical" href="{target}">'
f'<script id="opencv-dup-redirect">location.replace("{target}"+location.hash)</script>'
f'<noscript><meta http-equiv="refresh" content="0;url={target}"></noscript>'
)
new = text.replace("<head>", "<head>" + snippet, 1)
if new != text:
html.write_text(new, encoding="utf-8")
# DNN engine-selection topic: symbols → their API anchor (same dir as the page).
_DNN_ENGINE_LINKS = {
"readNet": "dnn.html#readnet",
"readNetFromONNX": "dnn.html#readnetfromonnx",
"ENGINE_NEW": "dnn.html#enginetype",
"ENGINE_CLASSIC": "dnn.html#enginetype",
"ENGINE_AUTO": "dnn.html#enginetype",
"ENGINE_ORT": "dnn.html#enginetype",
"EngineType": "dnn.html#enginetype",
"DNN_BACKEND_CUDA": "dnn.html#backend",
"DNN_BACKEND_OPENVINO": "dnn.html#backend",
"DNN_TARGET_CUDA": "dnn.html#target",
"setPreferableBackend": "classcv_1_1dnn_1_1Net.html#setpreferablebackend",
"setPreferableTarget": "classcv_1_1dnn_1_1Net.html#setpreferabletarget",
}
# Whole inline-code spans (qualified forms) → anchor; bare names reuse the above.
_DNN_ENGINE_INLINE = {
"cv::dnn::readNet()": "dnn.html#readnet",
"net.forward()": "classcv_1_1dnn_1_1Net.html#forward",
"readNet*()": "dnn.html#readnet",
**_DNN_ENGINE_LINKS,
}
_INLINE_CODE_SPAN_RE = re.compile(
r'<code class="docutils literal notranslate">'
r'(?:<span class="pre">)?([^<]+)(?:</span>)?</code>')
_PYG_TOKEN_SPAN_RE = re.compile(r'<span class="(n|nc|nf|nb|nv|na)">(\w+)</span>')
def _linkify_dnn_engine_selection(out_dir: pathlib.Path) -> None:
"""Make DNN engine/backend symbols clickable on the engine-selection topic,
in both inline code and highlighted code blocks. Idempotent."""
page = out_dir / "main_modules" / "dnn_engine_selection.html"
if not page.is_file():
return
text = page.read_text(encoding="utf-8")
if 'href="dnn.html#enginetype"' in text: # already linkified
return
def _inline(m: "re.Match") -> str:
href = _DNN_ENGINE_INLINE.get(m.group(1).strip())
return (f'<code class="docutils literal notranslate">'
f'<a class="reference internal" href="{href}">'
f'<span class="pre">{m.group(1)}</span></a></code>'
) if href else m.group(0)
def _token(m: "re.Match") -> str:
href = _DNN_ENGINE_LINKS.get(m.group(2))
return (f'<a class="reference internal" href="{href}">'
f'<span class="{m.group(1)}">{m.group(2)}</span></a>'
) if href else m.group(0)
text = _PYG_TOKEN_SPAN_RE.sub(_token, _INLINE_CODE_SPAN_RE.sub(_inline, text))
page.write_text(text, encoding="utf-8")
# HAL page: cv::* API symbols -> their module-page anchors (same dir as the page).
_HAL_INLINE = {
"cv::resize": "imgproc_transform.html#resize",
"cv::cvtColor": "imgproc_color_conversions.html#cvtcolor",
"cv::gemm": "core_array.html#gemm",
}
def _linkify_hal_page(out_dir: pathlib.Path) -> None:
"""Make the cv::* API symbols on the HAL page clickable. Idempotent.
HAL-only tokens (cv_hal_*, NOT_IMPLEMENTED, WITH_*, …) have no API page and
are intentionally left plain."""
page = out_dir / "main_modules" / "hal.html"
if not page.is_file():
return
text = page.read_text(encoding="utf-8")
if 'href="imgproc_transform.html#resize"' in text: # already linkified
return
def _inline(m: "re.Match") -> str:
href = _HAL_INLINE.get(m.group(1).strip())
return (f'<code class="docutils literal notranslate">'
f'<a class="reference internal" href="{href}">'
f'<span class="pre">{m.group(1)}</span></a></code>'
) if href else m.group(0)
page.write_text(_INLINE_CODE_SPAN_RE.sub(_inline, text), encoding="utf-8")
# Universal Intrinsics tutorial: link the intrinsics/types in prose code, which
# Sphinx doesn't auto-link. Anchors are read off core_hal_intrin + the symbol
# maps so coverage tracks the docs (undocumented symbols stay plain).
_UI_MM = "../../../main_modules/" # page is 3 dirs deep
def _univ_intrin_link_map(out_dir: pathlib.Path) -> dict:
m = {"cv::hfloat": _UI_MM + "classcv_1_1hfloat.html",
"cv::bfloat": _UI_MM + "classcv_1_1bfloat.html",
"CV_16F": _UI_MM + "core_hal_interface.html#cv-16f",
"CV_16BF": _UI_MM + "core_hal_interface.html#cv-16bf"}
# Intrinsic functions, from the anchors on the rendered group page.
hp = out_dir / "main_modules" / "core_hal_intrin.html"
if hp.is_file():
html = hp.read_text(encoding="utf-8")
for a in set(re.findall(r'id="(cv-v-[a-z0-9-]+)"', html)):
m.setdefault("v_" + a[len("cv-v-"):].replace("-", "_"),
_UI_MM + "core_hal_intrin.html#" + a)
# Register typedefs (v_float32, …) and the FP16/BF16 classes, from the maps.
for sym, url in {**_LOCAL_TYPEDEF_URL, **_LOCAL_CLASS_URL}.items():
if sym.startswith("v_") or sym in ("hfloat", "bfloat"):
m.setdefault(sym, _UI_MM + url)
# Symbols with no local page but in the Doxygen tag (v_exp, v_log, …) link
# to the official docs; those absent here too have no target and stay plain.
for sym, url in _CV_SYMBOL_URL.items():
if sym.startswith(("v_", "vx_")) and sym not in m:
m[sym] = url
return m
def _linkify_univ_intrin(out_dir: pathlib.Path) -> None:
"""Link every documented intrinsic/type on the univ_intrin tutorial (inline
code and highlighted blocks). Idempotent; undocumented symbols stay plain."""
page = out_dir / "tutorials" / "core" / "univ_intrin" / "univ_intrin.html"
if not page.is_file():
return
text = page.read_text(encoding="utf-8")
if _UI_MM + "classcv_1_1hfloat.html" in text: # already linkified
return
links = _univ_intrin_link_map(out_dir)
def _inline(m: "re.Match") -> str:
txt = m.group(1).strip()
href = links.get(txt)
if not href: # e.g. "v_exp(x)" -> v_exp
lead = re.match(r"[A-Za-z_:][\w:]*", txt)
href = links.get(lead.group(0)) if lead else None
return (f'<code class="docutils literal notranslate">'
f'<a class="reference internal" href="{href}">'
f'<span class="pre">{m.group(1)}</span></a></code>'
) if href else m.group(0)
def _token(m: "re.Match") -> str:
href = links.get(m.group(2))
return (f'<a class="reference internal" href="{href}">'
f'<span class="{m.group(1)}">{m.group(2)}</span></a>'
) if href else m.group(0)
text = _INLINE_CODE_SPAN_RE.sub(_inline, text)
# Token-link only outside existing <a>…</a> spans (typedefs/classes are
# already linked by _linkify_code_blocks) so we never nest anchors.
parts = re.split(r"(<a\b[^>]*>.*?</a>)", text, flags=re.DOTALL)
text = "".join(p if i % 2 else _PYG_TOKEN_SPAN_RE.sub(_token, p)
for i, p in enumerate(parts))
page.write_text(text, encoding="utf-8")
def _inline_coll_graphs_on_finish(app, exception):
"""build-finished entry point."""
if exception is not None:
@@ -516,3 +840,9 @@ def _inline_coll_graphs_on_finish(app, exception):
_copy_js_tryit_files(out)
_fix_gapi_images(out)
_generate_search_map(out)
_repair_dangling_toc_anchors(out)
_redirect_orphan_duplicates(app, out)
_linkify_dnn_engine_selection(out)
_linkify_hal_page(out)
_linkify_univ_intrin(out)
_inject_sidebar_autoscroll(out)

View File

@@ -137,6 +137,64 @@ def _module_group_stem(m: str) -> str:
return _mm.group(1) if _mm else m
return m
# -- Header-free API-group doc overrides ------------------------------------
# When a module's headers use `@addtogroup` only (never `@defgroup`), Doxygen
# emits its subgroups un-nested and auto-titled from their id, leaving the module
# landing page empty. These overrides supply the module-group description and
# re-parent + retitle the subgroups at stub time, without editing the headers
# (cf. `_XPHOTO_DOCS` in stubs.py). Keyed by module group stem (== group id ==
# module folder, by convention).
_GROUP_DOC_OVERRIDES: dict = {
"geometry": {
"detailed": (
"Coordinate geometry grouped into one module: 2D shape analysis and "
"fitting, planar subdivision, multi-view 3D vision (camera pose, "
"triangulation, homography) and point-cloud sampling and "
"segmentation. In OpenCV 5 these were consolidated here from the "
"[imgproc](imgproc.md) and [calib](calib.md) modules.\n\n"
"**Migration (OpenCV 5):** these symbols are no longer re-exported "
"by [imgproc](imgproc.md); code that calls e.g. "
"[convexHull](geometry_shape.md) must include "
"[opencv2/geometry.hpp](geometry_8hpp.md) directly."
),
# Direct children to nest under the module page (each pulls in its own
# subgroups recursively). NB: "d_projection" is Doxygen's mangling of
# `@defgroup 3d_projection` (a group id can't start with a digit), and
# "_3d" is never `@defgroup`'d.
"subgroups": ["geometry_shape", "d_projection", "_3d"],
},
"ptcloud": {
"detailed": (
"Point-cloud and mesh processing: reading and writing point clouds "
"and meshes, triangle rasterization, spatial partitioning (octree), "
"and RGB-D / volumetric 3D reconstruction (odometry, TSDF volumes)."
),
# Symbols harvested from the cv namespace (see _GROUP_NS_HARVEST).
},
}
# Title fixes for groups Doxygen auto-titled from their id, applied by group id
# to every node in an overridden module's tree. Groups with a real title are
# left out (e.g. "d_projection" already renders as "3D vision functionality").
_GROUP_TITLE_OVERRIDES: dict = {
"geometry": "Computational Geometry Primitives Module",
"geometry_shape": "Shape analysis and fitting",
"geometry_subdiv2d": "Planar subdivision",
"_3d": "Point-cloud sampling and segmentation",
"ptcloud": "Point Cloud Processing", # header typo'd "Clound"
}
# Group ids re-parented by an override — skipped by orphan-group emission so each
# renders once, nested under its module, not also as a standalone page.
_GROUP_OVERRIDE_SUBGROUPS: set = {
_sub for _ov in _GROUP_DOC_OVERRIDES.values()
for _sub in _ov.get("subgroups", ())
}
# group stem -> include prefix: harvest cv-namespace symbols (orphaned when a
# header opens @addtogroup outside `namespace cv`) back into the module page.
_GROUP_NS_HARVEST: dict = {
"ptcloud": "opencv2/ptcloud",
"geometry": "opencv2/geometry/mst.hpp", # mst.hpp has no @addtogroup
}
# -- Python enum/constant signatures ----------------------------------------
# C++ enumerator FQN -> cv2.* name; env OPENCV_PYTHON_SIGNATURES_FILE
_PY_SIGNATURES: dict = {}
@@ -686,7 +744,7 @@ def _bib_fields(body: str) -> dict[str, str]:
return fields
# LaTeX accent + special-char cleanup
_LATEX_ACCENT_RE = re.compile(r"\\([\"'`^~.])\s*\{?\s*([A-Za-z])\s*\}?")
_LATEX_ACCENT_RE = re.compile(r"\\([\"'`^~.])\s*\{?\s*\\?([A-Za-z])\s*\}?")
_LATEX_ACCENT_MAP = {
('"', 'a'): 'ä', ('"', 'e'): 'ë', ('"', 'i'): 'ï', ('"', 'o'): 'ö',
('"', 'u'): 'ü', ('"', 'A'): 'Ä', ('"', 'O'): 'Ö', ('"', 'U'): 'Ü',
@@ -700,10 +758,26 @@ _LATEX_ACCENT_MAP = {
('~', 'a'): 'ã', ('~', 'n'): 'ñ', ('~', 'o'): 'õ',
('.', 'c'): 'ċ', ('.', 'e'): 'ė',
}
# Letter-command accents: \c c (cedilla), \v s (caron), \u g (breve), \H o
# (double acute), \k a (ogonek), \r u (ring). Form is "\c{c}" or "\c c".
_LATEX_CMD_ACCENT_RE = re.compile(r"\\([cvuHkr])(?:\s+|\{)\s*\\?([A-Za-z])\s*\}?")
_LATEX_CMD_ACCENT_MAP = {
('c', 'c'): 'ç', ('c', 'C'): 'Ç', ('c', 's'): 'ş', ('c', 'S'): 'Ş',
('c', 'g'): 'ģ', ('c', 'e'): 'ȩ',
('v', 'c'): 'č', ('v', 'C'): 'Č', ('v', 's'): 'š', ('v', 'S'): 'Š',
('v', 'z'): 'ž', ('v', 'Z'): 'Ž', ('v', 'r'): 'ř', ('v', 'n'): 'ň',
('v', 'e'): 'ě', ('v', 'd'): 'ď', ('v', 't'): 'ť', ('v', 'g'): 'ǧ',
('u', 'g'): 'ğ', ('u', 'a'): 'ă',
('H', 'o'): 'ő', ('H', 'u'): 'ű', ('H', 'O'): 'Ő', ('H', 'U'): 'Ű',
('k', 'a'): 'ą', ('k', 'e'): 'ę', ('k', 'A'): 'Ą', ('k', 'E'): 'Ę',
('r', 'u'): 'ů', ('r', 'a'): 'å', ('r', 'U'): 'Ů', ('r', 'A'): 'Å',
}
_LATEX_SPECIAL = {
r"\&": "&", r"\%": "%", r"\#": "#", r"\$": "$",
r"\_": "_", r"\{": "{", r"\}": "}",
r"\textendash": "", r"\textemdash": "",
r"\textregistered": "®", r"\texttrademark": "",
r"\textcopyright": "©", r"\copyright": "©",
r"\ldots": "", r"\dots": "",
r"\o": "ø", r"\O": "Ø", r"\ss": "ß",
r"\aa": "å", r"\AA": "Å", r"\ae": "æ", r"\AE": "Æ",
@@ -712,8 +786,13 @@ _LATEX_SPECIAL = {
def _bib_clean(s: str) -> str:
s = re.sub(r"\s+", " ", s or "").strip()
s = re.sub(r"\\url\s*\{([^}]*)\}", r"\1", s) # \url{X} -> X
s = re.sub(r"\\([lL])(?![A-Za-z])", # \l -> ł, \L -> Ł
lambda m: "ł" if m.group(1) == "l" else "Ł", s)
s = _LATEX_ACCENT_RE.sub(
lambda m: _LATEX_ACCENT_MAP.get((m.group(1), m.group(2)), m.group(2)), s)
s = _LATEX_CMD_ACCENT_RE.sub(
lambda m: _LATEX_CMD_ACCENT_MAP.get((m.group(1), m.group(2)), m.group(2)), s)
for k, v in _LATEX_SPECIAL.items():
s = s.replace(k, v)
return s.replace("{", "").replace("}", "").strip()
@@ -991,6 +1070,8 @@ __all__ = [
"DOC_MODULES", "JS_DOC_MODULES", "PY_DOC_MODULES",
"CONTRIB_MODULES", "CONTRIB_ROOT", "SPHINX_INPUT_ROOT", "API_MODULES",
"_API_XML_DIR", "_PATCHED_XML_DIR", "_module_group_stem",
"_GROUP_DOC_OVERRIDES", "_GROUP_OVERRIDE_SUBGROUPS", "_GROUP_TITLE_OVERRIDES",
"_GROUP_NS_HARVEST",
"_PY_SIGNATURES", "_python_enum_name",
"HAVE_SPHINX_DESIGN", "HAVE_BREATHE",
"DOXYGEN_BASE_URL", "_doxygen_url",

View File

@@ -114,6 +114,12 @@ def _enhance_xphoto_member(m: dict, class_name: str = "") -> dict:
# Drives write-if-changed and the stale-file sweep.
_stub_written: set[pathlib.Path] = set()
# "Shell" groups (<=2 classes, no own members) merged with their classes; see
# _write_api_stub. _MERGED_GROUPS -> [(out_path, intro_lines, classes)];
# _MERGED_CLASS_TO_GROUP -> {class_refid: group_docname} for cross-refs/redirect.
_MERGED_GROUPS: list = []
_MERGED_CLASS_TO_GROUP: dict[str, str] = {}
def _stub_write(path: pathlib.Path, content: str) -> None:
"""Write only if changed; mark path live for this run."""
@@ -752,44 +758,14 @@ def _write_namespace_stub(ns: dict, out_dir: pathlib.Path,
items = ns_sections.get(section_title, [])
if not items:
continue
# Drop the redundant summary when the detail loop covers every member
# (all but template specializations); keep Enumerations always.
if section_title != "Enumerations" and all(
"<" not in (m.get("name") or "") for m in items):
continue
lines.append(f"## {section_title}")
lines.append("")
if section_title == "Functions":
lines += ["{.api-reference-table .api-function-table}",
"| Return | Name |", "|---|---|"]
from html import escape as _esc_html_ns
def _ns_func_row(m: dict) -> str:
target = f"#{m['id']}"
qual = (m.get("qualified") or m["name"])
name_text = qual.replace("::", "&#58;&#58;")
name_html = (f'<a class="reference internal" '
f'href="{target}">{name_text}</a>')
params_sig = m.get("params_sig") or []
def _esc(s: str) -> str:
return _esc_html_ns(s).replace("|", "&#124;")
if not params_sig:
inner = f"{name_html}()"
elif len(params_sig) == 1:
t, nm, dv = params_sig[0]
decl = nm + (f" = {dv}" if dv else "")
inner = f"{name_html}({_esc(t)} {_esc(decl)})"
else:
last_i = len(params_sig) - 1
parts = [f"{name_html}("]
for i, (t, nm, dv) in enumerate(params_sig):
tail = " )" if i == last_i else ","
decl = nm + (f" = {dv}" if dv else "")
parts.append(f" {_esc(t)} {_esc(decl)}{tail}")
inner = "<br>".join(parts)
return f'<code class="docutils literal notranslate">{inner}</code>'
for m in items:
ret_md = _type_to_md(m.get("type_elem"))
if not ret_md:
ret_md = _md_escape_cell(m["type"]) or "\u00a0"
if m.get("static"):
ret_md = "static " + ret_md
lines.append(f"| {ret_md} | {_ns_func_row(m)} |")
elif section_title in ("Typedefs", "Variables"):
if section_title in ("Typedefs", "Variables"):
for m in items:
lines.append("```cpp")
if section_title == "Typedefs":
@@ -995,6 +971,23 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
_stub_write(out_dir / f"{_cn}.md", _md + "\n")
return
# Classes-only shell group (<=2 classes, no own members/subgroups): defer the
# class content to the seeded pass (_generate_api_stubs), which hosts it here
# and redirects the standalone class pages.
if (1 <= len(node["innerclasses"]) <= 2 and not node["sections"]
and not node["children"]):
_intro = list(lines)
_txt = "\n\n".join(x for x in (
(node.get("brief") or "").strip(),
(node.get("detailed") or "").strip()) if x)
if _txt:
_intro += [_txt, ""]
for c in node["innerclasses"]:
classes_seen.setdefault(c["refid"], c)
_MERGED_CLASS_TO_GROUP[c["refid"]] = f"{out_dir.name}/{name}"
_MERGED_GROUPS.append((out, _intro, node["innerclasses"]))
return
# Brief + "View details" under the title (mirrors the Doxygen group page).
_brief = node.get("brief") or ""
if _brief:
@@ -1073,7 +1066,10 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
parent_qualified = q.rsplit("::", 1)[0]
for c in classes_seen.values():
if c.get("qualified") == parent_qualified:
return f"{_class_page_name(c['refid'])}.md"
# Used in a raw-HTML <a href> (see _func_row_split_md); MyST
# only rewrites .md->.html for Markdown []() links, not raw
# HTML, so point at .html directly or the href 404s.
return f"{_class_page_name(c['refid'])}.html"
# Functions on core pages: target the `_func_slug`-based anchor
# that `_render_core_basic_func` actually emits.
if _is_core_page and m.get("kind") == "function" and m.get("name"):
@@ -1214,13 +1210,15 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
def _safe(s: str) -> str:
return _html_mod.escape(s).replace("::", "&#58;&#58;")
for m in members:
# Named enums anchor on their `### Name` heading slug. Anonymous
# enums anchor on the detail block's MyST `({id})=` target, whose
# slug normalizes `_`-runs to `-`; a raw `#<id>` with underscores
# does NOT resolve in MyST, so match the normalized form here.
_enum_anchor = (m["name"].lower() if m.get("name")
else re.sub(r"_+", "-", m["id"]))
_more = ""
if _enum_more_link:
# Link to the enum detail block's heading-slug id
# (`### AccessFlag` → `#accessflag`). Same target
# the clickable synopsis tokens use, and a literal
# match on the actual element id on the page.
_more = f"[View details](#{m['name'].lower()})"
_more = f"[View details](#{_enum_anchor})"
if _clickable_synopsis:
_qual = m["qualified"] or m["name"]
_is_strong = bool(m.get("strong"))
@@ -1234,7 +1232,7 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
_val_prefix = _qual.rsplit("::", 1)[0] + "::"
else:
_val_prefix = ""
_href = f"#{m['name'].lower()}" # enum detail block id
_href = f"#{_enum_anchor}"
out.append(
'<div class="highlight-cpp notranslate '
'opencv-enum-clickable"><div class="highlight"><pre>'
@@ -1288,7 +1286,15 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
return out
_named_groups: list[tuple[str, str, list]] = [] # (header, section_title, members)
for _, section_title in _MEMBERDEF_SECTIONS:
def _grp_detail_covers(m, kind_key):
# Must mirror the detail loop's skips below: a summary member lacks a
# detail block only if it's a class member or a template specialization.
if kind_key in ("function", "variable") and _is_class_member(m):
return False
if _is_template_spec(m):
return False
return True
for kind_key, section_title in _MEMBERDEF_SECTIONS:
items = node["sections"].get(section_title, [])
if not items:
continue
@@ -1296,11 +1302,16 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
for _hdr, _members in _group_by_section_header(
[m for m in items if (m.get("section_header") or "")]):
_named_groups.append((_hdr, section_title, _members))
# Drop the redundant summary only when the detail loop covers every
# ungrouped member; keep Enumerations always.
if ungrouped:
lines.append(f"## {section_title}")
lines.append("")
lines += _summary_block(section_title, ungrouped)
lines.append("")
_covered = section_title != "Enumerations" and all(
_grp_detail_covers(m, kind_key) for m in ungrouped)
if not _covered:
lines.append(f"## {section_title}")
lines.append("")
lines += _summary_block(section_title, ungrouped)
lines.append("")
for _hdr, section_title, _members in _named_groups:
lines.append(f"## {_hdr}")
lines.append("")
@@ -1363,7 +1374,14 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
"",
]
else:
blk = [f'<h3 id="{m["id"]}">{_keyword}</h3>', ""]
# Anonymous enum has no heading slug; anchor it with a MyST
# `({id})=` target (what `_enum_anchor` links to). A raw
# `<h3 id=…>` is not a MyST reference target and dead-links.
blk = [
f"({m['id']})=",
f"### {_keyword}",
"",
]
if m.get("include_file"):
_einc = m["include_file"]
_ehref = _include_page_href(_einc)
@@ -1768,17 +1786,19 @@ def _render_core_basic_func(m: dict, idx: int, total: int,
def _write_class_stub(cls: dict, out_dir: pathlib.Path,
xml_dir: pathlib.Path) -> None:
xml_dir: pathlib.Path, inline: bool = False):
"""One .md per inner class, mirroring Doxygen's class-page layout.
Falls back to `{doxygenclass}`/`{doxygenstruct}` if class XML can't be read."""
Falls back to `{doxygenclass}`/`{doxygenstruct}` if class XML can't be read.
When `inline=True`, the body lines are returned (no title, not written) so a
single-class "shell" group page can host the class content directly."""
page = _class_page_name(cls["refid"])
out = out_dir / f"{page}.md"
qualified = cls["qualified"] or cls["name"]
kind_label = cls["kind"].title()
title = f"{kind_label} {qualified}"
# No `{#refid}` anchor; `_generate_api_stubs` seeds `_ANCHOR_TO_DOC` instead.
lines = [f"# {title}", ""]
lines = [] if inline else [f"# {title}", ""]
# Class-page header: brief + `View details` + `#include` line.
_header_data = _read_class_data(cls["refid"], xml_dir)
@@ -1786,10 +1806,12 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path,
import html as _html_pkg
_brief = (_header_data.get("brief") or "").strip()
if _brief:
# Link only when there's a detailed description to jump to.
# Link only when there's a detailed description to jump to; skip when
# inlined, where the #detailed-description anchor collides with a
# sibling class and the detail is right below anyway.
_more = (
' <a class="opencv-class-more" href="#detailed-description">View details</a>'
if _header_data.get("detailed") else ""
if _header_data.get("detailed") and not inline else ""
)
lines.append(
f'<p class="opencv-class-brief">'
@@ -1852,16 +1874,20 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path,
inline_inherited = []
_additional += [(summary_title, rid, qual, bi)
for rid, qual, bi in inherited]
if not items and not inline_inherited:
non_enum_items = [m for m in items if m["kind"] != "enum"]
enum_items = [m for m in items if m["kind"] == "enum"]
# Own typedef/function/variable get a detail block below, so drop their
# summary rows; keep kinds without one (e.g. friends) and enums.
kept_rows = [m for m in non_enum_items
if m["kind"] not in ("typedef", "function", "variable")]
if not kept_rows and not enum_items and not inline_inherited:
continue
lines.append(f"## {summary_title}")
lines.append("")
non_enum_items = [m for m in items if m["kind"] != "enum"]
enum_items = [m for m in items if m["kind"] == "enum"]
if non_enum_items:
if kept_rows:
lines += ["{.api-reference-table .api-function-table}",
"| Return | Name | Description |", "|---|---|---|"]
for m in non_enum_items:
for m in kept_rows:
ret = _md_escape_cell(m["type"])
if ret and m["static"]:
ret = "static " + ret
@@ -2012,6 +2038,8 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path,
"",
]
if inline:
return lines
_stub_write(out, "\n".join(lines))
@@ -2347,6 +2375,87 @@ def _fallback_module_tree(name: str):
}
def _apply_group_doc_override(tree: dict, xml_dir: pathlib.Path) -> None:
"""Repair module groups whose subgroups were `@addtogroup`'d but never nested
under a titled `@defgroup`, which Doxygen leaves as orphan top-level groups
with an empty landing page. Injects the group description and attaches the
real subgroups as children (recursively retitled). Driven by
`_GROUP_DOC_OVERRIDES` / `_GROUP_TITLE_OVERRIDES`; no-op without an override."""
ov = _GROUP_DOC_OVERRIDES.get(tree["name"])
if not ov:
return
if ov.get("detailed") and not tree["detailed"]:
tree["detailed"] = ov["detailed"]
have = {c["name"] for c in tree["children"]}
for sub in ov.get("subgroups", ()):
if sub in have:
continue
child = _build_api_hierarchy("group__" + sub.replace("_", "__"), xml_dir)
if child is not None:
tree["children"].append(child)
def _retitle(node: dict) -> None:
node["title"] = _GROUP_TITLE_OVERRIDES.get(node["name"], node["title"])
for c in node.get("children", ()):
_retitle(c)
_retitle(tree)
def _harvest_namespace_into_group(tree: dict, xml_dir: pathlib.Path) -> None:
# Pull funcs/classes under the module's include prefix (per _GROUP_NS_HARVEST)
# into the otherwise-empty group node, from the cv namespace and any group
# they were filed under (e.g. depth.hpp uses @addtogroup rgbd).
import xml.etree.ElementTree as _ET
prefix = _GROUP_NS_HARVEST.get(tree["name"])
if not prefix:
return
tree.setdefault("sections", {})
have = {c["refid"] for c in tree["innerclasses"]}
def _ingest(cd):
for title, members in _parse_member_sections(cd).items():
seen = {m["id"] for m in tree["sections"].get(title, [])}
kept = [m for m in members
if (m.get("include_file") or "").startswith(prefix)
and m["id"] not in seen]
if kept:
tree["sections"].setdefault(title, []).extend(kept)
for ic in cd.findall("innerclass"):
rid = ic.get("refid", "")
if ic.get("prot") != "public" or not rid or rid in have:
continue
cx = xml_dir / f"{rid}.xml"
if not cx.is_file():
continue
try:
ccd = _ET.parse(cx).getroot().find("compounddef")
except _ET.ParseError:
continue
loc = ccd.find("location") if ccd is not None else None
if loc is None or not (loc.get("file") or "").startswith(prefix):
continue
qualified = " ".join((ic.text or "").split())
tree["innerclasses"].append({
"refid": rid, "name": qualified, "qualified": qualified,
"kind": "struct" if rid.startswith("struct") else "class",
"brief": _read_class_brief(rid, xml_dir),
})
have.add(rid)
srcs = [xml_dir / "namespacecv.xml"]
srcs += [g for g in xml_dir.glob("group__*.xml")
if prefix in g.read_text(encoding="utf-8", errors="ignore")]
for s in srcs:
if not s.is_file():
continue
try:
cd = _ET.parse(s).getroot().find("compounddef")
except _ET.ParseError:
continue
if cd is not None:
_ingest(cd)
def _generate_api_stubs(modules, xml_dir, out_dir,
root_anchor="api_root", root_title="API Reference",
root_desc=None, extra_groups=()):
@@ -2394,8 +2503,10 @@ def _generate_api_stubs(modules, xml_dir, out_dir,
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
global _stub_written
global _stub_written, _MERGED_GROUPS, _MERGED_CLASS_TO_GROUP
_stub_written = set()
_MERGED_GROUPS = []
_MERGED_CLASS_TO_GROUP = {}
_desc = root_desc or (
"Sphinx-rendered API reference. Each entry below is a module's "
"umbrella `@defgroup`; sub-pages mirror the Doxygen subgroup hierarchy.")
@@ -2414,6 +2525,10 @@ def _generate_api_stubs(modules, xml_dir, out_dir,
_gapi_tree = None # saved to write gapi.md wrapper after all stubs
for m in list(modules) + list(extra_groups):
is_extra = m in extra_groups
if m in _GROUP_OVERRIDE_SUBGROUPS:
# Re-parented under its module by _apply_group_doc_override; skip so
# it isn't also emitted as a standalone orphan page.
continue
stem = _module_group_stem(m)
tree = _build_api_hierarchy("group__" + stem.replace("_", "__"), xml_dir)
if tree is None:
@@ -2422,6 +2537,8 @@ def _generate_api_stubs(modules, xml_dir, out_dir,
tree = _fallback_module_tree(m)
if tree is None:
continue
_apply_group_doc_override(tree, xml_dir)
_harvest_namespace_into_group(tree, xml_dir)
trees.append(tree)
if is_extra:
pass
@@ -2493,8 +2610,51 @@ def _generate_api_stubs(modules, xml_dir, out_dir,
for _cls in classes_seen.values():
_ANCHOR_TO_DOC.setdefault(
_cls["refid"], f"{_doc_prefix}/{_class_page_name(_cls['refid'])}")
# Merged shell groups: a Classes box then each class inline. Done after
# seeding above so bases resolve for "inherited from" links.
for _mout, _mlines, _mclasses in _MERGED_GROUPS:
_lines = list(_mlines)
# Slug matching the per-class `## heading` its box entry links to.
def _cls_slug(_c):
_h = f"{_c['kind'].title()} {_c.get('qualified') or _c['name']}"
return re.sub(r"[^a-z0-9]+", "-", _h.lower()).strip("-")
_lines += ["## Classes", "", "{.api-reference-table}",
"| Name | Description |", "|---|---|"]
for _c in _mclasses:
# Encode "::" so the cv-linkifier doesn't nest an <a> in our link.
_nm = _c["name"].replace("::", "&#58;&#58;")
_lines.append(
f'| <a href="#{_cls_slug(_c)}"><code>{_c["kind"]} '
f'{_nm}</code></a> '
f"| {_md_escape_cell(_c.get('brief', ''))} |")
_lines.append("")
for _c in _mclasses:
_q = _c.get("qualified") or _c["name"]
_lines += [f"## {_c['kind'].title()} {_q}", ""]
_lines += _write_class_stub(_c, out_dir, xml_dir, inline=True) or []
_stub_write(_mout, "\n".join(_lines) + "\n")
# Per-class pages; seed `_ANCHOR_TO_DOC` refid→docname for `@ref`.
for cls in classes_seen.values():
_grp = _MERGED_CLASS_TO_GROUP.get(cls["refid"])
if _grp:
# Inlined into its group page: turn this page into a redirect and
# aim refid xrefs at the group instead.
_rel = _grp.split("/", 1)[-1]
_stub_write(
out_dir / f"{_class_page_name(cls['refid'])}.md",
f"# {cls.get('qualified') or cls['name']}\n\n"
f'<script>window.location.replace("{_rel}.html"'
f' + window.location.hash);</script>\n\n'
f'<p>This page has moved to '
f'<a href="{_rel}.html">{_rel}</a>.</p>\n')
_ANCHOR_TO_DOC[cls["refid"]] = _grp
_ALL_CLASSES[cls["refid"]] = {
"qualified": cls.get("qualified") or cls.get("name", ""),
"kind": cls.get("kind", "class"),
"brief": cls.get("brief", ""),
"docname": _grp,
}
continue
_write_class_stub(cls, out_dir, xml_dir)
_docname = f"{_doc_prefix}/{_class_page_name(cls['refid'])}"
_ANCHOR_TO_DOC[cls["refid"]] = _docname

View File

@@ -15,6 +15,50 @@ def _normalize_lang(lang: str) -> str:
return _LANG_ALIASES.get(lang, lang)
# Snippet-boundary marker lines (e.g. `// [name]`, `## [name]`), stripped
# from rendered code to mirror docs.opencv.org. Matches only a bare
# marker + `[name]` on its own line, so real code embedding a `[token]`
# is left alone.
_SNIPPET_MARKER_RE = re.compile(
r"^[ \t]*(?://!|//|##|#)[ \t]*\[[\w\- ]+\][ \t]*$"
r"|^[ \t]*<!--[ \t]*\[[\w\- ]+\][ \t]*-->[ \t]*$"
)
# Doxygen block comments (`/** … */`, `/*! … */`) are decorative chrome in
# OpenCV samples; strip them to mirror docs.opencv.org. Plain `/* … */`,
# `//`, and `///` comments are kept as visible commentary.
# Line-block pass first removes whole-line blocks (consuming the trailing
# newline so no hollow blank remains); inline pass then sweeps the rare
# mid-line block without eating surrounding code.
_DOXY_LINE_BLOCK_RE = re.compile(
r"^[ \t]*/\*[*!].*?\*/[ \t]*\n?",
re.DOTALL | re.MULTILINE,
)
_DOXY_INLINE_BLOCK_RE = re.compile(
r"/\*[*!].*?\*/",
re.DOTALL,
)
def _strip_snippet_markers(text: str) -> str:
"""Drop snippet-boundary marker lines from a code body (whole line
removed, no hollow blank left). Runs of 3+ resulting blank lines are
collapsed to 2, matching docs.opencv.org's rendering."""
out = [ln for ln in text.split("\n")
if not _SNIPPET_MARKER_RE.match(ln)]
joined = "\n".join(out)
return re.sub(r"\n{3,}", "\n\n", joined)
def _strip_doxygen_block_comments(text: str) -> str:
"""Drop every `/** … */` / `/*! … */` Doxygen block comment anywhere in
a snippet (with or without `@tag` directives). Plain `/* … */`, `//`,
and `///` comments stay intact."""
text = _DOXY_LINE_BLOCK_RE.sub("", text)
text = _DOXY_INLINE_BLOCK_RE.sub("", text)
return text
def _read_snippet(rel_path: str, label: str | None) -> tuple[str, str]:
"""Return (code_text, language) for an @include / @snippet directive."""
rel_norm = rel_path.lstrip("/")
@@ -34,7 +78,11 @@ def _read_snippet(rel_path: str, label: str | None) -> tuple[str, str]:
".xml": "xml", ".html": "html",
".sh": "bash", ".bash": "bash"}.get(ext, "text")
if label is None:
return text, lang
# `@include`: whole file. Strip Doxygen block comments and snippet
# markers so the rendered block matches docs.opencv.org (starts at
# the first `#include`/`import`, no banners or doc-headers).
return _strip_snippet_markers(
_strip_doxygen_block_comments(text)), lang
# Match `[label]` after any comment marker.
pat = re.compile(r"^[^\[\n]*(?://!|//|##|#|<!--)[^\[\n]*\[" + re.escape(label)
+ r"\][^\n]*$", re.MULTILINE)
@@ -42,6 +90,10 @@ def _read_snippet(rel_path: str, label: str | None) -> tuple[str, str]:
if len(matches) < 2:
return f"// snippet not found: {rel_path} [{label}]\n", lang
body = text[matches[0].end():matches[1].start()].strip("\n")
# Strip nested markers and mid-snippet Doxygen blocks: samples often
# place a `/** @function bar */` doc-header inside a labelled range.
body = _strip_doxygen_block_comments(body)
body = _strip_snippet_markers(body)
lines = body.split("\n")
indents = [len(l) - len(l.lstrip(" ")) for l in lines if l.strip()]
if indents:
@@ -50,7 +102,172 @@ def _read_snippet(rel_path: str, label: str | None) -> tuple[str, str]:
return "\n".join(lines), lang
# Patterns used by `_dedent_dash_hash_indent` to skip code regions.
_DEDENT_AT_CODE_OPEN_RE = re.compile(r"^[ \t]*@code(?:\{[^}]*\})?\s*$")
_DEDENT_AT_CODE_CLOSE_RE = re.compile(r"^[ \t]*@endcode\s*$")
_DEDENT_FENCE_OPEN_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})")
_DEDENT_FENCE_CLOSE_RE = re.compile(r"^[ \t]*([`~]{3,})[ \t]*$")
# Both ordered-list flavours OpenCV sources use — `-#` (Doxygen) and
# `\d+\.` (CommonMark) — indent bodies +4 spaces/level, so both trigger
# the same dedent.
_DEDENT_DASH_HASH_RE = re.compile(r"^[ \t]*(?:-#|\d+\.)[ \t]")
# Directives that steps 4/5 turn into fenced code; their stray-indent
# handling matches `@code` (see step 0a).
_SNIPPET_DIRECTIVE_RE = re.compile(r"^[ \t]*@(?:snippet|include(?:lineno)?)\b")
def _map_list_indent(n: int) -> int:
"""Map 4-space-per-level list indent to 3-space-per-level, preserving
the 0-3 space remainder within a level. Shared by
`_dedent_dash_hash_indent` and `_fenced` so both land on the same
dedented baseline."""
return (n // 4) * 3 + (n % 4)
def _dedent_keep_blanks(s: str) -> str:
"""Strip the common leading-whitespace prefix from `s`, leaving
whitespace-only lines untouched. Unlike `textwrap.dedent`, it does NOT
normalise interior blank lines to empty — preserving them keeps snippet
bodies byte-stable through the re-indent/re-base round-trip."""
widths = [len(l) - len(l.lstrip(" \t")) for l in s.split("\n") if l.strip()]
n = min(widths) if widths else 0
return "\n".join(l[n:] if l.strip() else l for l in s.split("\n"))
def _fenced(base: str, lang: str, body: str) -> str:
"""Emit a fenced code block at `base` indentation.
Inside a list item the fence must sit at the item's content baseline
(relative-0): a column-0 fence terminates the list, and a relative +4
fence renders as an indented code block showing literal ``` backticks.
Only relative-0 keeps the fence recognised AND the list open.
`base=""` => column 0 (top-level, unchanged)."""
body = _dedent_keep_blanks(body).strip("\n")
if base:
body = "\n".join(base + ln if ln.strip() else ln
for ln in body.split("\n"))
return f"\n{base}```{lang}\n{body}\n{base}```\n"
_LIST_MARKER_RE = re.compile(
r"^(?P<ind> *)(?P<mk>-#|\d+\.|[-*+])(?P<gap> +)(?P<rest>.*)$")
def _dedent_dash_hash_indent(src: str) -> str:
"""Re-indent every list (ordered `-#`/`\\d+\\.` and bullet `-`/`*`/`+`)
to a canonical shape so deep nesting stays out of CommonMark's 4-space
indented-code trap.
OpenCV tutorials indent list bodies 4 spaces/level — exactly where
CommonMark/MyST reads a line as an indented code block, wrecking fences,
continuation prose/nested bullets, and inline images inside list bodies.
The fix normalises the gap after each marker to one space and nests each
level under its parent's content column, then re-bases body lines to
that column (keeping hanging indent). Normalising marker *width* (not
just scaling indent) keeps fixed-width bullets like `- ` aligned with
the fence beneath them, which the old proportional 4->3 scaling broke.
`@code…@endcode` and pre-existing fenced bodies pass through with native
indentation; only their marker lines and ordinary list content are
re-based. Lines outside any list are untouched."""
lines = src.split("\n")
out: list[str] = []
# Stack of open list ancestors: original marker/content columns (to test
# membership) plus the new content column each was re-based to.
stack: list[tuple[int, int, int]] = [] # (orig_col, orig_cont, new_cont)
in_at_code = False
in_fence = False
fence_char = ""
def rebase_content(line: str, code_marker: bool = False) -> str:
# Re-base a non-marker line to its governing marker's new content
# column, keeping hanging indent past it. Mutates `stack`.
ind = len(line) - len(line.lstrip(" "))
while stack and stack[-1][0] >= ind:
stack.pop()
if not stack:
# Outside any list: keep intended indented content as-is, but
# strip stray indent off a code directive/fence marker so a
# 4+-space `@code`/`@snippet` loosely attached to a top-level
# `@note` (uncaptured by the note regex past its blank line)
# doesn't become a root-level indented-code fence.
return line.lstrip(" ") if code_marker else line
_oc, ocont, ncont = stack[-1]
# Clamp code directives/fences to the item's content baseline
# (relative-0): extra source offset would land the fence at +N, and
# at +4 MyST shows literal backticks. Prose keeps its hanging indent.
new = ncont if code_marker else max(0, ncont + (ind - ocont))
return " " * new + line[ind:]
for line in lines:
# @code…@endcode body passes through unchanged; only the marker
# lines are re-based, so `_code_repl` later emits the fence at the
# list-content column (see `_fenced`).
if in_at_code:
if _DEDENT_AT_CODE_CLOSE_RE.match(line):
in_at_code = False
out.append(rebase_content(line, code_marker=True))
else:
out.append(line)
continue
if in_fence:
out.append(line)
cm = _DEDENT_FENCE_CLOSE_RE.match(line)
if cm and cm.group(1)[0] == fence_char:
in_fence = False
continue
if _DEDENT_AT_CODE_OPEN_RE.match(line):
in_at_code = True
out.append(rebase_content(line, code_marker=True))
continue
fm = _DEDENT_FENCE_OPEN_RE.match(line)
if fm:
fence_char = fm.group(1)[0]
in_fence = True
# Leave a pre-existing ``` fence as authored: only its opener is
# seen here (body/closer skipped via `in_fence`), so re-basing
# the opener alone would misalign the block. (`@code`/`@snippet`
# instead go through `_fenced`, re-indenting the whole block.)
out.append(line)
continue
if not line.strip():
out.append(line) # blank: keep, list stays open
continue
mm = _LIST_MARKER_RE.match(line)
if mm:
oc = len(mm.group("ind"))
mk = mm.group("mk")
ocont = oc + len(mk) + len(mm.group("gap"))
# Pop siblings / deeper levels this marker closes.
while stack and stack[-1][0] >= oc:
stack.pop()
# Nested marker sits at its parent's content column; a top-level
# marker is dedented 4->3-per-level so a merely-indented list
# (e.g. ` 1. step`) starts at 0-3 spaces and is recognised,
# not read as an indented code block at >=4 spaces.
new_mk = stack[-1][2] if stack else _map_list_indent(oc)
new_cont = new_mk + len(mk) + 1
out.append(" " * new_mk + mk + " " + mm.group("rest"))
stack.append((oc, ocont, new_cont))
continue
# `@snippet`/`@include` emit fenced code later, so treat them as code
# markers — a stray-indented one must not keep a 4+-space indent that
# would render as a root-level indented fence.
out.append(rebase_content(
line, code_marker=bool(_SNIPPET_DIRECTIVE_RE.match(line))))
return "\n".join(out)
def _emit_toggles(tabs: list[tuple[str, str]]) -> str:
# Re-base tab bodies to column 0: the `tab-item` container is emitted at
# column 0, so a fence arriving baseline-indented (from a snippet inside
# an indented `@add_toggle`) would sit at relative +N and render as
# literal backticks. No-op for the pre-existing column-0 snippet form.
tabs = [(lang, _dedent_keep_blanks(body)) for lang, body in tabs]
if HAVE_SPHINX_DESIGN:
out = ["", "``````{tab-set}"]
for lang, body in tabs:
@@ -115,6 +332,25 @@ def _translate(text: str, docname: str | None = None) -> str:
or docname.startswith("py_tutorials/py_objdetect/")):
text = "---\norphan: true\n---\n\n" + text
# 0a-dedent. Re-indent lists below CommonMark's 4-space indented-code
# threshold — see `_dedent_dash_hash_indent` for the rationale.
text = _dedent_dash_hash_indent(text)
# 0a2. Prose wedged between an `@endcode` and the next `@code` (no list,
# no blank line) is text, but its 4-space source indent makes CommonMark
# render it as a stray code block. Re-align it to the directives' column.
# Anchored on `@endcode … @code`, so inert on raw-indented-code pages.
def _dedent_interblock(m: re.Match) -> str:
base = m.group("ind")
mid = "\n".join((base + l.lstrip(" ")) if l.strip() else l
for l in m.group("mid").split("\n"))
return m.group("pre") + mid
text = re.sub(
r"(?m)^(?P<pre>(?P<ind>[ \t]*)@endcode[ \t]*\n)"
r"(?P<mid>(?:[ \t]+\S[^\n]*\n)+?)"
r"(?=(?P=ind)@code(?:\{[^}]*\})?[ \t]*$)",
_dedent_interblock, text)
# 0b. "-# foo" -> "1. foo".
text = re.sub(r"^(?P<indent>[ \t]*)-#[ \t]+",
lambda m: f"{m.group('indent')}1. ", text, flags=re.MULTILINE)
@@ -198,11 +434,36 @@ def _translate(text: str, docname: str | None = None) -> str:
def _admon_repl(m: re.Match) -> str:
kind = _ADMON_KIND[m.group("dir")]
raw = m.group("body")
# `head` is the matched `@note ` prefix before the body; it ends in a
# newline only in the next-line form, so a non-newline end means the
# body rode on the same line as the directive.
head = m.group(0)[: len(m.group(0)) - len(raw)]
same_line = not head.endswith("\n")
# Directive's leading indent (the list-content baseline after step
# 0a). Re-indent the whole admonition to it so an indented note stays
# inside its list item instead of breaking the list at column 0.
indent = head[: len(head) - len(head.lstrip(" \t"))]
lines = raw.split("\n")
min_ind = min(
(len(l) - len(l.lstrip()) for l in lines if l.strip()), default=0)
body = "\n".join(l[min_ind:] for l in lines).strip()
return f"\n:::{{{kind}}}\n{body}\n:::\n"
# Common body indent to strip. Exclude the same-line first line (which
# has zero leading indent) from the min, else min_ind collapses to 0
# and continuation lines keep their indent, rendering as a spurious
# code block inside the note box.
ind_src = lines[1:] if same_line else lines
cand = [len(l) - len(l.lstrip()) for l in ind_src if l.strip()]
min_ind = min(cand) if cand else 0
def _dedent(l: str) -> str:
# Strip up to min_ind leading-whitespace chars only — never slice
# into a less-indented line's text (e.g. the same-line first line).
i = 0
while i < min_ind and i < len(l) and l[i] in " \t":
i += 1
return l[i:]
body = "\n".join(_dedent(l) for l in lines).strip()
block = f":::{{{kind}}}\n{body}\n:::"
if indent:
block = "\n".join((indent + ln) if ln.strip() else ln
for ln in block.split("\n"))
return f"\n{block}\n"
_ac_stash: dict[str, str] = {}
def _ac_hide(m: re.Match) -> str:
k = f"\x00AC{len(_ac_stash)}\x00"; _ac_stash[k] = m.group(0); return k
@@ -252,11 +513,16 @@ def _translate(text: str, docname: str | None = None) -> str:
_split_adj_math, text, flags=re.MULTILINE)
def _fblock(m: re.Match) -> str:
ind = m.group("indent")
body = m.group("body").strip()
# Re-base the math body to fence baseline `ind` (relative-0 of the
# list item, matching `_fenced`); at the deeper source offset it lands
# at relative +4 and block recognition breaks.
body = _dedent_keep_blanks(m.group("body")).strip("\n").strip()
reindent = lambda b: "\n".join((ind + ln) if ln.strip() else ln
for ln in b.split("\n"))
if "\\\\" in body:
body = re.sub(r"\n\s*\n", "\n", body)
return f"\n{ind}```{{math}}\n{ind}{body}\n{ind}```\n"
return f"\n{ind}$$\n{body}\n{ind}$$\n"
return f"\n{ind}```{{math}}\n{reindent(body)}\n{ind}```\n"
return f"\n{ind}$$\n{reindent(body)}\n{ind}$$\n"
text = re.sub(r"^(?P<indent>[ \t]*)\\f\[(?P<body>.+?)\\f\]",
_fblock, text, flags=re.DOTALL | re.MULTILINE)
text = re.sub(r"\\f\[(.+?)\\f\]",
@@ -272,17 +538,12 @@ def _translate(text: str, docname: str | None = None) -> str:
+ r"\end{matrix}",
text)
# 3. @code{.lang} ... @endcode -> fenced block (indent preserved).
# 3. @code{.lang} ... @endcode -> fenced block at the list-content
# baseline (see `_fenced`). Step 0a already dedented the @code marker
# lines, so the captured `indent` is the right column.
def _code_repl(m: re.Match) -> str:
indent = m.group("indent") or ""
lang = _normalize_lang(m.group("lang") or "")
body = m.group("body")
if indent:
body = _textwrap.dedent(body).strip("\n")
body = "\n".join((indent + line) if line else line
for line in body.split("\n"))
return f"\n{indent}```{lang}\n{body}\n{indent}```\n"
return f"\n```{lang}\n{body.strip()}\n```\n"
return _fenced(m.group("indent") or "", lang, m.group("body"))
text = re.sub(
r"^(?P<indent>[ \t]*)@code(?:\{(?P<lang>[^}]*)\})?\s*\n(?P<body>.*?)\n[ \t]*@endcode",
_code_repl, text, flags=re.DOTALL | re.MULTILINE)
@@ -323,11 +584,12 @@ def _translate(text: str, docname: str | None = None) -> str:
lambda m: f"{m.group('fence')}{_normalize_lang(m.group('lang'))}",
text, flags=re.MULTILINE)
# Backtick fence with per-line indent (other fence forms break in tab-items).
# Backtick fence at the list-content baseline (see `_fenced`). Step 0a
# already dedented the `@include`/`@snippet` line, so `indent` is the
# baseline. A top-level snippet has indent "" (column 0); one inside an
# `@add_toggle` is re-based to column 0 by `_emit_toggles`.
def _emit_codeblock(indent: str, lang: str, body: str) -> str:
body_lines = body.rstrip().splitlines()
body_indented = "\n".join(indent + line for line in body_lines)
return f"\n{indent}```{lang}\n{body_indented}\n{indent}```\n"
return _fenced(indent, lang, body)
# 4. @include path / @includelineno path.
def _include_repl(m: re.Match) -> str:
@@ -395,6 +657,17 @@ def _translate(text: str, docname: str | None = None) -> str:
k = re.match(r"\s*", src[j:])
if not k or not re.match(r"@add_toggle_", src[j + k.end():]):
break
# A repeated language starts a NEW tab-set, not a duplicate
# tab: `cpp/python/cpp` must render as [C++|Python] then [C++].
# Distinct languages (cpp/java/python) still merge into one.
_nxt = re.match(r"@add_toggle_(\w+)", src[j + k.end():])
if _nxt and _nxt.group(1) in {t[0] for t in tabs}:
# Rewind to the toggle's line start so the outer loop's
# `^`-anchored opener re-matches it: the inner
# `@end_toggle\s*\n?` may have eaten the next line's
# leading indent, leaving the toggle stranded mid-line.
j = src.rfind("\n", 0, j + k.end()) + 1
break
j += k.end()
if not tabs:
out.append(src[m.start():m.start() + 1]); i = m.start() + 1; continue
@@ -722,7 +995,12 @@ def _translate(text: str, docname: str | None = None) -> str:
resolved.append((kind, "external", _doxygen_url(lookup),
disp or title, description, prefix, inline))
if not resolved:
return ""
# No item resolved — not really a subpage/toctree list (e.g. a
# bullet whose `@ref` is an inline cross-reference). Leave it
# untouched so its body (baseline-indented code/images) isn't
# swallowed as a bullet description and dropped; step 7 still
# links the bare `@ref`.
return m.group(1)
# toctree gets only @subpage (internal @ref would create cycles).
tt_lines = []
@@ -776,16 +1054,18 @@ def _translate(text: str, docname: str | None = None) -> str:
text = re.sub(r'@ref\s+(?P<name>[\w:-]+)(?:\s+"(?P<disp>[^"]+)")?',
_ref_repl, text)
# 7c. cv.Name -> Markdown link using _CV_SYMBOL_URL; skips code spans.
# 7c. cv.Name -> Markdown link using _CV_SYMBOL_URL. Routed through
# `_apply_outside_code` so fenced code (including nested in tab-sets) and
# inline spans are skipped — a prior brittle-regex approach mis-paired
# fence widths and leaked `[cv.foo](#anchor)` into tab-set code bodies.
if _CV_SYMBOL_URL:
_cv_dot_re = re.compile(
r'(?<!\[)(?<!\()cv\.([A-Za-z][A-Za-z0-9_]*)')
def _cvlink_repl(m: re.Match) -> str:
url = _CV_SYMBOL_URL.get(m.group(1))
return f'[cv.{m.group(1)}]({url})' if url else m.group(0)
_parts = re.split(r'(```.*?```|`[^`\n]+`)', text, flags=re.DOTALL)
text = ''.join(
p if i % 2 else re.sub(
r'(?<!\[)(?<!\()cv\.([A-Za-z][A-Za-z0-9_]*)', _cvlink_repl, p)
for i, p in enumerate(_parts))
text = _apply_outside_code(
text, lambda chunk: _cv_dot_re.sub(_cvlink_repl, chunk))
# 8. @cite KEY -> `[N]` HTML anchor to citelist (N from opencv.bib order).
def _cite_repl(m: re.Match) -> str:
@@ -798,13 +1078,19 @@ def _translate(text: str, docname: str | None = None) -> str:
else:
href = f"{DOXYGEN_BASE_URL}citelist.html#CITEREF_{key}"
return f'<a href="{href}">{label}</a>'
# Keys may contain ':'/'.' segments (Ma:2003:IVI, BT.709, Wulff:CVPR:2015);
# match those without swallowing a trailing sentence period.
text = _apply_outside_code(text, lambda chunk: re.sub(
r"@cite\s+(?P<key>[\w-]+)", _cite_repl, chunk))
r"@cite\s+(?P<key>[\w-]+(?:[.:][\w-]+)*)", _cite_repl, chunk))
if _CITE_NUMBER and docname != "citelist":
# Skip plain-lowercase keys (e.g. "pattern", "eigenfaces") in the bare pass:
# they collide with ordinary words and mis-cite prose. Mixed-case/digit keys
# (Zhang2000, BT2017, LBP) still link bare; explicit @cite always works.
_bare_keys = [k for k in _CITE_NUMBER if not re.fullmatch(r"[a-z]+", k)]
if _bare_keys and docname != "citelist":
_CITE_KEY_RE = re.compile(
r"(?<![\[\w])(?P<key>"
+ "|".join(re.escape(k) for k in _CITE_NUMBER)
+ "|".join(re.escape(k) for k in _bare_keys)
+ r")(?![\w\]])"
)
def _bare_cite_repl(m: re.Match) -> str:
@@ -1043,7 +1329,12 @@ def _translate(text: str, docname: str | None = None) -> str:
_BARE_URL_RE = re.compile(
r"(?<![<\[(\w\"'=])"
# Block a markdown-link URL via a 2-char lookbehind on `](`, instead of
# excluding a bare `(` — so a URL in plain parens, e.g.
# `PyPI (https://pypi.org/...)`, still gets autolinkified while
# markdown-link/autolink/HTML-attribute exclusions stay intact.
r"(?<!\]\()"
r"(?<![<\[\w\"'=])"
r"(?P<url>https?://[^\s<>()`\"']+[^\s<>()`\"'.,;:!?])"
)
# `cv.X`/`cv::X`; lookbehind blocks `frame.cv.X`-style false positives.
@@ -1059,10 +1350,82 @@ _FENCED_BLOCK_RE = re.compile(
_INLINE_CODE_RE = re.compile(r"`+[^`\n]*?`+")
# ATX heading line; exempted from the auto-linkifier.
_ATX_HEADING_RE = re.compile(r"^[ \t]{0,3}#{1,6}[ \t]")
# Per-line fence detector for `_code_regions`. Deliberately allows ANY
# leading whitespace (not CommonMark's ≤3): our pipeline emits fences at
# the 4+-space indent of their originating `@include`/`@snippet` inside an
# `@add_toggle`, and under the strict limit those slipped past, leaking
# `[cv.foo](#anchor)` into code blocks. Worst case of the relaxation is
# MORE text protected from the linkifiers, not less.
_FENCE_LINE_RE = re.compile(
r"^(?P<indent>[ \t]*)(?P<fence>`{3,}|~{3,})(?P<info>.*)$"
)
def _code_regions(src: str) -> list[tuple[int, int]]:
"""Return sorted `[(start, end), ...]` byte ranges spanning every fenced
code block (fence lines included) in `src`.
Handles MyST/Sphinx nested fences where the outer container is wider than
the inner code fence (e.g. `{tab-set}` (6) > `{tab-item}` (5) >
` ```python` (3)). Only *code*-block ranges are returned; a `{directive}`
info string marks a container, whose body the caller still walks so its
nested prose stays linkifiable. Inside a code fence, further fence-shaped
lines are inert per CommonMark §4.5."""
out: list[tuple[int, int]] = []
# Each stack entry: (fence_char, fence_width, is_code, opener_offset).
stack: list[tuple[str, int, bool, int]] = []
pos = 0
for line in src.splitlines(keepends=True):
line_start = pos
next_pos = pos + len(line)
ln = line.rstrip("\n").rstrip("\r")
m = _FENCE_LINE_RE.match(ln)
if m is None:
pos = next_pos
continue
fc = m.group("fence")[0]
fw = len(m.group("fence"))
info = m.group("info").strip()
# Inside a code fence nothing matters except the matching closer.
if stack and stack[-1][2]:
top_char, top_width, _, opener_start = stack[-1]
if fc == top_char and fw >= top_width and not info:
out.append((opener_start, next_pos))
stack.pop()
pos = next_pos
continue
# Outside code: an info-less fence of the same char & ≥ width closes
# the nearest open fence (a container, per the branch above); a fence
# with info opens a new block.
if stack and not info:
top_char, top_width, _, _ = stack[-1]
if fc == top_char and fw >= top_width:
stack.pop()
pos = next_pos
continue
# Opener: `{…}` info → container, anything else (language tag, plain
# text, or empty) → code block per CommonMark.
is_code = (not info) or (not info.startswith("{"))
stack.append((fc, fw, is_code, line_start))
# Shield a breathe directive's opener line: its argument (e.g.
# `{doxygenstruct} cv::MSTEdge`) must reach breathe unlinkified.
if info.startswith("{doxygen"):
out.append((line_start, next_pos))
pos = next_pos
# Unclosed code fences: extend protection to EOF so we don't mangle
# the tail of a pathologically-truncated document.
while stack:
char, width, is_code, opener_start = stack.pop()
if is_code:
out.append((opener_start, len(src)))
out.sort()
return out
def _apply_outside_code(src: str, transform) -> str:
"""Apply `transform` to regions outside fenced/inline code."""
"""Apply `transform` to regions outside fenced and inline code, using
`_code_regions` so nested container > code fences (tab-set > tab-item >
` ```python`) are excluded correctly."""
def _segment(text: str) -> str:
out, last = [], 0
for cm in _INLINE_CODE_RE.finditer(text):
@@ -1072,10 +1435,10 @@ def _apply_outside_code(src: str, transform) -> str:
out.append(transform(text[last:]))
return "".join(out)
out, last = [], 0
for fm in _FENCED_BLOCK_RE.finditer(src):
out.append(_segment(src[last:fm.start()]))
out.append(fm.group(0))
last = fm.end()
for s, e in _code_regions(src):
out.append(_segment(src[last:s]))
out.append(src[s:e])
last = e
out.append(_segment(src[last:]))
return "".join(out)

View File

@@ -0,0 +1,3 @@
OpenCV (Open Source Computer Vision Library) is an open-source computer vision and machine learning software library. It has more than 2,500 optimised algorithms, a comprehensive mix of both classic and state-of-the-art computer vision and machine learning methods. These can be used to detect and recognise faces, identify objects, classify human actions in video, track camera and object motion, extract 3D models, stitch images together to produce high-resolution panoramas, and much more. The library has interfaces for C++, Python, Java, and JavaScript, runs on Windows, Linux, macOS, Android, and iOS, and accelerates work on CPU (SIMD), CUDA, OpenCL, and Vulkan.
OpenCV 5.0 is a major release built on OpenCV 4.x. C++17 is now the minimum required standard, Python 2 support has been dropped (Python 3.6+ is required), and the legacy C API has been fully removed. New data types (CV_16BF, CV_32U, CV_64U, CV_64S, CV_Bool) and proper 0D/1D array support extend the core, while the former calib3d module is split into the geometry, calib, stereo, and ptcloud modules. A next-generation DNN engine now covers over 80% of the ONNX specification (up from under 23%), with ONNX Runtime integration and models hosted on Hugging Face. Performance gains include Universal Intrinsics 2.0 (SSE/AVX/NEON/SVE/RISC-V), Vulkan compute support, image-warping speed-ups of 10% to over 300%, and USAC as the default framework for robust estimation.