mirror of
https://github.com/opencv/opencv.git
synced 2026-07-30 07:43:03 +04:00
Merge pull request #29206 from kirtijindal14:doc_optimizations_v3
OpenCV New Documentation #29206 This PR introduces end to end working new Documentation in OpenCV. Co-authored by: @abhishek-gola @omrope79 @Akansha-977 @Prasadayus @varun-jaiswal17 ### 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 - [x] There is a reference to the original bug report and related work - [x] 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:
+120
-16
@@ -1,9 +1,3 @@
|
||||
# Sphinx wrapper for the OpenCV doc/ tree.
|
||||
# Adds a `sphinx` custom target so `cmake --build <build> --target sphinx`
|
||||
# works the same way `--target doxygen` does.
|
||||
#
|
||||
# Gated on BUILD_DOCS so it follows the existing toggle.
|
||||
|
||||
if(NOT BUILD_DOCS)
|
||||
return()
|
||||
endif()
|
||||
@@ -20,11 +14,23 @@ if(NOT SPHINX_BUILD)
|
||||
return()
|
||||
endif()
|
||||
|
||||
find_program(DOT_EXECUTABLE NAMES dot
|
||||
DOC "Path to graphviz `dot` (required for API collaboration diagrams)")
|
||||
if(NOT DOT_EXECUTABLE)
|
||||
message(WARNING
|
||||
"docs_sphinx: graphviz `dot` not found — API class-page collaboration "
|
||||
"diagrams will be skipped. Install graphviz (e.g. `sudo apt-get install "
|
||||
"graphviz`) and re-run cmake so the Doxygen HTML build enables HAVE_DOT.")
|
||||
else()
|
||||
message(STATUS
|
||||
"docs_sphinx: graphviz dot found (${DOT_EXECUTABLE}) — "
|
||||
"collaboration diagrams enabled")
|
||||
endif()
|
||||
|
||||
set(_SPHINX_CONFDIR "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
set(_SPHINX_OUTDIR "${CMAKE_CURRENT_BINARY_DIR}/html")
|
||||
set(_SPHINX_INPUT_ROOT "${CMAKE_CURRENT_BINARY_DIR}/docs_sphinx_input")
|
||||
|
||||
# Stage merged input tree: symlink main + contrib tutorial subtrees into one srcdir.
|
||||
set(_SPHINX_INPUT_TUTORIALS "${_SPHINX_INPUT_ROOT}/tutorials")
|
||||
set(_SPHINX_INPUT_CONTRIB "${_SPHINX_INPUT_ROOT}/tutorials_contrib")
|
||||
file(REMOVE_RECURSE "${_SPHINX_INPUT_ROOT}")
|
||||
@@ -36,11 +42,6 @@ file(CREATE_LINK
|
||||
"${_SPHINX_INPUT_TUTORIALS}/tutorials.markdown"
|
||||
SYMBOLIC COPY_ON_ERROR)
|
||||
|
||||
# Top-level standalone pages staged at srcdir root (siblings of tutorials/).
|
||||
# Add further `file(CREATE_LINK …)` lines here if more top-level pages get
|
||||
# onboarded. intro.markdown lives inside modules/core/doc/ (it's the core
|
||||
# module's @page; the legacy Doxygen build pulls it in via the same module
|
||||
# scan it uses for bib files) — symlink it under a flat name at srcdir root.
|
||||
file(CREATE_LINK
|
||||
"${CMAKE_SOURCE_DIR}/doc/faq.markdown"
|
||||
"${_SPHINX_INPUT_ROOT}/faq.markdown"
|
||||
@@ -71,7 +72,6 @@ foreach(_root js_tutorials py_tutorials images)
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
# Contrib tree: stage when OPENCV_EXTRA_MODULES_PATH is set; unselected modules dropped by conf.py.
|
||||
if(OPENCV_EXTRA_MODULES_PATH AND EXISTS "${OPENCV_EXTRA_MODULES_PATH}")
|
||||
file(MAKE_DIRECTORY "${_SPHINX_INPUT_CONTRIB}")
|
||||
set(_contrib_root_md "${_SPHINX_INPUT_CONTRIB}/contrib_root.markdown")
|
||||
@@ -97,22 +97,126 @@ if(OPENCV_EXTRA_MODULES_PATH AND EXISTS "${OPENCV_EXTRA_MODULES_PATH}")
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
# Forward staging paths via env. OPENCV_CONTRIB_MODULES is not forwarded; set it in shell to override.
|
||||
set(_LEGACY_DOXYFILE "${CMAKE_BINARY_DIR}/doc/Doxyfile")
|
||||
set(_DOXYGEN_XML_DIR "${CMAKE_BINARY_DIR}/doc/doxygen/xml")
|
||||
|
||||
if(NOT DEFINED SPHINX_API_MODULES)
|
||||
set(SPHINX_API_MODULES "")
|
||||
file(GLOB _umbrella_headers "${CMAKE_SOURCE_DIR}/modules/*/include/opencv2/*.hpp")
|
||||
# Also scan opencv_contrib modules when present.
|
||||
if(OPENCV_EXTRA_MODULES_PATH AND EXISTS "${OPENCV_EXTRA_MODULES_PATH}")
|
||||
file(GLOB _contrib_umbrellas "${OPENCV_EXTRA_MODULES_PATH}/*/include/opencv2/*.hpp")
|
||||
list(APPEND _umbrella_headers ${_contrib_umbrellas})
|
||||
endif()
|
||||
foreach(_hdr ${_umbrella_headers})
|
||||
get_filename_component(_stem "${_hdr}" NAME_WE)
|
||||
# Only the umbrella header modules/<m>/include/opencv2/<m>.hpp.
|
||||
if(_hdr MATCHES "/modules/${_stem}/include/opencv2/${_stem}\\.hpp$")
|
||||
file(READ "${_hdr}" _hdr_contents)
|
||||
if(_hdr_contents MATCHES "@defgroup")
|
||||
list(APPEND SPHINX_API_MODULES "${_stem}")
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
list(SORT SPHINX_API_MODULES)
|
||||
message(STATUS "docs_sphinx: discovered API modules: ${SPHINX_API_MODULES}")
|
||||
endif()
|
||||
set(_doxy_input "")
|
||||
foreach(_m ${SPHINX_API_MODULES})
|
||||
foreach(_base "${CMAKE_SOURCE_DIR}/modules" "${OPENCV_EXTRA_MODULES_PATH}")
|
||||
if(EXISTS "${_base}/${_m}/include")
|
||||
string(APPEND _doxy_input " ${_base}/${_m}/include")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
if(EXISTS "${_LEGACY_DOXYFILE}")
|
||||
set(_SPHINX_DOXYFILE "${CMAKE_CURRENT_BINARY_DIR}/Doxyfile-xml")
|
||||
file(WRITE "${_SPHINX_DOXYFILE}"
|
||||
"@INCLUDE = ${_LEGACY_DOXYFILE}\n"
|
||||
"GENERATE_HTML = NO\n"
|
||||
"GENERATE_LATEX = NO\n"
|
||||
"GENERATE_XML = YES\n"
|
||||
"XML_OUTPUT = xml\n"
|
||||
"XML_PROGRAMLISTING = NO\n"
|
||||
"CREATE_SUBDIRS = NO\n"
|
||||
"EXCLUDE_PATTERNS += cap_ios.h\n"
|
||||
"CLASS_GRAPH = NO\n"
|
||||
"COLLABORATION_GRAPH = NO\n"
|
||||
"GROUP_GRAPHS = NO\n"
|
||||
"INCLUDE_GRAPH = NO\n"
|
||||
"INCLUDED_BY_GRAPH = NO\n"
|
||||
"DIRECTORY_GRAPH = NO\n"
|
||||
"INPUT =${_doxy_input}\n"
|
||||
"RECURSIVE = YES\n"
|
||||
"EXCLUDE_SYMBOLS = cv::DataType<*> int void CV__* T __CV* cv::gapi::detail*\n"
|
||||
"MACRO_EXPANSION = YES\n"
|
||||
"EXPAND_ONLY_PREDEF = YES\n"
|
||||
"PREDEFINED += __device__= __host__= __forceinline__= __global__= __constant__= __shared__= __restrict__=\n"
|
||||
)
|
||||
find_program(DOXYGEN_EXE NAMES doxygen)
|
||||
if(DOXYGEN_EXE)
|
||||
# Stamp-based: Doxygen only re-runs when Doxyfile changes, not on every build.
|
||||
set(_SPHINX_XML_STAMP "${_DOXYGEN_XML_DIR}/.sphinx_xml.stamp")
|
||||
add_custom_command(
|
||||
OUTPUT "${_SPHINX_XML_STAMP}"
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${_DOXYGEN_XML_DIR}
|
||||
COMMAND ${DOXYGEN_EXE} ${_SPHINX_DOXYFILE}
|
||||
COMMAND ${CMAKE_COMMAND} -E touch "${_SPHINX_XML_STAMP}"
|
||||
DEPENDS "${_SPHINX_DOXYFILE}"
|
||||
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/doc
|
||||
COMMENT "Doxygen XML (for Sphinx breathe) -> ${_DOXYGEN_XML_DIR}"
|
||||
VERBATIM)
|
||||
add_custom_target(sphinx-xml DEPENDS "${_SPHINX_XML_STAMP}")
|
||||
message(STATUS "docs_sphinx: sphinx-xml target enabled (doxygen: ${DOXYGEN_EXE})")
|
||||
else()
|
||||
message(STATUS "docs_sphinx: doxygen not found; `sphinx-xml` disabled")
|
||||
endif()
|
||||
else()
|
||||
message(STATUS "docs_sphinx: legacy Doxyfile not present at ${_LEGACY_DOXYFILE}; "
|
||||
"`sphinx-xml` disabled (re-run cmake after doc/ subdir configures)")
|
||||
endif()
|
||||
|
||||
set(_SPHINX_ENV
|
||||
"OPENCV_SPHINX_INPUT_ROOT=${_SPHINX_INPUT_ROOT}"
|
||||
"OPENCV_CONTRIB_ROOT=${OPENCV_EXTRA_MODULES_PATH}"
|
||||
"OPENCV_DOXYGEN_XML_DIR=${_DOXYGEN_XML_DIR}"
|
||||
)
|
||||
if(OPENCV_PYTHON_SIGNATURES_FILE)
|
||||
list(APPEND _SPHINX_ENV "OPENCV_PYTHON_SIGNATURES_FILE=${OPENCV_PYTHON_SIGNATURES_FILE}")
|
||||
endif()
|
||||
|
||||
if(NOT SPHINX_JOBS)
|
||||
set(SPHINX_JOBS "auto")
|
||||
endif()
|
||||
set(_SPHINX_WARNINGS "${CMAKE_CURRENT_BINARY_DIR}/sphinx-warnings.log")
|
||||
add_custom_target(sphinx
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${_SPHINX_OUTDIR}
|
||||
COMMAND ${CMAKE_COMMAND} -E env ${_SPHINX_ENV}
|
||||
${SPHINX_BUILD} -c ${_SPHINX_CONFDIR}
|
||||
${SPHINX_BUILD} -j ${SPHINX_JOBS} --keep-going
|
||||
-c ${_SPHINX_CONFDIR}
|
||||
${_SPHINX_INPUT_ROOT}
|
||||
${_SPHINX_OUTDIR}
|
||||
WORKING_DIRECTORY ${_SPHINX_CONFDIR}
|
||||
COMMENT "Building Sphinx HTML site -> ${_SPHINX_OUTDIR}"
|
||||
COMMENT "Building Sphinx HTML site -> ${_SPHINX_OUTDIR} (jobs=${SPHINX_JOBS})"
|
||||
VERBATIM)
|
||||
|
||||
# Ensure breathe sees current XML on every `sphinx` build.
|
||||
if(TARGET sphinx-xml)
|
||||
add_dependencies(sphinx sphinx-xml)
|
||||
endif()
|
||||
# Auto-generate Python binding signatures (needed for Python names in enum tables).
|
||||
if(TARGET gen_opencv_python_source)
|
||||
add_dependencies(sphinx gen_opencv_python_source)
|
||||
endif()
|
||||
|
||||
option(SPHINX_BUILD_DIAGRAMS
|
||||
"Rebuild the Doxygen HTML (collaboration diagrams) as part of the sphinx target" ON)
|
||||
if(SPHINX_BUILD_DIAGRAMS AND TARGET doxygen)
|
||||
add_dependencies(sphinx doxygen)
|
||||
endif()
|
||||
|
||||
# Clean rebuild: `cmake --build <build> --target sphinx-clean`
|
||||
add_custom_target(sphinx-clean
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -rf ${_SPHINX_OUTDIR}
|
||||
|
||||
+944
-48
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
{% extends "!layout.html" %}
|
||||
{%- set _doxy = ('../' * ((pagename or '').count('/') + 2)) ~ 'doc/doxygen/html/' %}
|
||||
{%- set _search = _doxy ~ 'search/' %}
|
||||
{% block extrahead %}
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ _search }}search.css"/>
|
||||
<script src="{{ pathto('_static/search_map.js', 1) }}"></script>
|
||||
<script src="{{ _search }}searchdata.js"></script>
|
||||
<script src="{{ _search }}search.js"></script>
|
||||
<script>
|
||||
// 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) {}
|
||||
|
||||
// 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.
|
||||
function opencvOpenSearch() {
|
||||
var ov = document.getElementById("opencvSearchOverlay");
|
||||
if (!ov) return;
|
||||
ov.classList.add("opencv-search-open");
|
||||
var f = document.getElementById("MSearchField");
|
||||
if (f) { f.focus(); f.select(); }
|
||||
}
|
||||
function opencvCloseSearch() {
|
||||
var ov = document.getElementById("opencvSearchOverlay");
|
||||
if (ov) ov.classList.remove("opencv-search-open");
|
||||
if (window.searchBox && searchBox.CloseResultsWindow) searchBox.CloseResultsWindow();
|
||||
}
|
||||
|
||||
// Win Ctrl+K over the theme's deferred handler + the browser (registered
|
||||
// before the theme's, so stopImmediatePropagation pre-empts it).
|
||||
window.addEventListener("keydown", function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && /^k$/i.test(e.key)) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
opencvOpenSearch();
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// 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
|
||||
// (the visible button may be the second copy — getElementById sees only the
|
||||
// first, which is why clicking did nothing before).
|
||||
var overlays = document.querySelectorAll("#opencvSearchOverlay");
|
||||
for (var k = overlays.length - 1; k >= 1; k--) overlays[k].parentNode.removeChild(overlays[k]);
|
||||
var overlay = overlays[0];
|
||||
if (overlay && overlay.parentNode !== document.body) document.body.appendChild(overlay);
|
||||
|
||||
// 1) Wire opening FIRST so the modal works even if Doxygen init throws.
|
||||
document.querySelectorAll("#opencvSearchTrigger").forEach(function (btn) {
|
||||
btn.addEventListener("click", function (e) { e.preventDefault(); opencvOpenSearch(); });
|
||||
});
|
||||
if (overlay) overlay.addEventListener("mousedown", function (e) { if (e.target === overlay) opencvCloseSearch(); });
|
||||
document.addEventListener("keydown", function (e) { if (/^Escape$/i.test(e.key)) opencvCloseSearch(); });
|
||||
|
||||
// 2) Doxygen search backend (guarded: a failure must not break typing).
|
||||
try {
|
||||
var boxes = document.querySelectorAll("#MSearchBox");
|
||||
for (var i = 1; i < boxes.length; i++) boxes[i].remove();
|
||||
|
||||
var sel = document.createElement("div");
|
||||
sel.id = "MSearchSelectWindow";
|
||||
sel.setAttribute("onmouseover", "return searchBox.OnSearchSelectShow()");
|
||||
sel.setAttribute("onmouseout", "return searchBox.OnSearchSelectHide()");
|
||||
sel.setAttribute("onkeydown", "return searchBox.OnSearchSelectKey(event)");
|
||||
document.body.appendChild(sel);
|
||||
|
||||
var res = document.createElement("div");
|
||||
res.id = "MSearchResultsWindow";
|
||||
res.innerHTML = '<div id="MSearchResults"><div class="SRPage"><div id="SRIndex">' +
|
||||
'<div id="SRResults"></div>' +
|
||||
'<div class="SRStatus" id="Loading">Loading...</div>' +
|
||||
'<div class="SRStatus" id="Searching">Searching...</div>' +
|
||||
'<div class="SRStatus" id="NoMatches">No Matches</div></div></div></div>';
|
||||
document.body.appendChild(res);
|
||||
|
||||
init_search();
|
||||
searchBox.OnSelectItem(0); // default to "All" on every page load
|
||||
|
||||
// Redirect result clicks: Doxygen filename → Sphinx page via sphinxPageMap.
|
||||
var sphinxRoot = "{{ '../' * (pagename or '').count('/') }}";
|
||||
document.body.addEventListener("click", function (e) {
|
||||
var a = e.target.closest ? e.target.closest("a.SRSymbol, a.SRScope") : null;
|
||||
if (!a) return;
|
||||
var href = a.getAttribute("href") || "";
|
||||
if (!href || href.startsWith("javascript:")) return;
|
||||
var fname = href.split("/").pop().replace(/#.*$/, ""); // e.g. "group__core.html"
|
||||
var stem = fname.replace(/\.html$/, ""); // e.g. "group__core"
|
||||
// Normalise to the Sphinx stem: strip group__/tutorial_ prefix, __ → _
|
||||
var key = stem.replace(/^group__/, "").replace(/^tutorial_/, "").replace(/__/g, "_");
|
||||
// Also try exact stem (covers classcv_*, structcv_*, namespacecv_*, etc.)
|
||||
var sphinxPath = (typeof sphinxPageMap !== "undefined") &&
|
||||
(sphinxPageMap[stem] || sphinxPageMap[key]);
|
||||
if (sphinxPath) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = sphinxRoot + sphinxPath;
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Pin the Doxygen popups under the (centered) box with position:fixed
|
||||
// (Doxygen places them absolute at document-top → off-screen here).
|
||||
var box = boxes[0];
|
||||
function pin(w, left) {
|
||||
if (!box) return;
|
||||
var r = box.getBoundingClientRect();
|
||||
w.style.setProperty("position", "fixed", "important");
|
||||
w.style.setProperty("top", (r.bottom + 4) + "px", "important");
|
||||
var x = left ? r.left : Math.max(8, r.right - (w.offsetWidth || 300));
|
||||
w.style.setProperty("left", x + "px", "important");
|
||||
}
|
||||
[[res, false], [sel, true]].forEach(function (p) {
|
||||
var w = p[0], opt = { attributes: true, attributeFilter: ["style"] };
|
||||
var obs = new MutationObserver(function () {
|
||||
if (w.style.display === "block") { obs.disconnect(); pin(w, p[1]); obs.observe(w, opt); }
|
||||
});
|
||||
obs.observe(w, opt);
|
||||
});
|
||||
} catch (err) {
|
||||
if (window.console) console.error("Doxygen search init failed:", err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,19 +1,35 @@
|
||||
{# Override of pydata_sphinx_theme's components/navbar-nav.html.
|
||||
PyData's default renders both top-level toctree items AND the
|
||||
configured external_links in one bar. For OpenCV we want ONLY the
|
||||
Doxygen-style external nav (Main Page, Related Pages, Namespaces,
|
||||
…) in the header — the tutorial module tree belongs in the left
|
||||
sidebar, not the header. So we render external_links only. #}
|
||||
|
||||
PyData's default renders both top-level toctree items AND the configured
|
||||
external_links in one bar. For OpenCV we want ONLY this header nav (the
|
||||
tutorial module tree belongs in the left sidebar, not the header), so we
|
||||
render the `external_links` slot exclusively.
|
||||
|
||||
These are NOT external links: conf.py declares them via the theme's
|
||||
`external_links` key only because that's the data slot for a custom header
|
||||
nav. Each entry is one of:
|
||||
* internal -> {"docname": "<docname>", "name": ...}
|
||||
resolved with `pathto` so the relative URL is correct at ANY page depth
|
||||
and points at the Sphinx-rendered page (landing, tutorials, module roots);
|
||||
* external -> {"url": "<abs-url>", "name": ..., "external": true}
|
||||
rendered verbatim and opened in a new tab (e.g. the Java docs, which
|
||||
have no Sphinx equivalent).
|
||||
|
||||
Internal entries carry no target="_blank" / nav-external glyph since
|
||||
navigation never leaves the site. #}
|
||||
<nav>
|
||||
<ul class="bd-navbar-elements navbar-nav">
|
||||
{%- for link in (theme_external_links or []) %}
|
||||
{%- if link.docname %}
|
||||
{%- set _href = pathto(link.docname) %}
|
||||
{%- set _external = false %}
|
||||
{%- else %}
|
||||
{%- set _href = link.url %}
|
||||
{%- set _external = link.get('external', false) %}
|
||||
{%- endif %}
|
||||
<li class="nav-item">
|
||||
{# PyData's CSS adds the ↗ icon via .nav-external::after — don't
|
||||
render a second one inline or it doubles up. #}
|
||||
<a class="nav-link nav-external"
|
||||
href="{{ link.url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">{{ link.name }}</a>
|
||||
<a class="nav-link" href="{{ _href }}"
|
||||
{%- if _external %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ link.name }}</a>
|
||||
</li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{%- set _search = ('../' * ((pagename or '').count('/') + 2)) ~ 'doc/doxygen/html/search/' %}
|
||||
{# Set magnifier directly; a CSS var here resolves against search.css and 404s. #}
|
||||
<style>
|
||||
#MSearchSelect { background-image: url('{{ _search }}mag_sel.svg') !important; }
|
||||
html[data-theme="dark"] #MSearchSelect { background-image: url('{{ _search }}mag_seld.svg') !important; }
|
||||
</style>
|
||||
{# Navbar trigger: native PyData look (.search-button-field), but NOT the
|
||||
.search-button__button class — that is the theme's own show-modal hook. #}
|
||||
<button id="opencvSearchTrigger" type="button" class="btn search-button-field"
|
||||
title="{{ _('Search') }}" aria-label="{{ _('Search') }}">
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
<span class="search-button__default-text">{{ _('Search') }}</span>
|
||||
<span class="search-button__kbd-shortcut"><kbd class="kbd-shortcut__modifier">Ctrl</kbd>+<kbd class="kbd-shortcut__modifier">K</kbd></span>
|
||||
</button>
|
||||
{# Centered modal overlay (layout.html moves it to <body> and toggles the open
|
||||
class). Holds the Doxygen search box; results render via Doxygen's search.js. #}
|
||||
<div id="opencvSearchOverlay" class="opencv-search-overlay" role="dialog"
|
||||
aria-modal="true" aria-label="{{ _('Search') }}">
|
||||
<div class="opencv-search-modal">
|
||||
<div id="MSearchBox" class="MSearchBoxInactive">
|
||||
<span class="left">
|
||||
<span id="MSearchSelect"
|
||||
onmouseover="return searchBox.OnSearchSelectShow()"
|
||||
onmouseout="return searchBox.OnSearchSelectHide()"> </span>
|
||||
<input type="text" id="MSearchField" value="" placeholder="Search the docs ..."
|
||||
accesskey="S"
|
||||
onfocus="searchBox.OnSearchFieldFocus(true)"
|
||||
onblur="searchBox.OnSearchFieldFocus(false)"
|
||||
onkeyup="searchBox.OnSearchFieldChange(event)"/>
|
||||
</span>
|
||||
<span class="right">
|
||||
<span id="MSearchKbd" class="search-button__kbd-shortcut">
|
||||
<kbd class="kbd-shortcut__modifier">Ctrl</kbd>+<kbd>K</kbd>
|
||||
</span>
|
||||
<a id="MSearchClose" href="javascript:searchBox.CloseResultsWindow()">
|
||||
<img id="MSearchCloseImg" border="0" src="{{ _search }}close.svg" alt=""/></a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+98
-1867
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
# This file is part of OpenCV project.
|
||||
# It is subject to the license terms in the LICENSE file found in the top-level directory
|
||||
# of this distribution and at http://opencv.org/license.html.
|
||||
# Copyright (C) 2026, BigVision LLC, all rights reserved.
|
||||
# Third party copyrights are property of their respective owners.
|
||||
|
||||
"""Doc-build engine for the OpenCV Sphinx wrapper.
|
||||
|
||||
conf.py stays a thin Sphinx-settings file; everything heavy lives here:
|
||||
|
||||
* ``state`` — shared config, paths, tag maps, bib/citation numbering,
|
||||
redirect map, anchor indexes, constants
|
||||
* ``xml_render`` — Doxygen XML -> Markdown primitives (incl. enum synopsis)
|
||||
* ``stubs`` — API-reference stub writers (groups / classes)
|
||||
* ``translate`` — Doxygen-flavored .markdown -> MyST (the source-read engine)
|
||||
* ``patches`` — Sphinx C++ domain / breathe warning patches
|
||||
* ``postprocess`` — build-finished hook that inlines collaboration-diagram SVGs
|
||||
* ``build`` — import-time orchestration that populates the shared indexes
|
||||
"""
|
||||
@@ -0,0 +1,413 @@
|
||||
# This file is part of OpenCV project.
|
||||
# It is subject to the license terms in the LICENSE file found in the top-level directory
|
||||
# of this distribution and at http://opencv.org/license.html.
|
||||
# Copyright (C) 2026, BigVision LLC, all rights reserved.
|
||||
# Third party copyrights are property of their respective owners.
|
||||
|
||||
"""Import-time orchestration: populate the shared indexes."""
|
||||
from __future__ import annotations
|
||||
import pathlib, re, os as _os, shutil as _shutil, textwrap as _textwrap
|
||||
from .state import *
|
||||
from .xml_render import _patch_namespace_xml_for_breathe
|
||||
from .stubs import _generate_api_stubs
|
||||
|
||||
# Skip when input root is DOC_ROOT: writing there is forbidden.
|
||||
if _BIB_ENTRIES_SORTED and SPHINX_INPUT_ROOT != DOC_ROOT:
|
||||
try:
|
||||
SPHINX_INPUT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
(SPHINX_INPUT_ROOT / "citelist.markdown").write_text(
|
||||
_bib_render_all(_BIB_ENTRIES_SORTED, _CITE_NUMBER),
|
||||
encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Internal scan: enabled subtrees + standalone pages.
|
||||
_scan_internal(SPHINX_INPUT_ROOT / "tutorials" / "tutorials.markdown")
|
||||
for _m in DOC_MODULES:
|
||||
_scan_internal(SPHINX_INPUT_ROOT / "tutorials" / _m)
|
||||
if JS_DOC_MODULES:
|
||||
_scan_internal(DOC_ROOT / "js_tutorials" / "js_tutorials.markdown",
|
||||
base=DOC_ROOT)
|
||||
for _m in JS_DOC_MODULES:
|
||||
_scan_internal(DOC_ROOT / "js_tutorials" / _m, base=DOC_ROOT)
|
||||
if PY_DOC_MODULES:
|
||||
_scan_internal(DOC_ROOT / "py_tutorials" / "py_tutorials.markdown",
|
||||
base=DOC_ROOT)
|
||||
for _m in PY_DOC_MODULES:
|
||||
_scan_internal(DOC_ROOT / "py_tutorials" / _m, base=DOC_ROOT)
|
||||
|
||||
_contrib_dir = SPHINX_INPUT_ROOT / "tutorials_contrib"
|
||||
_contrib_root_md = next(
|
||||
(p for p in (_contrib_dir / "contrib_root.markdown",
|
||||
_contrib_dir / "tutorials_contrib.markdown") if p.is_file()),
|
||||
_contrib_dir / "contrib_root.markdown")
|
||||
if _contrib_root_md.is_file():
|
||||
_scan_internal(_contrib_root_md)
|
||||
for _m in CONTRIB_MODULES:
|
||||
_scan_internal(SPHINX_INPUT_ROOT / "tutorials_contrib" / _m)
|
||||
# Standalone top-level pages.
|
||||
_scan_internal(SPHINX_INPUT_ROOT / "faq.markdown")
|
||||
_scan_internal(SPHINX_INPUT_ROOT / "citelist.markdown")
|
||||
_scan_internal(SPHINX_INPUT_ROOT / "intro.markdown")
|
||||
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".bmp", ".webp"}
|
||||
for _root in ((DOC_ROOT / "tutorials").rglob("images/*"),
|
||||
(DOC_ROOT / "js_tutorials").rglob("images/*"),
|
||||
(DOC_ROOT / "js_tutorials" / "js_assets").glob("*"),
|
||||
(DOC_ROOT / "py_tutorials").rglob("images/*"),
|
||||
(DOC_ROOT / "images").glob("*")):
|
||||
for _img in _root:
|
||||
if _img.is_file():
|
||||
_IMAGE_INDEX.setdefault(_img.name, _img.relative_to(DOC_ROOT).as_posix())
|
||||
for _m in CONTRIB_MODULES:
|
||||
# <m>/tutorials/**/images/*
|
||||
_tut = CONTRIB_ROOT / _m / "tutorials"
|
||||
if _tut.is_dir():
|
||||
for _img in _tut.rglob("images/*"):
|
||||
if _img.is_file():
|
||||
_rel = _img.relative_to(_tut).as_posix()
|
||||
_IMAGE_INDEX.setdefault(_img.name,
|
||||
f"tutorials_contrib/{_m}/{_rel}")
|
||||
# Contrib images outside <m>/tutorials/.
|
||||
for _sub in ("doc", "samples"):
|
||||
_src = CONTRIB_ROOT / _m / _sub
|
||||
if _src.is_dir():
|
||||
for _img in _src.rglob("*"):
|
||||
if _img.is_file() and _img.suffix.lower() in _IMAGE_EXTS:
|
||||
_rel = _img.relative_to(CONTRIB_ROOT).as_posix()
|
||||
_IMAGE_INDEX.setdefault(_img.name,
|
||||
f"contrib_modules/{_rel}")
|
||||
|
||||
if API_MODULES:
|
||||
_api_pics = SPHINX_INPUT_ROOT / "api_pics"
|
||||
_stage_pics = SPHINX_INPUT_ROOT != DOC_ROOT
|
||||
if _stage_pics:
|
||||
_api_pics.mkdir(parents=True, exist_ok=True)
|
||||
_modules_root = DOC_ROOT.parent / "modules"
|
||||
if _modules_root.is_dir():
|
||||
for _doc_dir in sorted(_modules_root.glob("*/doc")):
|
||||
for _img in _doc_dir.rglob("*"):
|
||||
if not (_img.is_file() and _img.suffix.lower() in _IMAGE_EXTS):
|
||||
continue
|
||||
if _img.name in _IMAGE_INDEX:
|
||||
continue
|
||||
_IMAGE_INDEX[_img.name] = f"api_pics/{_img.name}"
|
||||
if _stage_pics:
|
||||
_link = _api_pics / _img.name
|
||||
if not _link.exists():
|
||||
try:
|
||||
_os.symlink(_img, _link)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
_shutil.copy2(_img, _link)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if _API_XML_DIR.is_dir():
|
||||
_patch_namespace_xml_for_breathe(_API_XML_DIR, _PATCHED_XML_DIR)
|
||||
|
||||
from conf_helpers.state import OPENCV_ROOT, CONTRIB_ROOT
|
||||
_is_contrib = lambda m: (CONTRIB_ROOT / m).is_dir() and not (
|
||||
OPENCV_ROOT / "modules" / m).is_dir()
|
||||
_main_api = [m for m in API_MODULES if not _is_contrib(m)]
|
||||
_extra_api = [m for m in API_MODULES if _is_contrib(m)]
|
||||
_generate_api_stubs(_main_api, _API_XML_DIR, SPHINX_INPUT_ROOT / "main_modules",
|
||||
root_anchor="api_root", root_title="Main modules")
|
||||
_scan_internal(SPHINX_INPUT_ROOT / "main_modules")
|
||||
if _extra_api:
|
||||
_generate_api_stubs(_extra_api, _API_XML_DIR, SPHINX_INPUT_ROOT / "extra_modules",
|
||||
root_anchor="extra_api_root", root_title="Extra modules")
|
||||
_scan_internal(SPHINX_INPUT_ROOT / "extra_modules")
|
||||
|
||||
|
||||
def _write_root_index() -> None:
|
||||
"""Generate the Sphinx landing page at ``index.html``.
|
||||
|
||||
The legacy tutorials root remains focused on C++ tutorials. Cross-family
|
||||
entry points live here so the site root no longer redirects users straight
|
||||
to ``tutorials/tutorials.html``.
|
||||
|
||||
Each entry renders as a section heading (the category) with the page link
|
||||
on the line beneath it. FAQ and Bibliography are direct links whose heading
|
||||
*is* the link. A hidden toctree mirrors the same order to drive the sidebar.
|
||||
"""
|
||||
if SPHINX_INPUT_ROOT == DOC_ROOT:
|
||||
return
|
||||
|
||||
entries: list[tuple[str, str | None, str]] = []
|
||||
|
||||
def add(heading: str, link_text: str | None, docname: str,
|
||||
condition: bool = True) -> None:
|
||||
if condition:
|
||||
entries.append((heading, link_text, docname))
|
||||
|
||||
add("Introduction", "Introduction", "intro", "intro" in _ANCHOR_TO_DOC)
|
||||
add("OpenCV Tutorials", "OpenCV tutorials", "tutorials/tutorials")
|
||||
add("Python Tutorials", "OpenCV-Python tutorials",
|
||||
"py_tutorials/py_tutorials", bool(PY_DOC_MODULES))
|
||||
add("Javascript Tutorials", "OpenCV.js tutorials",
|
||||
"js_tutorials/js_tutorials", bool(JS_DOC_MODULES))
|
||||
add("Contrib Tutorials", "Tutorials for contrib module",
|
||||
f"tutorials_contrib/{_contrib_root_md.stem}",
|
||||
bool(CONTRIB_MODULES) and _contrib_root_md.is_file())
|
||||
add("Main modules", "Documentation for main modules",
|
||||
"main_modules/api_root", "api_root" in _ANCHOR_TO_DOC)
|
||||
add("Extra modules", "Documentation for extra modules",
|
||||
"extra_modules/api_root", "extra_api_root" in _ANCHOR_TO_DOC)
|
||||
add("Frequently Asked Questions", None, "faq", "faq" in _ANCHOR_TO_DOC)
|
||||
add("Bibliography", None, "citelist", "citelist" in _ANCHOR_TO_DOC)
|
||||
|
||||
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">']
|
||||
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)
|
||||
|
||||
text = (
|
||||
"OpenCV modules\n"
|
||||
"==============\n\n"
|
||||
"```{toctree}\n"
|
||||
":hidden:\n"
|
||||
":maxdepth: 1\n"
|
||||
":titlesonly:\n\n"
|
||||
f"{toctree}\n"
|
||||
"```\n\n"
|
||||
f"{body}\n"
|
||||
)
|
||||
try:
|
||||
(SPHINX_INPUT_ROOT / "index.markdown").write_text(text, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _write_related_pages_index() -> None:
|
||||
"""Generate `related_pages.markdown` — the local analog of Doxygen's
|
||||
pages.html (the header "Related Pages" target).
|
||||
|
||||
Lists every standalone documentation page (\\page) that has a *local*
|
||||
Sphinx docname, so nothing points off-site. Titles and the canonical set
|
||||
come from the Doxygen tag page index (`_DOC_PAGE_TITLES`); a page is
|
||||
emitted only when its name resolves through `_ANCHOR_TO_DOC`, so the list
|
||||
contains exactly what this build actually rendered and grows automatically
|
||||
as more modules are enabled. Marked `orphan` — reached via the header link,
|
||||
not the sidebar toctree (intro/faq/citelist already live in the index toc).
|
||||
"""
|
||||
if SPHINX_INPUT_ROOT == DOC_ROOT:
|
||||
return
|
||||
rows: list[tuple[str, str]] = [] # (title, docname)
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(anchor: str) -> None:
|
||||
doc = _ANCHOR_TO_DOC.get(anchor)
|
||||
if doc and anchor not in seen:
|
||||
title = (_DOC_PAGE_TITLES.get(anchor)
|
||||
or _ANCHOR_TO_TITLE.get(anchor) or anchor)
|
||||
rows.append((title, doc))
|
||||
seen.add(anchor)
|
||||
|
||||
# Core standalone pages first, in a stable, friendly order.
|
||||
for _a in ("intro", "faq", "citelist"):
|
||||
add(_a)
|
||||
# Then every other \page that resolves locally, alphabetical by title.
|
||||
for _name in sorted(_DOC_PAGE_TITLES,
|
||||
key=lambda n: (_DOC_PAGE_TITLES.get(n) or n).lower()):
|
||||
add(_name)
|
||||
|
||||
items = "\n".join(f'<li><a href="{_d}.html">{_t}</a></li>' for _t, _d in rows)
|
||||
text = (
|
||||
"---\norphan: true\n---\n"
|
||||
"# Related Pages\n\n"
|
||||
"All standalone documentation pages available in this build.\n\n"
|
||||
f'<ul class="ocv-related-pages">\n{items}\n</ul>\n'
|
||||
)
|
||||
try:
|
||||
(SPHINX_INPUT_ROOT / "related_pages.markdown").write_text(
|
||||
text, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _write_examples_index() -> None:
|
||||
"""Generate `examples/examples_root.markdown` — the local analog of
|
||||
Doxygen's examples.html (the header "Examples" target).
|
||||
|
||||
The per-sample example pages are orphan pages reached from class "Examples"
|
||||
blocks; this index links them all in one place. Sourced from
|
||||
`_EXAMPLE_PAGES_NEEDED` (populated during API-stub generation), so it lists
|
||||
exactly the samples this build emitted. Also `orphan` (header-only entry).
|
||||
"""
|
||||
if SPHINX_INPUT_ROOT == DOC_ROOT:
|
||||
return
|
||||
from .examples import _EXAMPLE_PAGES_NEEDED, _example_pagename
|
||||
if not _EXAMPLE_PAGES_NEEDED:
|
||||
return
|
||||
items = "\n".join(
|
||||
f'<li><a href="{_example_pagename(_d)}.html">{_d}</a></li>'
|
||||
for _d in sorted(_EXAMPLE_PAGES_NEEDED))
|
||||
text = (
|
||||
"---\norphan: true\n---\n"
|
||||
"# Examples\n\n"
|
||||
"All example programs referenced in the API documentation.\n\n"
|
||||
f'<ul class="ocv-examples-index">\n{items}\n</ul>\n'
|
||||
)
|
||||
try:
|
||||
(SPHINX_INPUT_ROOT / "examples").mkdir(parents=True, exist_ok=True)
|
||||
(SPHINX_INPUT_ROOT / "examples" / "examples_root.markdown").write_text(
|
||||
text, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _esc(s: str) -> str:
|
||||
"""Minimal HTML escape for brief text injected into the index <li> markup."""
|
||||
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def _write_namespace_list_index() -> None:
|
||||
"""Generate `namespace_list.markdown` — local analog of Doxygen's
|
||||
namespaces.html (the header "Namespaces" target).
|
||||
|
||||
Renders the namespace tree (cv → cv::cuda → …) as a nested list, each node
|
||||
linking to its local namespace page with the brief description alongside.
|
||||
Intermediate namespaces with no page of their own render as plain text.
|
||||
Sourced from `_ALL_NAMESPACES` (populated during API-stub generation).
|
||||
"""
|
||||
if SPHINX_INPUT_ROOT == DOC_ROOT or not _ALL_NAMESPACES:
|
||||
return
|
||||
# Nested tree keyed by path component; each node tracks its full name.
|
||||
tree: dict = {}
|
||||
for _name in _ALL_NAMESPACES:
|
||||
node = tree
|
||||
parts = _name.split("::")
|
||||
for _i, _part in enumerate(parts):
|
||||
node = node.setdefault(
|
||||
_part, {"_full": "::".join(parts[:_i + 1]), "_kids": {}})["_kids"]
|
||||
|
||||
def render(node: dict) -> list[str]:
|
||||
out = ["<ul>"]
|
||||
for _part in sorted(node, key=str.lower):
|
||||
child = node[_part]
|
||||
info = _ALL_NAMESPACES.get(child["_full"])
|
||||
if info:
|
||||
label = f'<a href="{info["docname"]}.html">{_part}</a>'
|
||||
if info.get("brief"):
|
||||
label += f' — {_esc(info["brief"])}'
|
||||
else:
|
||||
label = _part
|
||||
out.append(f"<li>{label}")
|
||||
if child["_kids"]:
|
||||
out += render(child["_kids"])
|
||||
out.append("</li>")
|
||||
out.append("</ul>")
|
||||
return out
|
||||
|
||||
text = (
|
||||
"---\norphan: true\n---\n"
|
||||
"# Namespace List\n\n"
|
||||
"Here is a list of all documented namespaces with brief descriptions.\n\n"
|
||||
f'<div class="ocv-namespace-list">\n{chr(10).join(render(tree))}\n</div>\n'
|
||||
)
|
||||
try:
|
||||
(SPHINX_INPUT_ROOT / "namespace_list.markdown").write_text(
|
||||
text, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _write_class_list_index() -> None:
|
||||
"""Generate `class_list.markdown` — local analog of Doxygen's annotated.html
|
||||
(the header "Classes" target).
|
||||
|
||||
Lists every documented class/struct grouped by its enclosing namespace,
|
||||
each linking to its local page with the brief description. Sourced from
|
||||
`_ALL_CLASSES` (populated during API-stub generation).
|
||||
"""
|
||||
if SPHINX_INPUT_ROOT == DOC_ROOT or not _ALL_CLASSES:
|
||||
return
|
||||
by_ns: dict[str, list[tuple[str, dict]]] = {}
|
||||
for _info in _ALL_CLASSES.values():
|
||||
qualified = _info.get("qualified", "")
|
||||
if not qualified:
|
||||
continue
|
||||
ns, _, leaf = qualified.rpartition("::")
|
||||
by_ns.setdefault(ns, []).append((leaf, _info))
|
||||
|
||||
body = ['<ul class="ocv-class-list">']
|
||||
for ns in sorted(by_ns, key=lambda n: (n == "", n.lower())):
|
||||
heading = ns if ns else "(global namespace)"
|
||||
ns_info = _ALL_NAMESPACES.get(ns)
|
||||
if ns_info:
|
||||
heading = f'<a href="{ns_info["docname"]}.html">{ns}</a>'
|
||||
body.append(f"<li><b>{heading}</b>")
|
||||
body.append("<ul>")
|
||||
for leaf, info in sorted(by_ns[ns], key=lambda t: t[0].lower()):
|
||||
entry = f'<a href="{info["docname"]}.html">{leaf}</a>'
|
||||
if info.get("brief"):
|
||||
entry += f' — {_esc(info["brief"])}'
|
||||
body.append(f"<li>{entry}</li>")
|
||||
body.append("</ul></li>")
|
||||
body.append("</ul>")
|
||||
|
||||
text = (
|
||||
"---\norphan: true\n---\n"
|
||||
"# Class List\n\n"
|
||||
"Here are the classes, structs and unions with brief descriptions.\n\n"
|
||||
f'<div class="ocv-class-list-wrap">\n{chr(10).join(body)}\n</div>\n'
|
||||
)
|
||||
try:
|
||||
(SPHINX_INPUT_ROOT / "class_list.markdown").write_text(
|
||||
text, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
_write_root_index()
|
||||
_write_related_pages_index()
|
||||
if API_MODULES:
|
||||
_write_examples_index()
|
||||
_write_namespace_list_index()
|
||||
_write_class_list_index()
|
||||
|
||||
for _toc in (DOC_ROOT / "tutorials").glob("*/table_of_content_*.markdown"):
|
||||
if _toc.parent.name not in DOC_MODULES:
|
||||
_scan_external(_toc)
|
||||
# Same for js_tutorials (files are named js_table_of_contents_*.markdown there).
|
||||
for _toc in (DOC_ROOT / "js_tutorials").glob("*/js_table_of_contents_*.markdown"):
|
||||
if _toc.parent.name not in JS_DOC_MODULES:
|
||||
_scan_external(_toc)
|
||||
# py_tutorials uses the `py_table_of_contents_*.markdown` naming variant.
|
||||
for _toc in (DOC_ROOT / "py_tutorials").glob("*/py_table_of_contents_*.markdown"):
|
||||
if _toc.parent.name not in PY_DOC_MODULES:
|
||||
_scan_external(_toc)
|
||||
|
||||
_REFERENCED_ANCHORS.update({
|
||||
"intro", "faq", "citelist",
|
||||
"tutorial_js_root", "tutorial_py_root", "tutorial_contrib_root",
|
||||
"api_root", "extra_api_root",
|
||||
})
|
||||
|
||||
# Snippet basename index (mirrors Doxygen EXAMPLE_RECURSIVE lookup).
|
||||
_SNIPPET_EXTENSIONS = {
|
||||
".cpp", ".hpp", ".h", ".c", ".cc", ".cxx",
|
||||
".py", ".java", ".kt", ".scala", ".clj", ".groovy",
|
||||
".sh", ".bash", ".bat", ".ps1",
|
||||
".cmake", ".gradle",
|
||||
".xml", ".yaml", ".yml", ".json", ".html", ".css",
|
||||
".js", ".ts", ".rb",
|
||||
}
|
||||
_snippet_scan_roots = [OPENCV_ROOT / "samples", OPENCV_ROOT / "apps"] + [
|
||||
CONTRIB_ROOT / _m / "samples" for _m in CONTRIB_MODULES]
|
||||
for _root in _snippet_scan_roots:
|
||||
if _root.is_dir():
|
||||
for _f in _root.rglob("*"):
|
||||
if _f.is_file() and _f.suffix.lower() in _SNIPPET_EXTENSIONS:
|
||||
_SNIPPET_INDEX.setdefault(_f.name, _f)
|
||||
@@ -0,0 +1,435 @@
|
||||
# This file is part of OpenCV project.
|
||||
# It is subject to the license terms in the LICENSE file found in the top-level directory
|
||||
# of this distribution and at http://opencv.org/license.html.
|
||||
# Copyright (C) 2026, BigVision LLC, all rights reserved.
|
||||
# Third party copyrights are property of their respective owners.
|
||||
|
||||
"""Per-class "Examples" cross-reference system."""
|
||||
from __future__ import annotations
|
||||
import re, pathlib
|
||||
from .state import *
|
||||
|
||||
_EXAMPLE_SOURCE_EXTENSIONS = {
|
||||
# Program file types only; headers excluded.
|
||||
".cpp", ".cc", ".cxx", ".c",
|
||||
".py", ".java", ".js", ".ts",
|
||||
}
|
||||
_EXAMPLE_LANGUAGE = {
|
||||
".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".c": "c",
|
||||
".py": "python", ".java": "java",
|
||||
".js": "javascript", ".ts": "typescript",
|
||||
}
|
||||
_EXAMPLE_INCLUDE_SUBTREES = (
|
||||
"samples/cpp/tutorial_code/",
|
||||
"samples/python/",
|
||||
"samples/java/",
|
||||
"samples/dnn/",
|
||||
"samples/gpu/",
|
||||
)
|
||||
|
||||
|
||||
def _is_canonical_example(rel_path: str) -> bool:
|
||||
"""True iff this repo-relative path is a canonical example."""
|
||||
if any(rel_path.startswith(p) for p in _EXAMPLE_INCLUDE_SUBTREES):
|
||||
return True
|
||||
if rel_path.startswith("samples/cpp/"):
|
||||
rest = rel_path[len("samples/cpp/"):]
|
||||
return "/" not in rest # direct child only, not nested
|
||||
if re.match(r"opencv_contrib/modules/[^/]+/samples/", rel_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _example_pagename(display_path: str) -> str:
|
||||
"""`samples/cpp/pca.cpp` → `samples_cpp_pca_cpp` (Sphinx-safe basename)."""
|
||||
return re.sub(r"[^A-Za-z0-9]+", "_", display_path).strip("_").lower()
|
||||
|
||||
|
||||
_EXAMPLE_FILES: list[tuple[str, pathlib.Path, frozenset[str]]] = []
|
||||
_EXAMPLE_TOKEN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
||||
|
||||
# Not scoped to CONTRIB_MODULES: examples surface for all modules.
|
||||
_example_scan_roots: list[tuple[pathlib.Path, str]] = [
|
||||
(OPENCV_ROOT / "samples", "samples"),
|
||||
]
|
||||
if CONTRIB_ROOT.is_dir():
|
||||
for _module_dir in sorted(CONTRIB_ROOT.iterdir()):
|
||||
if not _module_dir.is_dir():
|
||||
continue
|
||||
_contrib_samples = _module_dir / "samples"
|
||||
if _contrib_samples.is_dir():
|
||||
_example_scan_roots.append((
|
||||
_contrib_samples,
|
||||
f"opencv_contrib/modules/{_module_dir.name}/samples",
|
||||
))
|
||||
|
||||
for _root, _display_prefix in _example_scan_roots:
|
||||
if not _root.is_dir():
|
||||
continue
|
||||
for _f in _root.rglob("*"):
|
||||
if not _f.is_file() or _f.suffix.lower() not in _EXAMPLE_SOURCE_EXTENSIONS:
|
||||
continue
|
||||
_rel = _f.relative_to(_root).as_posix()
|
||||
_display = f"{_display_prefix}/{_rel}"
|
||||
if not _is_canonical_example(_display):
|
||||
continue
|
||||
try:
|
||||
_text = _f.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
_EXAMPLE_FILES.append((
|
||||
_display,
|
||||
_f,
|
||||
frozenset(_EXAMPLE_TOKEN_RE.findall(_text)),
|
||||
))
|
||||
_EXAMPLE_FILES.sort(key=lambda t: t[0])
|
||||
|
||||
# Display path → source path; only referenced samples (avoids orphan pages).
|
||||
_EXAMPLE_PAGES_NEEDED: dict[str, pathlib.Path] = {}
|
||||
|
||||
|
||||
_TUTORIAL_LINK_RE = re.compile(
|
||||
r"\[([^\]]+)\]\(([^)]*?samples/[^)]+?\.(?:cpp|cc|cxx|c|py|java|js|ts))\)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Trailing connector phrase, stripped from end of description.
|
||||
_TUTORIAL_LINK_TRAILER_RE = re.compile(
|
||||
r"\s*(?:"
|
||||
r"can be found at|can be found in|"
|
||||
r"are available (?:at|in)|is available (?:at|in)|"
|
||||
r"located at|located in|found at|found in|"
|
||||
r"see also|see|here|"
|
||||
r"is at|is in|at|in"
|
||||
r")\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TUTORIAL_LEADING_DIRECTIVE_RE = re.compile(
|
||||
r"^@(?:note|see|sa|warning|attention|remark|brief|todo|deprecated)\s+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ANOTHER_LEAD_RE = re.compile(r"^Another\s+(\w)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_link_lead_in(text: str, link_start: int) -> str:
|
||||
"""Pull the cleaned-up prose right before a tutorial hyperlink."""
|
||||
LOOKBACK = 400
|
||||
before = text[max(0, link_start - LOOKBACK):link_start]
|
||||
cut = max(
|
||||
before.rfind("\n\n"),
|
||||
before.rfind(". "),
|
||||
before.rfind("! "),
|
||||
before.rfind("? "),
|
||||
before.rfind(":\n"),
|
||||
)
|
||||
if cut >= 0:
|
||||
before = before[cut + 2:] # skip past the 2-char boundary
|
||||
before = _TUTORIAL_LEADING_DIRECTIVE_RE.sub("", before.lstrip())
|
||||
before = re.sub(r"\s+", " ", before).strip()
|
||||
before = _TUTORIAL_LINK_TRAILER_RE.sub("", before).strip()
|
||||
m = _ANOTHER_LEAD_RE.match(before)
|
||||
if m:
|
||||
article = "An " if m.group(1).lower() in "aeiou" else "A "
|
||||
before = article + before[m.start(1):]
|
||||
return before
|
||||
|
||||
|
||||
def _scan_tutorial_sample_refs() -> dict[str, str]:
|
||||
"""Walk every tutorial markdown for sample-file hyperlinks."""
|
||||
refs: dict[str, str] = {}
|
||||
tutorial_roots = [
|
||||
DOC_ROOT / "tutorials",
|
||||
DOC_ROOT / "js_tutorials",
|
||||
DOC_ROOT / "py_tutorials",
|
||||
]
|
||||
for tut_root in tutorial_roots:
|
||||
if not tut_root.is_dir():
|
||||
continue
|
||||
for md in tut_root.rglob("*.markdown"):
|
||||
try:
|
||||
text = md.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
for m in _TUTORIAL_LINK_RE.finditer(text):
|
||||
sample_m = re.search(
|
||||
r"samples/.+\.(?:cpp|cc|cxx|c|py|java|js|ts)",
|
||||
m.group(2), re.IGNORECASE,
|
||||
)
|
||||
if not sample_m:
|
||||
continue
|
||||
sample = sample_m.group(0)
|
||||
brief = _extract_link_lead_in(text, m.start())
|
||||
if not brief:
|
||||
continue
|
||||
if sample not in refs or len(brief) > len(refs[sample]):
|
||||
refs[sample] = brief
|
||||
return refs
|
||||
|
||||
|
||||
_TUTORIAL_SAMPLE_REFS: dict[str, str] = _scan_tutorial_sample_refs()
|
||||
|
||||
_DOXY_EXAMPLE_RE = re.compile(
|
||||
r"[@\\]example\s+(\S+)" # declared path
|
||||
r"[^\n]*\n"
|
||||
r"(?P<desc>"
|
||||
r"(?:"
|
||||
r"(?!\s*\*/)" # not comment closer
|
||||
r"(?!\s*\*?\s*[@\\]\w+)" # not a new directive
|
||||
r"[^\n]*\n"
|
||||
r")*"
|
||||
r")"
|
||||
)
|
||||
|
||||
_DOXY_PERCENT_ESCAPE_RE = re.compile(r"%(\w+)")
|
||||
|
||||
|
||||
def _resolve_example_path(decl_path: str, header_path: pathlib.Path) -> str | None:
|
||||
"""Resolve a `@example <path>` to our repo-relative display path."""
|
||||
# Case 1: repo-relative.
|
||||
abs_main = OPENCV_ROOT / decl_path
|
||||
if abs_main.is_file():
|
||||
return abs_main.relative_to(OPENCV_ROOT).as_posix()
|
||||
|
||||
# Case 2: module-relative — module root is parent of include/.
|
||||
module_root: pathlib.Path | None = None
|
||||
for p in header_path.parents:
|
||||
if p.name == "include":
|
||||
module_root = p.parent
|
||||
break
|
||||
if module_root is None:
|
||||
return None
|
||||
|
||||
abs_mod = module_root / decl_path
|
||||
if not abs_mod.is_file():
|
||||
return None
|
||||
|
||||
# is_relative_to not startswith: opencv prefixes opencv_contrib.
|
||||
if abs_mod.is_relative_to(OPENCV_ROOT):
|
||||
return abs_mod.relative_to(OPENCV_ROOT).as_posix()
|
||||
contrib_parent = CONTRIB_ROOT.parent.parent
|
||||
if abs_mod.is_relative_to(contrib_parent):
|
||||
return abs_mod.relative_to(contrib_parent).as_posix()
|
||||
return f"opencv_contrib/modules/{module_root.name}/{decl_path}"
|
||||
|
||||
|
||||
def _scan_doxygen_example_decls() -> dict[str, tuple[str, str]]:
|
||||
"""Walk every module header for `@example` declarations."""
|
||||
refs: dict[str, tuple[str, str]] = {}
|
||||
roots = [OPENCV_ROOT / "modules"]
|
||||
if CONTRIB_ROOT.is_dir():
|
||||
roots.append(CONTRIB_ROOT)
|
||||
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for ext in ("*.hpp", "*.h"):
|
||||
for header in root.rglob(ext):
|
||||
# Only <module>/include/.
|
||||
if "/include/" not in header.as_posix():
|
||||
continue
|
||||
try:
|
||||
text = header.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
for m in _DOXY_EXAMPLE_RE.finditer(text):
|
||||
declared = m.group(1).strip().rstrip("*/").strip()
|
||||
resolved = _resolve_example_path(declared, header)
|
||||
if not resolved:
|
||||
continue
|
||||
raw = m.group("desc") or ""
|
||||
cleaned = re.sub(r"\n\s*\*\s?", " ", raw)
|
||||
cleaned = cleaned.replace("*/", "").strip()
|
||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||
cleaned = _DOXY_PERCENT_ESCAPE_RE.sub(r"\1", cleaned)
|
||||
# Keep longest if declared in multiple headers.
|
||||
existing = refs.get(resolved)
|
||||
if existing is None or len(cleaned) > len(existing[1]):
|
||||
refs[resolved] = (declared, cleaned)
|
||||
return refs
|
||||
|
||||
|
||||
_DOXY_EXAMPLE_DECLS: dict[str, tuple[str, str]] = _scan_doxygen_example_decls()
|
||||
|
||||
|
||||
def _find_examples_for_class(class_simple: str) -> list[tuple[str, str]]:
|
||||
"""Canonical sample files mentioning the class name."""
|
||||
if not class_simple:
|
||||
return []
|
||||
candidates = {class_simple}
|
||||
if class_simple.startswith("_") and len(class_simple) > 1:
|
||||
candidates.add(class_simple[1:]) # _InputArray → InputArray alias
|
||||
out: list[tuple[str, str]] = []
|
||||
for display, source_path, tokens in _EXAMPLE_FILES:
|
||||
# Stage 1: must be declared with @example.
|
||||
decl = _DOXY_EXAMPLE_DECLS.get(display)
|
||||
if decl is None:
|
||||
continue
|
||||
# Stage 2: must mention the class.
|
||||
if any(c in tokens for c in candidates):
|
||||
_EXAMPLE_PAGES_NEEDED[display] = source_path
|
||||
declared, _desc = decl
|
||||
out.append((declared, _example_pagename(display)))
|
||||
return out
|
||||
|
||||
|
||||
def _render_examples_block(examples: list[tuple[str, str]]) -> list[str]:
|
||||
"""HTML lines for the "Examples" footer; empty list if no matches."""
|
||||
if not examples:
|
||||
return []
|
||||
import html as _html_pkg
|
||||
parts = [
|
||||
f'<a class="opencv-example-link" '
|
||||
f'href="../examples/{_html_pkg.escape(page, quote=True)}.html">'
|
||||
f'{_html_pkg.escape(display)}</a>'
|
||||
for display, page in examples
|
||||
]
|
||||
if len(parts) == 1:
|
||||
joined = parts[0]
|
||||
else:
|
||||
joined = ", ".join(parts[:-1]) + ", and " + parts[-1]
|
||||
return [
|
||||
'<dl class="opencv-examples">',
|
||||
'<dt>Examples</dt>',
|
||||
f'<dd>{joined}.</dd>',
|
||||
'</dl>',
|
||||
"",
|
||||
]
|
||||
|
||||
# Boilerplate-paragraph filter for _extract_sample_brief.
|
||||
_SAMPLE_BRIEF_SKIP_RE = re.compile(
|
||||
r"^(?:"
|
||||
r"author|date|file|copyright|special\s+thanks|see\s+also|maintainer|"
|
||||
r"created|modified|version|license|brief"
|
||||
r")\b"
|
||||
r"|^\w+\.(?:cpp|cc|cxx|c|hpp|h|py|java|js|ts)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_sample_brief(text: str) -> str:
|
||||
"""Best-effort one-line description of a sample file."""
|
||||
# 1) Explicit @brief / \brief — preferred when present.
|
||||
m = re.search(
|
||||
r"[@\\]brief\s+(.*?)(?:"
|
||||
r"\n\s*[*/]?\s*\n" # paragraph break
|
||||
r"|\n\s*[*/]?\s*[@\\]\w+" # next Doxygen tag
|
||||
r"|\*/" # end of comment
|
||||
r")",
|
||||
text, re.DOTALL,
|
||||
)
|
||||
if m:
|
||||
brief = re.sub(r"\n\s*\*?\s*", " ", m.group(1))
|
||||
brief = re.sub(r"\s+", " ", brief).strip()
|
||||
brief = re.split(r"(?<=[.!?])\s", brief, maxsplit=1)[0].strip()
|
||||
if brief:
|
||||
return brief
|
||||
|
||||
# 2) First /* ... */ block, split into paragraphs.
|
||||
cm = re.match(r"\s*/\*+([\s\S]*?)\*+/", text)
|
||||
if not cm:
|
||||
return ""
|
||||
# .strip() not .rstrip(): leading space would bypass skip regex.
|
||||
norm_lines = [
|
||||
re.sub(r"^\s*\*+\s?", "", raw).strip()
|
||||
for raw in cm.group(1).splitlines()
|
||||
]
|
||||
paragraphs: list[list[str]] = [[]]
|
||||
for line in norm_lines:
|
||||
if line.strip():
|
||||
paragraphs[-1].append(line)
|
||||
elif paragraphs[-1]:
|
||||
paragraphs.append([])
|
||||
paragraphs = [p for p in paragraphs if p]
|
||||
|
||||
for para in paragraphs:
|
||||
if _SAMPLE_BRIEF_SKIP_RE.match(para[0]):
|
||||
continue
|
||||
joined = " ".join(line.strip() for line in para if line.strip())
|
||||
first = re.split(r"(?<=[.!?])\s", joined, maxsplit=1)[0].strip()
|
||||
if first:
|
||||
return first
|
||||
return ""
|
||||
|
||||
_BRIEF_REF_RE = re.compile(r'@ref\s+(?P<anchor>[\w:-]+)(?:\s+"(?P<label>[^"]+)")?')
|
||||
_BRIEF_IMG_RE = re.compile(r'!\[(?P<alt>[^\]]*)\]\((?P<src>[^)]+)\)')
|
||||
|
||||
|
||||
def _resolve_brief_markup(brief: str) -> str:
|
||||
"""Resolve `@ref` links and bare-filename images in a brief to Markdown."""
|
||||
def _ref(m: "re.Match") -> str:
|
||||
anchor = _resolve_redirect(m.group("anchor"))
|
||||
label = m.group("label")
|
||||
target = _ANCHOR_TO_DOC.get(anchor)
|
||||
if target:
|
||||
return f'[{label or _ANCHOR_TO_TITLE.get(anchor) or anchor}](/{target})'
|
||||
if anchor in _TAG_FILENAMES:
|
||||
return f'[{label or _TAG_TITLES.get(anchor, anchor)}]({_doxygen_url(anchor)})'
|
||||
return f'[{label or anchor}](#{anchor})'
|
||||
brief = _BRIEF_REF_RE.sub(_ref, brief)
|
||||
|
||||
def _img(m: "re.Match") -> str:
|
||||
alt, src = m.group("alt"), m.group("src")
|
||||
# Leave already-pathed / absolute / URL images for MyST to handle.
|
||||
if "/" in src or "://" in src:
|
||||
return m.group(0)
|
||||
hit = _IMAGE_INDEX.get(src)
|
||||
if not hit:
|
||||
return m.group(0)
|
||||
# Hard break after image so following prose drops to a new line.
|
||||
return f'\\\n'
|
||||
return _BRIEF_IMG_RE.sub(_img, brief).strip()
|
||||
|
||||
|
||||
def _generate_example_pages(examples_dir: pathlib.Path) -> None:
|
||||
"""Write one Sphinx page per sample referenced by an Examples block.
|
||||
|
||||
Uses MyST colon-fence `:::` not backticks: source backticks can
|
||||
close a backtick fence prematurely.
|
||||
"""
|
||||
if not _EXAMPLE_PAGES_NEEDED:
|
||||
return
|
||||
examples_dir.mkdir(parents=True, exist_ok=True)
|
||||
for display, source in _EXAMPLE_PAGES_NEEDED.items():
|
||||
try:
|
||||
body = source.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
language = _EXAMPLE_LANGUAGE.get(source.suffix.lower(), "text")
|
||||
# Brief cascade: @example desc, tutorial prose, top comment.
|
||||
decl = _DOXY_EXAMPLE_DECLS.get(display)
|
||||
declared_path = decl[0] if decl else display
|
||||
decl_desc = decl[1] if decl else ""
|
||||
brief = (
|
||||
decl_desc
|
||||
or _TUTORIAL_SAMPLE_REFS.get(display)
|
||||
or _extract_sample_brief(body)
|
||||
)
|
||||
lines = [
|
||||
"---",
|
||||
"orphan: true",
|
||||
"---",
|
||||
f"# {declared_path}",
|
||||
"",
|
||||
]
|
||||
if brief:
|
||||
# MyST paragraph (not raw <p>) so embedded @ref / image renders.
|
||||
lines.append("{.opencv-example-brief}")
|
||||
lines.append(_resolve_brief_markup(brief))
|
||||
lines.append("")
|
||||
lines.extend([
|
||||
f":::{{code-block}} {language}",
|
||||
":linenos:",
|
||||
"",
|
||||
])
|
||||
lines.extend(body.splitlines())
|
||||
lines.append(":::")
|
||||
lines.append("")
|
||||
(examples_dir / f"{_example_pagename(display)}.md").write_text(
|
||||
"\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
|
||||
__all__ = [
|
||||
"_find_examples_for_class",
|
||||
"_render_examples_block",
|
||||
"_generate_example_pages",
|
||||
]
|
||||
@@ -0,0 +1,171 @@
|
||||
# This file is part of OpenCV project.
|
||||
# It is subject to the license terms in the LICENSE file found in the top-level directory
|
||||
# of this distribution and at http://opencv.org/license.html.
|
||||
# Copyright (C) 2026, BigVision LLC, all rights reserved.
|
||||
# Third party copyrights are property of their respective owners.
|
||||
|
||||
"""Runtime patches for Sphinx C++ domain and breathe; applied at import."""
|
||||
from __future__ import annotations
|
||||
|
||||
def _patch_cpp_xref_resolver():
|
||||
"""Work around Sphinx 8.1.x parentSymbol assert in _resolve_xref_inner."""
|
||||
try:
|
||||
from sphinx.domains.cpp import CPPDomain
|
||||
except ImportError:
|
||||
return
|
||||
original = CPPDomain._resolve_xref_inner
|
||||
|
||||
def guarded(self, env, fromdocname, builder, typ, target, node, contnode):
|
||||
try:
|
||||
return original(self, env, fromdocname, builder, typ, target,
|
||||
node, contnode)
|
||||
except AssertionError:
|
||||
return None, None
|
||||
CPPDomain._resolve_xref_inner = guarded
|
||||
|
||||
# Drop breathe unresolvable-xref log noise; text still renders.
|
||||
import logging
|
||||
_UNRESOLVED_XREF_PATTERNS = (
|
||||
"Unable to resolve function",
|
||||
"Unable to resolve class",
|
||||
"Cannot find function",
|
||||
"Cannot find class",
|
||||
"Cannot find variable",
|
||||
"Cannot find typedef",
|
||||
"Cannot find enum",
|
||||
"Cannot find enumerator",
|
||||
"Cannot find define",
|
||||
"Duplicate C++ declaration",
|
||||
)
|
||||
|
||||
class _UnresolvedXrefFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
return not any(p in msg for p in _UNRESOLVED_XREF_PATTERNS)
|
||||
|
||||
_filt = _UnresolvedXrefFilter()
|
||||
for _logger_name in ("sphinx", "docutils"):
|
||||
logging.getLogger(_logger_name).addFilter(_filt)
|
||||
|
||||
|
||||
_patch_cpp_xref_resolver()
|
||||
|
||||
|
||||
def _silence_breathe_anon_enum_warning():
|
||||
"""Mute Sphinx parser warning on Doxygen's anonymous nested enums."""
|
||||
import logging
|
||||
class _AnonEnumFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
msg = record.getMessage()
|
||||
return not (
|
||||
"Invalid C++ declaration" in msg
|
||||
and "Expected identifier in nested name" in msg
|
||||
)
|
||||
for _name in ("sphinx", "docutils"):
|
||||
logging.getLogger(_name).addFilter(_AnonEnumFilter())
|
||||
|
||||
|
||||
_silence_breathe_anon_enum_warning()
|
||||
|
||||
|
||||
def _patch_breathe_operator_signatures():
|
||||
"""Fix breathe {doxygenfunction} mis-splitting operator overloads."""
|
||||
try:
|
||||
import breathe.directives.function as _bf
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
def _split_operator(s: str):
|
||||
rp = s.rfind(")")
|
||||
if rp == -1:
|
||||
return None
|
||||
depth, j = 0, rp
|
||||
while j >= 0:
|
||||
if s[j] == ")":
|
||||
depth += 1
|
||||
elif s[j] == "(":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
j -= 1
|
||||
if j < 0:
|
||||
return None
|
||||
func_part, args_part = s[:j].strip(), s[j:]
|
||||
k = func_part.find("::operator")
|
||||
if k != -1:
|
||||
return func_part[:k], func_part[k + 2:], args_part
|
||||
if "::" in func_part:
|
||||
ns, fn = func_part.rsplit("::", 1)
|
||||
return ns, fn, args_part
|
||||
return "", func_part, args_part
|
||||
|
||||
class _Shim:
|
||||
__slots__ = ("_g",)
|
||||
|
||||
def __init__(self, g1, g2, g3):
|
||||
self._g = (None, g1, g2, g3)
|
||||
|
||||
def group(self, i=0):
|
||||
return self._g[i]
|
||||
|
||||
class _OperatorAwareRe:
|
||||
def __init__(self, real):
|
||||
object.__setattr__(self, "_real", real)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._real, name)
|
||||
|
||||
def match(self, pattern, string, *args, **kwargs):
|
||||
m = self._real.match(pattern, string, *args, **kwargs)
|
||||
if (m is not None and getattr(m.re, "groups", 0) >= 3
|
||||
and "::operator" in string):
|
||||
res = _split_operator(string)
|
||||
if res is not None:
|
||||
ns, fn, ar = res
|
||||
return _Shim(ns or None, fn, ar)
|
||||
return m
|
||||
|
||||
if not isinstance(_bf.re, _OperatorAwareRe):
|
||||
_bf.re = _OperatorAwareRe(_bf.re)
|
||||
|
||||
|
||||
_patch_breathe_operator_signatures()
|
||||
|
||||
|
||||
def _patch_breathe_docsect():
|
||||
"""Render title-less docSectN nodes breathe 4.36 drops."""
|
||||
try:
|
||||
from breathe.renderer import sphinxrenderer as _bsr
|
||||
except ImportError:
|
||||
return
|
||||
_methods = _bsr.SphinxRenderer.methods
|
||||
if getattr(_methods.get("docsect1"), "_opencv_docsect_patch", False):
|
||||
return
|
||||
_orig_visit = _methods["docsect1"]
|
||||
|
||||
def _visit_docsectN(self, node):
|
||||
if not getattr(node, "title", None):
|
||||
return self.render_iterable(node.content_)
|
||||
return _orig_visit(self, node)
|
||||
|
||||
_visit_docsectN._opencv_docsect_patch = True
|
||||
for _kind in ("docsect1", "docsect2", "docsect3"):
|
||||
_methods[_kind] = _visit_docsectN
|
||||
|
||||
|
||||
_patch_breathe_docsect()
|
||||
|
||||
|
||||
def _silence_orphan_toctree_warning():
|
||||
"""Mute toctree-orphan warning for intentionally unlinked external pages."""
|
||||
import logging
|
||||
|
||||
class _OrphanFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return "included in any toctree" not in record.getMessage()
|
||||
|
||||
for _name in ("sphinx", "docutils"):
|
||||
logging.getLogger(_name).addFilter(_OrphanFilter())
|
||||
|
||||
|
||||
_silence_orphan_toctree_warning()
|
||||
@@ -0,0 +1,154 @@
|
||||
# This file is part of OpenCV project.
|
||||
# It is subject to the license terms in the LICENSE file found in the top-level directory
|
||||
# of this distribution and at http://opencv.org/license.html.
|
||||
# Copyright (C) 2026, BigVision LLC, all rights reserved.
|
||||
# Third party copyrights are property of their respective owners.
|
||||
|
||||
"""build-finished hook: inline coll-diagram SVGs, strip Breathe clutter, re-theme Doxygen."""
|
||||
from __future__ import annotations
|
||||
import pathlib, re
|
||||
|
||||
from .state import _doxy_page_to_local, _DOXY_ANCHOR_TO_MEMBER
|
||||
|
||||
|
||||
def _doxy_parent_page(page: str, api_dir: pathlib.Path) -> str:
|
||||
"""Nested types (e.g. `structcv_1_1SparseMat_1_1Hdr`) get no standalone
|
||||
Sphinx page — they're documented inline on the enclosing class. Walk up the
|
||||
`_1_1`-separated scope to the nearest ancestor page that DOES exist locally.
|
||||
Returns "" if no ancestor page exists."""
|
||||
stem = page[:-5] if page.endswith(".html") else page
|
||||
rest = None
|
||||
for pref in ("class", "struct", "union"):
|
||||
if stem.startswith(pref):
|
||||
rest = stem[len(pref):]
|
||||
break
|
||||
if rest is None:
|
||||
return ""
|
||||
while "_1_1" in rest:
|
||||
rest = rest.rsplit("_1_1", 1)[0]
|
||||
for pref in ("class", "struct", "union"):
|
||||
cand = f"{pref}{rest}.html"
|
||||
if (api_dir / cand).is_file():
|
||||
return cand
|
||||
return ""
|
||||
|
||||
|
||||
def _inline_collaboration_svgs(api_dir: pathlib.Path,
|
||||
image_dir: pathlib.Path) -> None:
|
||||
"""Inline coll-diagram SVGs so their links work; idempotent."""
|
||||
import re
|
||||
if not api_dir.is_dir():
|
||||
return
|
||||
img_re = re.compile(
|
||||
r'<img alt="(?P<alt>[^"]*)" '
|
||||
r'class="(?P<cls>opencv-coll-graph[^"]*)" '
|
||||
r'src="\.\./_images/(?P<file>[^"]+\.svg)"\s*/?>')
|
||||
href_re = re.compile(r'xlink:href="(?P<path>[^"]+)"')
|
||||
|
||||
def _rewrite_href(m: "re.Match") -> str:
|
||||
path = m.group("path")
|
||||
if "://" in path:
|
||||
return m.group(0)
|
||||
base = path.rsplit("/", 1)[-1]
|
||||
page, _, frag = base.partition("#")
|
||||
local = _doxy_page_to_local(page)
|
||||
if not (api_dir / local).is_file():
|
||||
# Nested type with no own page -> enclosing class page (inline docs).
|
||||
parent = _doxy_parent_page(page, api_dir)
|
||||
if parent:
|
||||
local = parent
|
||||
member = _DOXY_ANCHOR_TO_MEMBER.get(frag) if frag else None
|
||||
if member:
|
||||
return f'xlink:href="{local}#{member}"'
|
||||
return f'xlink:href="{local}"'
|
||||
|
||||
for html in api_dir.glob("*.html"):
|
||||
text = html.read_text(encoding="utf-8")
|
||||
if "opencv-coll-graph" not in text:
|
||||
continue
|
||||
|
||||
def _inline(m: "re.Match") -> str:
|
||||
svg_path = image_dir / m.group("file")
|
||||
if not svg_path.is_file():
|
||||
return m.group(0)
|
||||
svg = svg_path.read_text(encoding="utf-8")
|
||||
start = svg.find("<svg")
|
||||
if start < 0:
|
||||
return m.group(0)
|
||||
svg = href_re.sub(_rewrite_href, svg[start:])
|
||||
# carry theme classes + alt for dark mode / a11y
|
||||
return svg.replace(
|
||||
"<svg ",
|
||||
f'<svg class="{m.group("cls")}" role="img" '
|
||||
f'aria-label="{m.group("alt")}" ', 1)
|
||||
|
||||
new = img_re.sub(_inline, text)
|
||||
if new != text:
|
||||
html.write_text(new, encoding="utf-8")
|
||||
|
||||
|
||||
def _strip_breathe_class_clutter(api_dir: pathlib.Path) -> None:
|
||||
"""Drop Breathe's duplicate class signature header; idempotent."""
|
||||
import re
|
||||
if not api_dir.is_dir():
|
||||
return
|
||||
section_re = re.compile(
|
||||
r'(<section id="detailed-description"[^>]*>)'
|
||||
r'(?P<body>[\s\S]*?)'
|
||||
r'(</section>)'
|
||||
)
|
||||
dl_re = re.compile(
|
||||
r'<dl[^>]*\bclass="[^"]*\bclass\b[^"]*"[^>]*>\s*'
|
||||
r'<dt[^>]*>[\s\S]*?</dt>\s*'
|
||||
r'<dd>(?P<dd>[\s\S]*?)</dd>\s*'
|
||||
r'</dl>'
|
||||
)
|
||||
subclassed_re = re.compile(r'<p>Subclassed by[\s\S]*?</p>\s*')
|
||||
|
||||
for h in api_dir.glob("classcv*.html"):
|
||||
text = h.read_text(encoding="utf-8")
|
||||
if "detailed-description" not in text:
|
||||
continue
|
||||
|
||||
def _strip_section(sm):
|
||||
head, body, tail = sm.group(1), sm.group("body"), sm.group(3)
|
||||
|
||||
def _strip_dl(dm):
|
||||
dd_body = dm.group("dd").strip()
|
||||
dd_body = subclassed_re.sub("", dd_body).strip()
|
||||
return dd_body
|
||||
|
||||
new_body = dl_re.sub(_strip_dl, body, count=1)
|
||||
return head + new_body + tail
|
||||
|
||||
new = section_re.sub(_strip_section, text, count=1)
|
||||
if new != text:
|
||||
h.write_text(new, encoding="utf-8")
|
||||
|
||||
|
||||
def _generate_search_map(out_dir: pathlib.Path) -> None:
|
||||
"""Write _static/search_map.js: stem→Sphinx-path for every built HTML page."""
|
||||
import json
|
||||
skip = {"_static", "_sources", "_images", "_sphinx_design_static"}
|
||||
mapping = {}
|
||||
for f in out_dir.rglob("*.html"):
|
||||
rel = f.relative_to(out_dir)
|
||||
if rel.parts[0] in skip:
|
||||
continue
|
||||
mapping[f.stem] = rel.as_posix()
|
||||
lines = ["var sphinxPageMap = {"]
|
||||
for k, v in sorted(mapping.items()):
|
||||
lines.append(f" {json.dumps(k)}: {json.dumps(v)},")
|
||||
lines.append("};")
|
||||
(out_dir / "_static" / "search_map.js").write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def _inline_coll_graphs_on_finish(app, exception):
|
||||
"""build-finished entry point."""
|
||||
if exception is not None:
|
||||
return
|
||||
out = pathlib.Path(app.outdir)
|
||||
for _api in ("main_modules", "extra_modules"):
|
||||
_inline_collaboration_svgs(out / _api, out / "_images")
|
||||
_strip_breathe_class_clutter(out / _api)
|
||||
_generate_search_map(out)
|
||||
@@ -0,0 +1,888 @@
|
||||
# This file is part of OpenCV project.
|
||||
# It is subject to the license terms in the LICENSE file found in the top-level directory
|
||||
# of this distribution and at http://opencv.org/license.html.
|
||||
# Copyright (C) 2026, BigVision LLC, all rights reserved.
|
||||
# Third party copyrights are property of their respective owners.
|
||||
|
||||
"""Shared configuration & state for the OpenCV Sphinx wrapper."""
|
||||
from __future__ import annotations
|
||||
import pathlib, re, os as _os, shutil as _shutil, textwrap as _textwrap
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent.parent
|
||||
DOC_ROOT = (HERE.parent / "doc").resolve()
|
||||
OPENCV_ROOT = HERE.parent.resolve()
|
||||
|
||||
# SCOPE — tutorial module folders; env override or auto-discover per tree
|
||||
import os as _os
|
||||
|
||||
def _discover_doc_modules(subdir: str, toc_glob: str) -> list[str]:
|
||||
"""Folder names under DOC_ROOT/<subdir> that carry a TOC page (sorted)."""
|
||||
root = DOC_ROOT / subdir
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return sorted(p.name for p in root.iterdir()
|
||||
if p.is_dir() and any(p.glob(toc_glob)))
|
||||
|
||||
def _module_list(env_var: str, subdir: str, toc_glob: str) -> list[str]:
|
||||
"""Env-var override (comma list) when set & non-empty, else auto-discover."""
|
||||
val = _os.environ.get(env_var)
|
||||
if val:
|
||||
return [m.strip() for m in val.split(",") if m.strip()]
|
||||
return _discover_doc_modules(subdir, toc_glob)
|
||||
|
||||
DOC_MODULES = _module_list(
|
||||
"OPENCV_DOC_MODULES", "tutorials", "table_of_content_*.markdown")
|
||||
JS_DOC_MODULES = _module_list(
|
||||
"OPENCV_JS_DOC_MODULES", "js_tutorials", "js_table_of_contents_*.markdown")
|
||||
PY_DOC_MODULES = _module_list(
|
||||
"OPENCV_PY_DOC_MODULES", "py_tutorials", "py_table_of_contents_*.markdown")
|
||||
|
||||
# SCOPE — env OPENCV_CONTRIB_MODULES; empty/unset auto-discovers
|
||||
CONTRIB_ROOT = pathlib.Path(
|
||||
_os.environ.get("OPENCV_CONTRIB_ROOT")
|
||||
or str(HERE.parent.parent / "opencv_contrib" / "modules")
|
||||
).resolve()
|
||||
|
||||
def _discover_contrib_modules() -> list[str]:
|
||||
"""Contrib module folders carrying a `tutorials/` subtree (CMake's gate)."""
|
||||
if not CONTRIB_ROOT.is_dir():
|
||||
return []
|
||||
return sorted(p.name for p in CONTRIB_ROOT.iterdir()
|
||||
if p.is_dir() and (p / "tutorials").is_dir())
|
||||
|
||||
_contrib_env = _os.environ.get("OPENCV_CONTRIB_MODULES")
|
||||
CONTRIB_MODULES = ([m.strip() for m in _contrib_env.split(",") if m.strip()]
|
||||
if _contrib_env else _discover_contrib_modules())
|
||||
|
||||
# SCOPE — env OPENCV_API_MODULES (comma/semicolon); empty disables API pages
|
||||
def _discover_api_modules() -> list[str]:
|
||||
"""Main + contrib modules whose umbrella header declares `@defgroup`."""
|
||||
found = []
|
||||
# Scan both the main tree and opencv_contrib/modules.
|
||||
_roots = [OPENCV_ROOT / "modules"]
|
||||
if CONTRIB_ROOT.is_dir():
|
||||
_roots.append(CONTRIB_ROOT)
|
||||
for _root in _roots:
|
||||
for _hdr in _root.glob("*/include/opencv2/*.hpp"):
|
||||
if _hdr.stem != _hdr.parents[2].name: # only the umbrella header
|
||||
continue
|
||||
try:
|
||||
if "@defgroup" in _hdr.read_text(encoding="utf-8", errors="ignore"):
|
||||
found.append(_hdr.stem)
|
||||
except OSError:
|
||||
pass
|
||||
return sorted(found)
|
||||
|
||||
|
||||
# Default = discovered full set; override (comma/semicolon), empty disables
|
||||
API_MODULES = [
|
||||
m.strip()
|
||||
for m in re.split(r"[,;]", _os.environ.get("OPENCV_API_MODULES")
|
||||
or ",".join(_discover_api_modules()))
|
||||
if m.strip()
|
||||
]
|
||||
|
||||
# Sphinx srcdir; env OPENCV_SPHINX_INPUT_ROOT (`or` treats empty as unset)
|
||||
SPHINX_INPUT_ROOT = pathlib.Path(
|
||||
_os.environ.get("OPENCV_SPHINX_INPUT_ROOT") or str(DOC_ROOT)
|
||||
).resolve()
|
||||
|
||||
# sphinx_design availability
|
||||
try:
|
||||
import sphinx_design as _sphinx_design # noqa: F401
|
||||
HAVE_SPHINX_DESIGN = True
|
||||
except ImportError:
|
||||
HAVE_SPHINX_DESIGN = False
|
||||
|
||||
# -- Breathe (Doxygen XML -> Sphinx C++ domain) -----------------------------
|
||||
# env OPENCV_DOXYGEN_XML_DIR
|
||||
_API_XML_DIR = pathlib.Path(
|
||||
_os.environ.get("OPENCV_DOXYGEN_XML_DIR")
|
||||
or str(HERE.parent.parent / "build_doc" / "doc" / "doxygen" / "xml")
|
||||
).resolve()
|
||||
# Patched namespace XML for breathe; see _patch_namespace_xml_for_breathe
|
||||
_PATCHED_XML_DIR = _API_XML_DIR.parent / "xml_for_sphinx"
|
||||
|
||||
|
||||
def _module_group_stem(m: str) -> str:
|
||||
# Doxygen names a group after its @defgroup, which can differ from the
|
||||
# module folder (folder `3d` -> `@defgroup _3d`). Return that real stem.
|
||||
if (_API_XML_DIR / f"group__{m.replace('_', '__')}.xml").is_file():
|
||||
return m
|
||||
for _root in (OPENCV_ROOT / "modules", CONTRIB_ROOT):
|
||||
_hdr = _root / m / "include" / "opencv2" / f"{m}.hpp"
|
||||
if _hdr.is_file():
|
||||
_mm = re.search(r"@defgroup\s+(\S+)",
|
||||
_hdr.read_text(encoding="utf-8", errors="ignore"))
|
||||
return _mm.group(1) if _mm else m
|
||||
return m
|
||||
|
||||
# -- Python enum/constant signatures ----------------------------------------
|
||||
# C++ enumerator FQN -> cv2.* name; env OPENCV_PYTHON_SIGNATURES_FILE
|
||||
_PY_SIGNATURES: dict = {}
|
||||
import json as _json
|
||||
for _pysigs_candidate in (
|
||||
_API_XML_DIR.parents[2] / "modules" / "python_bindings_generator"
|
||||
/ "pyopencv_signatures.json",
|
||||
_os.environ.get("OPENCV_PYTHON_SIGNATURES_FILE") or None,
|
||||
):
|
||||
if not _pysigs_candidate:
|
||||
continue
|
||||
_pysigs_path = pathlib.Path(str(_pysigs_candidate))
|
||||
if _pysigs_path.is_file():
|
||||
try:
|
||||
_PY_SIGNATURES = _json.loads(_pysigs_path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError):
|
||||
_PY_SIGNATURES = {}
|
||||
break
|
||||
|
||||
|
||||
def _python_enum_name(enum_qualified: str, value_name: str,
|
||||
strong: bool) -> str | None:
|
||||
"""Return the cv2.* Python name for one C++ enumerator, or None."""
|
||||
if not _PY_SIGNATURES:
|
||||
return None
|
||||
if strong:
|
||||
scope = enum_qualified
|
||||
elif "::" in enum_qualified:
|
||||
scope = enum_qualified.rsplit("::", 1)[0]
|
||||
else:
|
||||
scope = ""
|
||||
cpp_key = f"{scope}::{value_name}" if scope else value_name
|
||||
entries = _PY_SIGNATURES.get(cpp_key)
|
||||
if entries:
|
||||
return entries[0].get("name")
|
||||
return None
|
||||
|
||||
|
||||
# -- Breathe availability ----------------------------------------------------
|
||||
# Absence empties API_MODULES
|
||||
HAVE_BREATHE = False
|
||||
if API_MODULES:
|
||||
try:
|
||||
import breathe # noqa: F401
|
||||
HAVE_BREATHE = True
|
||||
except ImportError:
|
||||
API_MODULES = []
|
||||
|
||||
# -- Doxygen integration -----------------------------------------------------
|
||||
DOXYGEN_BASE_URL = (
|
||||
_os.environ.get("OPENCV_DOXYGEN_BASE_URL", "https://docs.opencv.org/5.x/")
|
||||
.rstrip("/") + "/")
|
||||
# First existing wins; env OPENCV_DOXYGEN_TAGFILE overrides
|
||||
_TAG_CANDIDATES = (
|
||||
HERE.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag",
|
||||
HERE.parent.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag",
|
||||
# extra build-dir layouts (vanilla, contrib, nested CI)
|
||||
HERE.parent.parent / "build" / "doc" / "opencv.tag",
|
||||
HERE.parent.parent / "build_contrib" / "doc" / "doxygen" / "html" / "opencv.tag",
|
||||
HERE.parent.parent / "build_contrib" / "doc" / "opencv.tag",
|
||||
HERE.parent.parent / "build" / "build_contrib" / "build_contrib"
|
||||
/ "doc" / "doxygen" / "html" / "opencv.tag",
|
||||
)
|
||||
_TAG_FILE = pathlib.Path(_os.environ.get(
|
||||
"OPENCV_DOXYGEN_TAGFILE",
|
||||
str(next((p for p in _TAG_CANDIDATES if p.is_file()), _TAG_CANDIDATES[0])),
|
||||
))
|
||||
|
||||
# anchor -> doxygen URL filename
|
||||
_TAG_FILENAMES: dict[str, str] = {}
|
||||
# anchor -> title
|
||||
_TAG_TITLES: dict[str, str] = {}
|
||||
# Page-only subset of _TAG_TITLES (kind="page" compounds only — NOT groups/files),
|
||||
# i.e. Doxygen's "Related Pages" set: \page docs like intro, faq, cuda_intro, …
|
||||
_DOC_PAGE_TITLES: dict[str, str] = {}
|
||||
# (compound-stem, member-name, normalized-args) -> Doxygen HTML anchor.
|
||||
# Bridges our XML-driven members to the HTML anchors that name the call/caller
|
||||
# graph SVGs (XML memberdef ids and HTML anchors live in disjoint hash spaces).
|
||||
_CALL_GRAPH_ANCHORS: dict[tuple[str, str, str], str] = {}
|
||||
# Doxygen member anchor -> Sphinx anchor (lowercased member name). Lets diagram
|
||||
# cross-links jump to the exact member, as the original Doxygen pages did.
|
||||
_DOXY_ANCHOR_TO_MEMBER: dict[str, str] = {}
|
||||
|
||||
|
||||
def _norm_args(arglist: str) -> str:
|
||||
"""Normalize a C++ arg-list so an XML `argsstring` matches a tag `arglist`."""
|
||||
import html as _html
|
||||
return re.sub(r"\s+", "", _html.unescape(arglist or ""))
|
||||
# cv-namespace short-name -> doxygen URL; used by step 7c
|
||||
_CV_SYMBOL_URL: dict[str, str] = {}
|
||||
# include-path -> Doxygen HTML file URL; linkifies enum `#include` lines.
|
||||
_FILE_URL: dict[str, str] = {}
|
||||
if _TAG_FILE.is_file():
|
||||
try:
|
||||
import xml.etree.ElementTree as _ET
|
||||
_tag_root = _ET.parse(str(_TAG_FILE)).getroot()
|
||||
for _c in _tag_root.iter("compound"):
|
||||
_kind = _c.get("kind")
|
||||
# Call/caller-graph anchors: every compound's function members,
|
||||
# keyed by the page they're documented on (the SVG filename prefix).
|
||||
for _fm in _c.findall("member"):
|
||||
if _fm.get("kind") != "function":
|
||||
continue
|
||||
_fn = _fm.findtext("name")
|
||||
_faf = _fm.findtext("anchorfile") or ""
|
||||
_fan = _fm.findtext("anchor") or ""
|
||||
if not (_fn and _faf and _fan):
|
||||
continue
|
||||
_fstem = pathlib.Path(_faf).stem
|
||||
_CALL_GRAPH_ANCHORS.setdefault(
|
||||
(_fstem, _fn, _norm_args(_fm.findtext("arglist") or "")), _fan)
|
||||
# Doxygen anchor -> Sphinx member anchor (lowercased name).
|
||||
_DOXY_ANCHOR_TO_MEMBER.setdefault(_fan, _fn.lower())
|
||||
if _kind == "page":
|
||||
_n, _f = _c.findtext("name"), _c.findtext("filename")
|
||||
_t = _c.findtext("title")
|
||||
if _n and _f:
|
||||
_TAG_FILENAMES[_n] = _f if _f.endswith(".html") else _f + ".html"
|
||||
if _n and _t:
|
||||
_TAG_TITLES[_n] = _t
|
||||
_DOC_PAGE_TITLES[_n] = _t
|
||||
elif _kind == "namespace" and _c.findtext("name") == "cv":
|
||||
for _m in _c.findall("member"):
|
||||
_n = _m.findtext("name")
|
||||
_af = _m.findtext("anchorfile")
|
||||
_an = _m.findtext("anchor") or ""
|
||||
if not (_n and _af):
|
||||
continue
|
||||
_CV_SYMBOL_URL.setdefault(
|
||||
_n, DOXYGEN_BASE_URL + _af + (f"#{_an}" if _an else "")
|
||||
)
|
||||
elif _kind in ("class", "struct"):
|
||||
_full = _c.findtext("name") or ""
|
||||
if _full.startswith("cv::"):
|
||||
_short = _full.split("::")[-1]
|
||||
_af = _c.findtext("filename")
|
||||
if _short and _af:
|
||||
_CV_SYMBOL_URL.setdefault(_short, DOXYGEN_BASE_URL + _af)
|
||||
elif _kind == "group":
|
||||
# module pages are kind="group"
|
||||
_n = _c.findtext("name")
|
||||
_f = _c.findtext("filename")
|
||||
_t = _c.findtext("title")
|
||||
if _n and _f:
|
||||
_TAG_FILENAMES[_n] = _f if _f.endswith(".html") else _f + ".html"
|
||||
if _n and _t:
|
||||
_TAG_TITLES[_n] = _t
|
||||
elif _kind == "file":
|
||||
# Header file -> Doxygen page; key by include path.
|
||||
_n = _c.findtext("name") or ""
|
||||
_p = _c.findtext("path") or ""
|
||||
_f = _c.findtext("filename") or ""
|
||||
if _n and _f and not _p.startswith("/"):
|
||||
_key = (_p + _n) if _p.endswith("/") or not _p else f"{_p}/{_n}"
|
||||
_FILE_URL.setdefault(
|
||||
_key, _f if _f.endswith(".html") else _f + ".html")
|
||||
else:
|
||||
# CV_* macros (kind="define") re-exported as cv.CV_*
|
||||
for _m in _c.findall("member"):
|
||||
if _m.get("kind") != "define":
|
||||
continue
|
||||
_n = _m.findtext("name") or ""
|
||||
if not _n.startswith("CV_"):
|
||||
continue
|
||||
_af = _m.findtext("anchorfile")
|
||||
_an = _m.findtext("anchor") or ""
|
||||
if _af:
|
||||
_CV_SYMBOL_URL.setdefault(
|
||||
_n, DOXYGEN_BASE_URL + _af + (f"#{_an}" if _an else "")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _doxygen_url(page: str) -> str:
|
||||
return DOXYGEN_BASE_URL + _TAG_FILENAMES.get(page, page)
|
||||
|
||||
|
||||
# -- Live (docs.opencv.org) tagfile for API stub URL construction ----------
|
||||
# CREATE_SUBDIRS=NO flat names 404 live; live tag has subdir prefixes
|
||||
_LIVE_TAG_FILE = pathlib.Path(_os.environ.get(
|
||||
"OPENCV_DOXYGEN_LIVE_TAGFILE",
|
||||
str(HERE.parent.parent / "build" / "doc" / "doxygen" / "opencv-live.tag"),
|
||||
))
|
||||
if not _LIVE_TAG_FILE.is_file():
|
||||
for _alt in (
|
||||
HERE.parent.parent / "build" / "build_contrib" / "build_contrib"
|
||||
/ "doc" / "doxygen" / "opencv-live.tag",
|
||||
HERE.parent.parent / "build_contrib" / "doc" / "doxygen" / "opencv-live.tag",
|
||||
):
|
||||
if _alt.is_file():
|
||||
_LIVE_TAG_FILE = _alt
|
||||
break
|
||||
|
||||
_LIVE_GROUP_URL: dict[str, str] = {} # 'group__core__basic' -> live URL
|
||||
_LIVE_CLASS_URL: dict[str, str] = {} # 'Matx' -> live URL
|
||||
_LIVE_TYPEDEF_URL: dict[str, str] = {} # 'uchar' -> live URL (group anchor)
|
||||
if _LIVE_TAG_FILE.is_file():
|
||||
try:
|
||||
import xml.etree.ElementTree as _ET
|
||||
for _c in _ET.parse(str(_LIVE_TAG_FILE)).getroot().iter("compound"):
|
||||
_kind = _c.get("kind")
|
||||
_n = _c.findtext("name") or ""
|
||||
_f = _c.findtext("filename") or ""
|
||||
if not (_n and _f):
|
||||
continue
|
||||
_fn = _f if _f.endswith(".html") else _f + ".html"
|
||||
if _kind == "group":
|
||||
# key by filename basename
|
||||
_basename = pathlib.PurePosixPath(_fn).name[:-5] # strip .html
|
||||
_LIVE_GROUP_URL[_basename] = DOXYGEN_BASE_URL + _fn
|
||||
elif _kind == "class":
|
||||
_short = _n.split("::")[-1]
|
||||
_LIVE_CLASS_URL.setdefault(_short, DOXYGEN_BASE_URL + _fn)
|
||||
# typedef members -> live anchor URLs
|
||||
for _mem in _c.findall("member"):
|
||||
if _mem.get("kind") != "typedef":
|
||||
continue
|
||||
_mn = (_mem.findtext("name") or "").strip()
|
||||
_maf = (_mem.findtext("anchorfile") or "").strip()
|
||||
_man = (_mem.findtext("anchor") or "").strip()
|
||||
if _mn and _maf and _man:
|
||||
_LIVE_TYPEDEF_URL.setdefault(
|
||||
_mn, f"{DOXYGEN_BASE_URL}{_maf}#{_man}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# -- Local-link variants of the maps above (step 8g) ------------------------
|
||||
_LOCAL_SRC_TAG = _TAG_FILE if _TAG_FILE.is_file() else _LIVE_TAG_FILE
|
||||
_LOCAL_CLASS_URL: dict[str, str] = {
|
||||
# _Tp template-parameter placeholder stub
|
||||
"_Tp": "class_Tp.html",
|
||||
}
|
||||
_LOCAL_TYPEDEF_URL: dict[str, str] = {} # 'uchar' -> 'core_hal_interface.html#_CPPv45uchar'
|
||||
# Doxygen template-param placeholder pages -> (display name, Sphinx page).
|
||||
# Sphinx mirrors these as stubs (stubs._write_placeholder_stubs) so diagram
|
||||
# cross-links resolve instead of 404ing.
|
||||
_PLACEHOLDER_STUBS: dict[str, tuple[str, str]] = {
|
||||
"class__Tp.html": ("_Tp", "class_Tp.html"),
|
||||
"classfloat__type.html": ("float_type", "classfloat_type.html"),
|
||||
}
|
||||
# Doxygen compound filename -> Sphinx page filename (class/struct compounds).
|
||||
_LOCAL_PAGE_BY_DOXY_FILE: dict[str, str] = {
|
||||
_doxy: _page for _doxy, (_disp, _page) in _PLACEHOLDER_STUBS.items()}
|
||||
if _LOCAL_SRC_TAG.is_file():
|
||||
try:
|
||||
import xml.etree.ElementTree as _ET
|
||||
for _c in _ET.parse(str(_LOCAL_SRC_TAG)).getroot().iter("compound"):
|
||||
if _c.get("kind") in ("class", "struct"):
|
||||
_n = _c.findtext("name") or ""
|
||||
_f = _c.findtext("filename") or ""
|
||||
if _n and _f:
|
||||
_short = _n.split("::")[-1]
|
||||
_fn = _f if _f.endswith(".html") else _f + ".html"
|
||||
_doxy_base = pathlib.PurePosixPath(_fn).name
|
||||
_LOCAL_CLASS_URL.setdefault(_short, _doxy_base)
|
||||
# Sphinx mirrors Doxygen's filename except the few remapped.
|
||||
_LOCAL_PAGE_BY_DOXY_FILE.setdefault(
|
||||
_doxy_base, _LOCAL_CLASS_URL.get(_short, _doxy_base))
|
||||
for _mem in _c.findall("member"):
|
||||
# variable only from namespaces; class-member vars poison the map
|
||||
_mk = _mem.get("kind")
|
||||
if _mk == "typedef":
|
||||
pass
|
||||
elif _mk == "enumeration":
|
||||
pass # enum types like cv::DataLayout — linkable
|
||||
elif _mk == "variable" and _c.get("kind") == "namespace":
|
||||
pass
|
||||
else:
|
||||
continue
|
||||
_mn = (_mem.findtext("name") or "").strip()
|
||||
_maf = (_mem.findtext("anchorfile") or "").strip()
|
||||
if not (_mn and _maf):
|
||||
continue
|
||||
if _mn in _LOCAL_TYPEDEF_URL:
|
||||
continue # first-occurrence wins
|
||||
_bn = pathlib.PurePosixPath(_maf).name
|
||||
if _bn.startswith("group__"):
|
||||
# group__core__hal__interface.html -> core_hal_interface.html
|
||||
_local_page = (_bn[len("group__"):]
|
||||
.replace(".html", "")
|
||||
.replace("__", "_")
|
||||
+ ".html")
|
||||
elif _bn.startswith("namespacecv"):
|
||||
_local_page = "core_basic.html"
|
||||
else:
|
||||
_local_page = _bn
|
||||
# HAL typedefs are global C; else cv::-scoped (cpp-domain v4 anchor)
|
||||
if "hal_interface" in _local_page:
|
||||
_anchor = f"_CPPv4{len(_mn)}{_mn}"
|
||||
else:
|
||||
_anchor = f"_CPPv4N2cv{len(_mn)}{_mn}E"
|
||||
_LOCAL_TYPEDEF_URL[_mn] = f"{_local_page}#{_anchor}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _doxy_page_to_local(basename: str) -> str:
|
||||
"""Map a Doxygen compound page filename to the Sphinx page for the same
|
||||
symbol. Pure name transform — the caller verifies the file exists.
|
||||
group__core__utils.html -> core_utils.html
|
||||
namespacecv*.html -> core_basic.html
|
||||
class*/struct*/union* -> unchanged (or remapped, e.g. the _Tp stub)
|
||||
"""
|
||||
if basename.startswith("group__"):
|
||||
return (basename[len("group__"):].replace(".html", "")
|
||||
.replace("__", "_") + ".html")
|
||||
if basename.startswith("namespace"):
|
||||
return "core_basic.html"
|
||||
return _LOCAL_PAGE_BY_DOXY_FILE.get(basename, basename)
|
||||
|
||||
|
||||
# -- Class template-parameter display map (step 8e) -------------------------
|
||||
# class short name -> template-param list e.g. '< _Tp, cn >'
|
||||
_CLASS_TEMPLATE_DISPLAY: dict[str, str] = {}
|
||||
if _API_XML_DIR.is_dir():
|
||||
try:
|
||||
import xml.etree.ElementTree as _ET
|
||||
for _xml in _API_XML_DIR.glob("classcv_1_1*.xml"):
|
||||
try:
|
||||
_cd = _ET.parse(_xml).getroot().find("compounddef")
|
||||
except _ET.ParseError:
|
||||
continue
|
||||
if _cd is None:
|
||||
continue
|
||||
_tpl = _cd.find("templateparamlist")
|
||||
if _tpl is None:
|
||||
continue
|
||||
_names = []
|
||||
for _p in _tpl.findall("param"):
|
||||
_decl = (_p.findtext("declname")
|
||||
or _p.findtext("defname") or "").strip()
|
||||
_type = (_p.findtext("type") or "").strip()
|
||||
if _decl:
|
||||
_names.append(_decl)
|
||||
elif _type in ("typename", "class"):
|
||||
_names.append("_Tp")
|
||||
elif _type:
|
||||
_names.append(_type)
|
||||
if _names:
|
||||
_name = (_cd.findtext("compoundname") or "").split("::")[-1]
|
||||
_CLASS_TEMPLATE_DISPLAY[_name] = f"< {', '.join(_names)} >"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Punctuation -> alpha token so operator overloads get distinct slugs
|
||||
_FUNC_SLUG_PUNCT = {
|
||||
"=": "eq", "!": "ne", "<": "lt", ">": "gt", "+": "plus", "-": "minus",
|
||||
"*": "mul", "/": "div", "&": "amp", "|": "or", "%": "mod", "^": "xor",
|
||||
"~": "tilde", "[": "lbr", "]": "rbr",
|
||||
}
|
||||
|
||||
|
||||
def _func_slug(name: str) -> str:
|
||||
"""In-page anchor slug for a function name; shared by stub gen and translator."""
|
||||
parts = []
|
||||
for ch in name.lower():
|
||||
if ch.isalnum() or ch == "_":
|
||||
parts.append(ch)
|
||||
elif ch in _FUNC_SLUG_PUNCT:
|
||||
parts.append("-" + _FUNC_SLUG_PUNCT[ch])
|
||||
else:
|
||||
parts.append("-")
|
||||
s = re.sub(r"-+", "-", "".join(parts)).strip("-")
|
||||
return f"cv-{s}" if s else "cv"
|
||||
|
||||
|
||||
# ---- Citation numbering --------------------------------------------------
|
||||
# @cite KEY -> [N], N = bibtex-plain sort position
|
||||
def _bib_parse(text: str) -> list[dict]:
|
||||
"""Walk a BibTeX file into a list of {_type, _key, field: value, ...}."""
|
||||
out: list[dict] = []
|
||||
n, i = len(text), 0
|
||||
while i < n:
|
||||
m = re.search(r"@(\w+)\s*\{\s*([^\s,]+)\s*,", text[i:])
|
||||
if not m:
|
||||
break
|
||||
kind, key = m.group(1), m.group(2)
|
||||
i += m.end()
|
||||
depth, body_start = 1, i
|
||||
while i < n and depth > 0:
|
||||
c = text[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
if depth != 0:
|
||||
break # malformed entry
|
||||
out.append({"_type": kind.lower(), "_key": key,
|
||||
**_bib_fields(text[body_start:i - 1])})
|
||||
return out
|
||||
|
||||
def _bib_fields(body: str) -> dict[str, str]:
|
||||
fields: dict[str, str] = {}
|
||||
n, j = len(body), 0
|
||||
while j < n:
|
||||
while j < n and body[j] in " \t\n\r,":
|
||||
j += 1
|
||||
if j >= n:
|
||||
break
|
||||
ns = j
|
||||
while j < n and (body[j].isalnum() or body[j] == "_"):
|
||||
j += 1
|
||||
name = body[ns:j].lower()
|
||||
if not name:
|
||||
break
|
||||
while j < n and body[j] in " \t\n\r":
|
||||
j += 1
|
||||
if j >= n or body[j] != "=":
|
||||
break
|
||||
j += 1
|
||||
while j < n and body[j] in " \t\n\r":
|
||||
j += 1
|
||||
if j >= n:
|
||||
break
|
||||
if body[j] == "{":
|
||||
j += 1
|
||||
depth, vs = 1, j
|
||||
while j < n and depth > 0:
|
||||
if body[j] == "{":
|
||||
depth += 1
|
||||
elif body[j] == "}":
|
||||
depth -= 1
|
||||
j += 1
|
||||
value = body[vs:j - 1]
|
||||
elif body[j] == '"':
|
||||
j += 1
|
||||
vs = j
|
||||
while j < n and body[j] != '"':
|
||||
if body[j] == "\\" and j + 1 < n:
|
||||
j += 1
|
||||
j += 1
|
||||
value = body[vs:j]
|
||||
if j < n:
|
||||
j += 1
|
||||
else:
|
||||
vs = j
|
||||
while j < n and body[j] not in ",\n":
|
||||
j += 1
|
||||
value = body[vs:j].strip()
|
||||
fields[name] = value
|
||||
return fields
|
||||
|
||||
# LaTeX accent + special-char cleanup
|
||||
_LATEX_ACCENT_RE = re.compile(r"\\([\"'`^~.])\s*\{?\s*([A-Za-z])\s*\}?")
|
||||
_LATEX_ACCENT_MAP = {
|
||||
('"', 'a'): 'ä', ('"', 'e'): 'ë', ('"', 'i'): 'ï', ('"', 'o'): 'ö',
|
||||
('"', 'u'): 'ü', ('"', 'A'): 'Ä', ('"', 'O'): 'Ö', ('"', 'U'): 'Ü',
|
||||
("'", 'a'): 'á', ("'", 'e'): 'é', ("'", 'i'): 'í', ("'", 'o'): 'ó',
|
||||
("'", 'u'): 'ú', ("'", 'c'): 'ć', ("'", 'n'): 'ń', ("'", 'A'): 'Á',
|
||||
("'", 'E'): 'É', ("'", 'I'): 'Í', ("'", 'O'): 'Ó', ("'", 'U'): 'Ú',
|
||||
("`", 'a'): 'à', ("`", 'e'): 'è', ("`", 'i'): 'ì', ("`", 'o'): 'ò',
|
||||
("`", 'u'): 'ù',
|
||||
('^', 'a'): 'â', ('^', 'e'): 'ê', ('^', 'i'): 'î', ('^', 'o'): 'ô',
|
||||
('^', 'u'): 'û',
|
||||
('~', 'a'): 'ã', ('~', 'n'): 'ñ', ('~', 'o'): 'õ',
|
||||
('.', 'c'): 'ċ', ('.', 'e'): 'ė',
|
||||
}
|
||||
_LATEX_SPECIAL = {
|
||||
r"\&": "&", r"\%": "%", r"\#": "#", r"\$": "$",
|
||||
r"\_": "_", r"\{": "{", r"\}": "}",
|
||||
r"\textendash": "–", r"\textemdash": "—",
|
||||
r"\ldots": "…", r"\dots": "…",
|
||||
r"\o": "ø", r"\O": "Ø", r"\ss": "ß",
|
||||
r"\aa": "å", r"\AA": "Å", r"\ae": "æ", r"\AE": "Æ",
|
||||
"---": "—", "--": "–",
|
||||
}
|
||||
|
||||
def _bib_clean(s: str) -> str:
|
||||
s = re.sub(r"\s+", " ", s or "").strip()
|
||||
s = _LATEX_ACCENT_RE.sub(
|
||||
lambda m: _LATEX_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()
|
||||
|
||||
def _bib_join_authors(field: str) -> str:
|
||||
"""Render an author/editor list bibtex-plain style."""
|
||||
if not field:
|
||||
return ""
|
||||
parts = re.split(r"\s+and\s+", field)
|
||||
out: list[str] = []
|
||||
for p in parts:
|
||||
p = _bib_clean(p)
|
||||
if "," in p: # "Last, First" → "First Last"
|
||||
last, first = p.split(",", 1)
|
||||
p = f"{first.strip()} {last.strip()}"
|
||||
out.append(p)
|
||||
if len(out) <= 1:
|
||||
return out[0] if out else ""
|
||||
if len(out) == 2:
|
||||
return f"{out[0]} and {out[1]}"
|
||||
return ", ".join(out[:-1]) + f", and {out[-1]}"
|
||||
|
||||
def _bib_render_entry(e: dict, num: int | None) -> str:
|
||||
key = e["_key"]
|
||||
bracket = f"[{num}]" if num is not None else f"[{key}]"
|
||||
authors = _bib_join_authors(e.get("author") or e.get("editor") or "")
|
||||
title = _bib_clean(e.get("title", ""))
|
||||
year = _bib_clean(e.get("year", ""))
|
||||
month = _bib_clean(e.get("month", ""))
|
||||
pages = _bib_clean(e.get("pages", ""))
|
||||
volume = _bib_clean(e.get("volume", ""))
|
||||
number = _bib_clean(e.get("number", ""))
|
||||
doi = _bib_clean(e.get("doi", ""))
|
||||
url = _bib_clean(e.get("url", ""))
|
||||
journal = _bib_clean(e.get("journal", ""))
|
||||
booktitle = _bib_clean(e.get("booktitle", ""))
|
||||
publisher = _bib_clean(e.get("publisher") or e.get("howpublished")
|
||||
or e.get("institution") or "")
|
||||
|
||||
# title links to url, else doi.org
|
||||
title_url = url or (f"https://doi.org/{doi}" if doi else "")
|
||||
title_md = f"[{title}]({title_url})" if (title and title_url) else title
|
||||
|
||||
date = (f"{month} {year}".strip()) if (month or year) else ""
|
||||
|
||||
bits: list[str] = []
|
||||
if authors:
|
||||
bits.append(authors)
|
||||
if title_md:
|
||||
bits.append(title_md)
|
||||
|
||||
# venue formatting by entry kind
|
||||
kind = e.get("_type", "")
|
||||
if kind == "article" and journal:
|
||||
seg = f"*{journal}*"
|
||||
if volume:
|
||||
seg += f", {volume}" + (f"({number})" if number else "")
|
||||
if pages:
|
||||
seg += f":{pages}"
|
||||
elif pages:
|
||||
seg += f", {pages}"
|
||||
if date:
|
||||
seg += f", {date}"
|
||||
bits.append(seg)
|
||||
elif kind in ("inproceedings", "incollection") and booktitle:
|
||||
seg = f"In *{booktitle}*"
|
||||
if pages:
|
||||
seg += f", pages {pages}"
|
||||
if publisher:
|
||||
seg += f". {publisher}"
|
||||
if date:
|
||||
seg += f", {date}"
|
||||
bits.append(seg)
|
||||
else:
|
||||
# @book / @misc / fallback
|
||||
tail = []
|
||||
if publisher:
|
||||
tail.append(publisher)
|
||||
if booktitle and not publisher:
|
||||
tail.append(f"*{booktitle}*")
|
||||
if date:
|
||||
tail.append(date)
|
||||
if tail:
|
||||
bits.append(", ".join(tail))
|
||||
|
||||
body = ". ".join(bits)
|
||||
if body and not body.endswith("."):
|
||||
body += "."
|
||||
|
||||
# keep CITEREF_<Key> case so cached Doxygen links resolve
|
||||
return f'<a id="CITEREF_{key}"></a>\n\n**{bracket}** {body}'
|
||||
|
||||
def _bib_render_all(entries: list[dict], numbering: dict[str, int]) -> str:
|
||||
out = ["Bibliography {#citelist}", "============", ""]
|
||||
for e in entries:
|
||||
out.append(_bib_render_entry(e, numbering.get(e["_key"])))
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _bib_sort_key(e: dict) -> tuple:
|
||||
"""bibtex `plain` sort: author last name, year, title (matches live site)."""
|
||||
authors = e.get("author") or e.get("editor") or "zzz"
|
||||
first = re.split(r"\s+and\s+", authors)[0]
|
||||
first = _bib_clean(first)
|
||||
if "," in first: # "Last, First"
|
||||
last = first.split(",", 1)[0].strip()
|
||||
else: # "First Middle Last"
|
||||
toks = first.split()
|
||||
last = toks[-1] if toks else "zzz"
|
||||
return (last.lower(),
|
||||
_bib_clean(e.get("year", "")),
|
||||
_bib_clean(e.get("title", "")).lower())
|
||||
|
||||
# opencv.bib + per-module + contrib bibs (contrib affects sort order)
|
||||
_BIB_FILES: list[pathlib.Path] = []
|
||||
if (DOC_ROOT / "opencv.bib").is_file():
|
||||
_BIB_FILES.append(DOC_ROOT / "opencv.bib")
|
||||
_BIB_FILES += sorted((OPENCV_ROOT / "modules").glob("*/doc/*.bib"))
|
||||
if CONTRIB_ROOT.is_dir():
|
||||
_BIB_FILES += sorted(CONTRIB_ROOT.glob("*/doc/*.bib"))
|
||||
|
||||
_CITE_NUMBER: dict[str, int] = {}
|
||||
# reused by citelist generator for the same sort order
|
||||
_BIB_ENTRIES_SORTED: list[dict] = []
|
||||
_seen_keys: set[str] = set() # dedupe; first wins
|
||||
_all_entries: list[dict] = []
|
||||
for _bf in _BIB_FILES:
|
||||
try:
|
||||
_txt = _bf.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
for _e in _bib_parse(_txt):
|
||||
if _e["_key"] in _seen_keys:
|
||||
continue
|
||||
_seen_keys.add(_e["_key"])
|
||||
_all_entries.append(_e)
|
||||
_BIB_ENTRIES_SORTED = sorted(_all_entries, key=_bib_sort_key)
|
||||
for _i, _e in enumerate(_BIB_ENTRIES_SORTED, 1):
|
||||
_CITE_NUMBER[_e["_key"]] = _i
|
||||
|
||||
_REDIRECT_MAP: dict[str, str] = {}
|
||||
_REDIRECT_RE = re.compile(
|
||||
r"\{#(?P<src>[\w-]+)\}\s*\n[=\-]+\s*\n+"
|
||||
r"\s*(?:Content|Tutorial\s+content)\s+has\s+been\s+moved\b"
|
||||
r"[^@]{0,300}@ref\s+(?P<dst>[\w-]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for _scan_dir in ("tutorials", "py_tutorials", "js_tutorials"):
|
||||
_root = DOC_ROOT / _scan_dir
|
||||
if not _root.is_dir():
|
||||
continue
|
||||
for _md in _root.rglob("*.markdown"):
|
||||
try:
|
||||
_t = _md.read_text(encoding="utf-8", errors="replace")[:2000]
|
||||
except OSError:
|
||||
continue
|
||||
_m = _REDIRECT_RE.search(_t)
|
||||
if _m:
|
||||
_REDIRECT_MAP[_m.group("src")] = _m.group("dst")
|
||||
|
||||
def _resolve_redirect(anchor: str) -> str:
|
||||
"""Follow `_REDIRECT_MAP` transitively (cycle-safe)."""
|
||||
seen: set[str] = set()
|
||||
while anchor in _REDIRECT_MAP and anchor not in seen:
|
||||
seen.add(anchor)
|
||||
anchor = _REDIRECT_MAP[anchor]
|
||||
return anchor
|
||||
|
||||
# Anchor maps
|
||||
_ANCHOR_TO_DOC: dict[str, str] = {} # anchor -> docname (internal)
|
||||
_ANCHOR_TO_EXTERNAL: dict[str, tuple[str, str]] = {} # anchor -> (title, url)
|
||||
_ANCHOR_TO_TITLE: dict[str, str] = {} # anchor -> first-heading title
|
||||
# anchors reachable via @subpage/@ref; rest are orphans
|
||||
_REFERENCED_ANCHORS: set[str] = set()
|
||||
|
||||
_HEAD_RE = re.compile(
|
||||
r"^(?P<title1>[^\n]+?)\s*\{#(?P<anchor1>[\w-]+)\}\s*\n[=\-]{3,}\s*$"
|
||||
r"|"
|
||||
r"^#+\s+(?P<title2>[^\n]+?)\s*\{#(?P<anchor2>[\w-]+)\}\s*$",
|
||||
re.MULTILINE)
|
||||
|
||||
def _scan_internal(path: pathlib.Path, base: pathlib.Path | None = None) -> None:
|
||||
"""Add every {#anchor} and `@anchor NAME` in `path` to _ANCHOR_TO_DOC."""
|
||||
base = base or SPHINX_INPUT_ROOT
|
||||
_SUFFIXES = (".markdown", ".md")
|
||||
if path.is_file():
|
||||
files = [path] if path.suffix in _SUFFIXES else []
|
||||
elif path.is_dir():
|
||||
# skip _old/** (not compiled)
|
||||
files = [p for s in _SUFFIXES for p in path.rglob(f"*{s}")
|
||||
if "_old" not in p.parts]
|
||||
else:
|
||||
files = []
|
||||
for md in files:
|
||||
try:
|
||||
body = md.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
# unresolved path so staged symlinks give staging-root docnames
|
||||
try:
|
||||
rel = md.relative_to(base).with_suffix("").as_posix()
|
||||
except ValueError:
|
||||
# file outside base; fall back to DOC_ROOT-relative
|
||||
rel = md.relative_to(DOC_ROOT).with_suffix("").as_posix()
|
||||
for m in re.finditer(r"\{#([\w-]+)\}", body):
|
||||
_ANCHOR_TO_DOC[m.group(1)] = rel
|
||||
for m in re.finditer(r"^@anchor\s+([\w-]+)\s*$", body, re.MULTILINE):
|
||||
_ANCHOR_TO_DOC[m.group(1)] = rel
|
||||
# anchors this file links to (orphan detection)
|
||||
for m in re.finditer(r"@(?:subpage|ref)\s+([\w-]+)", body):
|
||||
_REFERENCED_ANCHORS.add(m.group(1))
|
||||
# first heading title
|
||||
tm = _HEAD_RE.search(body[:4000])
|
||||
if tm:
|
||||
anchor = tm.group("anchor1") or tm.group("anchor2")
|
||||
title = (tm.group("title1") or tm.group("title2") or "").strip()
|
||||
if anchor and title:
|
||||
_ANCHOR_TO_TITLE[anchor] = title
|
||||
|
||||
def _scan_external(toc_file: pathlib.Path) -> None:
|
||||
"""Add a module TOC file's top (title, anchor) to _ANCHOR_TO_EXTERNAL."""
|
||||
try:
|
||||
head = toc_file.read_text(encoding="utf-8", errors="replace")[:4000]
|
||||
except OSError:
|
||||
return
|
||||
m = _HEAD_RE.search(head)
|
||||
if not m:
|
||||
return
|
||||
anchor = m.group("anchor1") or m.group("anchor2")
|
||||
title = (m.group("title1") or m.group("title2") or "").strip()
|
||||
if not anchor:
|
||||
return
|
||||
url = DOXYGEN_BASE_URL + _TAG_FILENAMES.get(anchor, "index.html")
|
||||
_ANCHOR_TO_EXTERNAL[anchor] = (title, url)
|
||||
|
||||
# shared dict objects populated by the build module
|
||||
_IMAGE_INDEX: dict[str, str] = {}
|
||||
_SNIPPET_INDEX: dict[str, pathlib.Path] = {}
|
||||
|
||||
_ALL_CLASSES: dict[str, dict] = {} # refid -> {qualified, kind, brief, docname}
|
||||
_ALL_NAMESPACES: dict[str, dict] = {} # name -> {refid, brief, docname}
|
||||
|
||||
_TOGGLE_LABELS = {"cpp": "C++", "java": "Java", "python": "Python"}
|
||||
|
||||
|
||||
# Mirrors Doxygen EXAMPLE_PATH; OPENCV_ROOT first (order matters)
|
||||
_SNIPPET_BASES = [
|
||||
OPENCV_ROOT,
|
||||
OPENCV_ROOT / "samples",
|
||||
OPENCV_ROOT / "apps",
|
||||
] + [CONTRIB_ROOT / _m / "samples" for _m in CONTRIB_MODULES]
|
||||
|
||||
# Remap Doxygen language names Pygments doesn't know
|
||||
_LANG_ALIASES = {
|
||||
"none": "text",
|
||||
"unparsed": "text",
|
||||
"guess": "text",
|
||||
"gradle": "groovy",
|
||||
"run": "bash",
|
||||
# `m` = Objective-C in the iOS tutorials (.m sources); Pygments uses `objc`.
|
||||
"m": "objc",
|
||||
# No Pygments lexer for these fence tags used by ios/app/face tutorials —
|
||||
# render as plain text instead of warning "lexer name is not known".
|
||||
"csv": "text",
|
||||
"plaintext": "text",
|
||||
}
|
||||
|
||||
USE_INDEX_LANDING = True
|
||||
|
||||
__all__ = [
|
||||
"USE_INDEX_LANDING",
|
||||
"HERE", "DOC_ROOT", "OPENCV_ROOT",
|
||||
"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",
|
||||
"_PY_SIGNATURES", "_python_enum_name",
|
||||
"HAVE_SPHINX_DESIGN", "HAVE_BREATHE",
|
||||
"DOXYGEN_BASE_URL", "_doxygen_url",
|
||||
"_TAG_FILE", "_TAG_FILENAMES", "_TAG_TITLES", "_DOC_PAGE_TITLES",
|
||||
"_CV_SYMBOL_URL", "_FILE_URL",
|
||||
"_CALL_GRAPH_ANCHORS", "_DOXY_ANCHOR_TO_MEMBER", "_norm_args",
|
||||
"_LIVE_GROUP_URL", "_LIVE_CLASS_URL", "_LIVE_TYPEDEF_URL",
|
||||
"_LOCAL_CLASS_URL", "_LOCAL_TYPEDEF_URL", "_CLASS_TEMPLATE_DISPLAY",
|
||||
"_LOCAL_PAGE_BY_DOXY_FILE", "_PLACEHOLDER_STUBS", "_doxy_page_to_local",
|
||||
"_func_slug",
|
||||
"_CITE_NUMBER", "_BIB_ENTRIES_SORTED", "_bib_render_all",
|
||||
"_REDIRECT_MAP", "_resolve_redirect",
|
||||
"_ANCHOR_TO_DOC", "_ANCHOR_TO_EXTERNAL", "_ANCHOR_TO_TITLE",
|
||||
"_REFERENCED_ANCHORS", "_HEAD_RE",
|
||||
"_scan_internal", "_scan_external",
|
||||
"_IMAGE_INDEX", "_SNIPPET_INDEX", "_SNIPPET_BASES",
|
||||
"_ALL_CLASSES", "_ALL_NAMESPACES",
|
||||
"_TOGGLE_LABELS", "_LANG_ALIASES",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,997 @@
|
||||
# This file is part of OpenCV project.
|
||||
# It is subject to the license terms in the LICENSE file found in the top-level directory
|
||||
# of this distribution and at http://opencv.org/license.html.
|
||||
# Copyright (C) 2026, BigVision LLC, all rights reserved.
|
||||
# Third party copyrights are property of their respective owners.
|
||||
|
||||
"""Doxygen-flavored .markdown -> MyST translation (the source-read engine)."""
|
||||
from __future__ import annotations
|
||||
import pathlib, re, os as _os, shutil as _shutil, textwrap as _textwrap
|
||||
from .state import *
|
||||
|
||||
|
||||
def _normalize_lang(lang: str) -> str:
|
||||
lang = (lang or "").strip(".").strip().lower() or "text"
|
||||
return _LANG_ALIASES.get(lang, lang)
|
||||
|
||||
|
||||
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("/")
|
||||
p = next((b / rel_norm for b in _SNIPPET_BASES
|
||||
if (b / rel_norm).is_file()), None)
|
||||
# Doxygen EXAMPLE_RECURSIVE basename lookup.
|
||||
if p is None:
|
||||
hit = _SNIPPET_INDEX.get(pathlib.Path(rel_norm).name)
|
||||
if hit and hit.is_file():
|
||||
p = hit
|
||||
if p is None:
|
||||
return f"// not found: {rel_path}\n", "text"
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
ext = p.suffix.lower()
|
||||
lang = {".cpp": "cpp", ".hpp": "cpp", ".h": "cpp", ".c": "c",
|
||||
".py": "python", ".java": "java",
|
||||
".xml": "xml", ".html": "html",
|
||||
".sh": "bash", ".bash": "bash"}.get(ext, "text")
|
||||
if label is None:
|
||||
return text, lang
|
||||
# Match `[label]` after any comment marker.
|
||||
pat = re.compile(r"^[^\[\n]*(?://!|//|##|#|<!--)[^\[\n]*\[" + re.escape(label)
|
||||
+ r"\][^\n]*$", re.MULTILINE)
|
||||
matches = list(pat.finditer(text))
|
||||
if len(matches) < 2:
|
||||
return f"// snippet not found: {rel_path} [{label}]\n", lang
|
||||
body = text[matches[0].end():matches[1].start()].strip("\n")
|
||||
lines = body.split("\n")
|
||||
indents = [len(l) - len(l.lstrip(" ")) for l in lines if l.strip()]
|
||||
if indents:
|
||||
dedent = min(indents)
|
||||
lines = [l[dedent:] if len(l) >= dedent else l for l in lines]
|
||||
return "\n".join(lines), lang
|
||||
|
||||
|
||||
def _emit_toggles(tabs: list[tuple[str, str]]) -> str:
|
||||
if HAVE_SPHINX_DESIGN:
|
||||
out = ["", "``````{tab-set}"]
|
||||
for lang, body in tabs:
|
||||
label = _TOGGLE_LABELS.get(lang, lang.title())
|
||||
out += [f"`````{{tab-item}} {label}", body, "`````"]
|
||||
out += ["``````", ""]
|
||||
return "\n".join(out)
|
||||
# Fallback without sphinx-design.
|
||||
out = [""]
|
||||
for lang, body in tabs:
|
||||
label = _TOGGLE_LABELS.get(lang, lang.title())
|
||||
out += [f"**{label}**", "", body, ""]
|
||||
return "\n".join(out)
|
||||
|
||||
def _translate(text: str, docname: str | None = None) -> str:
|
||||
# 0v. @verbatim — stash body; restored at end.
|
||||
_verbatim_stash: dict[str, str] = {}
|
||||
def _verbatim_save(body: str, inline: bool) -> str:
|
||||
key = f"VERBATIM_{len(_verbatim_stash)}"
|
||||
if inline:
|
||||
_verbatim_stash[key] = f"`{body.strip()}`"
|
||||
else:
|
||||
_verbatim_stash[key] = f"\n```text\n{body.strip()}\n```\n"
|
||||
return key
|
||||
# Block form — run first.
|
||||
text = re.sub(
|
||||
r"@verbatim[ \t]*\n(?P<body>.*?)\n[ \t]*@endverbatim",
|
||||
lambda m: _verbatim_save(m.group("body"), inline=False),
|
||||
text, flags=re.DOTALL)
|
||||
# Inline form.
|
||||
text = re.sub(
|
||||
r"@verbatim[ \t]+(?P<body>[^\n]+?)[ \t]+@endverbatim",
|
||||
lambda m: _verbatim_save(m.group("body"), inline=True),
|
||||
text)
|
||||
|
||||
if docname == "tutorials/tutorials" and not USE_INDEX_LANDING:
|
||||
# Lead sidebar with intro; faq/bibliography stay appended.
|
||||
if "intro" in _ANCHOR_TO_DOC:
|
||||
text = re.sub(r"^- @subpage", "- @subpage intro\n- @subpage",
|
||||
text, count=1, flags=re.MULTILINE)
|
||||
if JS_DOC_MODULES:
|
||||
text += "\n- @subpage tutorial_js_root\n"
|
||||
if PY_DOC_MODULES:
|
||||
text += "\n- @subpage tutorial_py_root\n"
|
||||
if "faq" in _ANCHOR_TO_DOC:
|
||||
text += "\n- @subpage faq\n"
|
||||
if "citelist" in _ANCHOR_TO_DOC:
|
||||
text += "\n- @subpage citelist\n"
|
||||
|
||||
# 0a. py_tutorials root: promote @ref to @subpage (case-by-case avoids cycles).
|
||||
if docname == "py_tutorials/py_tutorials":
|
||||
if "py_video" in PY_DOC_MODULES:
|
||||
text = re.sub(
|
||||
r"@ref\s+tutorial_table_of_content_video\b",
|
||||
"@subpage tutorial_py_table_of_contents_video",
|
||||
text,
|
||||
)
|
||||
# Object Detection stays @ref (its C++ page is already in main toctree).
|
||||
|
||||
# 0d. py_video/py_objdetect stub trees -> :orphan: (real link via 0a).
|
||||
if docname and (docname.startswith("py_tutorials/py_video/")
|
||||
or docname.startswith("py_tutorials/py_objdetect/")):
|
||||
text = "---\norphan: true\n---\n\n" + text
|
||||
|
||||
# 0b. "-# foo" -> "1. foo".
|
||||
text = re.sub(r"^(?P<indent>[ \t]*)-#[ \t]+",
|
||||
lambda m: f"{m.group('indent')}1. ", text, flags=re.MULTILINE)
|
||||
|
||||
# 0c. Dedent orphan indented bullets under a paragraph.
|
||||
_orphan_bullets_re = re.compile(
|
||||
r"^(?P<before>(?![ \t#=*\->]).+\n)"
|
||||
r"(?P<bullets>(?:[ \t]{2,}-[ \t]+[^\n]+\n)+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
def _dedent_orphan_bullets(m: re.Match) -> str:
|
||||
before = m.group("before")
|
||||
raw = m.group("bullets")
|
||||
lines = raw.split("\n")
|
||||
nonempty = [l for l in lines if l.strip()]
|
||||
if not nonempty:
|
||||
return m.group(0)
|
||||
min_indent = min(len(l) - len(l.lstrip(" \t")) for l in nonempty)
|
||||
dedented = "\n".join(
|
||||
l[min_indent:] if len(l) >= min_indent else l for l in lines
|
||||
)
|
||||
return before + "\n" + dedented
|
||||
text = _orphan_bullets_re.sub(_dedent_orphan_bullets, text)
|
||||
|
||||
# 1. Heading anchors "Title {#name}" -> MyST label + ATX.
|
||||
def _setext_repl(m: re.Match) -> str:
|
||||
title = m.group("title").strip()
|
||||
level = 1 if m.group("bar") == "=" else 2
|
||||
return f"({m.group('anchor')})=\n{'#' * level} {title}"
|
||||
text = re.sub(
|
||||
r"^(?P<title>[^\n]+?)\s*\{#(?P<anchor>[\w-]+)\}\s*\n(?P<bar>[=\-])[=\-]{2,}\s*$",
|
||||
_setext_repl, text, flags=re.MULTILINE)
|
||||
text = re.sub(
|
||||
r"^(?P<hashes>#+)\s+(?P<title>[^\n]+?)\s*\{#(?P<anchor>[\w-]+)\}\s*$",
|
||||
lambda m: f"({m.group('anchor')})=\n{m.group('hashes')} {m.group('title')}",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 1b. Trailing setext heading at EOF -> ATX (docutils rejects trailing transition).
|
||||
text = re.sub(
|
||||
r"^(?P<title>[^\n#=\-][^\n]*?)[ \t]*\n(?P<bar>[=\-])[=\-]{2,}[ \t]*$\s*\Z",
|
||||
lambda m: f"{'#' if m.group('bar') == '=' else '##'} {m.group('title').strip()}\n",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 1c. Remaining mid-doc setext H1s -> ATX (so 1d sees them).
|
||||
text = re.sub(
|
||||
r"^(?P<title>[^\n#=\-][^\n]*?)[ \t]*\n=[=]{2,}[ \t]*$",
|
||||
lambda m: f"# {m.group('title').strip()}",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 1d. Demote every H1 after the first to H2.
|
||||
def _demote_extra_h1s(src: str) -> str:
|
||||
fence_open_re = re.compile(r'^[ \t]*(?:`{3,}|~{3,})')
|
||||
atx_h1_re = re.compile(r'^#\s')
|
||||
h1_count = 0
|
||||
in_fence = False
|
||||
out = []
|
||||
for line in src.split('\n'):
|
||||
if fence_open_re.match(line):
|
||||
in_fence = not in_fence
|
||||
out.append(line)
|
||||
continue
|
||||
if in_fence:
|
||||
out.append(line)
|
||||
continue
|
||||
if atx_h1_re.match(line):
|
||||
h1_count += 1
|
||||
if h1_count > 1:
|
||||
line = '#' + line
|
||||
out.append(line)
|
||||
return '\n'.join(out)
|
||||
text = _demote_extra_h1s(text)
|
||||
|
||||
_ADMON_KIND = {"note": "note", "see": "seealso", "warning": "warning",
|
||||
"sa": "seealso"}
|
||||
def _admon_repl(m: re.Match) -> str:
|
||||
kind = _ADMON_KIND[m.group("dir")]
|
||||
raw = m.group("body")
|
||||
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"
|
||||
text = re.sub(
|
||||
r"^[ \t]*@(?P<dir>note|see|warning|sa)[ \t]*\n?(?P<body>.+?)(?=\n[ \t]*\n|\n[ \t]*@[A-Za-z]|\Z)",
|
||||
_admon_repl, text, flags=re.DOTALL | re.MULTILINE)
|
||||
|
||||
# 2. Doxygen LaTeX math markers; preserve indent so blocks inside list items stay in the list.
|
||||
def _split_adj_math(m: re.Match) -> str:
|
||||
indent = m.group("indent")
|
||||
return m.group(0).replace("\\f]\\f[", f"\\f]\n{indent}\\f[")
|
||||
text = re.sub(r"^(?P<indent>[ \t]*)[^\n]*\\f\]\\f\[",
|
||||
_split_adj_math, text, flags=re.MULTILINE)
|
||||
def _fblock(m: re.Match) -> str:
|
||||
ind = m.group("indent")
|
||||
return f"\n{ind}$$\n{m.group('body').strip()}\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\]",
|
||||
lambda m: f"\n$$\n{m.group(1).strip()}\n$$\n",
|
||||
text, flags=re.DOTALL)
|
||||
text = re.sub(r"\\f\$(.+?)\\f\$", lambda m: f"${m.group(1)}$",
|
||||
text, flags=re.DOTALL)
|
||||
|
||||
# 2b. \bordermatrix is Plain-TeX; convert to a `matrix` env, `\cr` -> `\\`.
|
||||
text = re.sub(
|
||||
r"\\bordermatrix\s*\{([^}]*)\}",
|
||||
lambda m: r"\begin{matrix}" + m.group(1).replace(r"\cr", r"\\")
|
||||
+ r"\end{matrix}",
|
||||
text)
|
||||
|
||||
# 3. @code{.lang} ... @endcode -> fenced block (indent preserved).
|
||||
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"
|
||||
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)
|
||||
|
||||
# 3b. Tilde fences `~~~~{.lang}` -> backtick fences (MyST reads `{.lang}` as directive).
|
||||
def _tilde_repl(m: re.Match) -> str:
|
||||
lang = (m.group("lang") or "").strip().lstrip(".") or "text"
|
||||
if lang == "none":
|
||||
lang = "text"
|
||||
return f"\n```{lang}\n{m.group('body').strip()}\n```\n"
|
||||
text = re.sub(
|
||||
r"^~~~+(?:[ \t]*\{(?P<lang>[^}\n]+)\})?[ \t]*\n"
|
||||
r"(?P<body>.*?)\n^~~~+[ \t]*$",
|
||||
_tilde_repl, text, flags=re.DOTALL | re.MULTILINE)
|
||||
|
||||
# 3c. Fence lexer `yml` -> `yaml` (Pygments warns on 'yml'); runs after 3/3b.
|
||||
text = re.sub(
|
||||
r"^(?P<fence>`{3,}|~{3,})yml\b(?P<rest>[ \t]*)$",
|
||||
lambda m: f"{m.group('fence')}yaml{m.group('rest')}",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 3d. \htmlonly ... \endhtmlonly -> `{raw} html`.
|
||||
text = re.sub(
|
||||
r"\\htmlonly\s*\n(?P<body>.*?)\n\s*\\endhtmlonly",
|
||||
lambda m: f"\n```{{raw}} html\n{m.group('body').strip()}\n```\n",
|
||||
text, flags=re.DOTALL)
|
||||
|
||||
# 3e. Plain fences with Doxygen lang spec ("```.sh") -> strip dot, alias-map.
|
||||
text = re.sub(
|
||||
r"^(?P<fence>`{3,})(?P<lang>\.?[\w-]+)[ \t]*$",
|
||||
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).
|
||||
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"
|
||||
|
||||
# 4. @include path / @includelineno path.
|
||||
def _include_repl(m: re.Match) -> str:
|
||||
code, lang = _read_snippet(m.group("path"), None)
|
||||
return _emit_codeblock(m.group("indent") or "", lang, code)
|
||||
text = re.sub(r"^(?P<indent>[ \t]*)@include(?:lineno)?\s+(?P<path>\S+)",
|
||||
_include_repl, text, flags=re.MULTILINE)
|
||||
|
||||
# 4b. Drop a stray @snippet right after @end_toggle (duplicate of tab-set).
|
||||
text = re.sub(
|
||||
r"(^([ \t]*)@end_toggle[ \t]*\n)\2@snippet[^\n]*\n",
|
||||
r"\1",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 4b. @htmlinclude path -> raw HTML embed; lift just <body> for full docs.
|
||||
def _htmlinclude_repl(m: re.Match) -> str:
|
||||
rel = m.group("path")
|
||||
p = next((b / rel for b in _SNIPPET_BASES
|
||||
if (b / rel).is_file()), None)
|
||||
if p is None:
|
||||
name = pathlib.Path(rel).name
|
||||
for base in _SNIPPET_BASES:
|
||||
hit = next(iter(base.rglob(name)), None)
|
||||
if hit is not None:
|
||||
p = hit
|
||||
break
|
||||
if p is None:
|
||||
return f"<!-- htmlinclude not found: {rel} -->"
|
||||
raw = p.read_text(encoding="utf-8", errors="replace")
|
||||
# Allow quoted attrs so a `=>` inside onload="..." doesn't end the tag.
|
||||
body = re.search(
|
||||
r"<body\b(?:[^>\"']|\"[^\"]*\"|'[^']*')*>(.*?)</body>",
|
||||
raw, re.DOTALL | re.IGNORECASE)
|
||||
inner = body.group(1).strip() if body else raw
|
||||
return f"\n```{{raw}} html\n{inner}\n```\n"
|
||||
text = re.sub(r"^@htmlinclude\s+(?P<path>\S+)\s*$",
|
||||
_htmlinclude_repl, text, flags=re.MULTILINE)
|
||||
|
||||
# 5. @snippet path [Label]
|
||||
def _snippet_repl(m: re.Match) -> str:
|
||||
code, lang = _read_snippet(m.group("path"), m.group("label"))
|
||||
return _emit_codeblock(m.group("indent") or "", lang, code)
|
||||
text = re.sub(
|
||||
r"^(?P<indent>[ \t]*)@snippet\s+(?P<path>\S+)\s+(?P<label>[^\n]+?)\s*$",
|
||||
_snippet_repl, text, flags=re.MULTILINE)
|
||||
|
||||
# 6. @add_toggle_LANG ... @end_toggle -> one tab-set
|
||||
def _toggle_collapse(src: str) -> str:
|
||||
out, i = [], 0
|
||||
opener = re.compile(r"^\s*@add_toggle_(\w+)\s*$", re.MULTILINE)
|
||||
while True:
|
||||
m = opener.search(src, i)
|
||||
if not m:
|
||||
out.append(src[i:]); break
|
||||
out.append(src[i:m.start()])
|
||||
tabs, j = [], m.start()
|
||||
while True:
|
||||
m2 = re.match(
|
||||
r"\s*@add_toggle_(\w+)\s*\n(.*?)\n\s*@end_toggle\s*\n?",
|
||||
src[j:], flags=re.DOTALL)
|
||||
if not m2:
|
||||
break
|
||||
tabs.append((m2.group(1), m2.group(2)))
|
||||
j += m2.end()
|
||||
k = re.match(r"\s*", src[j:])
|
||||
if not k or not re.match(r"@add_toggle_", src[j + k.end():]):
|
||||
break
|
||||
j += k.end()
|
||||
if not tabs:
|
||||
out.append(src[m.start():m.start() + 1]); i = m.start() + 1; continue
|
||||
out.append(_emit_toggles(tabs))
|
||||
i = j
|
||||
return "".join(out)
|
||||
text = _toggle_collapse(text)
|
||||
|
||||
def _link_repl(m: re.Match) -> str:
|
||||
target = m.group("target")
|
||||
disp = (m.group("disp") or "").strip().replace('"', '')
|
||||
return f'@ref {target} "{disp}"' if disp else f"@ref {target}"
|
||||
text = re.sub(
|
||||
r"@link\s+(?P<target>[\w-]+)(?P<disp>.*?)@endlink",
|
||||
_link_repl, text, flags=re.DOTALL)
|
||||
|
||||
if docname and (docname.startswith("main_modules/") or docname.startswith("extra_modules/")):
|
||||
def _rewrite_class_row(m: re.Match) -> str:
|
||||
kind = m.group("kind")
|
||||
name = m.group("name") # 'cv::dnn::BackendNode'
|
||||
page = m.group("page") # 'classcv_1_1dnn_1_1BackendNode'
|
||||
desc = m.group("desc").strip()
|
||||
short = name.split("::")[-1]
|
||||
tparams = _CLASS_TEMPLATE_DISPLAY.get(short, "")
|
||||
link_label = f"{name}{tparams}"
|
||||
more = (f'<a class="opencv-class-more" '
|
||||
f'href="{page}.html#detailed-description">View details</a>')
|
||||
desc_out = f"{desc} {more}" if desc else more
|
||||
return (f"| `{kind}` [`{link_label}`]({page}.md) "
|
||||
f"| {desc_out} |")
|
||||
text = re.sub(
|
||||
r"\| \[`(?P<kind>class|struct) (?P<name>cv::[^`]+)`\]"
|
||||
r"\((?P<page>(?:class|struct)cv_1_1[A-Za-z0-9_]+)\.md\)"
|
||||
r" \| (?P<desc>[^\n|]*?) \|",
|
||||
_rewrite_class_row, text)
|
||||
|
||||
|
||||
if docname == "main_modules/core_basic":
|
||||
# 8c. `{doxygentypedef} cv::Ptr` -> hand-rolled cpp:type (breathe skips C++11 aliases).
|
||||
text = re.sub(
|
||||
r"```\{doxygentypedef\} cv::Ptr\s*\n:project: opencv\s*\n```",
|
||||
"```{eval-rst}\n"
|
||||
".. cpp:namespace:: cv\n"
|
||||
".. cpp:type:: template<typename _Tp> Ptr = std::shared_ptr<_Tp>\n"
|
||||
"```",
|
||||
text)
|
||||
|
||||
# 8a. Name-column typedef anchors -> slugified detail-block id.
|
||||
text = re.sub(
|
||||
r"\[`(?P<name>[A-Za-z_][A-Za-z0-9_]*)`\]"
|
||||
r"\(#(?P<ref>group__[a-z0-9_]+?_1[a-z0-9]+)\)",
|
||||
lambda m: (f"[`{m.group('name')}`]"
|
||||
f"(#{re.sub(r'_+', '-', m.group('ref'))})"),
|
||||
text)
|
||||
|
||||
# 8i. Functions-table rows: split broken-anchor link into name link + params span.
|
||||
def _rewrite_function_row(m: re.Match) -> str:
|
||||
ret = m.group("ret").strip()
|
||||
name = m.group("name")
|
||||
params = m.group("params")
|
||||
desc = m.group("desc")
|
||||
slug = _func_slug(name)
|
||||
return (f"| {ret} | [`cv::{name}`](#{slug}) "
|
||||
f"`({params})` | {desc} |")
|
||||
text = re.sub(
|
||||
r"\| (?P<ret>[^|\n]*?) \| "
|
||||
r"\[`(?P<name>[^(`\n]+?)\((?P<params>[^`\n]*)\)`\]"
|
||||
r"\(#group__[a-z0-9_]+?_1[a-z0-9]+\) \| "
|
||||
r"(?P<desc>[^\n|]*) \|",
|
||||
_rewrite_function_row, text)
|
||||
|
||||
# 8j. Inject a raw HTML anchor before surviving `{doxygenfunction}` (dedup; first wins).
|
||||
_seen_anchors: set[str] = set()
|
||||
def _inject_func_anchor(m: re.Match) -> str:
|
||||
qname = m.group("qname").strip() # "cv::log" / "cv::operator!="
|
||||
slug = _func_slug(qname.rsplit("::", 1)[-1])
|
||||
if slug in _seen_anchors:
|
||||
return m.group(0)
|
||||
_seen_anchors.add(slug)
|
||||
return f'<a id="{slug}"></a>\n\n{m.group(0)}'
|
||||
text = re.sub(
|
||||
r"^```\{doxygenfunction\} (?P<qname>[^\n(]+)\([^\n]*\n"
|
||||
r":project: opencv\n"
|
||||
r"```",
|
||||
_inject_func_anchor, text, flags=re.MULTILINE)
|
||||
|
||||
# 8b. Type-column class code spans -> inline HTML link to sibling api page.
|
||||
if _LIVE_CLASS_URL:
|
||||
def _linkify_class_codespan(m: re.Match) -> str:
|
||||
cls = m.group("cls")
|
||||
rest = m.group("rest")
|
||||
full = _LIVE_CLASS_URL.get(cls)
|
||||
if not full:
|
||||
return m.group(0)
|
||||
href = pathlib.PurePosixPath(full).name # same module directory
|
||||
rest_esc = (rest.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">"))
|
||||
return (f'<code class="docutils literal notranslate">'
|
||||
f'<a class="reference internal" href="{href}">{cls}</a>'
|
||||
f'{rest_esc}</code>')
|
||||
text = re.sub(
|
||||
r"`(?P<cls>[A-Z][A-Za-z0-9_]*)(?P<rest><[^`\n]*>)`",
|
||||
_linkify_class_codespan, text)
|
||||
|
||||
# 8g. Linkify tokens step 8b missed: inner 8b `<code>`, then plain code spans.
|
||||
if _LOCAL_CLASS_URL or _LOCAL_TYPEDEF_URL:
|
||||
def _token_url(tok: str) -> str | None:
|
||||
# Tokens absent from the tagfile stay plain.
|
||||
return _LOCAL_CLASS_URL.get(tok) or _LOCAL_TYPEDEF_URL.get(tok)
|
||||
# Match an optional `cv::` prefix so the anchor spans `cv::Name`.
|
||||
_tok_re = re.compile(r"(?:cv::)?_?[A-Za-z][A-Za-z0-9_]*")
|
||||
def _bare(tok: str) -> str:
|
||||
return tok[4:] if tok.startswith("cv::") else tok
|
||||
def _anchor_text(tok: str) -> str:
|
||||
# Encode `::` so the later cv-linkifier doesn't nest a second anchor.
|
||||
return tok.replace("::", "::")
|
||||
def _linkify_html_segment(seg: str) -> str:
|
||||
def _sub(m: re.Match) -> str:
|
||||
url = _token_url(_bare(m.group(0)))
|
||||
if not url:
|
||||
return m.group(0)
|
||||
return (f'<a class="reference internal" '
|
||||
f'href="{url}">{_anchor_text(m.group(0))}</a>')
|
||||
return _tok_re.sub(_sub, seg)
|
||||
def _linkify_inside_code(m: re.Match) -> str:
|
||||
inner = m.group("inner")
|
||||
out, i, n = [], 0, len(m.group("inner"))
|
||||
while i < n:
|
||||
if inner.startswith("<a ", i):
|
||||
j = inner.find("</a>", i)
|
||||
if j < 0:
|
||||
out.append(inner[i:]); break
|
||||
out.append(inner[i:j + 4]); i = j + 4
|
||||
else:
|
||||
k = inner.find("<a ", i)
|
||||
if k < 0:
|
||||
out.append(_linkify_html_segment(inner[i:])); break
|
||||
out.append(_linkify_html_segment(inner[i:k])); i = k
|
||||
return m.group("open") + "".join(out) + m.group("close")
|
||||
text = re.sub(
|
||||
r'(?P<open><code class="docutils literal notranslate">)'
|
||||
r'(?P<inner>.*?)(?P<close></code>)',
|
||||
_linkify_inside_code, text, flags=re.DOTALL)
|
||||
|
||||
def _linkify_markdown_codespan(m: re.Match) -> str:
|
||||
content = m.group("content")
|
||||
# Prefix-aware: `cv::Name` is one hit so the anchor covers both.
|
||||
hits = [(t.start(), t.end(), t.group(0)) for t in
|
||||
_tok_re.finditer(content)
|
||||
if _token_url(_bare(t.group(0)))]
|
||||
if not hits:
|
||||
return m.group(0)
|
||||
from html import escape as _esc
|
||||
parts, last = [], 0
|
||||
for s, e, tok in hits:
|
||||
parts.append(_esc(content[last:s]))
|
||||
parts.append(f'<a class="reference internal" '
|
||||
f'href="{_token_url(_bare(tok))}">'
|
||||
f'{_anchor_text(tok)}</a>')
|
||||
last = e
|
||||
parts.append(_esc(content[last:]))
|
||||
return (f'<code class="docutils literal notranslate">'
|
||||
f'{"".join(parts)}</code>')
|
||||
_masked: list[str] = []
|
||||
def _mask(m: re.Match) -> str:
|
||||
_masked.append(m.group(0))
|
||||
return f"\x00MDLINK{len(_masked)-1}\x00"
|
||||
text = re.sub(r"\[(?:`[^`\n]+`|<br>)+\]\([^)\n]+\)", _mask, text)
|
||||
text = re.sub(r"`(?P<content>[^`\n]+?)`",
|
||||
_linkify_markdown_codespan, text)
|
||||
text = re.sub(r"\x00MDLINK(\d+)\x00",
|
||||
lambda m: _masked[int(m.group(1))], text)
|
||||
|
||||
# 6c. Bullet lists of @subpage/@ref -> toctree + visible list. Runs BEFORE step 7.
|
||||
def _subpage_list_to_toctree(src: str) -> str:
|
||||
bullet = r"^[ \t]*-\s+[^\n@]*?@(?:subpage|ref)\s+[\w-]+(?:[^\n]*)\n"
|
||||
desc_re = r"(?:(?:[ \t]*\n)*(?:[ \t]+[^\n]+\n)+)*"
|
||||
pat = re.compile(rf"((?:{bullet}{desc_re}(?:[ \t]*\n)*)+)", re.MULTILINE)
|
||||
item_pat = re.compile(
|
||||
rf"^[ \t]*-\s+(?P<prefix>[^\n@]*?)@(?P<kind>subpage|ref)\s+(?P<anchor>[\w-]+)"
|
||||
rf'(?:[ \t]+"(?P<disp>[^"\n]+)")?'
|
||||
rf"(?P<inline>[^\n]*)\n"
|
||||
rf"(?P<desc>{desc_re})",
|
||||
re.MULTILINE)
|
||||
|
||||
def repl(m: re.Match) -> str:
|
||||
resolved: list[tuple[str, str, str, str, str, str, str]] = []
|
||||
for im in item_pat.finditer(m.group(1)):
|
||||
kind = im.group("kind")
|
||||
anchor = im.group("anchor")
|
||||
disp = im.group("disp")
|
||||
prefix = (im.group("prefix") or "").strip()
|
||||
inline = im.group("inline") or ""
|
||||
desc_lines = [l.strip() for l in (im.group("desc") or "").splitlines() if l.strip()]
|
||||
description = " ".join(desc_lines)
|
||||
# @ref follows redirects; @subpage uses literal target.
|
||||
lookup = _resolve_redirect(anchor) if kind == "ref" else anchor
|
||||
if lookup in _ANCHOR_TO_DOC:
|
||||
resolved.append((kind, "internal", _ANCHOR_TO_DOC[lookup],
|
||||
disp or _ANCHOR_TO_TITLE.get(lookup, lookup),
|
||||
description, prefix, inline))
|
||||
elif lookup in _ANCHOR_TO_EXTERNAL:
|
||||
title, url = _ANCHOR_TO_EXTERNAL[lookup]
|
||||
resolved.append((kind, "external", url, disp or title,
|
||||
description, prefix, inline))
|
||||
elif lookup in _TAG_FILENAMES:
|
||||
title = _TAG_TITLES.get(lookup, lookup)
|
||||
resolved.append((kind, "external", _doxygen_url(lookup),
|
||||
disp or title, description, prefix, inline))
|
||||
if not resolved:
|
||||
return ""
|
||||
|
||||
# toctree gets only @subpage (internal @ref would create cycles).
|
||||
tt_lines = []
|
||||
for t in resolved:
|
||||
kind, doctype, target, title = t[0], t[1], t[2], t[3]
|
||||
if kind != "subpage":
|
||||
continue
|
||||
tt_lines.append("/" + target if doctype == "internal"
|
||||
else f"{title} <{target}>")
|
||||
tt_body = "\n".join(tt_lines)
|
||||
|
||||
has_descriptions = any(t[4] for t in resolved)
|
||||
has_prefixes = any(t[5] for t in resolved)
|
||||
# `inline` alone stays a plain toctree; visible mode needs desc/prefix.
|
||||
if not (has_descriptions or has_prefixes):
|
||||
if tt_body:
|
||||
return f"\n```{{toctree}}\n:maxdepth: 1\n:titlesonly:\n\n{tt_body}\n```\n"
|
||||
return ""
|
||||
|
||||
# Hidden toctree (subpages) + visible list (all items).
|
||||
list_lines = []
|
||||
for t in resolved:
|
||||
doctype, target, title, desc, prefix, inline = t[1], t[2], t[3], t[4], t[5], t[6]
|
||||
href = f"/{target}" if doctype == "internal" else target
|
||||
prefix_text = f"{prefix} " if prefix else ""
|
||||
list_lines.append(f"- {prefix_text}[{title}]({href}){inline}")
|
||||
if desc:
|
||||
list_lines.append("")
|
||||
list_lines.append(f" {desc}")
|
||||
list_lines.append("")
|
||||
preamble = (f"\n```{{toctree}}\n:hidden:\n:maxdepth: 1\n:titlesonly:\n\n{tt_body}\n```\n"
|
||||
if tt_body else "")
|
||||
return f"{preamble}\n{chr(10).join(list_lines).rstrip()}\n"
|
||||
return pat.sub(repl, src)
|
||||
text = _subpage_list_to_toctree(text)
|
||||
|
||||
# 7. @ref name -> internal docname / Doxygen tag URL / fragment fallback.
|
||||
def _ref_repl(m: re.Match) -> str:
|
||||
name = m.group("name"); disp = m.group("disp")
|
||||
resolved = _resolve_redirect(name)
|
||||
target = _ANCHOR_TO_DOC.get(resolved)
|
||||
if target:
|
||||
link_text = (disp or _ANCHOR_TO_TITLE.get(resolved)
|
||||
or _TAG_TITLES.get(resolved) or resolved)
|
||||
return f"[{link_text}](/{target})"
|
||||
if resolved in _TAG_FILENAMES:
|
||||
link_text = disp or _TAG_TITLES.get(resolved, resolved)
|
||||
return f"[{link_text}]({_doxygen_url(resolved)})"
|
||||
return f"[{disp or resolved}](#{resolved})"
|
||||
# `:` allowed for qualified C++ identifiers.
|
||||
text = re.sub(r'@ref\s+(?P<name>[\w:-]+)(?:\s+"(?P<disp>[^"]+)")?',
|
||||
_ref_repl, text)
|
||||
|
||||
# 8. @cite KEY -> `[N]` HTML anchor to citelist (N from opencv.bib order).
|
||||
def _cite_repl(m: re.Match) -> str:
|
||||
key = m.group("key")
|
||||
num = _CITE_NUMBER.get(key)
|
||||
label = f"[{num}]" if num is not None else f"[{key}]"
|
||||
if "citelist" in _ANCHOR_TO_DOC:
|
||||
depth = docname.count("/") if docname else 0
|
||||
href = ("../" * depth) + f"citelist.html#CITEREF_{key}"
|
||||
else:
|
||||
href = f"{DOXYGEN_BASE_URL}citelist.html#CITEREF_{key}"
|
||||
return f'<a href="{href}">{label}</a>'
|
||||
text = _apply_outside_code(text, lambda chunk: re.sub(
|
||||
r"@cite\s+(?P<key>[\w-]+)", _cite_repl, chunk))
|
||||
|
||||
if _CITE_NUMBER and docname != "citelist":
|
||||
_CITE_KEY_RE = re.compile(
|
||||
r"(?<![\[\w])(?P<key>"
|
||||
+ "|".join(re.escape(k) for k in _CITE_NUMBER)
|
||||
+ r")(?![\w\]])"
|
||||
)
|
||||
def _bare_cite_repl(m: re.Match) -> str:
|
||||
key = m.group("key")
|
||||
num = _CITE_NUMBER.get(key)
|
||||
label = f"[{num}]" if num is not None else f"[{key}]"
|
||||
if "citelist" in _ANCHOR_TO_DOC:
|
||||
depth = docname.count("/") if docname else 0
|
||||
href = ("../" * depth) + f"citelist.html#CITEREF_{key}"
|
||||
else:
|
||||
href = f"{DOXYGEN_BASE_URL}citelist.html#CITEREF_{key}"
|
||||
return f'<a href="{href}">{label}</a>'
|
||||
text = _apply_outside_code(
|
||||
text, lambda chunk: _CITE_KEY_RE.sub(_bare_cite_repl, chunk))
|
||||
|
||||
# 8b. @youtube{ID} -> responsive raw-HTML embed.
|
||||
text = re.sub(
|
||||
r"^@youtube\{(?P<id>[\w-]+)\}\s*$",
|
||||
lambda m: (
|
||||
'\n<div class="opencv-youtube">'
|
||||
f'<iframe src="https://www.youtube-nocookie.com/embed/{m.group("id")}" '
|
||||
'title="YouTube video player" frameborder="0" '
|
||||
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; '
|
||||
'gyroscope; picture-in-picture" allowfullscreen></iframe></div>\n'
|
||||
),
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 8c. @note / @see / @warning handled at step 1b above.
|
||||
|
||||
# 8d. Dedent indented descriptions after `- @subpage X`.
|
||||
def _dedent_subpage_descriptions(src: str) -> str:
|
||||
# Continuation indent: 4+ spaces/tabs or a single leading tab.
|
||||
pat = re.compile(
|
||||
r"^(?P<bullet>[ \t]*-\s+[^\n]*@subpage\s+[\w-]+[^\n]*)\n"
|
||||
r"(?P<desc>(?:[ \t]*\n|(?:\t|[ \t]{4,})[^\n]+(?:\n|$))+)",
|
||||
re.MULTILINE)
|
||||
def repl(m: re.Match) -> str:
|
||||
desc = _textwrap.dedent(m.group("desc")).strip("\n")
|
||||
if not desc.strip():
|
||||
return m.group(0)
|
||||
return f"{m.group('bullet')}\n\n{desc}\n\n"
|
||||
return pat.sub(repl, src)
|
||||
text = _dedent_subpage_descriptions(text)
|
||||
|
||||
# 10. @next_tutorial / @prev_tutorial -> drop
|
||||
text = re.sub(r"^@(?:next|prev)_tutorial\{[^}]*\}\s*$", "",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 11. @tableofcontents / [TOC] -> drop
|
||||
text = re.sub(r"^(?:@tableofcontents|\[TOC\])\s*$", "",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 11b. @cond/@endcond and @parblock/@endparblock -> strip markers.
|
||||
text = re.sub(r"^@cond\s+\S+\s*$", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^@endcond\s*$", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^[ \t]*@parblock\s*$", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^[ \t]*@endparblock\s*$", "", text, flags=re.MULTILINE)
|
||||
|
||||
# 11c. @anchor NAME -> MyST label "(NAME)=".
|
||||
text = re.sub(r"^@anchor\s+(?P<name>[\w-]+)\s*$",
|
||||
lambda m: f"({m.group('name')})=",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 11d. `-#` -> `1.` (indent preserved).
|
||||
text = re.sub(r"^(?P<indent>[ \t]*)-#(?P<sp>[ \t]+)",
|
||||
lambda m: f"{m.group('indent')}1.{m.group('sp')}",
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 11e. Normalize 5+ space bullet markers to 3 (avoids code-block reading).
|
||||
def _normalize_over_indented_markers(src: str) -> str:
|
||||
lines_in = src.split("\n")
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(lines_in):
|
||||
m = re.match(r"^([ \t]*)([-*+])( {5,})(.*)", lines_in[i])
|
||||
if m:
|
||||
outer, marker, spaces, content = (
|
||||
m.group(1), m.group(2), m.group(3), m.group(4))
|
||||
old_col = len(outer) + 1 + len(spaces)
|
||||
new_col = len(outer) + 1 + 3
|
||||
delta = old_col - new_col
|
||||
out.append(f"{outer}{marker} {content}")
|
||||
i += 1
|
||||
while i < len(lines_in):
|
||||
line = lines_in[i]
|
||||
stripped = line.lstrip(" \t")
|
||||
actual = len(line) - len(stripped)
|
||||
if not stripped:
|
||||
out.append(line); i += 1; continue
|
||||
if actual >= old_col:
|
||||
out.append(" " * (actual - delta) + stripped); i += 1
|
||||
else:
|
||||
break
|
||||
else:
|
||||
out.append(lines_in[i]); i += 1
|
||||
return "\n".join(out)
|
||||
text = _normalize_over_indented_markers(text)
|
||||
|
||||
# 11f. Strip one 4-space level from bullets right after a heading.
|
||||
text = re.sub(
|
||||
r"(^#{1,6}[ \t][^\n]+\n(?:[ \t]*\n)*)((?: [ \t]*[-*+][^\n]*\n)+)",
|
||||
lambda m: m.group(1) + re.sub(r"^ ", "", m.group(2), flags=re.MULTILINE),
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# Depth-relative prefix to site root for contrib_modules/ <img src> URLs.
|
||||
_depth = docname.count("/") if docname else 0
|
||||
_contrib_url_prefix = ("../" * _depth) + "contrib_modules/"
|
||||
|
||||
def _emit_contrib_img(rel_url: str, alt: str) -> str:
|
||||
"""Raw-HTML <img>/<figure> for a contrib_modules/<rel> path."""
|
||||
src = _contrib_url_prefix + rel_url
|
||||
img = f'<img src="{src}" alt="{alt}"/>'
|
||||
if alt.startswith("Figure "):
|
||||
return (f'<figure>{img}'
|
||||
f'<figcaption>{alt}</figcaption></figure>')
|
||||
return img
|
||||
|
||||
# 12. Image paths -> doc-sibling, global basename, or tutorials/others/images/.
|
||||
def _img_repl(m: re.Match) -> str:
|
||||
alt = m.group("alt")
|
||||
rel = m.group("rel")
|
||||
asset_dir = m.group("dir")
|
||||
if docname:
|
||||
parts = pathlib.Path(docname).parent.parts
|
||||
local = None
|
||||
if parts and parts[0] in ("tutorials", "js_tutorials", "py_tutorials"):
|
||||
local = DOC_ROOT / pathlib.Path(docname).parent / asset_dir / rel
|
||||
elif len(parts) >= 2 and parts[0] == "tutorials_contrib":
|
||||
rest = pathlib.Path(*parts[2:]) if len(parts) > 2 else pathlib.Path()
|
||||
local = CONTRIB_ROOT / parts[1] / "tutorials" / rest / asset_dir / rel
|
||||
if local is not None and local.is_file():
|
||||
return f''
|
||||
hit = _IMAGE_INDEX.get(pathlib.Path(rel).name)
|
||||
if hit:
|
||||
if hit.startswith("contrib_modules/"):
|
||||
return _emit_contrib_img(hit[len("contrib_modules/"):], alt)
|
||||
return f''
|
||||
return f''
|
||||
text = re.sub(
|
||||
r'!\[(?P<alt>[^\]]*)\]\((?:[^)]*?/)?(?P<dir>images|js_assets)/(?P<rel>[^)]+)\)',
|
||||
_img_repl, text)
|
||||
|
||||
# 12b. Cross-tree contrib image refs -> raw-HTML <img>, depth-relative URL.
|
||||
def _img_xtree(m: re.Match) -> str:
|
||||
alt, rel = m.group("alt"), m.group("rel")
|
||||
if rel.startswith("/") or "://" in rel:
|
||||
return m.group(0)
|
||||
if rel.startswith("./"):
|
||||
rel = rel[2:]
|
||||
if not docname or not docname.startswith("tutorials_contrib/"):
|
||||
return m.group(0)
|
||||
parts = pathlib.Path(docname).parent.parts
|
||||
if len(parts) < 2:
|
||||
return m.group(0)
|
||||
module = parts[1]
|
||||
for cand in (f"{module}/doc/{rel}",
|
||||
f"{module}/{rel}",
|
||||
rel):
|
||||
if (CONTRIB_ROOT / cand).is_file():
|
||||
return _emit_contrib_img(cand, alt)
|
||||
return m.group(0)
|
||||
text = re.sub(
|
||||
r'!\[(?P<alt>[^\]]*)\]\((?P<rel>[^)]+)\)',
|
||||
_img_xtree, text)
|
||||
|
||||
# 12d. Blank line between consecutive `Label: ` lines (skip table rows).
|
||||
text = re.sub(
|
||||
r"^(?P<line>(?!\|)[^\n]*!\[[^\]]*\]\([^)]+\)[^\n]*)\n"
|
||||
r"(?=(?!\|)[^\n]*!\[[^\]]*\]\([^)]+\))",
|
||||
r"\g<line>\n\n", text, flags=re.MULTILINE)
|
||||
|
||||
# 12e. `` -> MyST `{figure}`.
|
||||
text = re.sub(
|
||||
r"^(?P<indent>[ \t]*)!\[(?P<caption>Figure\s[^\]]+)\]\((?P<url>[^)]+)\)\s*$",
|
||||
lambda m: (f"{m.group('indent')}:::{{figure}} {m.group('url')}\n"
|
||||
f"{m.group('indent')}{m.group('caption')}\n"
|
||||
f"{m.group('indent')}:::"),
|
||||
text, flags=re.MULTILINE)
|
||||
|
||||
# 13. Wrap front-matter table in a `.opencv-meta-table` div.
|
||||
def _wrap_front_matter(src: str) -> str:
|
||||
pat = re.compile(
|
||||
r"(^\|[^\n]*\|[ \t]*\n"
|
||||
r"\|[ \t]*-:[ \t]*\|[ \t]*:-[ \t]*\|[ \t]*\n"
|
||||
r"(?:\|[^\n]*\|[ \t]*\n)+)",
|
||||
re.MULTILINE)
|
||||
def repl(m: re.Match) -> str:
|
||||
return f":::{{div}} opencv-meta-table\n\n{m.group(1)}\n:::\n"
|
||||
return pat.sub(repl, src, count=1)
|
||||
text = _wrap_front_matter(text)
|
||||
|
||||
# 14a. Auto-link `cv.SymbolName` in prose.
|
||||
text = _linkify_cv_symbols(text)
|
||||
|
||||
# 14b. Wrap bare URLs in `<...>` (runs after 14a).
|
||||
text = _linkify_bare_urls(text)
|
||||
|
||||
# 14c. Resolve `opencv_source_code/<path>` Doxygen alias -> GitHub link.
|
||||
text = _linkify_opencv_source_code(text)
|
||||
|
||||
# 14d. Resolve Doxygen `#funcName` cross-references in prose -> link.
|
||||
text = _linkify_dox_hash_refs(text)
|
||||
|
||||
# 15. Restore @verbatim stash (see step 0v).
|
||||
for _vk, _vv in _verbatim_stash.items():
|
||||
text = text.replace(_vk, _vv)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
|
||||
_BARE_URL_RE = re.compile(
|
||||
r"(?<![<\[(\w\"'=])"
|
||||
r"(?P<url>https?://[^\s<>()`\"']+[^\s<>()`\"'.,;:!?])"
|
||||
)
|
||||
# `cv.X`/`cv::X`; lookbehind blocks `frame.cv.X`-style false positives.
|
||||
_CV_SYMBOL_RE = re.compile(
|
||||
r"(?<![/\w.:\[])(?P<sep>cv(?:\.|::))(?P<sym>[A-Za-z_]\w*)(?P<parens>\(\))?"
|
||||
)
|
||||
# Bare `funcName()` (Doxygen auto-linked these).
|
||||
_BARE_FN_RE = re.compile(r"(?<![/\w.<>\"])(?P<sym>[A-Za-z_]\w{2,})\(\)")
|
||||
_FENCED_BLOCK_RE = re.compile(
|
||||
r"^(?P<fence>[`~]{3,})[^\n]*\n[\s\S]*?\n(?P=fence)[ \t]*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_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]")
|
||||
|
||||
|
||||
def _apply_outside_code(src: str, transform) -> str:
|
||||
"""Apply `transform` to regions outside fenced/inline code."""
|
||||
def _segment(text: str) -> str:
|
||||
out, last = [], 0
|
||||
for cm in _INLINE_CODE_RE.finditer(text):
|
||||
out.append(transform(text[last:cm.start()]))
|
||||
out.append(cm.group(0))
|
||||
last = cm.end()
|
||||
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()
|
||||
out.append(_segment(src[last:]))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _linkify_bare_urls(src: str) -> str:
|
||||
return _apply_outside_code(src,
|
||||
lambda chunk: _BARE_URL_RE.sub(r"<\g<url>>", chunk))
|
||||
|
||||
_OPENCV_SOURCE_CODE_RE = re.compile(
|
||||
r"\bopencv_source_code/(?P<path>[\w./\-]+)"
|
||||
)
|
||||
|
||||
|
||||
def _linkify_opencv_source_code(src: str) -> str:
|
||||
def _repl(m: re.Match) -> str:
|
||||
path = m.group("path")
|
||||
url = f"https://github.com/opencv/opencv/blob/5.x/{path}"
|
||||
return f"[opencv_source_code/{path}]({url})"
|
||||
return _apply_outside_code(
|
||||
src, lambda chunk: _OPENCV_SOURCE_CODE_RE.sub(_repl, chunk))
|
||||
|
||||
_DOX_HASH_REF_RE = re.compile(
|
||||
r"(?<![{(#\w])#(?P<name>[A-Za-z_]\w*)\b"
|
||||
)
|
||||
|
||||
|
||||
def _linkify_dox_hash_refs(src: str) -> str:
|
||||
if not _CV_SYMBOL_URL:
|
||||
return src
|
||||
def _repl(m: re.Match) -> str:
|
||||
name = m.group("name")
|
||||
url = _CV_SYMBOL_URL.get(name)
|
||||
if not url:
|
||||
return m.group(0)
|
||||
return f"[{name}]({url})"
|
||||
return _apply_outside_code(
|
||||
src, lambda chunk: _DOX_HASH_REF_RE.sub(_repl, chunk))
|
||||
|
||||
|
||||
def _linkify_cv_symbols(src: str) -> str:
|
||||
if not _CV_SYMBOL_URL:
|
||||
return src
|
||||
def _local_url(sym: str) -> str | None:
|
||||
"""Prefer a Sphinx-local URL when one exists for `sym`.
|
||||
`_LOCAL_CLASS_URL` and `_LOCAL_TYPEDEF_URL` are populated from
|
||||
the tagfile but rewritten to point at the local api/ tree
|
||||
(e.g. `classcv_1_1Mat.html`, `core_basic.html#vec2b`). If no
|
||||
local entry exists, return None so the caller drops the link —
|
||||
we never bounce readers off-site for symbols whose Sphinx
|
||||
version isn't present in this build.
|
||||
"""
|
||||
return _LOCAL_CLASS_URL.get(sym) or _LOCAL_TYPEDEF_URL.get(sym)
|
||||
def repl_cv(m: re.Match) -> str:
|
||||
sym = m.group("sym")
|
||||
url = _local_url(sym)
|
||||
if not url:
|
||||
return m.group(0)
|
||||
sep = m.group("sep")
|
||||
parens = m.group("parens") or ""
|
||||
return f'<a href="{url}">{sep}{sym}{parens}</a>'
|
||||
def repl_bare(m: re.Match) -> str:
|
||||
sym = m.group("sym")
|
||||
url = _local_url(sym)
|
||||
if not url:
|
||||
return m.group(0)
|
||||
return f'<a href="{url}">{sym}()</a>'
|
||||
def transform(chunk: str) -> str:
|
||||
# Headings are definitions — don't linkify.
|
||||
out_lines = []
|
||||
for line in chunk.split("\n"):
|
||||
if _ATX_HEADING_RE.match(line):
|
||||
out_lines.append(line)
|
||||
continue
|
||||
line = _CV_SYMBOL_RE.sub(repl_cv, line)
|
||||
line = _BARE_FN_RE.sub(repl_bare, line)
|
||||
out_lines.append(line)
|
||||
return "\n".join(out_lines)
|
||||
return _apply_outside_code(src, transform)
|
||||
|
||||
|
||||
_referenced_docs_cache: set[str] | None = None
|
||||
|
||||
|
||||
def _referenced_docs() -> set[str]:
|
||||
"""Docnames reachable from some @subpage/@ref (computed once)."""
|
||||
global _referenced_docs_cache
|
||||
if _referenced_docs_cache is None:
|
||||
_referenced_docs_cache = {
|
||||
_ANCHOR_TO_DOC[a] for a in _REFERENCED_ANCHORS
|
||||
if a in _ANCHOR_TO_DOC
|
||||
}
|
||||
return _referenced_docs_cache
|
||||
|
||||
|
||||
_TUTORIAL_PREFIXES = ("tutorials/", "js_tutorials/",
|
||||
"py_tutorials/", "tutorials_contrib/")
|
||||
|
||||
|
||||
def _source_read(app, docname, source):
|
||||
# Translate tutorial docs + API stubs; doxygengroup bodies pass through.
|
||||
if not (docname.startswith("tutorials/")
|
||||
or docname.startswith("js_tutorials/")
|
||||
or docname.startswith("py_tutorials/")
|
||||
or docname.startswith("tutorials_contrib/")
|
||||
or docname.startswith("main_modules/")
|
||||
or docname.startswith("extra_modules/")
|
||||
or docname == "faq"
|
||||
or docname == "citelist"
|
||||
or docname == "intro"
|
||||
or docname == "index"):
|
||||
return
|
||||
text = source[0]
|
||||
# Master doc: append contrib/api roots without editing tutorials.markdown.
|
||||
if docname == "tutorials/tutorials" and not USE_INDEX_LANDING:
|
||||
if CONTRIB_MODULES and "tutorial_contrib_root" in _ANCHOR_TO_DOC:
|
||||
text = text.rstrip() + "\n\n- @subpage tutorial_contrib_root\n"
|
||||
if API_MODULES and "api_root" in _ANCHOR_TO_DOC:
|
||||
text = text.rstrip() + "\n\n- @subpage api_root\n"
|
||||
out = _translate(text, docname)
|
||||
# Mark unreferenced tutorial pages :orphan: (skip master/front-matter/referenced).
|
||||
if (docname.startswith(_TUTORIAL_PREFIXES)
|
||||
and docname != "tutorials/tutorials"
|
||||
and docname not in _referenced_docs()
|
||||
and not out.lstrip().startswith("---")):
|
||||
out = "---\norphan: true\n---\n\n" + out
|
||||
source[0] = out
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
accessible-pygments==0.0.5
|
||||
alabaster==1.0.0
|
||||
anyio==4.13.0
|
||||
babel==2.18.0
|
||||
beautifulsoup4==4.14.3
|
||||
breathe==4.36.0
|
||||
certifi==2026.4.22
|
||||
charset-normalizer==3.4.7
|
||||
click==8.4.0
|
||||
colorama==0.4.6
|
||||
docutils==0.21.2
|
||||
exceptiongroup==1.3.1
|
||||
exhale==0.3.7
|
||||
h11==0.16.0
|
||||
idna==3.15
|
||||
imagesize==2.0.0
|
||||
Jinja2==3.1.6
|
||||
lxml==6.1.1
|
||||
markdown-it-py==3.0.0
|
||||
MarkupSafe==3.0.3
|
||||
mdit-py-plugins==0.6.1
|
||||
mdurl==0.1.2
|
||||
myst-parser==4.0.1
|
||||
packaging==26.0
|
||||
pydata-sphinx-theme==0.17.1
|
||||
Pygments==2.20.0
|
||||
PyYAML==6.0.3
|
||||
requests==2.34.2
|
||||
six==1.17.0
|
||||
snowballstemmer==3.0.1
|
||||
soupsieve==2.8.3
|
||||
Sphinx==8.1.3
|
||||
sphinx-autobuild==2024.10.3
|
||||
sphinx_design==0.6.1
|
||||
sphinxcontrib-applehelp==2.0.0
|
||||
sphinxcontrib-devhelp==2.0.0
|
||||
sphinxcontrib-htmlhelp==2.1.0
|
||||
sphinxcontrib-jsmath==1.0.1
|
||||
sphinxcontrib-qthelp==2.0.0
|
||||
sphinxcontrib-serializinghtml==2.0.0
|
||||
starlette==1.0.0
|
||||
tomli==2.4.1
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.47.0
|
||||
watchfiles==1.2.0
|
||||
websockets==16.0
|
||||
@@ -87,7 +87,15 @@ install(FILES ${OPENCV_JAR_FILE} OPTIONAL DESTINATION ${OPENCV_JAR_INSTALL_PATH}
|
||||
|
||||
add_dependencies(${the_module} ${the_module}_jar)
|
||||
|
||||
if(BUILD_DOCS)
|
||||
# Javadoc generation can be disabled independently of BUILD_DOCS so the C++
|
||||
# Doxygen / Sphinx docs still build when the Java bindings carry Doxygen-style
|
||||
# tags (e.g. @retval, @remarks) that JDK doclint rejects as fatal errors.
|
||||
# Default ON for parity with upstream; when OFF the `doxygen_javadoc` target is
|
||||
# never created and doc/CMakeLists.txt's `if(TARGET doxygen_javadoc)` guard
|
||||
# drops it from the doxygen_cpp dependency chain automatically.
|
||||
option(BUILD_JAVADOC "Generate Javadoc as part of the documentation build" ON)
|
||||
|
||||
if(BUILD_DOCS AND BUILD_JAVADOC)
|
||||
if(OPENCV_JAVA_SDK_BUILD_TYPE STREQUAL "ANT")
|
||||
add_custom_command(OUTPUT "${OPENCV_DEPHELPER}/${the_module}doc"
|
||||
COMMAND ${ANT_EXECUTABLE} -noinput -k javadoc
|
||||
|
||||
Reference in New Issue
Block a user