From 7c3cc793870da809ea549a6601bec914022f0cab Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 29 Mar 2026 13:40:10 +0900 Subject: [PATCH 1/3] Fix Japanese nested page missing interactive scene iframe content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Sphinx extension that overrides the upstream ``offlineviewer`` directive to use ``doctreedir.parent`` instead of ``outdir.parent`` as the build root. With ``mini18n_build_style = "nested"``, the Japanese sub-build outputs to ``build/mini18n-html/ja/``. ``pyvista.ext.viewer_directive`` computes the build root as ``outdir.parent = build/mini18n-html/``, but the vtksz assets generated by ``plot_directive`` live under ``build/plot_directive/…``. The ``is_path_relative_to`` check therefore fails and the directive returns an empty node list, leaving the Interactive Scene tab blank. Both the primary (English) and the mini18n language sub-builds share the same ``doctreedir`` (``build/.doctrees``), so ``doctreedir.parent = build/`` is the correct, consistent build root for all builders. Fixes #607 Co-Authored-By: Claude Sonnet 4.6 --- doc/source/_ext/viewer_directive_fix.py | 113 ++++++++++++++++++++++++ doc/source/conf.py | 2 + 2 files changed, 115 insertions(+) create mode 100644 doc/source/_ext/viewer_directive_fix.py diff --git a/doc/source/_ext/viewer_directive_fix.py b/doc/source/_ext/viewer_directive_fix.py new file mode 100644 index 0000000000..5b7fbc0dcb --- /dev/null +++ b/doc/source/_ext/viewer_directive_fix.py @@ -0,0 +1,113 @@ +"""Fix for offlineviewer directive to support nested output directories. + +When using atsphinx-mini18n with ``mini18n_build_style = "nested"``, the Japanese +sub-build outputs to ``build/mini18n-html/ja/``. The upstream +``pyvista.ext.viewer_directive`` computes the build root as ``outdir.parent``, which +gives ``build/mini18n-html/`` instead of the correct ``build/``. This causes the +``is_path_relative_to`` check to fail and the directive to return an empty node list, +leaving the "Interactive Scene" tab empty. + +The fix: derive the build root from ``doctreedir.parent`` instead of ``outdir.parent``. +Both the English and Japanese mini18n builds share the same ``doctreedir`` +(``build/.doctrees``), so ``doctreedir.parent = build/`` is correct for both. +""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil + +from docutils import nodes +from docutils.utils import relative_path # pragma: no cover +from sphinx.util import logging +from trame_vtk.tools.vtksz2html import HTML_VIEWER_PATH + +logger = logging.getLogger(__name__) + + +def _is_path_relative_to(path: Path, other: Path) -> bool: + return path.is_relative_to(other) + + +class FixedOfflineViewerDirective: + """Replacement for OfflineViewerDirective with corrected build-root detection.""" + + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + has_content = True + + def run(self): # pragma: no cover + source_dir = Path(self.state.document.settings.env.app.srcdir) + output_dir = Path(self.state.document.settings.env.app.outdir) + # Use doctreedir.parent as the build root so that nested output directories + # (e.g., build/mini18n-html/ja/ produced by atsphinx-mini18n) resolve + # correctly. Both the primary and language sub-builds share the same + # doctreedir (build/.doctrees), so doctreedir.parent == build/ in all cases. + build_dir = Path(self.state.document.settings.env.app.doctreedir).parent + + # path passed to ``.. offlineviewer:: `` + source_file = str(Path(self.state.document.current_source).parent / self.arguments[0]) + source_file = Path(source_file).absolute().resolve() + if not source_file.is_file(): + logger.warning(f'Source file {source_file} does not exist.') + return [] + + # copy viewer HTML to _static + static_path = output_dir / '_static' + static_path.mkdir(exist_ok=True) + viewer_name = Path(HTML_VIEWER_PATH).name + if not (static_path / viewer_name).exists(): + shutil.copy(HTML_VIEWER_PATH, static_path) + + if _is_path_relative_to(source_file, build_dir): + dest_partial_path = source_file.parent.relative_to(build_dir) + elif _is_path_relative_to(source_file, source_dir): + dest_partial_path = source_file.parent.relative_to(source_dir) + else: + logger.warning( + f'Source file {source_file} is not a subpath of either the build ' + f'directory or the source directory. Cannot extract base path.', + ) + return [] + + dest_path = output_dir / '_images' / dest_partial_path + dest_path.mkdir(parents=True, exist_ok=True) + dest_file = (dest_path / source_file.name).resolve() + if source_file != dest_file: + try: + shutil.copy(source_file, dest_file) + except Exception as e: # noqa: BLE001 + logger.warning(f'Failed to copy file from {source_file} to {dest_file}: {e}') + + relpath_to_source_root = relative_path(self.state.document.current_source, source_dir) + rel_viewer_path = ( + Path() / relpath_to_source_root / '_static' / viewer_name + ).as_posix() + rel_asset_path = Path(os.path.relpath(dest_file, static_path)).as_posix() + html = ( + f"" + ) + return [nodes.raw('', html, format='html')] + + +def setup(app): + """Re-register the offlineviewer directive with the fixed implementation.""" + from docutils.parsers.rst import Directive + + # Merge the Directive base class into FixedOfflineViewerDirective at + # registration time so it satisfies docutils' class-hierarchy check. + FixedClass = type( + 'OfflineViewerDirective', + (FixedOfflineViewerDirective, Directive), + {}, + ) + app.add_directive('offlineviewer', FixedClass, override=True) + + return { + 'version': '0.1', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/doc/source/conf.py b/doc/source/conf.py index c603d83df0..967cb1ce44 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -10,6 +10,7 @@ faulthandler.enable() sys.path.insert(0, str(Path(__file__).absolute().parent)) +sys.path.insert(0, str(Path(__file__).absolute().parent / '_ext')) import make_external_gallery # noqa: E402 make_external_gallery.make_example_gallery() @@ -58,6 +59,7 @@ "jupyter_sphinx", "pyvista.ext.plot_directive", "pyvista.ext.viewer_directive", + "viewer_directive_fix", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", From d86a6cda2186430155f03d5054e46143be554c40 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 04:41:38 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/source/_ext/viewer_directive_fix.py | 30 ++++++++++++------------- doc/source/conf.py | 2 +- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/doc/source/_ext/viewer_directive_fix.py b/doc/source/_ext/viewer_directive_fix.py index 5b7fbc0dcb..fdbc5a02b5 100644 --- a/doc/source/_ext/viewer_directive_fix.py +++ b/doc/source/_ext/viewer_directive_fix.py @@ -15,8 +15,8 @@ from __future__ import annotations import os -from pathlib import Path import shutil +from pathlib import Path from docutils import nodes from docutils.utils import relative_path # pragma: no cover @@ -51,11 +51,11 @@ def run(self): # pragma: no cover source_file = str(Path(self.state.document.current_source).parent / self.arguments[0]) source_file = Path(source_file).absolute().resolve() if not source_file.is_file(): - logger.warning(f'Source file {source_file} does not exist.') + logger.warning(f"Source file {source_file} does not exist.") return [] # copy viewer HTML to _static - static_path = output_dir / '_static' + static_path = output_dir / "_static" static_path.mkdir(exist_ok=True) viewer_name = Path(HTML_VIEWER_PATH).name if not (static_path / viewer_name).exists(): @@ -67,30 +67,28 @@ def run(self): # pragma: no cover dest_partial_path = source_file.parent.relative_to(source_dir) else: logger.warning( - f'Source file {source_file} is not a subpath of either the build ' - f'directory or the source directory. Cannot extract base path.', + f"Source file {source_file} is not a subpath of either the build " + f"directory or the source directory. Cannot extract base path.", ) return [] - dest_path = output_dir / '_images' / dest_partial_path + dest_path = output_dir / "_images" / dest_partial_path dest_path.mkdir(parents=True, exist_ok=True) dest_file = (dest_path / source_file.name).resolve() if source_file != dest_file: try: shutil.copy(source_file, dest_file) except Exception as e: # noqa: BLE001 - logger.warning(f'Failed to copy file from {source_file} to {dest_file}: {e}') + logger.warning(f"Failed to copy file from {source_file} to {dest_file}: {e}") relpath_to_source_root = relative_path(self.state.document.current_source, source_dir) - rel_viewer_path = ( - Path() / relpath_to_source_root / '_static' / viewer_name - ).as_posix() + rel_viewer_path = (Path() / relpath_to_source_root / "_static" / viewer_name).as_posix() rel_asset_path = Path(os.path.relpath(dest_file, static_path)).as_posix() html = ( f"" ) - return [nodes.raw('', html, format='html')] + return [nodes.raw("", html, format="html")] def setup(app): @@ -100,14 +98,14 @@ def setup(app): # Merge the Directive base class into FixedOfflineViewerDirective at # registration time so it satisfies docutils' class-hierarchy check. FixedClass = type( - 'OfflineViewerDirective', + "OfflineViewerDirective", (FixedOfflineViewerDirective, Directive), {}, ) - app.add_directive('offlineviewer', FixedClass, override=True) + app.add_directive("offlineviewer", FixedClass, override=True) return { - 'version': '0.1', - 'parallel_read_safe': True, - 'parallel_write_safe': True, + "version": "0.1", + "parallel_read_safe": True, + "parallel_write_safe": True, } diff --git a/doc/source/conf.py b/doc/source/conf.py index 967cb1ce44..d533f36b06 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -10,7 +10,7 @@ faulthandler.enable() sys.path.insert(0, str(Path(__file__).absolute().parent)) -sys.path.insert(0, str(Path(__file__).absolute().parent / '_ext')) +sys.path.insert(0, str(Path(__file__).absolute().parent / "_ext")) import make_external_gallery # noqa: E402 make_external_gallery.make_example_gallery() From 8871aca4118036ce38cc787403d12e99cfa2ac72 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 29 Mar 2026 14:10:25 +0900 Subject: [PATCH 3/3] Fix ruff linting errors in viewer_directive_fix.py - Replace f-strings in logger calls with % formatting (G004) - Move Directive import to module level (PLC0415) - Rename FixedClass variable to snake_case (N806) Co-Authored-By: Claude Sonnet 4.6 --- doc/source/_ext/viewer_directive_fix.py | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/doc/source/_ext/viewer_directive_fix.py b/doc/source/_ext/viewer_directive_fix.py index fdbc5a02b5..2533026082 100644 --- a/doc/source/_ext/viewer_directive_fix.py +++ b/doc/source/_ext/viewer_directive_fix.py @@ -19,6 +19,7 @@ from pathlib import Path from docutils import nodes +from docutils.parsers.rst import Directive from docutils.utils import relative_path # pragma: no cover from sphinx.util import logging from trame_vtk.tools.vtksz2html import HTML_VIEWER_PATH @@ -51,7 +52,7 @@ def run(self): # pragma: no cover source_file = str(Path(self.state.document.current_source).parent / self.arguments[0]) source_file = Path(source_file).absolute().resolve() if not source_file.is_file(): - logger.warning(f"Source file {source_file} does not exist.") + logger.warning("Source file %s does not exist.", source_file) return [] # copy viewer HTML to _static @@ -67,8 +68,9 @@ def run(self): # pragma: no cover dest_partial_path = source_file.parent.relative_to(source_dir) else: logger.warning( - f"Source file {source_file} is not a subpath of either the build " - f"directory or the source directory. Cannot extract base path.", + "Source file %s is not a subpath of either the build " + "directory or the source directory. Cannot extract base path.", + source_file, ) return [] @@ -79,7 +81,7 @@ def run(self): # pragma: no cover try: shutil.copy(source_file, dest_file) except Exception as e: # noqa: BLE001 - logger.warning(f"Failed to copy file from {source_file} to {dest_file}: {e}") + logger.warning("Failed to copy file from %s to %s: %s", source_file, dest_file, e) relpath_to_source_root = relative_path(self.state.document.current_source, source_dir) rel_viewer_path = (Path() / relpath_to_source_root / "_static" / viewer_name).as_posix() @@ -91,18 +93,16 @@ def run(self): # pragma: no cover return [nodes.raw("", html, format="html")] +_fixed_directive_class = type( + "OfflineViewerDirective", + (FixedOfflineViewerDirective, Directive), + {}, +) + + def setup(app): """Re-register the offlineviewer directive with the fixed implementation.""" - from docutils.parsers.rst import Directive - - # Merge the Directive base class into FixedOfflineViewerDirective at - # registration time so it satisfies docutils' class-hierarchy check. - FixedClass = type( - "OfflineViewerDirective", - (FixedOfflineViewerDirective, Directive), - {}, - ) - app.add_directive("offlineviewer", FixedClass, override=True) + app.add_directive("offlineviewer", _fixed_directive_class, override=True) return { "version": "0.1",