Repository: zauberzeug/nicegui
Pull request #3860 (2024-10-11 05:42:13)

Add demo about LaTeX formulas in `ui.markdown`
As @motorst1 noticed in https://github.com/zauberzeug/nicegui/discussions/696#discussioncomment-10909097, `ui.markdown` can display LaTeX formulas. This PR adds a demo with notes about required packages.

Open tasks:

- [x] add latex2mathml to release.dockerfile? (and somewhere else?)
- [x] raise markdown2 requirement to version >=2.5? --> No, we leave it as it is.



Patch:
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index cff50b49..e35defd5 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -29,6 +29,7 @@ ENV CHROME_BINARY_LOCATION=/usr/bin/chromium
 RUN pip install -U pip && pip install poetry==$POETRY_VERSION
 COPY pyproject.toml poetry.lock README.md ./
 RUN poetry install --all-extras --no-root
+RUN pip install latex2mathml
 
 USER $USERNAME
 
diff --git a/development.dockerfile b/development.dockerfile
index eaa60e34..c4c17109 100644
--- a/development.dockerfile
+++ b/development.dockerfile
@@ -13,4 +13,6 @@ WORKDIR /app
 COPY . .
 RUN poetry install --all-extras
 
+RUN pip install latex2mathml
+
 CMD python3 -m debugpy --listen 5678 main.py
diff --git a/fly.dockerfile b/fly.dockerfile
index 283057cd..8357e3b9 100644
--- a/fly.dockerfile
+++ b/fly.dockerfile
@@ -29,6 +29,8 @@ COPY pyproject.toml poetry.lock*  ./
 
 RUN poetry install --no-root --extras "plotly matplotlib highcharts sass"
 
+RUN pip install latex2mathml
+
 ADD . .
 
 # ensure unique version to not serve cached and hence potentially wrong static files
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index 4cd7e29e..312a6402 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -9,7 +9,6 @@ from typing import Any, Awaitable, Callable, List, Optional, Union
 
 from fastapi import FastAPI, HTTPException, Request, Response
 from fastapi.responses import FileResponse
-from fastapi.staticfiles import StaticFiles
 
 from .. import background_tasks, helpers
 from ..client import Client
@@ -17,6 +16,7 @@ from ..logging import log
 from ..native import NativeConfig
 from ..observables import ObservableSet
 from ..server import Server
+from ..staticfiles import CacheControlledStaticFiles
 from ..storage import Storage
 from .app_config import AppConfig
 from .range_response import get_range_response
@@ -157,7 +157,7 @@ class App(FastAPI):
         if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
 
-        handler = StaticFiles(directory=local_directory, follow_symlink=follow_symlink)
+        handler = CacheControlledStaticFiles(directory=local_directory, follow_symlink=follow_symlink)
 
         @self.get(url_path + '/{path:path}')
         async def static_file(request: Request, path: str = '') -> Response:
diff --git a/nicegui/elements/scene_object3d.py b/nicegui/elements/scene_object3d.py
index 5cf508bc..67a133ed 100644
--- a/nicegui/elements/scene_object3d.py
+++ b/nicegui/elements/scene_object3d.py
@@ -196,10 +196,14 @@ class Object3D:
             self._draggable()
         return self
 
+    @property
+    def children(self) -> List[Object3D]:
+        """List of children of the object."""
+        return [object for object in self.scene.objects.values() if object.parent == self]
+
     def delete(self) -> None:
         """Delete the object."""
-        children = [object for object in self.scene.objects.values() if object.parent == self]
-        for child in children:
+        for child in self.children:
             child.delete()
         del self.scene.objects[self.id]
         self._delete()
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 72878a9d..456027eb 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -9,7 +9,6 @@ import socketio
 from fastapi import HTTPException, Request
 from fastapi.middleware.gzip import GZipMiddleware
 from fastapi.responses import FileResponse, Response
-from fastapi.staticfiles import StaticFiles
 
 from . import air, background_tasks, binding, core, favicon, helpers, json, run, welcome
 from .app import App
@@ -21,6 +20,7 @@ from .logging import log
 from .middlewares import RedirectWithPrefixMiddleware
 from .page import page
 from .slot import Slot
+from .staticfiles import CacheControlledStaticFiles
 from .version import __version__
 
 
