1
0
mirror of https://github.com/opencv/opencv.git synced 2026-07-29 15:23:05 +04:00

Merge pull request #29100 from omrope79:docs-update-followup

[FOLLOW UP] Update documentation for core, calib3d, features, and introduction modules #29100

### Pull Request Readiness Checklist

This PR is a follow-up to #29091, continuing the series of documentation updates for the core, calib3d, features, and introductory modules.

To be merged after:  #29091

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:
omrope79
2026-05-28 22:33:37 +05:30
committed by GitHub
parent a1ad509753
commit ccb808ba55
3 changed files with 737 additions and 79 deletions
+83 -4
View File
@@ -20,13 +20,92 @@ if(NOT SPHINX_BUILD)
return()
endif()
set(_SPHINX_SOURCE "${CMAKE_SOURCE_DIR}/doc")
set(_SPHINX_CONFDIR "${CMAKE_CURRENT_SOURCE_DIR}")
set(_SPHINX_OUTDIR "${CMAKE_CURRENT_BINARY_DIR}/html")
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 a merged input tree at ${CMAKE_BINARY_DIR}/docs_sphinx_input/.
# Sphinx requires a single srcdir, but the wrapper needs to see both
# opencv/doc/tutorials/ and (optionally) opencv_contrib/modules/<m>/tutorials/.
# We symlink each main module subtree under tutorials/ and each enabled contrib
# module subtree under tutorials_contrib/, then auto-generate the contrib root
# page — the same pattern opencv/doc/CMakeLists.txt already uses for Doxygen.
# Nothing under opencv/doc/ or opencv_contrib/modules/ is modified.
# ---------------------------------------------------------------------------
set(_SPHINX_INPUT_TUTORIALS "${_SPHINX_INPUT_ROOT}/tutorials")
set(_SPHINX_INPUT_CONTRIB "${_SPHINX_INPUT_ROOT}/tutorials_contrib")
file(REMOVE_RECURSE "${_SPHINX_INPUT_ROOT}")
file(MAKE_DIRECTORY "${_SPHINX_INPUT_TUTORIALS}")
# Main tree: symlink the master file + each main module subtree.
file(CREATE_LINK
"${CMAKE_SOURCE_DIR}/doc/tutorials/tutorials.markdown"
"${_SPHINX_INPUT_TUTORIALS}/tutorials.markdown"
SYMBOLIC COPY_ON_ERROR)
file(GLOB _main_tutorial_children
RELATIVE "${CMAKE_SOURCE_DIR}/doc/tutorials"
"${CMAKE_SOURCE_DIR}/doc/tutorials/*")
foreach(_d ${_main_tutorial_children})
if(IS_DIRECTORY "${CMAKE_SOURCE_DIR}/doc/tutorials/${_d}")
file(CREATE_LINK
"${CMAKE_SOURCE_DIR}/doc/tutorials/${_d}"
"${_SPHINX_INPUT_TUTORIALS}/${_d}"
SYMBOLIC COPY_ON_ERROR)
endif()
endforeach()
# Contrib tree: only when OPENCV_EXTRA_MODULES_PATH is set, matching the legacy
# Doxygen contrib build's activation gate exactly. Stage every contrib module
# that has a tutorials/ dir — the canonical "which modules to compile" list
# lives in conf.py's CONTRIB_MODULES, exactly parallel to DOC_MODULES for main.
# Staged-but-unselected modules have their @subpage entries silently dropped
# by the toctree machinery (conf.py only populates _ANCHOR_TO_DOC for enabled
# modules), so listing them all in contrib_root.markdown is safe and means
# adding a new contrib module is a one-line conf.py edit (no reconfigure).
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")
file(WRITE "${_contrib_root_md}"
"Tutorials for contrib modules {#tutorial_contrib_root}\n"
"=============================\n\n")
file(GLOB _contrib_subdirs RELATIVE "${OPENCV_EXTRA_MODULES_PATH}"
"${OPENCV_EXTRA_MODULES_PATH}/*")
foreach(_m ${_contrib_subdirs})
set(_tut_dir "${OPENCV_EXTRA_MODULES_PATH}/${_m}/tutorials")
if(IS_DIRECTORY "${_tut_dir}")
file(CREATE_LINK "${_tut_dir}" "${_SPHINX_INPUT_CONTRIB}/${_m}"
SYMBOLIC COPY_ON_ERROR)
# Match every top-level *.markdown in <m>/tutorials/, the same shape the
# legacy Doxygen build uses (opencv/doc/CMakeLists.txt) — many contrib
# modules use names like alphamat_tutorial.markdown or fuzzy.markdown
# instead of the table_of_content_<m>.markdown convention.
file(GLOB _tocs RELATIVE "${_tut_dir}" "${_tut_dir}/*.markdown")
foreach(_t ${_tocs})
file(STRINGS "${_tut_dir}/${_t}" _id LIMIT_COUNT 1 REGEX ".*\\{#[^}]+\\}")
string(REGEX REPLACE ".*\\{#([^}]+)\\}.*" "\\1" _id "${_id}")
if(_id)
file(APPEND "${_contrib_root_md}" "- ${_m}. @subpage ${_id}\n")
endif()
endforeach()
endif()
endforeach()
endif()
# Forward staging info to conf.py via env. OPENCV_CONTRIB_MODULES is NOT
# forwarded — conf.py's default list wins unless the caller sets it in their
# shell env (OPENCV_CONTRIB_MODULES=ml cmake --build ...) to override per-build.
set(_SPHINX_ENV
"OPENCV_SPHINX_INPUT_ROOT=${_SPHINX_INPUT_ROOT}"
"OPENCV_CONTRIB_ROOT=${OPENCV_EXTRA_MODULES_PATH}"
)
add_custom_target(sphinx
COMMAND ${CMAKE_COMMAND} -E make_directory ${_SPHINX_OUTDIR}
COMMAND ${SPHINX_BUILD} -c ${_SPHINX_CONFDIR} ${_SPHINX_SOURCE} ${_SPHINX_OUTDIR}
COMMAND ${CMAKE_COMMAND} -E env ${_SPHINX_ENV}
${SPHINX_BUILD} -c ${_SPHINX_CONFDIR}
${_SPHINX_INPUT_ROOT}
${_SPHINX_OUTDIR}
WORKING_DIRECTORY ${_SPHINX_CONFDIR}
COMMENT "Building Sphinx HTML site -> ${_SPHINX_OUTDIR}"
VERBATIM)
+89
View File
@@ -417,3 +417,92 @@ dl.cpp.function .sig-prename.descclassname { color: var(--pst-color-text-muted);
/* --- MathJax inline alignment tweak ----------------------------------- */
mjx-container.MathJax:not([display="true"]) { vertical-align: -0.15em !important; }
/* --- Copy-to-clipboard button -----------------------------------------
* Provided by the `sphinx-copybutton` extension, which injects
* `<button class="copybtn">` into every `div.highlight > pre` at runtime
* and ships its own `copybutton.css`. We override its defaults here so
* the button uses the same design tokens (PST surface + opencv-accent)
* as the rest of the wrapper. custom.css is loaded after copybutton.css
* so these rules win on equal-specificity matches.
*/
button.copybtn {
width: 1.85rem !important;
height: 1.85rem !important;
top: 0.5rem !important;
right: 0.5rem !important;
color: var(--pst-color-text-muted) !important;
background: var(--pst-color-surface) !important;
border: 1px solid var(--pst-color-border) !important;
border-radius: 0.4rem !important; /* matches code-block radius */
transition: opacity 0.15s ease, color 0.15s ease,
border-color 0.15s ease, background 0.15s ease,
transform 0.05s ease !important;
}
button.copybtn svg {
width: 0.95rem !important;
height: 0.95rem !important;
padding: 0 !important;
stroke: currentColor;
}
.highlight:hover button.copybtn:hover,
button.copybtn:focus-visible {
color: var(--opencv-accent) !important;
border-color: var(--opencv-accent) !important;
background: var(--pst-color-surface) !important;
}
button.copybtn:focus-visible {
outline: 2px solid var(--opencv-accent) !important;
outline-offset: 2px;
}
button.copybtn:active { transform: translateY(1px); }
/* Confirmation: use the OpenCV accent rather than the default green so the
success state reads as on-brand. */
button.copybtn.success {
color: var(--opencv-accent) !important;
border-color: var(--opencv-accent) !important;
background: var(--pst-color-surface) !important;
opacity: 1 !important;
}
/* Light-mode fine-tune to match the code block's #f6f8fa surface. */
html[data-theme="light"] button.copybtn {
background: #ffffff !important;
color: #57606a !important;
border-color: #d0d7de !important;
}
html[data-theme="light"] button.copybtn:hover,
html[data-theme="light"] button.copybtn.success {
color: var(--opencv-accent) !important;
border-color: var(--opencv-accent) !important;
}
/* Dark-mode tune to match the slate code-block surface (#2d333b). */
html[data-theme="dark"] button.copybtn {
background: #373e47 !important;
color: #adbac7 !important;
border-color: #444c56 !important;
}
html[data-theme="dark"] button.copybtn:hover,
html[data-theme="dark"] button.copybtn.success {
color: var(--opencv-accent) !important;
border-color: var(--opencv-accent) !important;
}
/* Tooltip from the extension's `o-tooltip--left` helper — soften the
default grey/white pill into something that matches our type stack. */
.o-tooltip--left:after {
font-family: var(--pst-font-family-base);
background: var(--pst-color-surface);
color: var(--pst-color-text-base);
border: 1px solid var(--pst-color-border);
border-radius: 0.35rem;
padding: 0.25rem 0.5rem;
font-size: 0.78rem;
}
/* On touch devices the hover-reveal is invisible — keep it shown. */
@media (max-width: 768px) {
button.copybtn { opacity: 1 !important; }
}
+565 -75
View File
@@ -18,7 +18,7 @@ from toctrees automatically.
"""
from __future__ import annotations
import pathlib, re
import pathlib, re, textwrap as _textwrap
HERE = pathlib.Path(__file__).parent.resolve()
DOC_ROOT = (HERE.parent / "doc").resolve()
@@ -32,17 +32,45 @@ OPENCV_ROOT = HERE.parent.resolve()
import os as _os
DOC_MODULES = [
m.strip()
for m in (_os.environ.get("OPENCV_DOC_MODULES") or "photo,objdetect").split(",")
for m in (_os.environ.get("OPENCV_DOC_MODULES") or "photo,objdetect,core,calib3d,features,introduction").split(",")
if m.strip()
]
# ---------------------------------------------------------------------------
# SCOPE — contrib tree. Folder names under opencv_contrib/modules/.
# Override via env var to avoid editing this file:
# OPENCV_CONTRIB_MODULES=ml,bgsegm cmake --build <build> --target sphinx
# Empty list = main-only build (legacy behavior, no contrib site).
# ---------------------------------------------------------------------------
CONTRIB_MODULES = [
m.strip()
for m in (_os.environ.get("OPENCV_CONTRIB_MODULES") or "ml,bgsegm,bioinspired,cannops,ccalib,cnn_3dobj,cvv,dnn_objdetect,dnn_superres,gapi,hdf,julia,line_descriptor,phase_unwrapping,structured_light").split(",")
if m.strip()
]
CONTRIB_ROOT = pathlib.Path(
_os.environ.get("OPENCV_CONTRIB_ROOT")
or str(HERE.parent.parent / "opencv_contrib" / "modules")
).resolve()
# Sphinx srcdir as seen by conf.py. CMake stages a merged tree at
# ${CMAKE_BINARY_DIR}/docs_sphinx_input/ and forwards this env var.
# Default = DOC_ROOT so ad-hoc sphinx-build runs keep working. The `or`
# idiom (rather than dict.get's default) treats an empty-string env var
# the same as unset — CMake forwards "" when contrib is disabled.
SPHINX_INPUT_ROOT = pathlib.Path(
_os.environ.get("OPENCV_SPHINX_INPUT_ROOT") or str(DOC_ROOT)
).resolve()
# -- Project ----------------------------------------------------------------
project = "OpenCV"
author = "OpenCV Team"
release = "5.x"
# -- Sphinx core ------------------------------------------------------------
extensions = ["myst_parser"]
extensions = ["myst_parser", "sphinx.ext.graphviz"]
# Render Doxygen \dot ... \enddot blocks as inline SVG (matches Doxygen's
# DOT_IMAGE_FORMAT=svg default — keeps text crisp and selectable).
graphviz_output_format = "svg"
for _ext in ("sphinx_design", "sphinx_copybutton"):
try:
__import__(_ext)
@@ -57,18 +85,30 @@ source_suffix = {".md": "markdown", ".markdown": "markdown"}
# regardless of how many modules are in DOC_MODULES.
master_doc = "tutorials/tutorials"
# Source dir is opencv/doc/ — scope to the master + enabled modules only.
# Source dir is the staged tree (or DOC_ROOT for legacy ad-hoc runs).
# Scope: master + enabled main modules + (optionally) enabled contrib modules.
include_patterns = ["tutorials/tutorials.markdown"] + [
f"tutorials/{m}/**" for m in DOC_MODULES
]
exclude_patterns = ["**/Thumbs.db", "**/.DS_Store"]
if CONTRIB_MODULES and (SPHINX_INPUT_ROOT / "tutorials_contrib").is_dir():
include_patterns.append("tutorials_contrib/contrib_root.markdown")
include_patterns += [f"tutorials_contrib/{m}/**" for m in CONTRIB_MODULES]
exclude_patterns = [
"**/Thumbs.db", "**/.DS_Store",
"tutorials/core/how_to_use_OpenCV_parallel_for_/**",
"tutorials/introduction/load_save_image/**",
]
myst_enable_extensions = [
"colon_fence", "deflist", "dollarmath", "amsmath",
"attrs_inline", "attrs_block", "smartquotes",
]
myst_heading_anchors = 4
suppress_warnings = ["myst.header", "myst.xref_missing", "toc.not_included"]
suppress_warnings = [
"myst.header", "myst.xref_missing", "toc.not_included",
"misc.highlighting_failure",
"image.not_readable",
]
# -- Doxygen integration -----------------------------------------------------
# External links in the navbar and unbuilt-module sidebar entries point at
@@ -161,17 +201,25 @@ _HEAD_RE = re.compile(
r"^#+\s+(?P<title2>[^\n]+?)\s*\{#(?P<anchor2>[\w-]+)\}\s*$",
re.MULTILINE)
def _scan_internal(path: pathlib.Path) -> None:
"""Add every {#anchor} in `path` (file or dir) to _ANCHOR_TO_DOC."""
def _scan_internal(path: pathlib.Path, base: pathlib.Path | None = None) -> None:
"""Add every {#anchor} and standalone `@anchor NAME` in `path` (file or
dir) to _ANCHOR_TO_DOC. Docname is computed relative to `base` (default
SPHINX_INPUT_ROOT) so the same scanner serves both main and contrib."""
base = base or SPHINX_INPUT_ROOT
files = [path] if (path.is_file() and path.suffix == ".markdown") \
else (list(path.rglob("*.markdown")) if path.is_dir() else [])
for md in files:
try:
head = md.read_text(encoding="utf-8", errors="replace")[:4000]
body = md.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
rel = md.relative_to(DOC_ROOT).with_suffix("").as_posix()
for m in re.finditer(r"\{#([\w-]+)\}", head):
# Use the unresolved path so symlinks in the staged input tree
# produce docnames relative to the staging root, not to their
# real source location (opencv/doc/ or opencv_contrib/modules/).
rel = md.relative_to(base).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
def _scan_external(toc_file: pathlib.Path) -> None:
@@ -191,54 +239,167 @@ def _scan_external(toc_file: pathlib.Path) -> None:
url = DOXYGEN_BASE_URL + _TAG_FILENAMES.get(anchor, "index.html")
_ANCHOR_TO_EXTERNAL[anchor] = (title, url)
# Internal scan: master + every enabled module subtree.
_scan_internal(DOC_ROOT / "tutorials" / "tutorials.markdown")
# Internal scan: master + every enabled main and contrib module subtree.
# Walk the staged tree so docnames stay relative to SPHINX_INPUT_ROOT (Sphinx
# srcdir), regardless of where the actual source files live on disk.
_scan_internal(SPHINX_INPUT_ROOT / "tutorials" / "tutorials.markdown")
for _m in DOC_MODULES:
_scan_internal(DOC_ROOT / "tutorials" / _m)
_scan_internal(SPHINX_INPUT_ROOT / "tutorials" / _m)
_contrib_root_md = SPHINX_INPUT_ROOT / "tutorials_contrib" / "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)
# External scan: every OTHER module's top-level table_of_content_*.markdown.
# External scan: every OTHER main module's top-level table_of_content_*.markdown.
# Sources live under DOC_ROOT (the staged tree only contains *enabled* main
# modules, not the rest), so scan DOC_ROOT directly here.
for _toc in (DOC_ROOT / "tutorials").glob("*/table_of_content_*.markdown"):
if _toc.parent.name not in DOC_MODULES:
_scan_external(_toc)
# Doxygen flattens IMAGE_PATH across every `images/` folder under the tutorial
# tree, so a tutorial can reference `images/foo.png` even when `foo.png` lives
# in a sibling module's `images/` directory. Mirror that behavior by building
# a basename -> doc-root-relative-path index once at import time.
# Basename -> srcdir-relative URL index for image lookup, mirroring
# Doxygen's flat IMAGE_PATH. Walks source trees directly (not the staged
# tree) because pathlib.rglob in Python <3.13 doesn't follow symlinks.
_IMAGE_INDEX: dict[str, str] = {}
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".bmp", ".webp"}
for _img in (DOC_ROOT / "tutorials").rglob("images/*"):
if _img.is_file():
_IMAGE_INDEX.setdefault(_img.name, _img.relative_to(DOC_ROOT).as_posix())
_IMAGE_INDEX.setdefault(_img.name,
_img.relative_to(DOC_ROOT).as_posix())
for _m in CONTRIB_MODULES:
# <m>/tutorials/**/images/* — same shape as main, reachable through
# the existing tutorials_contrib/<m> symlink CMake stages.
_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/ (<m>/doc/pics, <m>/samples).
# URL is /contrib_modules/<m>/<rest>. Files are served from there via
# html_extra_path set below — no copies in srcdir.
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}")
# Expose each enabled contrib module as a symlink under a build-dir subdir
# and let Sphinx's html_extra_path publish the tree to the output. Output
# URLs are /contrib_modules/<m>/... — no files duplicated in srcdir.
# Skipped when SPHINX_INPUT_ROOT lives inside a source tree (i.e. ad-hoc
# sphinx-build without CMake's OPENCV_SPHINX_INPUT_ROOT) — matches the
# documented "always build through CMake" expectation.
html_extra_path: list[str] = []
def _in_source_tree(p: pathlib.Path) -> bool:
for _root in (DOC_ROOT, CONTRIB_ROOT):
try: p.relative_to(_root); return True
except ValueError: pass
return False
if not _in_source_tree(SPHINX_INPUT_ROOT):
_extras = SPHINX_INPUT_ROOT.parent / "contrib_extras"
_prefix = _extras / "contrib_modules"
_prefix.mkdir(parents=True, exist_ok=True)
for _m in CONTRIB_MODULES:
_src, _link = CONTRIB_ROOT / _m, _prefix / _m
if _src.is_dir() and not _link.exists():
try: _os.symlink(_src, _link, target_is_directory=True)
except (OSError, NotImplementedError): pass
html_extra_path = [str(_extras)]
_TOGGLE_LABELS = {"cpp": "C++", "java": "Java", "python": "Python"}
# Mirror of Doxygen's EXAMPLE_PATH (see opencv/doc/Doxyfile.in) — the bases a
# bare `@snippet some/path.cpp` is resolved against. OPENCV_ROOT comes first so
# fully-qualified paths like `samples/cpp/...` keep working.
# fully-qualified paths like `samples/cpp/...` keep working. Contrib module
# samples are appended so `@snippet introduction_to_svm.cpp ...` in a contrib
# tutorial resolves to opencv_contrib/modules/<m>/samples/...
_SNIPPET_BASES = [
OPENCV_ROOT,
OPENCV_ROOT / "samples",
OPENCV_ROOT / "apps",
]
] + [CONTRIB_ROOT / _m / "samples" for _m in CONTRIB_MODULES]
# Doxygen's Doxyfile has EXAMPLE_RECURSIVE = YES, so a bare basename like
# `@snippet linux_quick_install.sh body` resolves to
# `samples/install/linux_quick_install.sh` even though the directive omits
# the `install/` qualifier. Mirror that with a basename -> path index built
# once at import time. Restricted to common source-file extensions to keep
# the scan fast.
_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_INDEX: dict[str, pathlib.Path] = {}
_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)
# Doxygen accepts language names that Pygments doesn't recognize (or wraps
# them with a leading `.` in the `@code{.lang}` and ```.lang fenced forms).
# Strip the dot and remap a few aliases so Pygments stays warning-free.
_LANG_ALIASES = {
"none": "text",
"unparsed": "text",
"guess": "text",
"gradle": "groovy",
# `run` is a custom convention some contrib tutorials use to mean
# "this is a shell command you run" (e.g. dnn_superres/upscale_image_*).
# Pygments has no `run` lexer — map to bash so it highlights as shell.
"run": "bash",
}
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."""
p = next((b / rel_path for b in _SNIPPET_BASES
if (b / rel_path).is_file()), None)
# Some sources write the path with a leading slash (e.g. `@include
# /samples/android/.../tutorial1_surface_view.xml`). pathlib's `/` would
# treat that as absolute and lose the snippet base, so strip it.
rel_norm = rel_path.lstrip("/")
p = next((b / rel_norm for b in _SNIPPET_BASES
if (b / rel_norm).is_file()), None)
# Doxygen does a recursive basename lookup across EXAMPLE_PATH (see
# opencv/doc/Doxyfile.in: EXAMPLE_RECURSIVE = YES). If the direct join
# doesn't find the file, fall back to the prebuilt basename index.
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"}.get(ext, "text")
".py": "python", ".java": "java",
".xml": "xml", ".html": "html",
".sh": "bash", ".bash": "bash"}.get(ext, "text")
if label is None:
return text, lang
# Doxygen matches `[label]` after any comment-style marker (//, //!, #, ##)
# anywhere on a line — including labels wrapped in block-comments like
# `/* //! [label]` or `//! [label] */`.
pat = re.compile(r"^[^\[\n]*(?://!|//|##|#)[^\[\n]*\[" + re.escape(label)
# Doxygen matches `[label]` after any comment-style marker anywhere on a
# line: //, //! and // for C/C++/Java/Kotlin, # and ## for Python/shell,
# <!-- for XML/HTML. Block-comment-wrapped labels like
# `/* //! [label] */` are matched via the `//`-prefix branch too.
pat = re.compile(r"^[^\[\n]*(?://!|//|##|#|<!--)[^\[\n]*\[" + re.escape(label)
+ r"\][^\n]*$", re.MULTILINE)
matches = list(pat.finditer(text))
if len(matches) < 2:
@@ -269,6 +430,32 @@ def _emit_toggles(tabs: list[tuple[str, str]]) -> str:
def _translate(text: str, docname: str | None = None) -> str:
# 0. @verbatim ... @endverbatim — stash content first so neither math
# markers, @code, nor any other rule below mangles the body. Used
# heavily in introduction/documenting_opencv/documentation_tutorial,
# which shows Doxygen syntax (so the body contains literal directives,
# `\f[...\f]` math, and code-fences as examples). Body is restored at
# the very end of this function with a private-use placeholder so the
# inserted text is safe from re-processing.
_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 (markers on separate lines) — run first; DOTALL across body.
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 (both markers on the same line).
text = re.sub(
r"@verbatim[ \t]+(?P<body>[^\n]+?)[ \t]+@endverbatim",
lambda m: _verbatim_save(m.group("body"), inline=True),
text)
# 1. Heading anchors: "Title {#name}\n===" (setext) and "## Title {#name}" (ATX).
# Strip the anchor from the rendered heading and emit a MyST label
# "(name)=" immediately above. Setext converted to ATX for simplicity.
@@ -284,6 +471,45 @@ def _translate(text: str, docname: str | None = None) -> str:
lambda m: f"({m.group('anchor')})=\n{m.group('hashes')} {m.group('title')}",
text, flags=re.MULTILINE)
# 1b. Convert a trailing setext heading at EOF to ATX. Otherwise
# docutils rejects the doc as ending with a 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. Convert remaining mid-doc setext H1s to ATX so 1d can see 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 so multi-H1 Doxygen docs
# (one `# Heading` per section) end up with a proper "1 title +
# N sections" outline. Without this, Sphinx's toctree lists every
# H1 as a separate entry on the parent TOC page.
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 # H1 → H2
out.append(line)
return '\n'.join(out)
text = _demote_extra_h1s(text)
# 2. Doxygen LaTeX math markers
text = re.sub(r"\\f\[(.+?)\\f\]",
lambda m: f"\n$$\n{m.group(1).strip()}\n$$\n",
@@ -291,30 +517,90 @@ def _translate(text: str, docname: str | None = None) -> str:
text = re.sub(r"\\f\$(.+?)\\f\$", lambda m: f"${m.group(1)}$",
text, flags=re.DOTALL)
# 3. @code{.lang} ... @endcode
def _code_repl(m: re.Match) -> str:
lang = (m.group("lang") or "").strip(".") or "text"
if lang == "none":
lang = "text"
return f"\n```{lang}\n{m.group('body').strip()}\n```\n"
# 2b. \bordermatrix{...} is a Plain-TeX macro (not LaTeX), so MathJax
# leaves it raw. Convert to a standard `matrix` environment and
# translate `\cr` row separators to `\\`. Loses the bracket lines
# of bordermatrix but the contents render correctly.
text = re.sub(
r"@code(?:\{(?P<lang>[^}]*)\})?\s*\n(?P<body>.*?)\n\s*@endcode",
_code_repl, text, flags=re.DOTALL)
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. Preserve the indent
# so blocks nested under a bullet item stay inside the list; for
# col-0 @code keep the legacy .strip() form (byte-identical).
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)
# 3a. \dot ... \enddot → MyST `{graphviz}` fenced directive. Body is
# raw DOT source; the fenced form keeps it out of MyST's smartquotes
# and URL-autolink passes.
def _dot_repl(m: re.Match) -> str:
indent = m.group("indent") or ""
body = _textwrap.dedent(m.group("body")).strip("\n")
if indent:
body = "\n".join((indent + line) if line else line
for line in body.split("\n"))
return f"\n{indent}```{{graphviz}}\n{body}\n{indent}```\n"
return f"\n```{{graphviz}}\n{body}\n```\n"
text = re.sub(
r"^(?P<indent>[ \t]*)\\dot[ \t]*\n(?P<body>.*?)\n[ \t]*\\enddot[ \t]*$",
_dot_repl, text, flags=re.DOTALL | re.MULTILINE)
# 3b. Plain Markdown fences with a Doxygen-flavored language spec
# (e.g. "```.sh") confuse Pygments — strip the leading dot and apply
# the same alias map as @code{.lang}.
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)
# Plain backtick fence with leading indent applied to every body
# line so the fence stays inside an enclosing list-item scope.
# ({code-block} and `:::` colon-fence forms break inside 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
# (line numbering hint is dropped — MyST fenced blocks don't take :linenos:
# and PyData's code-block styling is already legible without it.)
def _include_repl(m: re.Match) -> str:
code, lang = _read_snippet(m.group("path"), None)
return f"\n```{lang}\n{code.rstrip()}\n```\n"
text = re.sub(r"@include(?:lineno)?\s+(?P<path>\S+)", _include_repl, text)
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. Remove stray @snippet that immediately follows @end_toggle at the same
# indent (no blank line between them). These are Doxygen fallback snippets
# for non-toggle Doxygen mode; the Sphinx build already shows them inside
# the tab-set, so the stray duplicate must be dropped before step 5
# would otherwise emit a second copy at document level.
text = re.sub(
r"(^([ \t]*)@end_toggle[ \t]*\n)\2@snippet[^\n]*\n",
r"\1",
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 f"\n```{lang}\n{code.rstrip()}\n```\n"
text = re.sub(r"@snippet\s+(?P<path>\S+)\s+(?P<label>[^\n]+?)\s*$",
_snippet_repl, text, flags=re.MULTILINE)
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 (coalesce runs into one tab-set)
def _toggle_collapse(src: str) -> str:
@@ -352,7 +638,9 @@ def _translate(text: str, docname: str | None = None) -> str:
if target:
return f"[{disp or name}]({'/' + target})"
return f"[{disp or name}](#{name})"
text = re.sub(r'@ref\s+(?P<name>[\w-]+)(?:\s+"(?P<disp>[^"]+)")?',
# Names may be qualified C++ identifiers like `cv::saturate_cast`, so
# the character class allows `:` in addition to word chars and `-`.
text = re.sub(r'@ref\s+(?P<name>[\w:-]+)(?:\s+"(?P<disp>[^"]+)")?',
_ref_repl, text)
# 8. @cite KEY -> [KEY]
@@ -370,25 +658,46 @@ def _translate(text: str, docname: str | None = None) -> str:
),
text, flags=re.MULTILINE)
# 8c. @note ... / @see ... -> MyST admonitions. Each directive body runs
# 8c. @note / @see / @warning -> MyST admonitions. Each directive body runs
# until the next blank line, the next @directive at start-of-line, or
# end of file (matches Doxygen's paragraph-level semantics).
_ADMON_KIND = {"note": "note", "see": "seealso"}
_ADMON_KIND = {"note": "note", "see": "seealso", "warning": "warning"}
def _admon_repl(m: re.Match) -> str:
kind = _ADMON_KIND[m.group("dir")]
body = m.group("body").strip()
return f"\n:::{{{kind}}}\n{body}\n:::\n"
text = re.sub(
r"^@(?P<dir>note|see)\s+(?P<body>.+?)(?=\n[ \t]*\n|\n@[A-Za-z]|\Z)",
r"^@(?P<dir>note|see|warning)\s+(?P<body>.+?)(?=\n[ \t]*\n|\n@[A-Za-z]|\Z)",
_admon_repl, text, flags=re.DOTALL | re.MULTILINE)
# 9. @subpage NAME (collected blocks -> real toctree).
# Enabled modules' anchors become internal toctree entries.
# Disabled modules' anchors become external links into the Doxygen
# build, so the left sidebar still shows the full module list.
# 8d. Dedent indented description paragraphs after `- @subpage X`
# so they render as normal text, not as code blocks.
def _dedent_subpage_descriptions(src: str) -> str:
# Accept either 4+ spaces/tabs OR a single leading tab as the
# continuation indent (one tab = bullet content column in
# CommonMark — phase_unwrapping uses this).
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")
# All-blank description (e.g. `- @subpage X\n\n##### Section`):
# don't rewrite, or we'd accumulate extra blank lines.
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)
# 9. Bullet `@subpage` lists → real toctree. Enabled modules become
# internal entries; disabled ones become external Doxygen links.
# Allows any text between `-` and `@subpage` to accept the
# `- <module>. @subpage <id>` form used by contrib_root.markdown.
def _subpage_list_to_toctree(src: str) -> str:
pat = re.compile(
r"((?:^[ \t]*-\s+@subpage\s+[\w-]+(?:[^\n]*)\n)+)", re.MULTILINE)
r"((?:^[ \t]*-\s+[^\n]*?@subpage\s+[\w-]+(?:[^\n]*)\n)+)",
re.MULTILINE)
def repl(m: re.Match) -> str:
entries = re.findall(r"@subpage\s+([\w-]+)", m.group(1))
lines = []
@@ -409,39 +718,168 @@ def _translate(text: str, docname: str | None = None) -> str:
text = re.sub(r"^@(?:next|prev)_tutorial\{[^}]*\}\s*$", "",
text, flags=re.MULTILINE)
# 11. @tableofcontents -> drop (PyData right sidebar replaces it)
text = re.sub(r"^@tableofcontents\s*$", "", text, flags=re.MULTILINE)
# 11. @tableofcontents / [TOC] -> drop. PyData's right sidebar
# already shows the per-page outline.
text = re.sub(r"^(?:@tableofcontents|\[TOC\])\s*$", "",
text, flags=re.MULTILINE)
# 11b. @cond NAME ... @endcond -> strip just the markers; if the
# enclosed @subpage points to a disabled module it gets dropped
# by _subpage_list_to_toctree above.
# by _subpage_list_to_toctree above. Same treatment for @parblock /
# @endparblock — they exist only to let Doxygen accept multi-
# paragraph arguments to directives like @note, which Markdown
# already handles natively, so the markers can be dropped.
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)
# 12. Image paths "images/foo.png" — resolve like Doxygen's flat IMAGE_PATH:
# prefer the doc's own "images/" sibling, then fall back to a global
# basename lookup across every tutorial `images/` folder. As a final
# fallback, point at the consolidated `tutorials/others/images/` dir
# (where modules like `photo` store their assets).
# 11c. @anchor NAME -> MyST label "(NAME)=" so the following block
# element (typically a heading) becomes the cross-reference target.
text = re.sub(r"^@anchor\s+(?P<name>[\w-]+)\s*$",
lambda m: f"({m.group('name')})=",
text, flags=re.MULTILINE)
# 11d. Doxygen numbered-list marker `-#` at line start -> Markdown `1.`.
# Markdown auto-increments numbered lists, so emitting `1.` for every
# item produces the right output. Preserves leading indent for nested
# lists.
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. Bullet markers with 5+ spaces between marker and content cause MyST
# to treat the continuation lines as code blocks. Normalize such
# bullets to 3-space separation and re-flow the continuation column
# by the same delta so nested content stays aligned with the marker.
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. Bullet lists immediately after a heading are sometimes indented by
# 4 spaces in Doxygen sources — Markdown would interpret that as a
# code block. Strip exactly one level of 4-space indent off such
# runs so MyST renders a proper list.
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 from the current doc back to the site root,
# used to point `<img src>` at `<output>/contrib_modules/...` files
# that html_extra_path publishes (Sphinx can't pathto-rewrite URLs
# for files outside srcdir, so we compute the ../ count ourselves).
_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> (or <figure> if alt is 'Figure ...') for a
contrib_modules/<rel> path — bypasses Sphinx's image processing
so the depth-relative URL survives to the rendered HTML."""
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 `images/foo.png`. Try the doc's local `images/`
# sibling first, then the global basename index, then a final
# well-known fallback dir (mirrors Doxygen flat IMAGE_PATH).
def _img_repl(m: re.Match) -> str:
rel = m.group("rel")
alt, rel = m.group("alt"), m.group("rel")
if docname:
local = DOC_ROOT / pathlib.Path(docname).parent / "images" / rel
if local.is_file():
return m.group(0)
parts = pathlib.Path(docname).parent.parts
local = None
if parts and parts[0] == "tutorials":
local = DOC_ROOT / pathlib.Path(docname).parent / "images" / rel
elif len(parts) >= 2 and parts[0] == "tutorials_contrib":
# Contrib doc → resolve under <m>/tutorials/<rest>/images/.
rest = pathlib.Path(*parts[2:]) if len(parts) > 2 else pathlib.Path()
local = CONTRIB_ROOT / parts[1] / "tutorials" / rest / "images" / rel
if local is not None and local.is_file():
return f'![{alt}](images/{rel})'
hit = _IMAGE_INDEX.get(pathlib.Path(rel).name)
if hit:
return f'{m.group("pre")}/{hit})'
return f'{m.group("pre")}/tutorials/others/images/{rel})'
if hit.startswith("contrib_modules/"):
return _emit_contrib_img(hit[len("contrib_modules/"):], alt)
return f'![{alt}](/{hit})'
return f'![{alt}](/tutorials/others/images/{rel})'
text = re.sub(
r'(?P<pre>!\[[^\]]*\]\()images/(?P<rel>[^)]+)\)',
r'!\[(?P<alt>[^\]]*)\]\((?:[^)]*?/)?images/(?P<rel>[^)]+)\)',
_img_repl, text)
# 13. Front-matter table: OpenCV tutorials use the "| -: | :- |"
# alignment pattern for the Original-author/Compatibility block.
# Wrap it in a {div} carrying .opencv-meta-table so custom.css
# can pin the rounded card + label-column styling without us
# modifying the .markdown source.
# 12b. Cross-tree image refs for contrib pages (Doxygen IMAGE_PATH
# flattening): `pics/foo.jpg` → <m>/doc/pics/, `<m>/samples/...`,
# etc. Try module-relative bases; first match becomes raw-HTML
# <img> with a depth-relative /contrib_modules/<m>/<rest> 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. Force a blank line between consecutive `Label: ![](image)`
# lines so each pair becomes its own paragraph (otherwise the
# images flow inline). Skip `|`-prefixed table rows.
text = re.sub(
r"^(?P<line>(?!\|)[^\n]*!\[[^\]]*\]\([^)]+\)[^\n]*)\n"
r"(?=(?!\|)[^\n]*!\[[^\]]*\]\([^)]+\))",
r"\g<line>\n\n", text, flags=re.MULTILINE)
# 12e. `![Figure N: caption](url)` → MyST `{figure}` directive so the
# caption renders visibly (plain image syntax keeps caption only
# in alt=). Used by hdf/* tutorials.
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 the Original-author/Compatibility front-matter table
# in a `.opencv-meta-table` div so custom.css can style it.
def _wrap_front_matter(src: str) -> str:
pat = re.compile(
r"(^\|[^\n]*\|[ \t]*\n" # header row (often empty)
@@ -453,15 +891,67 @@ def _translate(text: str, docname: str | None = None) -> str:
return pat.sub(repl, src, count=1)
text = _wrap_front_matter(text)
# 13b. Auto-linkify bare URLs (Doxygen default; alternative is the
# linkify-it-py package). Skip code blocks/spans, existing
# markdown links, existing autolinks, and HTML attributes.
# Trailing sentence punctuation is left outside the autolink.
_fence_open_re = re.compile(r"^[ \t]*(`{3,}|~{3,})")
_inline_code_re = re.compile(r"`[^`\n]+`")
_bare_url_re = re.compile(
r"(?<!\]\()(?<!<)(?<!=\")(?<!=')"
r"https?://[^\s<>\[\]()\"']+"
)
def _wrap_one_url(m: re.Match) -> str:
u = m.group(0)
trailing = ""
while u and u[-1] in ".,;:!?":
trailing = u[-1] + trailing
u = u[:-1]
return f"<{u}>{trailing}" if u else m.group(0)
def _wrap_outside_inline(line: str) -> str:
# Split on inline `code` so URLs inside backticks stay untouched.
parts = _inline_code_re.split(line)
codes = _inline_code_re.findall(line)
result = []
for i, p in enumerate(parts):
result.append(_bare_url_re.sub(_wrap_one_url, p))
if i < len(codes):
result.append(codes[i])
return "".join(result)
_autolink_out, _in_fence = [], False
for _line in text.split("\n"):
if _fence_open_re.match(_line):
_in_fence = not _in_fence
_autolink_out.append(_line)
elif _in_fence:
_autolink_out.append(_line)
else:
_autolink_out.append(_wrap_outside_inline(_line))
text = "\n".join(_autolink_out)
# 14. Restore @verbatim stash (see step 0). Placeholder keys are private-
# use-area-safe strings so this is a literal replace.
for _vk, _vv in _verbatim_stash.items():
text = text.replace(_vk, _vv)
return text
def _source_read(app, docname, source):
# Translate any tutorial doc — the root index plus everything under a
# module we enabled in DOC_MODULES.
if not docname.startswith("tutorials/"):
# Translate any tutorial doc — the root index, everything under an enabled
# main module, and (when staged) everything under an enabled contrib module.
if not (docname.startswith("tutorials/")
or docname.startswith("tutorials_contrib/")):
return
source[0] = _translate(source[0], docname)
text = source[0]
# On the master doc, append `- @subpage tutorial_contrib_root` so the
# contrib site appears in the unified left sidebar without modifying
# opencv/doc/tutorials/tutorials.markdown on disk.
if (docname == "tutorials/tutorials"
and CONTRIB_MODULES
and "tutorial_contrib_root" in _ANCHOR_TO_DOC):
text = text.rstrip() + "\n\n- @subpage tutorial_contrib_root\n"
source[0] = _translate(text, docname)
def setup(app):