mirror of
https://github.com/opencv/opencv.git
synced 2026-07-29 07:13:02 +04:00
1964 lines
88 KiB
Python
1964 lines
88 KiB
Python
"""Sphinx wrapper for opencv/doc/.
|
||
|
||
The wrapper lives in opencv/docs_sphinx/ as a single conf.py. Sphinx is
|
||
invoked with config-dir / source-dir separation so the wrapper never
|
||
duplicates the legacy tree. Build via the CMake `sphinx` target:
|
||
|
||
cmake --build <build> --target sphinx
|
||
# output -> <build>/docs_sphinx/html/
|
||
|
||
opencv/doc/ stays untouched: Doxygen-flavored directives in the .markdown
|
||
sources are translated to MyST in a `source-read` hook below.
|
||
|
||
To enable additional tutorial modules, append their directory names (the
|
||
folder under opencv/doc/tutorials/) to DOC_MODULES below. The root index
|
||
(tutorials/tutorials.markdown) lists every module, but only modules in
|
||
DOC_MODULES are actually compiled — entries for the rest are dropped
|
||
from toctrees automatically.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
import pathlib, re, textwrap as _textwrap
|
||
|
||
HERE = pathlib.Path(__file__).parent.resolve()
|
||
DOC_ROOT = (HERE.parent / "doc").resolve()
|
||
OPENCV_ROOT = HERE.parent.resolve()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# SCOPE — add module folder names from opencv/doc/tutorials/ here.
|
||
# Override via env var to avoid editing this file:
|
||
# OPENCV_DOC_MODULES=photo,imgproc cmake --build <build> --target sphinx
|
||
# ---------------------------------------------------------------------------
|
||
import os as _os
|
||
DOC_MODULES = [
|
||
m.strip()
|
||
for m in (_os.environ.get("OPENCV_DOC_MODULES") or "photo,objdetect,core,calib3d,features,3d,app,introduction,imgproc,ios,dnn,gpu,others").split(",")
|
||
if m.strip()
|
||
]
|
||
DOC_JS_MODULES = [
|
||
m.strip()
|
||
for m in (_os.environ.get("OPENCV_DOC_JS_MODULES") or "js_setup,js_gui,js_core,js_imgproc,js_video,js_dnn").split(",")
|
||
if m.strip()
|
||
]
|
||
|
||
DOC_PY_MODULES = [
|
||
m.strip()
|
||
for m in (_os.environ.get("OPENCV_DOC_PY_MODULES") or "py_setup,py_gui,py_core,py_imgproc,py_features,py_video,py_calib3d,py_ml,py_photo,py_objdetect,py_bindings").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,sfm,viz,tracking").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", "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)
|
||
extensions.append(_ext)
|
||
except ImportError:
|
||
pass
|
||
HAVE_SPHINX_DESIGN = "sphinx_design" in extensions
|
||
|
||
source_suffix = {".md": "markdown", ".markdown": "markdown"}
|
||
|
||
# Root tutorial index (lists all modules via @subpage). Stays the master
|
||
# regardless of how many modules are in DOC_MODULES.
|
||
master_doc = "tutorials/tutorials"
|
||
|
||
# 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", "faq.markdown",
|
||
"citelist.markdown", "intro.markdown"] + [
|
||
f"tutorials/{m}/**" for m in DOC_MODULES
|
||
] + (["js_tutorials/js_tutorials.markdown"] + [
|
||
f"js_tutorials/{m}/**" for m in DOC_JS_MODULES
|
||
] if DOC_JS_MODULES else []) + (["py_tutorials/py_tutorials.markdown"] + [
|
||
f"py_tutorials/{m}/**" for m in DOC_PY_MODULES
|
||
] if DOC_PY_MODULES else [])
|
||
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/**",
|
||
"tutorials/app/_old/**",
|
||
]
|
||
|
||
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",
|
||
"misc.highlighting_failure",
|
||
"image.not_readable",
|
||
]
|
||
|
||
# -- Doxygen integration -----------------------------------------------------
|
||
# External links in the navbar and unbuilt-module sidebar entries point at
|
||
# the existing Doxygen build. Override the base URL or tagfile via env vars.
|
||
DOXYGEN_BASE_URL = (
|
||
_os.environ.get("OPENCV_DOXYGEN_BASE_URL", "https://docs.opencv.org/5.x/")
|
||
.rstrip("/") + "/")
|
||
_TAG_FILE = pathlib.Path(_os.environ.get("OPENCV_DOXYGEN_TAGFILE", ""))
|
||
if not (_TAG_FILE.name and _TAG_FILE.is_file()):
|
||
_TAG_FILE_CANDIDATES = (
|
||
[HERE.parent.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag",
|
||
HERE.parent.parent / "build" / "doc" / "opencv.tag"]
|
||
+ sorted((HERE.parent.parent).glob("build*/doc/opencv.tag"))
|
||
+ sorted((HERE.parent.parent).glob("build*/doc/doxygen/html/opencv.tag"))
|
||
# Also support an in-tree build/ inside opencv/ (the usual
|
||
# `cmake -B build` from the repo root). Tried after the out-of-tree
|
||
# candidates so existing setups resolve unchanged.
|
||
+ [HERE.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag",
|
||
HERE.parent / "build" / "doc" / "opencv.tag"]
|
||
+ sorted((HERE.parent).glob("build*/doc/opencv.tag"))
|
||
+ sorted((HERE.parent).glob("build*/doc/doxygen/html/opencv.tag"))
|
||
)
|
||
_TAG_FILE = next((p for p in _TAG_FILE_CANDIDATES if p.is_file()), pathlib.Path(""))
|
||
|
||
# anchor -> doxygen URL filename (from opencv.tag if available).
|
||
_TAG_FILENAMES: dict[str, str] = {}
|
||
# anchor -> human-readable page title (from opencv.tag).
|
||
_TAG_PAGE_TITLES: dict[str, str] = {}
|
||
# cv API name -> full doxygen URL (for cv.Name auto-linking).
|
||
_CV_API: dict[str, str] = {}
|
||
if _TAG_FILE.is_file():
|
||
try:
|
||
import xml.etree.ElementTree as _ET
|
||
for _c in _ET.parse(str(_TAG_FILE)).getroot().iter("compound"):
|
||
if _c.get("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 _t:
|
||
_TAG_PAGE_TITLES[_n] = _t
|
||
if _c.get("kind") in ("class", "namespace"):
|
||
_cn = (_c.findtext("name") or "").split("::")[-1]
|
||
_cf = _c.findtext("filename", "")
|
||
if _cn and _cf:
|
||
_CV_API.setdefault(_cn, DOXYGEN_BASE_URL + (_cf if _cf.endswith(".html") else _cf + ".html"))
|
||
if _c.get("kind") == "group":
|
||
# Doxygen module pages (core, imgproc, dnn, …) live as
|
||
# `kind="group"` compounds in the tagfile. Without capturing
|
||
# them here, inline `@ref core` and bullet-list refs to module
|
||
# roots in intro.markdown don't resolve to anything.
|
||
_gn = _c.findtext("name")
|
||
_gf = _c.findtext("filename")
|
||
_gt = _c.findtext("title")
|
||
if _gn and _gf:
|
||
_TAG_FILENAMES[_gn] = _gf if _gf.endswith(".html") else _gf + ".html"
|
||
if _gn and _gt:
|
||
_TAG_PAGE_TITLES[_gn] = _gt
|
||
for _m in _c.findall("member"):
|
||
_n = _m.findtext("name", "")
|
||
_af = _m.findtext("anchorfile", "")
|
||
_an = _m.findtext("anchor", "")
|
||
if _n and _af and _an:
|
||
_CV_API.setdefault(_n, DOXYGEN_BASE_URL + _af + "#" + _an)
|
||
except Exception:
|
||
pass
|
||
|
||
def _doxygen_url(page: str) -> str:
|
||
return DOXYGEN_BASE_URL + _TAG_FILENAMES.get(page, page)
|
||
|
||
|
||
# ---- Citation numbering --------------------------------------------------
|
||
# `@cite KEY` resolves to `[N]` where N is the entry's position in
|
||
# `doc/opencv.bib` sorted case-insensitively by key (Doxygen's default
|
||
# ordering). Doxygen's live citelist.html numbers map keys to integers the
|
||
# same way; reading from the bib means our build is self-contained and
|
||
# doesn't need a network fetch. The same parsed entries also feed the
|
||
# Sphinx-side bibliography page generated further down, so the `[N]`
|
||
# emitted at @cite-resolution time always matches the `[N]` rendered on
|
||
# the citelist page.
|
||
def _bib_parse(text: str) -> list[dict]:
|
||
"""Walk a BibTeX file into a list of {_type, _key, field: value, ...}.
|
||
Brace-balanced; handles `{...}` and `"..."` value forms. Concatenation
|
||
(`val # "more"`) is not supported — opencv.bib doesn't use it."""
|
||
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; stop rather than misparse the rest
|
||
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. Just enough to render opencv.bib
|
||
# readably; not a full parser.
|
||
_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:
|
||
1 author -> "A"
|
||
2 authors -> "A and B"
|
||
3+ -> "A, B, and C" (Oxford comma + 'and')"""
|
||
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 "")
|
||
|
||
# bibtex `plain` style wraps the title in the URL hyperlink (or DOI URL
|
||
# when no `url` field is set). DOI without a URL field is rendered as a
|
||
# https://doi.org/... link on the title.
|
||
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 = month + year ("nov 2012") — bibtex's plain `byear` formatter.
|
||
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 differs by entry kind (Doxygen/bibtex plain style):
|
||
# @article -> "*Journal*, V(N):pages, date."
|
||
# @inproceedings -> "In *Booktitle*, pages X-Y. Publisher, date."
|
||
# @incollection -> "In *Booktitle*, pages X-Y. Publisher, date."
|
||
# @book/@misc -> "Publisher, date." (or just date)
|
||
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 / @techreport / 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 += "."
|
||
|
||
# Raw HTML anchor preserves the original CITEREF_<Key> case (matches
|
||
# Doxygen's URL convention exactly, so cached links to
|
||
# `citelist.html#CITEREF_<Key>` keep resolving on the new site too).
|
||
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` style: sort by first author's last name, then year,
|
||
then title. Without this, our citelist numbering doesn't match
|
||
docs.opencv.org/5.x — Doxygen runs bibtex with LATEX_BIB_STYLE=plain
|
||
(set in doc/Doxyfile.in), which sorts by author, NOT by bib key."""
|
||
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())
|
||
|
||
# Discover every .bib file Doxygen would feed bibtex (see opencv/doc/
|
||
# CMakeLists.txt: paths_bib accumulates `${m}.bib` for each module in
|
||
# OPENCV_DOC_LIST plus the main opencv.bib). Reading them all here is
|
||
# what makes `[1] Achanta…` appear first — that entry lives in
|
||
# opencv_contrib/modules/ximgproc/doc/ximgproc.bib, not in doc/opencv.bib.
|
||
_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] = {}
|
||
# Parsed entries kept in module scope so the citelist generator (below) reuses
|
||
# the same sort order that fed `_CITE_NUMBER`. Numbering stays consistent
|
||
# without re-parsing or re-sorting in two places.
|
||
_BIB_ENTRIES_SORTED: list[dict] = []
|
||
_seen_keys: set[str] = set() # dedupe across bibs; first occurrence 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
|
||
|
||
# Stage the bibliography into the Sphinx srcdir so `@subpage citelist`
|
||
# below resolves to an internal docname. Skipped when SPHINX_INPUT_ROOT
|
||
# is DOC_ROOT (ad-hoc sphinx-build) — writing into opencv/doc/ is
|
||
# forbidden, and `@cite` falls back to the external Doxygen URL in that
|
||
# case (see _cite_repl).
|
||
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
|
||
|
||
|
||
# ---- Redirect-page map ---------------------------------------------------
|
||
# OpenCV's docs include many "stub" pages whose entire body is
|
||
# `Content has been moved: @ref destination`. Following these chains at
|
||
# render time means inline `@ref X` ends up pointing at the actual content
|
||
# instead of an intermediate redirect (which would itself need clicking
|
||
# through). Built from anything under `doc/{tutorials,py_tutorials,
|
||
# js_tutorials}/**/*.markdown`; `_old/` subtrees are intentionally included
|
||
# because that's where many of the canonical redirect stubs live.
|
||
_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. Cycles bail safely."""
|
||
seen: set[str] = set()
|
||
while anchor in _REDIRECT_MAP and anchor not in seen:
|
||
seen.add(anchor)
|
||
anchor = _REDIRECT_MAP[anchor]
|
||
return anchor
|
||
|
||
# -- HTML / PyData theme ----------------------------------------------------
|
||
try:
|
||
import pydata_sphinx_theme # noqa: F401
|
||
html_theme = "pydata_sphinx_theme"
|
||
except ImportError:
|
||
html_theme = "alabaster"
|
||
|
||
html_title = "OpenCV Tutorials"
|
||
html_show_sourcelink = False
|
||
templates_path = ["_templates"]
|
||
html_static_path = ["_static"]
|
||
html_css_files = [
|
||
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700"
|
||
"&family=JetBrains+Mono:wght@400;500&display=swap",
|
||
"custom.css",
|
||
]
|
||
html_theme_options = {
|
||
"logo": {"text": f"OpenCV {release}"},
|
||
# Show all 7 Doxygen-style external links inline (no "More" dropdown).
|
||
"header_links_before_dropdown": 7,
|
||
# Doxygen-style top-level nav (the legacy site's MAIN PAGE / RELATED
|
||
# PAGES / NAMESPACES / CLASSES / FILES / EXAMPLES / JAVA DOCUMENTATION).
|
||
# All external — they target the existing Doxygen build.
|
||
"external_links": [
|
||
{"url": _doxygen_url("index.html"), "name": "Main Page"},
|
||
{"url": _doxygen_url("pages.html"), "name": "Related Pages"},
|
||
{"url": _doxygen_url("namespaces.html"), "name": "Namespaces"},
|
||
{"url": _doxygen_url("annotated.html"), "name": "Classes"},
|
||
{"url": _doxygen_url("files.html"), "name": "Files"},
|
||
{"url": _doxygen_url("examples.html"), "name": "Examples"},
|
||
{"url": DOXYGEN_BASE_URL + "javadoc/", "name": "Java Documentation"},
|
||
],
|
||
"show_toc_level": 2,
|
||
"navigation_with_keys": True,
|
||
"show_prev_next": True,
|
||
"show_nav_level": 2,
|
||
"navigation_depth": 4,
|
||
"secondary_sidebar_items": ["page-toc"],
|
||
"back_to_top_button": True,
|
||
"show_version_warning_banner": False,
|
||
"icon_links": [{"name": "GitHub",
|
||
"url": "https://github.com/opencv/opencv",
|
||
"icon": "fa-brands fa-github"}],
|
||
}
|
||
|
||
# Doxygen defines \fork{a}{b}{c}{d} as a piecewise-function shorthand.
|
||
# Define it as a MathJax macro so threshold.markdown renders correctly.
|
||
mathjax3_config = {
|
||
"tex": {
|
||
"macros": {
|
||
"fork": [r"\left\{ \begin{array}{ll} #1 & \mbox{#2}\\ #3 & \mbox{#4}\end{array} \right.", 4],
|
||
}
|
||
}
|
||
}
|
||
|
||
# ===========================================================================
|
||
# Doxygen-flavored .markdown -> MyST translation via source-read.
|
||
# Nothing on disk under opencv/doc/ is modified.
|
||
# ===========================================================================
|
||
|
||
# Build anchor maps. Two kinds:
|
||
# _ANCHOR_TO_DOC anchor -> docname (internal — for enabled modules)
|
||
# _ANCHOR_TO_EXTERNAL anchor -> (title, url) (external — for the rest)
|
||
# Disabled modules still appear in the master toctree as external links to
|
||
# the Doxygen build, so the left sidebar shows the full module list.
|
||
_ANCHOR_TO_DOC: dict[str, str] = {}
|
||
_ANCHOR_TO_EXTERNAL: dict[str, tuple[str, str]] = {}
|
||
|
||
_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 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.
|
||
Picks up both `.markdown` (the bulk of the tree) and `.md` (the form
|
||
used by ports like dnn/dnn_pytorch_tf_*)."""
|
||
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():
|
||
files = [p for s in _SUFFIXES for p in path.rglob(f"*{s}")]
|
||
else:
|
||
files = []
|
||
for md in files:
|
||
try:
|
||
body = md.read_text(encoding="utf-8", errors="replace")
|
||
except OSError:
|
||
continue
|
||
# 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:
|
||
"""Pull the top heading's (title, anchor) from a module's table_of_content
|
||
file and add it to _ANCHOR_TO_EXTERNAL with a URL into the Doxygen build."""
|
||
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)
|
||
|
||
# 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(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)
|
||
|
||
# Standalone top-level pages (siblings of tutorials/ in the staged tree).
|
||
# Registers their {#anchor} in _ANCHOR_TO_DOC so the master-doc @subpage
|
||
# injection below resolves to an internal docname instead of being dropped.
|
||
_scan_internal(SPHINX_INPUT_ROOT / "faq.markdown")
|
||
_scan_internal(SPHINX_INPUT_ROOT / "citelist.markdown")
|
||
_scan_internal(SPHINX_INPUT_ROOT / "intro.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)
|
||
|
||
_scan_internal(SPHINX_INPUT_ROOT / "js_tutorials" / "js_tutorials.markdown")
|
||
for _m in DOC_JS_MODULES:
|
||
_scan_internal(SPHINX_INPUT_ROOT / "js_tutorials" / _m)
|
||
for _toc in (DOC_ROOT / "js_tutorials").glob("*/js_table_of_contents_*.markdown"):
|
||
if _toc.parent.name not in DOC_JS_MODULES:
|
||
_scan_external(_toc)
|
||
|
||
_scan_internal(SPHINX_INPUT_ROOT / "py_tutorials" / "py_tutorials.markdown")
|
||
for _m in DOC_PY_MODULES:
|
||
_scan_internal(SPHINX_INPUT_ROOT / "py_tutorials" / _m)
|
||
for _toc in (DOC_ROOT / "py_tutorials").glob("*/py_table_of_contents_*.markdown"):
|
||
if _toc.parent.name not in DOC_PY_MODULES:
|
||
_scan_external(_toc)
|
||
|
||
for _m in CONTRIB_MODULES:
|
||
_tut_dir = CONTRIB_ROOT / _m / "tutorials"
|
||
if not _tut_dir.is_dir():
|
||
continue
|
||
for _md in list(_tut_dir.rglob("*.markdown")) + list(_tut_dir.rglob("*.md")):
|
||
try:
|
||
_head = _md.read_text(encoding="utf-8", errors="replace")[:4000]
|
||
except OSError:
|
||
continue
|
||
_rel = "tutorials_contrib/" + _m + "/" + _md.relative_to(_tut_dir).with_suffix("").as_posix()
|
||
for _mm in re.finditer(r"\{#([\w-]+)\}", _head):
|
||
_ANCHOR_TO_DOC[_mm.group(1)] = _rel
|
||
|
||
# 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_tree in (DOC_ROOT / "tutorials", DOC_ROOT / "js_tutorials", DOC_ROOT / "py_tutorials"):
|
||
for _img in _img_tree.rglob("images/*"):
|
||
if _img.is_file():
|
||
_IMAGE_INDEX.setdefault(_img.name, _img.relative_to(DOC_ROOT).as_posix())
|
||
for _img in (DOC_ROOT / "js_tutorials" / "js_assets").glob("*"):
|
||
if _img.is_file():
|
||
_IMAGE_INDEX.setdefault(_img.name, _img.relative_to(DOC_ROOT).as_posix())
|
||
for _img in (DOC_ROOT / "images").glob("*"):
|
||
if _img.is_file():
|
||
_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. 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."""
|
||
# 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",
|
||
".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: //, //! 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:
|
||
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]], indent: str = "") -> str:
|
||
def _dedent(body: str) -> str:
|
||
# Fully dedent the tab-item body to column 0. The {tab-item} directive
|
||
# resets the parser context, so directives (```{code-block}```) must be
|
||
# at column 0 to be recognised — not at 4-space where CommonMark treats
|
||
# them as indented code blocks. Using the actual minimum indent of all
|
||
# non-empty lines (instead of exactly len(indent)) handles the case
|
||
# where @snippet/@include is indented deeper than @add_toggle (e.g.
|
||
# 8-space snippet inside a 4-space toggle block).
|
||
lines = body.split("\n")
|
||
non_empty = [l for l in lines if l.strip()]
|
||
if not non_empty:
|
||
return body
|
||
min_ind = min(len(l) - len(l.lstrip()) for l in non_empty)
|
||
if min_ind == 0:
|
||
return body
|
||
return "\n".join(l[min_ind:] if l.strip() else l for l in lines)
|
||
if HAVE_SPHINX_DESIGN:
|
||
out = ["", "``````{tab-set}"]
|
||
for lang, body in tabs:
|
||
label = _TOGGLE_LABELS.get(lang, lang.title())
|
||
out += [f"`````{{tab-item}} {label}", _dedent(body), "`````"]
|
||
out += ["``````", ""]
|
||
return "\n".join(out)
|
||
# Fallback: render each toggle as a labeled section.
|
||
out = [""]
|
||
for lang, body in tabs:
|
||
label = _TOGGLE_LABELS.get(lang, lang.title())
|
||
out += [f"**{label}**", "", _dedent(body), ""]
|
||
return "\n".join(out)
|
||
|
||
|
||
def _translate(text: str, docname: str | None = None) -> str:
|
||
# 0d. py_video and py_objdetect are pure "Content has been moved" stub
|
||
# trees — every page just redirects to the corresponding C++ tutorial.
|
||
# They're compiled (for legacy URL compatibility) but marked `:orphan:`
|
||
# so they stay out of the sidebar nav and don't trigger "not in any
|
||
# toctree" warnings. Front-matter must lead the file, so this runs
|
||
# first. The py_tutorials root still links to the real destinations
|
||
# via its existing `@ref` bullets.
|
||
if docname and (docname.startswith("py_tutorials/py_video/")
|
||
or docname.startswith("py_tutorials/py_objdetect/")):
|
||
text = "---\norphan: true\n---\n\n" + text
|
||
|
||
# 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)
|
||
|
||
# 0b. \htmlonly ... \endhtmlonly → raw HTML block; rewrite relative iframe
|
||
# src (../../foo.html) to absolute docs.opencv.org URL so demos load.
|
||
def _htmlonly_repl(m: re.Match) -> str:
|
||
body = re.sub(r'src="\.\.\/\.\.\/([\w/.-]+)"',
|
||
lambda mm: f'src="{DOXYGEN_BASE_URL}{mm.group(1)}"',
|
||
m.group(1))
|
||
body = re.sub(r'\s*onload="[^"]*"', ' height="700px"', body)
|
||
return f"\n```{{raw}} html\n{body}\n```\n"
|
||
text = re.sub(r"^\\htmlonly\s*\n(.*?)\\endhtmlonly\s*$",
|
||
_htmlonly_repl, text, flags=re.DOTALL | re.MULTILINE)
|
||
|
||
|
||
# 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.
|
||
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)
|
||
text = re.sub(
|
||
r"^(?P<title>[^\n{]+?)\s*\n={3,}\s*$",
|
||
lambda m: f"## {m.group('title').strip()}",
|
||
text, flags=re.MULTILINE)
|
||
# 1c. "N. Title\n---" Setext h2 — CommonMark parses "N. " as a list item, not a heading.
|
||
text = re.sub(
|
||
r"^(\d+\.[^\n]+)\n-{3,}\s*$",
|
||
lambda m: f"## {m.group(1).strip()}",
|
||
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
|
||
# 1a-i. Over-indented list markers.
|
||
# A bullet marker (-/*/+) followed by 5+ spaces causes CommonMark to use
|
||
# marker_col+2 as the continuation indent (the extra spaces beyond 4 are
|
||
# treated as content), making the actual text 6+ spaces into the content —
|
||
# above the 4-space threshold for indented code blocks. Normalise to
|
||
# exactly 3 spaces after the marker and reduce all continuation lines by
|
||
# the same delta so nested structure is preserved.
|
||
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)
|
||
|
||
# 1a-ii. 4-space indented list items directly under a heading.
|
||
# Doxygen ignores visual indentation; CommonMark treats 4-space-indented
|
||
# lines as indented code blocks. Strip the 4-space prefix when list items
|
||
# immediately follow a heading (with optional blank lines between).
|
||
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)
|
||
|
||
# 1a-iii. 4-space list items under a plain paragraph line → strip to fix lazy continuation.
|
||
text = re.sub(
|
||
r"(^(?![ \t#@`]|-#|[-*+]\s|\d+[.)]\s)[^\n]+\n)((?: [-*+][ \t][^\n]*\n(?:[ \t]{5,}[^\n]*\n)*)+)",
|
||
lambda m: m.group(1) + re.sub(r"^ ", "", m.group(2), flags=re.MULTILINE),
|
||
text, flags=re.MULTILINE)
|
||
|
||
# 1b0. \b word → **word** (Doxygen bold macro — single next word only).
|
||
text = re.sub(r"\\b\s+(\S+)", r"**\1**", text)
|
||
|
||
# 1b. @note ... / @see ... -> MyST admonitions.
|
||
# Runs BEFORE math conversion so that \f[...\f] inside a note body is
|
||
# still on one logical line and does not create a blank-line terminator
|
||
# that would cut the body short.
|
||
# Allow optional leading indent and bare @note (body on next line).
|
||
# Dedent the body so indented lines don't become code blocks inside
|
||
# the directive.
|
||
_ADMON_KIND = {"note": "note", "see": "seealso", "warning": "warning", "sa": "seealso"}
|
||
def _admon_repl(m: re.Match) -> str:
|
||
indent = m.group("indent")
|
||
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{indent}:::{{{kind}}}\n{indent}{body}\n{indent}:::\n"
|
||
text = re.sub(
|
||
r"^(?P<indent>[ \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)
|
||
|
||
# 1c. @param / @return → MyST definition list.
|
||
def _param_block_repl(m: re.Match) -> str:
|
||
items: list[list] = []
|
||
for line in m.group(0).split("\n"):
|
||
pm = re.match(r"^@param\s+(\S+)\s*(.*)", line)
|
||
rm = re.match(r"^@return\s*(.*)", line)
|
||
if pm:
|
||
items.append([f"`{pm.group(1)}`", [pm.group(2).strip()]])
|
||
elif rm:
|
||
items.append(["*(return value)*", [rm.group(1).strip()]])
|
||
elif items and line.strip():
|
||
items[-1][1].append(line.strip())
|
||
def _inline_block_math(s: str) -> str:
|
||
if re.match(r"^\\f\[.+\\f\]$", s.strip()):
|
||
return s
|
||
return re.sub(r"\\f\[(.+?)\\f\]", lambda mm: f"${mm.group(1).strip()}$", s)
|
||
result = []
|
||
has_param = False
|
||
for key, desc_lines in items:
|
||
desc_lines = [l for l in desc_lines if l]
|
||
if not desc_lines:
|
||
continue
|
||
if key != "*(return value)*":
|
||
has_param = True
|
||
entry = f"{key}\n: {_inline_block_math(desc_lines[0])}"
|
||
for cont in desc_lines[1:]:
|
||
entry += f"\n {_inline_block_math(cont)}"
|
||
result.append(entry)
|
||
header = "\n**Parameters**\n\n" if has_param else "\n"
|
||
return header + "\n\n".join(result) + "\n"
|
||
text = re.sub(
|
||
r"((?:^@(?:param\s+\S+|return)\s+[^\n]+\n(?:[ \t]+[^\n]+\n)*)+)",
|
||
_param_block_repl, text, flags=re.MULTILINE)
|
||
|
||
# 2. Doxygen LaTeX math markers.
|
||
# Block \f[...\f]: consume leading indent and re-emit it on the $$
|
||
# fence lines so the block stays inside any enclosing list item and
|
||
# the text that follows (at the same indent) is not misread as a code block.
|
||
#
|
||
# Preprocess: when two \f[...\f] blocks are adjacent on the same source
|
||
# line (e.g. \end{bmatrix}\f]\f[G_{y}), split them onto separate lines
|
||
# and prefix the second \f[ with the line's own leading indent. Without
|
||
# this the primary regex below would convert the first block correctly but
|
||
# leave the second \f[ at column 0 in the output; the fallback then emits
|
||
# that block at column 0, breaking any enclosing list structure.
|
||
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 _block_math_repl(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\]",
|
||
_block_math_repl, text, flags=re.DOTALL | re.MULTILINE)
|
||
# Fallback for any \f[...\f] not at line-start (e.g. two adjacent blocks).
|
||
text = re.sub(r"\\f\[(.+?)\\f\]",
|
||
lambda m: f"\n$$\n{m.group(1).strip()}\n$$\n",
|
||
text, flags=re.DOTALL)
|
||
# Inline math.
|
||
text = re.sub(r"\\f\$(.+?)\\f\$", lambda m: f"${m.group(1)}$",
|
||
text, flags=re.DOTALL)
|
||
|
||
# 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"\\bordermatrix\s*\{([^}]*)\}",
|
||
lambda m: r"\begin{matrix}" + m.group(1).replace(r"\cr", r"\\")
|
||
+ r"\end{matrix}",
|
||
text)
|
||
|
||
# 2c. Normalise unknown Pygments lexer names: plaintext/bash/sh → text.
|
||
text = re.sub(r"^([ \t]*)```plaintext\b", r"\1```text", text, flags=re.MULTILINE)
|
||
text = re.sub(r"^([ \t]*)```(?:bash|sh)\b", r"\1```text", text, flags=re.MULTILINE)
|
||
|
||
# 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 "")
|
||
if lang == "m":
|
||
lang = "objc"
|
||
if lang == "js":
|
||
lang = "javascript"
|
||
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"
|
||
|
||
# 3b. Tilde-fenced code blocks: `~~~~{.lang} ... ~~~~` (Doxygen-style).
|
||
# MyST treats `{.lang}` as a directive name, so `.py` raises an
|
||
# "Unknown directive" warning. Normalise to backtick-fenced output.
|
||
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. Normalize `yml` fence lexer -> `yaml`.
|
||
# Pygments emits "Lexer succeeded for unknown lexer 'yml'" otherwise.
|
||
# Runs after 3 and 3b so @code{.yml} / ~~~~{.yml} fences caught here too.
|
||
# Matches the lang token on its own — won't touch `yamllint`, `yml-foo`,
|
||
# or strings like ` ```yml-config` (none currently exist, but the \b
|
||
# guard keeps this safe under future edits).
|
||
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)
|
||
|
||
# 4. @include path / @includelineno path
|
||
# Indent preserved so code blocks inside list items don't break the list.
|
||
def _include_repl(m: re.Match) -> str:
|
||
indent = m.group("indent")
|
||
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. 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)
|
||
|
||
# 4b. @htmlinclude path -> raw HTML embed.
|
||
# Doxygen's EXAMPLE_PATH search is recursive, so a bare basename
|
||
# like `js_face_recognition.html` still resolves to its sample
|
||
# directory. If the file is a complete HTML document, lift just
|
||
# the <body>...</body> content to avoid nesting <html> inside
|
||
# the rendered page.
|
||
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")
|
||
# `<body ...>` opener: allow quoted attribute values so a `=>`
|
||
# arrow function inside `onload="..."` doesn't terminate 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]
|
||
# Indent preserved so code blocks inside list items don't break the list.
|
||
def _snippet_repl(m: re.Match) -> str:
|
||
indent = m.group("indent")
|
||
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)
|
||
|
||
# 5b. @snippetlineno — same as @snippet with :linenos:.
|
||
def _snippetlineno_repl(m: re.Match) -> str:
|
||
indent = m.group("indent")
|
||
code, lang = _read_snippet(m.group("path"), m.group("label"))
|
||
body = "\n".join(indent + l for l in code.rstrip().split("\n"))
|
||
return f"\n{indent}```{{code-block}} {lang}\n{indent}:linenos:\n{body}\n{indent}```\n"
|
||
text = re.sub(r"^(?P<indent>[ \t]*)@snippetlineno\s+(?P<path>\S+)\s+(?P<label>[^\n]+?)\s*$",
|
||
_snippetlineno_repl, text, flags=re.MULTILINE)
|
||
|
||
# 6. @add_toggle_LANG ... @end_toggle (coalesce runs into one tab-set)
|
||
# Capture the leading indent of each toggle block and emit the tab-set
|
||
# fence lines at the same indent, so toggles inside list items stay as
|
||
# list-item continuation content (where 4-space fences are valid).
|
||
# Body content is dedented so code blocks at column 0 inside the
|
||
# directive body are parsed correctly by myst-parser.
|
||
def _toggle_collapse(src: str) -> str:
|
||
out, i = [], 0
|
||
opener = re.compile(r"^([ \t]*)@add_toggle_(\w+)[ \t]*$", re.MULTILINE)
|
||
while True:
|
||
m = opener.search(src, i)
|
||
if not m:
|
||
out.append(src[i:]); break
|
||
out.append(src[i:m.start()])
|
||
block_ind, tabs, j = m.group(1), [], m.start()
|
||
while True:
|
||
m2 = re.match(
|
||
r"[ \t]*@add_toggle_(\w+)[ \t]*\n(.*?)\n[ \t]*@end_toggle[ \t]*\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, block_ind))
|
||
i = j
|
||
return "".join(out)
|
||
text = _toggle_collapse(text)
|
||
|
||
# 6b. Strip list-item continuation indent stranded after a col-0 tab-set close.
|
||
def _strip_tabset_continuations(src: str) -> str:
|
||
lines = src.split("\n")
|
||
out: list[str] = []
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
if line == "``````":
|
||
out.append(line)
|
||
i += 1
|
||
while i < len(lines):
|
||
ln = lines[i]
|
||
if ln.startswith(" "):
|
||
out.append(ln[4:])
|
||
i += 1
|
||
elif not ln.strip():
|
||
out.append(ln)
|
||
i += 1
|
||
else:
|
||
break
|
||
else:
|
||
out.append(line)
|
||
i += 1
|
||
return "\n".join(out)
|
||
text = _strip_tabset_continuations(text)
|
||
|
||
# 7. toctree before @ref so list-item @ref tutorial_* entries aren't converted first
|
||
def _subpage_list_to_toctree(src: str) -> str:
|
||
if not src.endswith("\n"):
|
||
src += "\n"
|
||
pat = re.compile(
|
||
r"((?:^[ \t]*-\s+@(?:subpage\s+[\w-]+|ref\s+tutorial_[\w-]+)[^\n]*\n(?:(?:[ \t]*\n)?[ \t]+[^\n]+\n)*)+)",
|
||
re.MULTILINE)
|
||
def repl(m: re.Match) -> str:
|
||
block = m.group(1)
|
||
entries = re.findall(r"@(?:subpage|ref)\s+([\w-]+)", block)
|
||
dm = re.search(
|
||
r"@(?:subpage|ref)\s+[\w-]+[^\n]*\n((?:(?:[ \t]*\n)?[ \t]+[^\n]+\n)*)", block)
|
||
desc_raw = dm.group(1) if dm and dm.group(1) else ""
|
||
_groups: list[list[str]] = [[]]
|
||
for _l in desc_raw.splitlines():
|
||
if _l.strip():
|
||
_groups[-1].append(_l.strip())
|
||
elif _groups[-1]:
|
||
_groups.append([])
|
||
desc = "\n\n".join(" ".join(g) for g in _groups if g) or None
|
||
# Current doc's directory — used to drop up-references below.
|
||
_cur_dir = docname.rsplit("/", 1)[0] if (docname and "/" in docname) else ""
|
||
lines = []
|
||
for e in entries:
|
||
if e in _ANCHOR_TO_DOC:
|
||
_tgt = _ANCHOR_TO_DOC[e]
|
||
# Skip up-references: a child page linking back to an
|
||
# ancestor index (e.g. a py_setup page's bare
|
||
# "- @ref tutorial_py_root" back-link). Promoting these
|
||
# into a toctree makes the ancestor a child of its own
|
||
# descendant -> infinite toctree recursion at build time.
|
||
_tgt_dir = _tgt.rsplit("/", 1)[0] if "/" in _tgt else ""
|
||
if _tgt == docname or (
|
||
_tgt_dir != _cur_dir
|
||
and (_cur_dir + "/").startswith(_tgt_dir + "/")):
|
||
continue
|
||
lines.append("/" + _tgt)
|
||
elif e in _ANCHOR_TO_EXTERNAL:
|
||
title, url = _ANCHOR_TO_EXTERNAL[e]
|
||
lines.append(f"{title} <{url}>")
|
||
elif e in _TAG_FILENAMES:
|
||
title = _TAG_PAGE_TITLES.get(e, e)
|
||
lines.append(f"{title} <{DOXYGEN_BASE_URL + _TAG_FILENAMES[e]}>")
|
||
if not lines:
|
||
return ""
|
||
body = "\n".join(lines)
|
||
result = f"\n```{{toctree}}\n:maxdepth: 1\n\n{body}\n```\n"
|
||
if desc:
|
||
result += f"\n{desc}\n"
|
||
return result
|
||
return pat.sub(repl, src)
|
||
text = _subpage_list_to_toctree(text)
|
||
|
||
# 7a. @link target [display text] @endlink -> @ref target ["display"]
|
||
# Doxygen's @link/@endlink pair is the verbose form of @ref. MyST has
|
||
# no equivalent, so normalise to @ref and let step 7b resolve the anchor
|
||
# against _ANCHOR_TO_DOC (covers e.g. dnn_googlenet automatically).
|
||
# Display text is non-greedy and may span lines; embedded `"` chars are
|
||
# stripped because step 7b's disp parser is `"[^"]+"`.
|
||
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)
|
||
|
||
# 7b. @ref name [optional "Display Text"]
|
||
# Follow redirect stubs ("Content has been moved: @ref X") so refs
|
||
# land on the real page. Resolution order after redirect-following:
|
||
# enabled-module anchor (internal docname) -> Doxygen tag (external
|
||
# URL; covers `@ref core` and other module-root refs captured from
|
||
# the tagfile's kind="group" compounds) -> fragment-only 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:
|
||
return f"[{disp or name}]({'/' + target})"
|
||
if resolved in _TAG_FILENAMES:
|
||
return f"[{disp or name}]({_doxygen_url(resolved)})"
|
||
return f"[{disp or name}](#{resolved})"
|
||
# 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)
|
||
|
||
# 7b. cv.Name → [cv.Name](doxygen url) for names in the API index.
|
||
if _CV_API:
|
||
def _cvlink_repl(m: re.Match) -> str:
|
||
url = _CV_API.get(m.group(1))
|
||
return f'[cv.{m.group(1)}]({url})' if url else m.group(0)
|
||
_parts = re.split(r'(```.*?```|`[^`\n]+`)', text, flags=re.DOTALL)
|
||
text = ''.join(
|
||
p if i % 2 else re.sub(
|
||
r'(?<!\[)(?<!\()cv\.([A-Za-z][A-Za-z0-9_]*)',
|
||
_cvlink_repl, p)
|
||
for i, p in enumerate(_parts))
|
||
|
||
# 7c. `cv::Name` inline code → [`cv::Name`](doxygen url).
|
||
# Tag-file lookup when available; search URL fallback for tutorials-only builds.
|
||
def _cvcodelink_repl(m: re.Match) -> str:
|
||
full = m.group("name")
|
||
last = full.split("::")[-1]
|
||
url = (_CV_API.get(full) or _CV_API.get(last)
|
||
or f"{DOXYGEN_BASE_URL}search.html?q={last}")
|
||
return f'[`{full}`]({url})'
|
||
text = re.sub(
|
||
r'`{1,3}(?P<name>cv::[A-Za-z_][\w:]*)`{1,3}',
|
||
_cvcodelink_repl, text)
|
||
|
||
# 8. @cite KEY -> `[N]` linking to the citelist page, where N is the
|
||
# entry's bibtex-plain position (built into `_CITE_NUMBER` at module
|
||
# load). Links to the internal generated citelist page when it was
|
||
# staged + scanned; otherwise falls back to the external Doxygen
|
||
# citelist.html. Falls back to the raw key when not in the bib map.
|
||
# HTML anchor so the brackets survive markdown processing.
|
||
def _cite_repl(m: re.Match) -> str:
|
||
key = m.group(1)
|
||
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 = re.sub(r"@cite\s+([\w-]+)", _cite_repl, text)
|
||
|
||
# 8b. @youtube{ID} -> responsive embed (raw HTML, passed through by MyST).
|
||
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 -> 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", "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|warning)\s+(?P<body>.+?)(?=\n[ \t]*\n|\n@[A-Za-z]|\Z)",
|
||
_admon_repl, text, flags=re.DOTALL | re.MULTILINE)
|
||
|
||
# 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")
|
||
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:
|
||
# Bullet items may carry a descriptive prefix before @subpage, e.g.
|
||
# `- stitching. @subpage tutorial_stitcher` (tutorials/others/...).
|
||
pat = re.compile(
|
||
r"((?:^[ \t]*-\s+[^\n@]*?@subpage\s+[\w-]+[^\n]*\n(?:(?:[ \t]*\n)?[ \t]+[^\n]+\n)*)+)",
|
||
re.MULTILINE)
|
||
def repl(m: re.Match) -> str:
|
||
block = m.group(1)
|
||
entries = re.findall(r"@subpage\s+([\w-]+)", block)
|
||
dm = re.search(
|
||
r"@subpage\s+[\w-]+[^\n]*\n(?:(?:[ \t]*\n)?([ \t]+[^\n]+)\n)?", block)
|
||
desc = dm.group(1).strip() if dm and dm.group(1) else None
|
||
lines = []
|
||
for e in entries:
|
||
if e in _ANCHOR_TO_DOC:
|
||
lines.append("/" + _ANCHOR_TO_DOC[e])
|
||
elif e in _ANCHOR_TO_EXTERNAL:
|
||
title, url = _ANCHOR_TO_EXTERNAL[e]
|
||
lines.append(f"{title} <{url}>")
|
||
if not lines:
|
||
return ""
|
||
body = "\n".join(lines)
|
||
result = f"\n```{{toctree}}\n:maxdepth: 1\n\n{body}\n```\n"
|
||
if desc:
|
||
result += f"\n{desc}\n"
|
||
return result
|
||
return pat.sub(repl, src)
|
||
text = _subpage_list_to_toctree(text)
|
||
|
||
# 10. @next_tutorial / @prev_tutorial -> drop
|
||
text = re.sub(r"^@(?:next|prev)_tutorial\{[^}]*\}\s*$", "",
|
||
text, flags=re.MULTILINE)
|
||
|
||
# 10a. col-8 -# under a col-0 parent: move to col 5 (avoids CommonMark code-block threshold).
|
||
def _fix_nested_ordered(src: str) -> str:
|
||
lines = src.split("\n")
|
||
out = []
|
||
for i, line in enumerate(lines):
|
||
if re.match(r"^ -#\s", line):
|
||
nearest = ""
|
||
for j in range(i - 1, max(i - 40, -1), -1):
|
||
m = re.match(r"^([ \t]*)-", lines[j])
|
||
if m and len(m.group(1)) < 8:
|
||
nearest = lines[j]
|
||
break
|
||
out.append(line if (nearest and re.match(r"^ ", nearest)) else " " + line[8:])
|
||
else:
|
||
out.append(line)
|
||
return "\n".join(out)
|
||
text = _fix_nested_ordered(text)
|
||
|
||
# 10b. Doxygen ordered-list marker: "-#" -> "1."
|
||
text = re.sub(r"^([ \t]*)-#([ \t]+)", r"\g<1>1.\g<2>",
|
||
text, flags=re.MULTILINE)
|
||
|
||
# 11. @tableofcontents -> drop (PyData right sidebar replaces it)
|
||
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. 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)
|
||
|
||
text = re.sub(r"</?center>", "", text, flags=re.IGNORECASE)
|
||
|
||
# 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)
|
||
|
||
# 11h. Escape C++ template <Type> in paragraph text; skip fenced code blocks.
|
||
_cpp_tpl_re = re.compile(r'\b(\w+)<([A-Za-z_][\w:, *&]*?)>')
|
||
_lines_out: list[str] = []
|
||
_in_fence = False
|
||
for _ln in text.splitlines(keepends=True):
|
||
if re.match(r'^\s*```', _ln):
|
||
_in_fence = not _in_fence
|
||
if not _in_fence:
|
||
_ln = _cpp_tpl_re.sub(lambda m: f'{m.group(1)}<{m.group(2)}>', _ln)
|
||
_lines_out.append(_ln)
|
||
text = "".join(_lines_out)
|
||
|
||
# 11g. Wrap bare http(s) URLs in <> for CommonMark autolink; skip [text](url).
|
||
def _autolink_repl(m: re.Match) -> str:
|
||
if m.group(1):
|
||
return m.group(1)
|
||
url = m.group(0)
|
||
trail = ""
|
||
while url and url[-1] in ".,;:!?)":
|
||
trail = url[-1] + trail
|
||
url = url[:-1]
|
||
return f"<{url}>{trail}" if url else m.group(0)
|
||
text = re.sub(
|
||
r'(\[[^\]]*\]\([^)]*\))|(?<!\]\()(?<![<"])https?://\S+',
|
||
_autolink_repl, text)
|
||
|
||
# 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:
|
||
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:
|
||
alt, rel = m.group("alt"), m.group("rel")
|
||
if docname:
|
||
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''
|
||
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''
|
||
if docname and docname.startswith("js_tutorials/"):
|
||
return m.group(0)
|
||
return f''
|
||
text = re.sub(
|
||
r'!\[(?P<alt>[^\]]*)\]\((?:[^)]*?/)?images/(?P<rel>[^)]+)\)',
|
||
_img_repl, text)
|
||
|
||
# 12a2. "pics/foo.png" — contrib modules use pics/ instead of images/.
|
||
def _pics_img_repl(m: re.Match) -> str:
|
||
alt = m.group("alt")
|
||
hit = _IMAGE_INDEX.get(pathlib.Path(m.group("rel")).name)
|
||
if hit:
|
||
if hit.startswith("contrib_modules/"):
|
||
return _emit_contrib_img(hit[len("contrib_modules/"):], alt)
|
||
return f''
|
||
return m.group(0)
|
||
text = re.sub(
|
||
r'!\[(?P<alt>[^\]]*)\]\(pics/(?P<rel>[^)]+)\)',
|
||
_pics_img_repl, text)
|
||
|
||
# 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: `
|
||
# 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. `` → 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.
|
||
# 12b. Bare image filenames with no directory prefix (e.g. "psf.png") that
|
||
# Doxygen resolves via IMAGE_PATH but Sphinx cannot find as-is.
|
||
# Redirect to images/<name> when the file lives in the doc's own
|
||
# images/ sibling, otherwise fall back to the global index.
|
||
def _bare_img_repl(m: re.Match) -> str:
|
||
rel = m.group("rel")
|
||
if docname:
|
||
local = DOC_ROOT / pathlib.Path(docname).parent / "images" / rel
|
||
if local.is_file():
|
||
return f'{m.group("pre")}images/{rel})'
|
||
hit = _IMAGE_INDEX.get(rel)
|
||
if hit:
|
||
return f'{m.group("pre")}/{hit})'
|
||
return m.group(0)
|
||
text = re.sub(
|
||
r'(?P<pre>!\[[^\]]*\]\()(?P<rel>[A-Za-z0-9_.-]+\.[A-Za-z]{2,4})\)',
|
||
_bare_img_repl, text)
|
||
|
||
# 12c. Standalone  → {figure} so alt text is a visible caption.
|
||
text = re.sub(
|
||
r"^([ \t]*)!\[(?P<alt>[^\]]+)\]\((?P<path>[^)\n]+)\)[ \t]*$",
|
||
lambda m: (
|
||
f"\n{m.group(1)}```{{figure}} {m.group('path')}\n"
|
||
f"{m.group(1)}{m.group('alt').strip()}\n"
|
||
f"{m.group(1)}```\n"
|
||
),
|
||
text, flags=re.MULTILINE)
|
||
|
||
# 12d. Doxygen ^ rowspan cell → merge into row above via <hr class="cv-rowdiv">.
|
||
def _merge_caret_rows(src: str) -> str:
|
||
lines = src.split("\n")
|
||
out: list[str] = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if stripped.startswith("|") and "|" in stripped[1:]:
|
||
cells = [c.strip() for c in stripped.strip("|").split("|")]
|
||
if "^" in cells:
|
||
prev_idx = next(
|
||
(k for k in range(len(out) - 1, -1, -1)
|
||
if out[k].strip().startswith("|") and
|
||
not re.match(r"^\|[\s|:-]+\|$", out[k].strip())),
|
||
None)
|
||
if prev_idx is not None:
|
||
prev_cells = [c.strip() for c in
|
||
out[prev_idx].strip().strip("|").split("|")]
|
||
merged = []
|
||
for j, cell in enumerate(cells):
|
||
pv = prev_cells[j] if j < len(prev_cells) else ""
|
||
if cell == "^":
|
||
merged.append(pv)
|
||
else:
|
||
sep = '<hr class="cv-rowdiv">'
|
||
merged.append(f"{pv}{sep}{cell}" if pv else cell)
|
||
out[prev_idx] = "| " + " | ".join(merged) + " |"
|
||
continue
|
||
out.append(line)
|
||
return "\n".join(out)
|
||
text = _merge_caret_rows(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.
|
||
def _wrap_front_matter(src: str) -> str:
|
||
pat = re.compile(
|
||
r"(^\|[^\n]*\|[ \t]*\n" # header row (often empty)
|
||
r"\|[ \t]*-:[ \t]*\|[ \t]*:-[ \t]*\|[ \t]*\n" # alignment row
|
||
r"(?:\|[^\n]*\|[ \t]*\n)+)", # one or more body rows
|
||
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)
|
||
|
||
# 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 tutorial docs plus the standalone top-level pages (intro,
|
||
# faq, and the generated citelist) — they carry Doxygen markup too.
|
||
if not (docname.startswith("tutorials/")
|
||
or docname.startswith("tutorials_contrib/")
|
||
or docname.startswith("js_tutorials/")
|
||
or docname.startswith("py_tutorials/")
|
||
or docname in ("intro", "faq", "citelist")):
|
||
return
|
||
text = source[0]
|
||
if docname == "tutorials/tutorials":
|
||
# Lead the sidebar with the intro page (matches the order in
|
||
# opencv/doc/root.markdown.in, where "- @ref intro" sits above the
|
||
# tutorial root). FAQ + Bibliography are appended after the module
|
||
# list. All guarded on _ANCHOR_TO_DOC so they no-op when the page
|
||
# wasn't staged/scanned (e.g. ad-hoc sphinx-build).
|
||
if "intro" in _ANCHOR_TO_DOC:
|
||
text = re.sub(r"^- @subpage", "- @subpage intro\n- @subpage",
|
||
text, count=1, flags=re.MULTILINE)
|
||
if CONTRIB_MODULES and "tutorial_contrib_root" in _ANCHOR_TO_DOC:
|
||
text = text.rstrip() + "\n\n- @subpage tutorial_contrib_root\n"
|
||
if "faq" in _ANCHOR_TO_DOC:
|
||
text = text.rstrip() + "\n\n- @subpage faq\n"
|
||
if "citelist" in _ANCHOR_TO_DOC:
|
||
text = text.rstrip() + "\n\n- @subpage citelist\n"
|
||
source[0] = _translate(text, docname)
|
||
if docname == "tutorials/tutorials" and DOC_JS_MODULES:
|
||
source[0] += (
|
||
"\n\n```{toctree}\n:maxdepth: 1\n:caption: JavaScript Tutorials\n\n"
|
||
"/js_tutorials/js_tutorials\n```\n"
|
||
)
|
||
if docname == "tutorials/tutorials" and DOC_PY_MODULES:
|
||
source[0] += (
|
||
"\n\n```{toctree}\n:maxdepth: 1\n:caption: Python Tutorials\n\n"
|
||
"/py_tutorials/py_tutorials\n```\n"
|
||
)
|
||
if docname == "tutorials/tutorials" and CONTRIB_MODULES:
|
||
source[0] += (
|
||
"\n\n```{toctree}\n:maxdepth: 1\n:caption: Contrib Tutorials\n\n"
|
||
"/tutorials_contrib/contrib_root\n```\n"
|
||
)
|
||
|
||
|
||
def setup(app):
|
||
app.connect("source-read", _source_read)
|
||
return {"parallel_read_safe": True, "parallel_write_safe": True}
|