@@ -56,7 +56,7 @@ mimetypes.add_type('text/css', '.css')
 
 app.add_middleware(GZipMiddleware)
 app.add_middleware(RedirectWithPrefixMiddleware)
-static_files = StaticFiles(
+static_files = CacheControlledStaticFiles(
     directory=(Path(__file__).parent / 'static').resolve(),
     follow_symlink=True,
 )
diff --git a/nicegui/scripts/pack.py b/nicegui/scripts/pack.py
index 7bb87e01..347d60e8 100755
--- a/nicegui/scripts/pack.py
+++ b/nicegui/scripts/pack.py
@@ -41,7 +41,9 @@ def main() -> None:
     parser.add_argument('--add-data', type=str, action='append', default=[
         f'{Path(nicegui.__file__).parent}{os.pathsep}nicegui',
     ], help='Include additional data.')
-    parser.add_argument('--dry-run', action='store_true', help='Dry run', default=False)
+    parser.add_argument('--icon', type=str, help='Path to an icon file.')
+    parser.add_argument('--osx-bundle-identifier', type=str, help='Mac OS .app bundle identifier.')
+    parser.add_argument('--dry-run', action='store_true', help='Dry run.', default=False)
     parser.add_argument('main', default='main.py', help='Main file which calls `ui.run()`.')
     args = parser.parse_args()
 
@@ -57,6 +59,11 @@ def main() -> None:
         command.append('--onefile')
     for data in args.add_data:
         command.extend(['--add-data', data])
+    if args.icon:
+        command.extend(['--icon', args.icon])
+    if args.osx_bundle_identifier:
+        command.extend(['--osx-bundle-identifier', args.osx_bundle_identifier])
+
     command.extend([args.main])
 
     print('PyInstaller command:')
diff --git a/nicegui/staticfiles.py b/nicegui/staticfiles.py
new file mode 100644
index 00000000..731beaba
--- /dev/null
+++ b/nicegui/staticfiles.py
@@ -0,0 +1,11 @@
+from starlette.responses import Response
+from starlette.staticfiles import StaticFiles
+from starlette.types import Scope
+
+
+class CacheControlledStaticFiles(StaticFiles):
+
+    async def get_response(self, path: str, scope: Scope) -> Response:
+        response = await super().get_response(path, scope)
+        response.headers['Cache-Control'] = 'public, max-age=3600'
+        return response
diff --git a/release.dockerfile b/release.dockerfile
index eea0b52f..12506482 100644
--- a/release.dockerfile
+++ b/release.dockerfile
@@ -26,6 +26,7 @@ RUN python -m pip install \
     itsdangerous \
     pytest \
     requests \
+    latex2mathml \
     selenium
 
 WORKDIR /app
diff --git a/website/documentation/content/markdown_documentation.py b/website/documentation/content/markdown_documentation.py
index 2a1b80ae..28d7532b 100644
--- a/website/documentation/content/markdown_documentation.py
+++ b/website/documentation/content/markdown_documentation.py
@@ -55,6 +55,18 @@ def markdown_tables():
     ''', extras=['tables'])
 
 
+@doc.demo('LaTeX formulas', '''
+    By activating the "latex" extra, you can use LaTeX formulas.
+    This requires markdown2 version >=2.5 as well as latex2mathml to be installed.
+''')
+def markdown_latex():
+    ui.markdown(r'''
+        Euler's identity:
+
+        $$e^{i\pi} = -1$$
+    ''', extras=['latex'])
+
+
 @doc.demo('Change Markdown content', '''
     You can change the content of a Markdown element by setting its `content` property or calling `set_content`.
 ''')


Test patch:
diff --git a/tests/test_serving_files.py b/tests/test_serving_files.py
index 131dd2f0..ebca6e85 100644
--- a/tests/test_serving_files.py
+++ b/tests/test_serving_files.py
@@ -61,6 +61,7 @@ def test_get_from_static_files_dir(url_path: str, screen: Screen):
     with httpx.Client() as http_client:
         r = http_client.get(f'http://localhost:{Screen.PORT}/static/examples/slideshow/slides/slide1.jpg')
         assert r.status_code == 200
+        assert 'max-age=' in r.headers['Cache-Control']
 
 
 def test_404_for_non_existing_static_file(screen: Screen):