diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index f9b1d30..5c561f8 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,54 +1,102 @@
-FROM mcr.microsoft.com/devcontainers/base:ubuntu
+# syntax=docker/dockerfile:1
+FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04
-# Install dependencies for TeX Live installation and usage
-RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
+LABEL org.opencontainers.image.title="LaTeX Dev Container" \
+ org.opencontainers.image.description="Full TeX Live + LaTeX Workshop dev container" \
+ org.opencontainers.image.source="https://github.com/khannurien/latex-devcontainer" \
+ org.opencontainers.image.licenses="MIT"
+
+ARG USERNAME=vscode
+ARG TEXLIVE_YEAR=2026
+# Point this at a frozen snapshot (e.g. the TUG historic tlnet-final tree for a
+# given year) once TEXLIVE_YEAR is no longer the current release.
+ARG TEXLIVE_REPO="https://mirror.ctan.org/systems/texlive/tlnet"
+# Docs are what `texdoc` reads; sources are almost never needed.
+ARG INSTALL_DOCFILES=1
+ARG INSTALL_SRCFILES=0
+
+# Runtime dependencies. Note: biber, latexmk, latexindent and chktex all ship
+# with scheme-full, so they are deliberately NOT installed from apt -- the apt
+# versions routinely disagree with the TeX Live ones (biblatex/biber in
+# particular). What apt provides here is what TeX Live cannot: perl itself, the
+# perl modules latexindent needs, ghostscript for pdfcrop/ps2pdf/epstopdf, and
+# fontconfig so xelatex/lualatex can see system fonts.
+RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
perl \
- biber \
wget \
+ ca-certificates \
fontconfig \
+ ghostscript \
make \
git \
curl \
python3 \
python3-pygments \
+ libyaml-tiny-perl \
+ libfile-homedir-perl \
+ libunicode-linebreak-perl \
+ liblog-log4perl-perl \
+ liblog-dispatch-perl \
&& rm -rf /var/lib/apt/lists/*
-# Set non-root user provided by Microsoft base image
-ARG USERNAME=vscode
-
-# Switch to user home directory
-USER $USERNAME
-WORKDIR /home/$USERNAME
-
-# Set TeX Live paths and update environment
ENV TEXLIVE_INSTALL_PREFIX="/usr/local/texlive"
-ENV TEXLIVE_INSTALL_TEXDIR="${TEXLIVE_INSTALL_PREFIX}/2025"
-ENV PATH="${TEXLIVE_INSTALL_TEXDIR}/bin/x86_64-linux:${PATH}"
+ENV TEXLIVE_INSTALL_TEXDIR="${TEXLIVE_INSTALL_PREFIX}/${TEXLIVE_YEAR}"
-# Download and install TeX Live 2025 as root
-RUN wget -q https://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz && \
- tar -xzf install-tl-unx.tar.gz && \
- rm install-tl-unx.tar.gz && \
- cd install-tl-* && \
- echo "selected_scheme scheme-full" > texlive.profile && \
- echo "TEXDIR ${TEXLIVE_INSTALL_TEXDIR}" >> texlive.profile && \
- echo "TEXMFCONFIG /home/vscode/.texlive2025/texmf-config" >> texlive.profile && \
- echo "TEXMFVAR /home/vscode/.texlive2025/texmf-var" >> texlive.profile && \
- echo "instopt_adjustpath 1" >> texlive.profile && \
- echo "tlpdbopt_autobackup 0" >> texlive.profile && \
- sudo perl ./install-tl --profile=texlive.profile --no-interaction && \
- cd .. && rm -rf install-tl-*
+# Install TeX Live as root. instopt_adjustpath symlinks the binaries into
+# /usr/local/bin, which keeps the image architecture-agnostic.
+RUN set -eux; \
+ cd /tmp; \
+ # mirror.ctan.org redirects to a *different* random mirror on every request, so
+ # resolve it once and take the tarball, its checksum and the packages all from
+ # that same host. Retry because individual mirrors have broken TLS or lag sync.
+ for attempt in 1 2 3 4 5; do \
+ rm -f install-tl-unx.tar.gz install-tl-unx.tar.gz.sha512; \
+ resolved="$(curl -fsSL --retry 3 -o install-tl-unx.tar.gz.sha512 \
+ -w '%{url_effective}' "${TEXLIVE_REPO}/install-tl-unx.tar.gz.sha512")" || continue; \
+ base="${resolved%/*}"; \
+ curl -fsSL --retry 3 -o install-tl-unx.tar.gz "${base}/install-tl-unx.tar.gz" || continue; \
+ if sha512sum -c install-tl-unx.tar.gz.sha512; then TL_REPO="${base}"; break; fi; \
+ echo "attempt ${attempt}: checksum mismatch from ${base}, retrying"; \
+ done; \
+ test -n "${TL_REPO:-}"; \
+ echo "using mirror: ${TL_REPO}"; \
+ tar -xzf install-tl-unx.tar.gz; \
+ cd install-tl-*/; \
+ { \
+ echo "selected_scheme scheme-full"; \
+ echo "TEXDIR ${TEXLIVE_INSTALL_TEXDIR}"; \
+ echo "TEXMFCONFIG /home/${USERNAME}/.texlive${TEXLIVE_YEAR}/texmf-config"; \
+ echo "TEXMFVAR /home/${USERNAME}/.texlive${TEXLIVE_YEAR}/texmf-var"; \
+ echo "TEXMFHOME /home/${USERNAME}/texmf"; \
+ echo "instopt_adjustpath 1"; \
+ echo "tlpdbopt_autobackup 0"; \
+ echo "tlpdbopt_install_docfiles ${INSTALL_DOCFILES}"; \
+ echo "tlpdbopt_install_srcfiles ${INSTALL_SRCFILES}"; \
+ } > texlive.profile; \
+ perl ./install-tl --profile=texlive.profile --no-interaction --repository "${TL_REPO}"; \
+ cd /tmp; rm -rf install-tl-*; \
+ ln -s "$(ls -1 "${TEXLIVE_INSTALL_TEXDIR}/bin" | head -n1)" "${TEXLIVE_INSTALL_TEXDIR}/bin/default"; \
+ mkdir -p "/home/${USERNAME}/.texlive${TEXLIVE_YEAR}/texmf-config" \
+ "/home/${USERNAME}/.texlive${TEXLIVE_YEAR}/texmf-var" \
+ "/home/${USERNAME}/texmf"; \
+ chown -R "${USERNAME}:${USERNAME}" "/home/${USERNAME}/.texlive${TEXLIVE_YEAR}" "/home/${USERNAME}/texmf"
-# User-writeable directories for TeX Live and LuaLaTeX
-ENV TEXMFCONFIG="/home/vscode/.texlive2025/texmf-config"
-ENV TEXMFVAR="/home/vscode/.texlive2025/texmf-var"
-ENV LUAOTFLOAD_HOME="/home/vscode/.texlive2025/texmf-var/luatex-cache"
-ENV LUAOTFLOAD_DB="/home/vscode/.texlive2025/texmf-var/luatex-cache/database.sqlite"
+# Stable, architecture-independent path to the engines.
+ENV PATH="${TEXLIVE_INSTALL_TEXDIR}/bin/default:${PATH}"
+ENV TEXMFCONFIG="/home/${USERNAME}/.texlive${TEXLIVE_YEAR}/texmf-config"
+ENV TEXMFVAR="/home/${USERNAME}/.texlive${TEXLIVE_YEAR}/texmf-var"
+ENV TEXMFHOME="/home/${USERNAME}/texmf"
-# Install latexmk
-RUN sudo tlmgr option autobackup 0 && \
- sudo tlmgr install latexmk && \
- sudo tlmgr path add
+USER $USERNAME
+
+# Warm the luaotfload font database so the first lualatex run isn't a minutes-long
+# font scan, and fail the build early if the toolchain is broken.
+RUN set -eux; \
+ tlmgr --version; \
+ lualatex --version >/dev/null; \
+ biber --version >/dev/null; \
+ latexmk --version >/dev/null; \
+ latexindent --version >/dev/null; \
+ luaotfload-tool --update --quiet
-# Set the working directory back to a neutral location
WORKDIR /workspace
diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json
new file mode 100644
index 0000000..32ffe06
--- /dev/null
+++ b/.devcontainer/devcontainer-lock.json
@@ -0,0 +1,19 @@
+{
+ "features": {
+ "ghcr.io/khannurien/devcontainer-features/claude-code:1": {
+ "version": "1.2.0",
+ "resolved": "ghcr.io/khannurien/devcontainer-features/claude-code@sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9",
+ "integrity": "sha256:17eb69bfd15ff5206c0bff2945935a4349f7423cabee461a0ba2681d52205db9"
+ },
+ "ghcr.io/khannurien/devcontainer-features/opencode:1": {
+ "version": "1.1.0",
+ "resolved": "ghcr.io/khannurien/devcontainer-features/opencode@sha256:8df3f8c98a104ce7e089ba3e8df39e0d3890753c8dc92e03deb54527d30d3574",
+ "integrity": "sha256:8df3f8c98a104ce7e089ba3e8df39e0d3890753c8dc92e03deb54527d30d3574"
+ },
+ "ghcr.io/khannurien/devcontainer-features/rtk:1": {
+ "version": "1.1.0",
+ "resolved": "ghcr.io/khannurien/devcontainer-features/rtk@sha256:4a60b3a1d1b64904de3d856c61cdf7e5943e3c74d114609787dc88d9db7c788f",
+ "integrity": "sha256:4a60b3a1d1b64904de3d856c61cdf7e5943e3c74d114609787dc88d9db7c788f"
+ }
+ }
+}
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index d41169f..de08d3b 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,12 +1,20 @@
{
"name": "LaTeX Dev Container",
- "image": "ghcr.io/khannurien/latex-devcontainer:2025.12",
+ "image": "ghcr.io/khannurien/latex-devcontainer:2026.08",
+ // "build": {
+ // "dockerfile": "Dockerfile",
+ // "context": ".."
+ // },
+ "remoteUser": "vscode",
+ "updateRemoteUserUID": true,
+ // Local extensions, packaged and installed by the script below:
+ // .devcontainer/overleaf-keymap -- Overleaf's keyboard shortcuts
+ // .devcontainer/paste-figure -- Ctrl+V an image -> figure environment
+ "postCreateCommand": "bash .devcontainer/install-extensions.sh",
"customizations": {
"vscode": {
"settings": {
// VSCode text editor settings
- "editor.padding.top": 20,
- "editor.padding.bottom": 20,
"editor.fontSize": 16,
"editor.tabSize": 2,
"editor.lineHeight": 1.8,
@@ -25,7 +33,18 @@
"latex-workshop.intellisense.package.enabled": true,
"latex-workshop.intellisense.unimathsymbols.enabled": true,
"latex-workshop.intellisense.citation.backend": "biblatex",
+ // Linting and formatting (chktex and latexindent ship with TeX Live)
+ "latex-workshop.chktex.enabled": true,
+ "latex-workshop.chktex.run": "onSave",
+ "latex-workshop.formatting.latex": "latexindent",
+ "[latex]": {
+ "editor.formatOnSave": true
+ },
"latex-workshop.latex.outDir": "build",
+ "latex-workshop.latex.clean.subfolder.enabled": true,
+ "search.exclude": {
+ "**/build": true
+ },
"latex-workshop.latex.autoBuild.run": "onFileChange",
"latex-workshop.latex.autoBuild.interval": 3000,
"latex-workshop.synctex.afterBuild.enabled": true,
@@ -107,22 +126,16 @@
}
],
"latex-workshop.latex.recipe.default": "lastUsed",
- // VSCode workbench settings
- "workbench.startupEditor": "none",
- "workbench.sideBar.location": "left",
- "workbench.activityBar.location": "top",
- "workbench.panel.opensMaximized": "always"
+ // Overleaf-style image paste, from .devcontainer/paste-figure. Ctrl+V (or a
+ // drag & drop) of an image into a .tex file writes it here and inserts a
+ // figure environment around it; edit "pasteFigure.template" to change the
+ // block that gets inserted.
+ "pasteFigure.directory": "img"
},
"extensions": [
"james-yu.latex-workshop",
- "wayou.vscode-todo-highlight",
- "Braver.vscode-solarized",
- "hediet.vscode-drawio",
- "mathematic.vscode-pdf"
+ "jgclark.vscode-todo-highlight"
]
}
- },
- "remoteUser": "vscode",
- "updateRemoteUserUID": true,
- "features": {}
+ }
}
diff --git a/.devcontainer/install-extensions.sh b/.devcontainer/install-extensions.sh
new file mode 100644
index 0000000..6177e6e
--- /dev/null
+++ b/.devcontainer/install-extensions.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+# Install this project's local VS Code extensions into the container's VS Code server.
+#
+# devcontainer.json has no place for keyboard shortcuts or editor behaviour beyond
+# settings: customizations.vscode only understands "settings" and "extensions", and
+# keybindings.json is resolved by the VS Code client, not by the server -- so
+# dropping a file into ~/.vscode-server/data/User/ does nothing. What *does* travel
+# with the container is an extension. So each directory listed below is packaged as
+# a .vsix here and installed; edit those directories to change behaviour, this
+# script only ships them.
+set -euo pipefail
+
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+extensions=(
+ overleaf-keymap # Overleaf's keyboard shortcuts
+ paste-figure # Ctrl+V an image -> saved to img/ and wrapped in a figure
+)
+
+# The Dev Containers extension installs customizations.vscode.extensions around
+# the same time postCreateCommand fires, and both writes touch extensions.json.
+# Wait (bounded) for its marker so we don't interleave with it.
+marker="$HOME/.vscode-server/data/Machine/.installExtensionsMarker"
+for _ in $(seq 1 60); do
+ [ -e "$marker" ] && break
+ sleep 1
+done
+
+# The remote CLI isn't on PATH during postCreateCommand; find the server binary.
+code_server="$(ls -1t "$HOME"/.vscode-server/bin/*/bin/code-server 2>/dev/null | head -n1 || true)"
+if [ -z "$code_server" ]; then
+ echo "install-extensions: no VS Code server found, skipping local extensions." >&2
+ exit 0
+fi
+
+build="$(mktemp -d)"
+trap 'rm -rf "$build"' EXIT
+
+for name in "${extensions[@]}"; do
+ src="${here}/${name}"
+ vsix="${build}/${name}.vsix"
+
+ python3 - "$src" "$vsix" <<'PY'
+import json, mimetypes, os, sys, zipfile
+from xml.sax.saxutils import escape
+
+src, out = sys.argv[1], sys.argv[2]
+pkg = json.load(open(f"{src}/package.json"))
+ident, version, publisher = pkg["name"], pkg["version"], pkg["publisher"]
+kinds = ",".join(pkg.get("extensionKind", ["workspace"]))
+
+manifest = f"""
+
+
+
+ {escape(pkg["displayName"])}
+ {escape(pkg["description"])}
+ {escape(",".join(pkg.get("categories", [])))}
+ {escape(",".join(pkg.get("categories", [])))}
+ Public
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+# Every file of the extension goes in, so extensions can ship code, not just a manifest.
+files = []
+for root, _, names in os.walk(src):
+ for name in sorted(names):
+ path = os.path.join(root, name)
+ files.append((path, os.path.relpath(path, src).replace(os.sep, "/")))
+
+defaults = {"json": "application/json", "js": "application/javascript",
+ "vsixmanifest": "text/xml", "xml": "text/xml"}
+for path, _ in files:
+ ext = os.path.splitext(path)[1].lstrip(".").lower()
+ if ext and ext not in defaults:
+ defaults[ext] = mimetypes.guess_type(path)[0] or "application/octet-stream"
+
+content_types = "\n".join(
+ ['',
+ '',
+ *[f' ' for ext, ctype in sorted(defaults.items())],
+ '', ''])
+
+with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
+ z.writestr("extension.vsixmanifest", manifest)
+ z.writestr("[Content_Types].xml", content_types)
+ for path, rel in files:
+ z.write(path, f"extension/{rel}")
+PY
+
+ "$code_server" --install-extension "$vsix" --force
+done
diff --git a/.devcontainer/overleaf-keymap/package.json b/.devcontainer/overleaf-keymap/package.json
new file mode 100644
index 0000000..a356c1b
--- /dev/null
+++ b/.devcontainer/overleaf-keymap/package.json
@@ -0,0 +1,144 @@
+{
+ "name": "overleaf-keymap",
+ "displayName": "Overleaf Keymap",
+ "description": "Makes VS Code's keyboard shortcuts behave like Overleaf's.",
+ "publisher": "local",
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "vscode": "^1.75.0"
+ },
+ "categories": [
+ "Keymaps"
+ ],
+ "extensionKind": [
+ "workspace"
+ ],
+ "contributes": {
+ "keybindings": [
+ {
+ "//": "Overleaf: Ctrl+B -> \\textbf{...}. Only inside LaTeX files, so Ctrl+B still toggles the sidebar everywhere else.",
+ "key": "ctrl+b",
+ "mac": "cmd+b",
+ "command": "latex-workshop.shortcut.textbf",
+ "when": "editorTextFocus && !editorReadonly && editorLangId =~ /^latex$|^latex-expl3$|^doctex$|^bibtex$|^rsweave$/"
+ },
+ {
+ "//": "Overleaf: Ctrl+I -> \\textit{...}",
+ "key": "ctrl+i",
+ "mac": "cmd+i",
+ "command": "latex-workshop.shortcut.textit",
+ "when": "editorTextFocus && !editorReadonly && editorLangId =~ /^latex$|^latex-expl3$|^doctex$|^bibtex$|^rsweave$/"
+ },
+ {
+ "//": "Overleaf: Ctrl+Enter -> compile. (Ctrl+S also compiles here, via latex-workshop.latex.autoBuild.run = onFileChange.)",
+ "key": "ctrl+enter",
+ "mac": "cmd+enter",
+ "command": "latex-workshop.build",
+ "when": "editorTextFocus && !virtualWorkspace && editorLangId =~ /^latex$|^latex-expl3$|^doctex$|^bibtex$|^rsweave$/"
+ },
+ {
+ "//": "Overleaf: Ctrl+D -> delete current line (VS Code default: add selection to next find match).",
+ "key": "ctrl+d",
+ "mac": "cmd+d",
+ "command": "editor.action.deleteLines",
+ "when": "textInputFocus && !editorReadonly"
+ },
+ {
+ "//": "Overleaf: Ctrl+Shift+D -> duplicate selection (VS Code default: show Run and Debug).",
+ "key": "ctrl+shift+d",
+ "mac": "cmd+shift+d",
+ "command": "editor.action.duplicateSelection",
+ "when": "textInputFocus && !editorReadonly"
+ },
+ {
+ "//": "Overleaf: Ctrl+U -> uppercase the selection (VS Code default: soft undo of cursor motion).",
+ "key": "ctrl+u",
+ "mac": "ctrl+u",
+ "command": "editor.action.transformToUppercase",
+ "when": "editorTextFocus && !editorReadonly"
+ },
+ {
+ "//": "Overleaf: Ctrl+Shift+U -> lowercase the selection (VS Code default: focus the Output panel).",
+ "key": "ctrl+shift+u",
+ "mac": "ctrl+shift+u",
+ "command": "editor.action.transformToLowercase",
+ "when": "editorTextFocus && !editorReadonly"
+ },
+ {
+ "//": "Overleaf: Ctrl+Shift+L -> go to line number (VS Code default: select all occurrences of selection).",
+ "key": "ctrl+shift+l",
+ "mac": "cmd+shift+l",
+ "command": "workbench.action.gotoLine",
+ "when": "editorTextFocus"
+ },
+ {
+ "//": "Overleaf: Ctrl+G -> find next (VS Code default: go to line).",
+ "key": "ctrl+g",
+ "mac": "cmd+g",
+ "command": "editor.action.nextMatchFindAction",
+ "when": "editorFocus"
+ },
+ {
+ "//": "Overleaf: Ctrl+Shift+G -> find previous (VS Code default: show Source Control).",
+ "key": "ctrl+shift+g",
+ "mac": "cmd+shift+g",
+ "command": "editor.action.previousMatchFindAction",
+ "when": "editorFocus"
+ },
+ {
+ "//": "Overleaf: F2 -> toggle fold (VS Code default: rename symbol, which LaTeX has no use for).",
+ "key": "f2",
+ "mac": "f2",
+ "command": "editor.toggleFold",
+ "when": "editorTextFocus && editorLangId =~ /^latex$|^latex-expl3$|^doctex$|^bibtex$|^rsweave$/"
+ },
+ {
+ "//": "Overleaf: Alt+Shift+0 -> unfold all",
+ "key": "alt+shift+0",
+ "mac": "cmd+alt+0",
+ "command": "editor.unfoldAll",
+ "when": "editorTextFocus"
+ },
+ {
+ "//": "Overleaf: Alt+Shift+1 -> fold all",
+ "key": "alt+shift+1",
+ "mac": "cmd+alt+1",
+ "command": "editor.foldAll",
+ "when": "editorTextFocus"
+ },
+ {
+ "//": "Overleaf: Alt+Left/Right -> start/end of line (VS Code default: navigate back/forward, still on Ctrl+Alt+- and Ctrl+Shift+-).",
+ "key": "alt+left",
+ "mac": "ctrl+a",
+ "command": "cursorHome",
+ "when": "editorTextFocus"
+ },
+ {
+ "key": "alt+right",
+ "mac": "ctrl+e",
+ "command": "cursorEnd",
+ "when": "editorTextFocus"
+ },
+ {
+ "key": "alt+shift+left",
+ "mac": "ctrl+shift+a",
+ "command": "cursorHomeSelect",
+ "when": "editorTextFocus"
+ },
+ {
+ "key": "alt+shift+right",
+ "mac": "ctrl+shift+e",
+ "command": "cursorEndSelect",
+ "when": "editorTextFocus"
+ },
+ {
+ "//": "Overleaf: Alt+Space -> open autocomplete (Ctrl+Space is already the VS Code default).",
+ "key": "alt+space",
+ "mac": "alt+space",
+ "command": "editor.action.triggerSuggest",
+ "when": "editorTextFocus && !editorReadonly && !suggestWidgetVisible"
+ }
+ ]
+ }
+}
diff --git a/.devcontainer/paste-figure/extension.js b/.devcontainer/paste-figure/extension.js
new file mode 100644
index 0000000..46d8983
--- /dev/null
+++ b/.devcontainer/paste-figure/extension.js
@@ -0,0 +1,196 @@
+// Overleaf-style image paste for LaTeX: Ctrl+V an image into a .tex file and it
+// is written into the project and replaced by a \begin{figure} block.
+//
+// This has to be a paste *provider* rather than one of the usual "paste image"
+// extensions (mushan.vscode-paste-image and friends): those shell out to
+// xclip/pbpaste/powershell on whichever machine runs the extension host, which
+// for a dev container is the container -- where there is no clipboard. VS Code
+// serialises the clipboard payload from the client to the provider instead, so
+// DataTransferItem.asFile() gives us the bytes the user copied on their desktop.
+
+const vscode = require('vscode')
+const path = require('path')
+
+const LANGUAGES = ['latex', 'latex-expl3', 'rsweave', 'doctex']
+
+// What \includegraphics can actually consume, for payloads that carry a filename.
+const IMAGE_EXTENSIONS = new Set([
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tif', '.tiff', '.svg', '.pdf', '.eps'
+])
+
+// Clipboard bitmaps arrive with a mime type and a placeholder name, so the
+// extension has to come from the mime instead.
+const IMAGE_MIMES = new Map([
+ ['image/png', '.png'],
+ ['image/jpeg', '.jpg'],
+ ['image/jpg', '.jpg'],
+ ['image/gif', '.gif'],
+ ['image/webp', '.webp'],
+ ['image/bmp', '.bmp'],
+ ['image/tiff', '.tif'],
+ ['image/svg+xml', '.svg'],
+ ['application/pdf', '.pdf']
+])
+
+// 'files' matches any DataTransferItem backed by a file (drag & drop, or a copy
+// from the OS file manager); the explicit mimes cover clipboard bitmaps.
+const MIME_HINTS = ['files', ...IMAGE_MIMES.keys()]
+
+// Names the OS invents for a clipboard bitmap; not worth keeping.
+const GENERIC_NAMES = new Set(['', 'image', 'images', 'clipboard', 'unnamed', 'untitled', 'download'])
+
+function settings(document) {
+ return vscode.workspace.getConfiguration('pasteFigure', document)
+}
+
+/** First image-ish file in the payload, or undefined -- in which case we let VS Code paste normally. */
+function findImage(dataTransfer) {
+ const candidates = []
+ dataTransfer.forEach((item, mime) => {
+ const file = item.asFile()
+ if (!file) {
+ return
+ }
+ const named = path.extname(file.name || '').toLowerCase()
+ if (IMAGE_EXTENSIONS.has(named)) {
+ candidates.push({ file, ext: named })
+ return
+ }
+ const byMime = IMAGE_MIMES.get(mime.toLowerCase().split(';')[0].trim())
+ if (byMime) {
+ candidates.push({ file, ext: byMime })
+ }
+ })
+ return candidates[0]
+}
+
+function timestamp() {
+ const d = new Date()
+ const pad = n => String(n).padStart(2, '0')
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
+}
+
+/** A filename LaTeX will not choke on: no spaces, no accents, no braces. */
+function slugify(raw) {
+ return raw
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '') // drop the accents NFKD just split off
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+}
+
+function defaultName(file) {
+ const slug = slugify(path.basename(file.name || '', path.extname(file.name || '')))
+ return GENERIC_NAMES.has(slug) ? `pasted-${timestamp()}` : slug
+}
+
+/**
+ * Ask for the image name, pre-filled with the generated one so Enter keeps it.
+ * Whatever is typed goes through the same slugify; an extension typed out of habit
+ * is dropped since the real one comes from the payload. undefined means Escape.
+ */
+async function askName(file, ext, directory, token) {
+ const fallback = defaultName(file)
+ const resolve = input => {
+ const typed = input.trim()
+ const stem = path.extname(typed).toLowerCase() === ext ? typed.slice(0, -ext.length) : typed
+ return slugify(stem) || fallback
+ }
+ const shown = vscode.workspace.asRelativePath(directory, false)
+ const input = await vscode.window.showInputBox({
+ title: 'Pasted image name',
+ prompt: 'Enter keeps the suggested name, Escape cancels the paste.',
+ value: fallback,
+ validateInput: input => ({
+ message: `Saved as ${shown}/${resolve(input)}${ext}`,
+ severity: vscode.InputBoxValidationSeverity.Info
+ })
+ }, token)
+ return input === undefined ? undefined : resolve(input)
+}
+
+/**
+ * Where images live and what \includegraphics paths are relative to. Both are the
+ * workspace folder when there is one -- latexmk runs from there, and the existing
+ * slides use workspace-relative paths like img/logos.png.
+ */
+function locate(document) {
+ const folder = vscode.workspace.getWorkspaceFolder(document.uri)
+ const root = folder ? folder.uri : vscode.Uri.joinPath(document.uri, '..')
+ const relative = settings(document).get('directory') || 'img'
+ return { root, directory: vscode.Uri.joinPath(root, ...relative.split('/').filter(Boolean)) }
+}
+
+async function freeUri(directory, base, ext) {
+ for (let i = 0; i < 1000; i++) {
+ const uri = vscode.Uri.joinPath(directory, i === 0 ? `${base}${ext}` : `${base}-${i}${ext}`)
+ try {
+ await vscode.workspace.fs.stat(uri)
+ } catch {
+ return uri
+ }
+ }
+ throw new Error(`too many files named ${base}${ext} in ${directory.fsPath}`)
+}
+
+/** Save the image next to the document and return the figure snippet to insert. */
+async function insertFigure(document, dataTransfer, token) {
+ const image = findImage(dataTransfer)
+ if (!image) {
+ return undefined
+ }
+
+ const data = await image.file.data()
+ if (token.isCancellationRequested || data.length === 0) {
+ return undefined
+ }
+
+ const { root, directory } = locate(document)
+ const name = await askName(image.file, image.ext, directory, token)
+ if (name === undefined || token.isCancellationRequested) {
+ return undefined
+ }
+
+ await vscode.workspace.fs.createDirectory(directory)
+ const target = await freeUri(directory, name, image.ext)
+ await vscode.workspace.fs.writeFile(target, data)
+
+ const relative = path.relative(root.fsPath, target.fsPath).split(path.sep).join('/')
+ const template = settings(document).get('template')
+ const body = Array.isArray(template) ? template.join('\n') : String(template)
+ // $ and \ are snippet syntax; the path is slugified but the directory may not be.
+ return new vscode.SnippetString(body.replace(/\$IMAGE/g, relative.replace(/[$\\}]/g, '\\$&')))
+}
+
+/** Failures must not swallow the paste: report, then fall back to a normal paste. */
+async function trySnippet(document, dataTransfer, token) {
+ try {
+ return await insertFigure(document, dataTransfer, token)
+ } catch (err) {
+ vscode.window.showErrorMessage(`Could not insert the pasted image: ${err instanceof Error ? err.message : String(err)}`)
+ return undefined
+ }
+}
+
+function activate(context) {
+ const selector = LANGUAGES.map(language => ({ language, scheme: 'file' }))
+ const kind = vscode.DocumentPasteEditKind?.Empty?.append('latex', 'figure')
+
+ context.subscriptions.push(vscode.languages.registerDocumentPasteEditProvider(selector, {
+ async provideDocumentPasteEdits(document, _ranges, dataTransfer, _context, token) {
+ const snippet = await trySnippet(document, dataTransfer, token)
+ return snippet ? [new vscode.DocumentPasteEdit(snippet, 'Insert figure', kind)] : undefined
+ }
+ }, { providedPasteEditKinds: kind ? [kind] : [], pasteMimeTypes: MIME_HINTS }))
+
+ // Same handling for images dragged into the editor.
+ context.subscriptions.push(vscode.languages.registerDocumentDropEditProvider(selector, {
+ async provideDocumentDropEdits(document, _position, dataTransfer, token) {
+ const snippet = await trySnippet(document, dataTransfer, token)
+ return snippet ? new vscode.DocumentDropEdit(snippet, 'Insert figure', kind) : undefined
+ }
+ }, { providedDropEditKinds: kind ? [kind] : [], dropMimeTypes: MIME_HINTS }))
+}
+
+module.exports = { activate, deactivate() {} }
diff --git a/.devcontainer/paste-figure/package.json b/.devcontainer/paste-figure/package.json
new file mode 100644
index 0000000..cca7b89
--- /dev/null
+++ b/.devcontainer/paste-figure/package.json
@@ -0,0 +1,59 @@
+{
+ "name": "latex-paste-figure",
+ "displayName": "LaTeX Paste Figure",
+ "description": "Overleaf-style Ctrl+V: pasting an image into a .tex file saves it and inserts a figure environment.",
+ "publisher": "local",
+ "version": "1.1.0",
+ "license": "MIT",
+ "engines": {
+ "vscode": "^1.97.0"
+ },
+ "categories": [
+ "Other"
+ ],
+ "//": "Must run in the container: the provider writes the image into the workspace.",
+ "extensionKind": [
+ "workspace"
+ ],
+ "capabilities": {
+ "untrustedWorkspaces": {
+ "supported": true
+ }
+ },
+ "main": "./extension.js",
+ "activationEvents": [
+ "onLanguage:latex",
+ "onLanguage:latex-expl3",
+ "onLanguage:rsweave",
+ "onLanguage:doctex"
+ ],
+ "contributes": {
+ "configuration": {
+ "title": "LaTeX Paste Figure",
+ "properties": {
+ "pasteFigure.directory": {
+ "type": "string",
+ "default": "img",
+ "scope": "resource",
+ "markdownDescription": "Folder the pasted image is written to, relative to the workspace root. Created on demand."
+ },
+ "pasteFigure.template": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "scope": "resource",
+ "default": [
+ "\\begin{figure}",
+ "\t\\centering",
+ "\t\\includegraphics[width=${1:0.8\\linewidth}]{$IMAGE}",
+ "\t\\caption{${2:Enter Caption}}",
+ "\t\\label{fig:${3:enter-label}}",
+ "\\end{figure}"
+ ],
+ "markdownDescription": "Lines inserted at the cursor, in [snippet syntax](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax). `$IMAGE` is replaced by the path of the saved image; `${1:...}` are the tab stops you cycle through with `Tab`."
+ }
+ }
+ }
+ }
+}
diff --git a/Slides/README.md b/Slides/README.md
new file mode 100644
index 0000000..5357a07
--- /dev/null
+++ b/Slides/README.md
@@ -0,0 +1,77 @@
+# Slides
+
+Compile `slides.tex` with LuaLaTeX (and Biber for references).
+
+## Partner and funding logos
+
+Enable partners in the preamble, in the desired display order:
+
+```latex
+\partner{labsticc}\partner{ubo}\partner{aid}\partner{ifremer}
+```
+
+Logos for the following partners are included in `img/partners/`:
+
+| Identifier | Partner |
+| --- | --- |
+| `labsticc` | Lab-STICC |
+| `ubo` | Université de Bretagne Occidentale |
+| `ensta` | ENSTA, Institut Polytechnique de Paris |
+| `aid` | Agence de l'innovation de défense |
+| `ifremer` | Ifremer |
+| `lip6` | Laboratoire d'informatique de Sorbonne Université |
+| `sorbonne` | Sorbonne Université |
+| `academia-sinica` | Academia Sinica |
+| `taipei` | University of Taipei |
+
+For example, enable the additional four with:
+
+```latex
+\partner{lip6}\partner{sorbonne}\partner{academia-sinica}\partner{taipei}
+```
+
+Enable ENSTA with `\partner{ensta}`.
+
+Asset sources are recorded in [`img/partners/README.md`](img/partners/README.md).
+Add other individual logos in `img/partners/`, named after their identifier:
+
+```text
+img/partners/labsticc.png
+img/partners/ubo.png
+img/partners/aid.png
+img/partners/ifremer.png
+```
+
+PNG, PDF, JPG and JPEG are supported, searched in that order. All nine bundled
+assets are transparent PNGs. Prefer high-resolution transparent PNGs or vector
+PDFs, tightly cropped to the artwork;
+embedded white space counts towards a logo's dimensions. SVGs should first be
+exported to PDF. The existing `img/logos.png` is a combined UBO/Lab-STICC image,
+not two independently selectable logos.
+
+For other file locations or identifiers, register a path (with or without its
+extension) and then enable the partner:
+
+```latex
+\declarepartner{my-funder}{img/custom-funder.pdf}
+\partner{my-funder}
+```
+
+The title page places the logos at the bottom right, inside the theme's margins.
+Column count, logo sizes, cell sizes, spacing and row wrapping are computed from
+the number of available logos, their aspect ratios, the column width and the
+slide height. Logos have equal bounding-box area for comparable visual weight:
+wide wordmarks are shorter than square seals, rather than dominating the block.
+Aspect ratios are preserved; logos are centred within evenly spaced grid cells.
+The final row is right-aligned and stays within a bounded title-page area.
+Repeated selections are ignored. Missing files produce warnings and are skipped,
+without inserting placeholder logos. Partners do not appear on ordinary slides.
+
+With no partners selected, the existing `\titlegraphic{...}` is still used.
+When partners are selected, they replace the legacy title graphic.
+
+### Additional information
+
+No additional logo files are needed for the nine included partners. If a funder
+requires a specific logo variant, minimum size, ordering or acknowledgement text,
+provide those project-specific requirements. No manual layout dimensions are needed.
diff --git a/Slides/img/partners/README.md b/Slides/img/partners/README.md
new file mode 100644
index 0000000..e4a49b7
--- /dev/null
+++ b/Slides/img/partners/README.md
@@ -0,0 +1,30 @@
+# Partner logo sources
+
+Downloaded from official institutional websites on 2026-10-11. Logos remain the
+property of their respective institutions; availability here does not grant
+unrestricted use or imply endorsement. Follow the applicable branding rules.
+
+| File | Official asset source |
+| --- | --- |
+| `labsticc.png` | [IMT Atlantique / Lab-STICC](https://www.imt-atlantique.fr/sites/default/files/users/user1867/logo-lab-sticc.png) |
+| `ubo.png` | Official inline SVG labelled `Logo UBO` on [UBO's branding page](https://www.univ-brest.fr/fr/page/charte-graphique-ubo-et-logos-disposition-des-partenaires), rendered to a transparent 3,161 × 1,299 PNG at 600 DPI |
+| `ensta.png` | Official blue vertical ENSTA / IP Paris transparent PNG (`Ensta-logotype-05-bleu-rvb.png`) from the [ENSTA RGB logo archive](https://www.ensta.fr/sites/default/files/content/article/pdf/LogoENSTA_RVB.zip), linked from [ENSTA's branding page](https://www.ensta.fr/logo-et-charte-graphique); cropped to 3,638 × 2,286 pixels |
+| `aid.png` | [Ministère des Armées / AID](https://www.defense.gouv.fr/sites/default/files/aid/logo.png), displayed on [AID's RAPID page](https://www.defense.gouv.fr/aid/deposez-votre-projet/rapid-regime-dappui-linnovation-duale) |
+| `ifremer.png` | Inline `logo-ifremer` SVG from [Ifremer](https://www.ifremer.fr/fr); rendered using the site's brand colour `#0032c6` |
+| `lip6.png` | Inline `LIP6` SVG from [LIP6](https://www.lip6.fr/); rendered using `--LIP6_logo_color: #25314F` from the site's `css/common.css` |
+| `sorbonne.png` | [Sorbonne Université SVG](https://www.sorbonne-universite.fr/sites/default/files/media/2019-10/logo_4.svg), rendered to PNG |
+| `academia-sinica.png` | [Academia Sinica](https://www.sinica.edu.tw/images/AS_logo_2.png), referenced by its [official website](https://www.sinica.edu.tw/en) |
+| `taipei.png` | [University of Taipei masthead](https://www.utaipei.edu.tw/var/file/0/1000/img/1583/366369593.jpg), linked from its [official website](https://www.utaipei.edu.tw/) |
+
+All nine files are tightly cropped RGBA PNGs with actual transparent pixels,
+not just an alpha channel containing an opaque white background. Existing alpha
+was preserved. For opaque sources, only white background connected to the outer
+edge was removed; white artwork inside the Academia Sinica seal was retained,
+and antialiased contour pixels were unmatted to avoid white fringes.
+
+The SVG-derived assets were rendered at high resolution (at least 1,900 pixels
+wide). UBO was re-rendered from vector paths, not upscaled from the old 489-pixel
+PNG; its SVG viewport was extended to preserve the descender in “Bretagne”.
+LIP6's white circular website backdrop was omitted, keeping the plain
+logotype; glyph paths, proportions and lettering were not changed. Compilation
+needs no SVG converter, network access or additional shell commands.
diff --git a/Slides/img/partners/academia-sinica.png b/Slides/img/partners/academia-sinica.png
new file mode 100644
index 0000000..ba5f5b9
Binary files /dev/null and b/Slides/img/partners/academia-sinica.png differ
diff --git a/Slides/img/partners/aid.png b/Slides/img/partners/aid.png
new file mode 100644
index 0000000..c0c5209
Binary files /dev/null and b/Slides/img/partners/aid.png differ
diff --git a/Slides/img/partners/ensta.png b/Slides/img/partners/ensta.png
new file mode 100644
index 0000000..9eef4aa
Binary files /dev/null and b/Slides/img/partners/ensta.png differ
diff --git a/Slides/img/partners/ifremer.png b/Slides/img/partners/ifremer.png
new file mode 100644
index 0000000..bbd12d9
Binary files /dev/null and b/Slides/img/partners/ifremer.png differ
diff --git a/Slides/img/partners/labsticc.png b/Slides/img/partners/labsticc.png
new file mode 100644
index 0000000..ca8cbd6
Binary files /dev/null and b/Slides/img/partners/labsticc.png differ
diff --git a/Slides/img/partners/lip6.png b/Slides/img/partners/lip6.png
new file mode 100644
index 0000000..057f886
Binary files /dev/null and b/Slides/img/partners/lip6.png differ
diff --git a/Slides/img/partners/sorbonne.png b/Slides/img/partners/sorbonne.png
new file mode 100644
index 0000000..d519c78
Binary files /dev/null and b/Slides/img/partners/sorbonne.png differ
diff --git a/Slides/img/partners/taipei.png b/Slides/img/partners/taipei.png
new file mode 100644
index 0000000..7ea3c1a
Binary files /dev/null and b/Slides/img/partners/taipei.png differ
diff --git a/Slides/img/partners/ubo.png b/Slides/img/partners/ubo.png
new file mode 100644
index 0000000..8db58dc
Binary files /dev/null and b/Slides/img/partners/ubo.png differ
diff --git a/Slides/slides-partners.sty b/Slides/slides-partners.sty
new file mode 100644
index 0000000..86bead4
--- /dev/null
+++ b/Slides/slides-partners.sty
@@ -0,0 +1,202 @@
+\NeedsTeXFormat{LaTeX2e}
+\ProvidesPackage{slides-partners}[2026/10/11 Automatic title-page partner logos]
+\RequirePackage{graphicx}
+\RequirePackage{expl3}
+\RequirePackage{xparse}
+
+\ExplSyntaxOn
+\seq_new:N \g__slides_partners_seq
+\prop_new:N \g__slides_partner_files_prop
+\seq_new:N \l__slides_partner_files_seq
+\seq_new:N \l__slides_partner_ratios_seq
+\box_new:N \l__slides_partner_box
+\tl_new:N \l__slides_partner_file_tl
+\tl_new:N \l__slides_partner_resolved_tl
+\int_new:N \l__slides_partner_count_int
+\int_new:N \l__slides_partner_columns_int
+\int_new:N \l__slides_partner_rows_int
+\fp_new:N \l__slides_partner_minratio_fp
+\fp_new:N \l__slides_partner_maxratio_fp
+\fp_new:N \l__slides_partner_candidate_fp
+\fp_new:N \l__slides_partner_scale_fp
+\dim_new:N \l__slides_partner_width_dim
+\dim_new:N \l__slides_partner_maxheight_dim
+\dim_new:N \l__slides_partner_xgap_dim
+\dim_new:N \l__slides_partner_ygap_dim
+\dim_new:N \l__slides_partner_cellwidth_dim
+\dim_new:N \l__slides_partner_cellheight_dim
+
+% Selection is ordered and idempotent. File lookup is delayed until rendering,
+% so a declaration can appear before or after its corresponding selection.
+\NewDocumentCommand{\partner}{m}
+ {
+ \seq_if_in:NnF \g__slides_partners_seq {#1}
+ { \seq_gput_right:Nn \g__slides_partners_seq {#1} }
+ }
+\NewDocumentCommand{\declarepartner}{mm}
+ { \prop_gput:Nnn \g__slides_partner_files_prop {#1} {#2} }
+
+\cs_new_protected:Npn \__slides_partners_collect:
+ {
+ \seq_clear:N \l__slides_partner_files_seq
+ \seq_clear:N \l__slides_partner_ratios_seq
+ \seq_map_inline:Nn \g__slides_partners_seq
+ {
+ \prop_get:NnNF \g__slides_partner_files_prop {##1}
+ \l__slides_partner_file_tl
+ { \tl_set:Nn \l__slides_partner_file_tl {img/partners/##1} }
+ \tl_clear:N \l__slides_partner_resolved_tl
+ \file_if_exist:VTF \l__slides_partner_file_tl
+ { \tl_set_eq:NN \l__slides_partner_resolved_tl \l__slides_partner_file_tl }
+ {
+ \clist_map_inline:nn {png,pdf,jpg,jpeg}
+ {
+ \file_if_exist:nT {\l__slides_partner_file_tl .####1}
+ {
+ \tl_set:Nx \l__slides_partner_resolved_tl
+ {\l__slides_partner_file_tl .####1}
+ \clist_map_break:
+ }
+ }
+ }
+ \tl_if_empty:NTF \l__slides_partner_resolved_tl
+ {
+ \PackageWarning{slides-partners}
+ {Logo~for~partner~`##1'~not~found~at~
+ `\tl_use:N \l__slides_partner_file_tl'.~Skipping~it}
+ }
+ {
+ \seq_put_right:NV \l__slides_partner_files_seq
+ \l__slides_partner_resolved_tl
+ \hbox_set:Nn \l__slides_partner_box
+ { \includegraphics{\tl_use:N \l__slides_partner_resolved_tl} }
+ \seq_put_right:Nx \l__slides_partner_ratios_seq
+ {
+ \fp_eval:n
+ { \dim_to_fp:n {\box_wd:N \l__slides_partner_box} /
+ \dim_to_fp:n {\box_ht:N \l__slides_partner_box +
+ \box_dp:N \l__slides_partner_box} }
+ }
+ }
+ }
+ }
+
+% Equal-area logos in equal-width cells give seals and wide wordmarks comparable
+% visual weight. Try every column count, maximizing the common area scale while
+% fitting the tallest and widest logos into the available title-page rectangle.
+\cs_new_protected:Npn \__slides_partners_layout:
+ {
+ \int_set:Nn \l__slides_partner_count_int
+ { \seq_count:N \l__slides_partner_files_seq }
+ \dim_set:Nn \l__slides_partner_width_dim {\linewidth}
+ \dim_set:Nn \l__slides_partner_maxheight_dim {.24\paperheight}
+ \dim_set:Nn \l__slides_partner_xgap_dim
+ { \fp_to_dim:n {min(.04 * \dim_to_fp:n {\linewidth},
+ .25 * \dim_to_fp:n {\linewidth} / max(1,\l__slides_partner_count_int-1))} }
+ \dim_set:Nn \l__slides_partner_ygap_dim
+ { \fp_to_dim:n {min(.015 * \dim_to_fp:n {\paperheight},
+ .25 * \dim_to_fp:n {\l__slides_partner_maxheight_dim} /
+ max(1,\l__slides_partner_count_int-1))} }
+ \fp_set:Nn \l__slides_partner_minratio_fp {inf}
+ \fp_zero:N \l__slides_partner_maxratio_fp
+ \seq_map_inline:Nn \l__slides_partner_ratios_seq
+ {
+ \fp_set:Nn \l__slides_partner_minratio_fp
+ {min(\l__slides_partner_minratio_fp,##1)}
+ \fp_set:Nn \l__slides_partner_maxratio_fp
+ {max(\l__slides_partner_maxratio_fp,##1)}
+ }
+ \fp_zero:N \l__slides_partner_scale_fp
+ \int_set:Nn \l__slides_partner_columns_int {1}
+ \int_step_inline:nnnn {\l__slides_partner_count_int} {-1} {1}
+ {
+ \int_set:Nn \l__slides_partner_rows_int
+ { \fp_eval:n {ceil(\l__slides_partner_count_int / ##1)} }
+ \fp_set:Nn \l__slides_partner_candidate_fp
+ { min(.14 * \dim_to_fp:n {\paperheight},
+ (\dim_to_fp:n {\l__slides_partner_maxheight_dim} -
+ (\l__slides_partner_rows_int-1) *
+ \dim_to_fp:n {\l__slides_partner_ygap_dim}) /
+ \l__slides_partner_rows_int * sqrt(\l__slides_partner_minratio_fp),
+ (\dim_to_fp:n {\l__slides_partner_width_dim} -
+ (##1-1) * \dim_to_fp:n {\l__slides_partner_xgap_dim}) /
+ ##1 / sqrt(\l__slides_partner_maxratio_fp)) }
+ % Break equal-size ties by minimizing unused cells in the final row.
+ \fp_compare:nT
+ { \l__slides_partner_candidate_fp > \l__slides_partner_scale_fp + .0001 ||
+ (abs(\l__slides_partner_candidate_fp - \l__slides_partner_scale_fp) <= .0001 &&
+ ##1 * \l__slides_partner_rows_int <
+ \l__slides_partner_columns_int *
+ ceil(\l__slides_partner_count_int / \l__slides_partner_columns_int)) }
+ {
+ \fp_set_eq:NN \l__slides_partner_scale_fp \l__slides_partner_candidate_fp
+ \int_set:Nn \l__slides_partner_columns_int {##1}
+ }
+ }
+ % Size cells to the widest rendered logo, not the entire available column.
+ % The enclosing row still right-aligns the compact grid in the title footer.
+ \dim_set:Nn \l__slides_partner_cellwidth_dim
+ { \fp_to_dim:n {\l__slides_partner_scale_fp * sqrt(\l__slides_partner_maxratio_fp)} }
+ \dim_set:Nn \l__slides_partner_cellheight_dim
+ { \fp_to_dim:n {\l__slides_partner_scale_fp / sqrt(\l__slides_partner_minratio_fp)} }
+ }
+
+\cs_new_protected:Npn \__slides_partners_typeset:
+ {
+ \vbox
+ {
+ \offinterlineskip
+ \int_step_inline:nn {\l__slides_partner_count_int}
+ {
+ \int_compare:nNnT
+ {\int_mod:nn {##1-1} {\l__slides_partner_columns_int}} = {0}
+ {
+ \int_compare:nNnT {##1} > {1}
+ { \vskip \l__slides_partner_ygap_dim }
+ \hbox_to_wd:nn {\l__slides_partner_width_dim}
+ {
+ \hfil
+ \int_step_inline:nnn {##1}
+ {\int_min:nn {##1+\l__slides_partner_columns_int-1}
+ {\l__slides_partner_count_int}}
+ {
+ \int_compare:nNnT {####1} > {##1}
+ { \hskip \l__slides_partner_xgap_dim }
+ \vbox_to_ht:nn {\l__slides_partner_cellheight_dim}
+ {
+ \vfil
+ \hbox_to_wd:nn {\l__slides_partner_cellwidth_dim}
+ {
+ \hfil
+ \includegraphics[height=\fp_to_dim:n
+ {\l__slides_partner_scale_fp / sqrt(
+ \seq_item:Nn \l__slides_partner_ratios_seq {####1})}]
+ {\seq_item:Nn \l__slides_partner_files_seq {####1}}
+ \hfil
+ }
+ \vfil
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+\NewDocumentCommand{\insertpartnerlogos}{}
+ {
+ \group_begin:
+ \seq_if_empty:NTF \g__slides_partners_seq
+ { \inserttitlegraphic } % Backwards compatibility with existing slides.
+ {
+ \__slides_partners_collect:
+ \seq_if_empty:NF \l__slides_partner_files_seq
+ {
+ \__slides_partners_layout:
+ \__slides_partners_typeset:
+ }
+ }
+ \group_end:
+ }
+\ExplSyntaxOff
+\endinput
diff --git a/Slides/slides.cls b/Slides/slides.cls
index 0d7c9f7..7bc329a 100644
--- a/Slides/slides.cls
+++ b/Slides/slides.cls
@@ -170,6 +170,7 @@
\RequirePackage{amsmath,amssymb,amsfonts}
% resizebox
\RequirePackage{graphicx}
+\RequirePackage{slides-partners}
\RequirePackage{subcaption}
% tables
\RequirePackage{tabularx}
@@ -199,36 +200,24 @@
% https://ctan.math.illinois.edu/macros/latex/contrib/beamer-contrib/themes/moloch/moloch.pdf
\makeatletter
\setbeamertemplate{title page}{
- \begin{minipage}[b][\paperheight]{\textwidth}
- % \vfill%
+ % Use the theme's margins rather than a full-paper-height box, which placed
+ % the footer almost on the page edge. Stretch only the space above the footer.
+ \begin{minipage}[t][\dimexpr\paperheight-2\beamer@leftmargin\relax][s]{\textwidth}
+ \vspace*{.04\paperheight}
\ifx\inserttitle\@empty\else\usebeamertemplate*{title}\fi
\ifx\insertsubtitle\@empty\else\usebeamertemplate*{subtitle}\fi
\vspace{.2cm}
\ifx\insertdate\@empty\else\usebeamertemplate*{date}\fi
\usebeamertemplate*{title separator}
- \vspace{-.2cm}
- \begin{columns}[]
- \column{0.4\linewidth}
- \ifx\beamer@shortauthor\@empty\else\usebeamertemplate*{author}\fi
- \column{0.4\linewidth}
- % \begin{table}[]
- % \begin{flushleft}
- % \small
- % \begin{tabular}{ll}
- % Rapporteur & Blabla \\
- % Rapporteur & Blabla
- % \end{tabular}
- % \end{flushleft}
- % \end{table}
- \end{columns}
- \begin{columns}[b]
- \column{0.4\linewidth}
+ \ifx\beamer@shortauthor\@empty\else\usebeamertemplate*{author}\fi
+ \vfill
+ \begin{columns}[b,onlytextwidth]
+ \column{0.52\linewidth}
\ifx\insertinstitute\@empty\else\usebeamertemplate*{institute}\fi
- \column{0.5\linewidth}
- \vspace{-2cm}
- \ifx\inserttitlegraphic\@empty\else\inserttitlegraphic\fi
+ \column{0.44\linewidth}
+ \insertpartnerlogos
\end{columns}
- \vspace*{.2cm}
+ \vspace*{.02\paperheight}
\end{minipage}
}
\makeatother
diff --git a/Slides/slides.tex b/Slides/slides.tex
index ff49d4b..a3c092a 100644
--- a/Slides/slides.tex
+++ b/Slides/slides.tex
@@ -4,20 +4,28 @@
\title{Présentation}
\subtitle{À propos de...}
\date{\today}
-\titlegraphic{\flushright\includegraphics[height=1.3cm]{img/logos.png}\vspace{.5cm}}
+% Enable partners in display order; logo layout and dimensions are automatic.
+\partner{aid}
+\partner{labsticc}
+\partner{ubo}
+\partner{ensta}
+% \partner{ifremer}
+% \partner{lip6}
+% \partner{sorbonne}
+% \partner{academia-sinica}
+% \partner{taipei}
+% See README.md for supported formats and custom logo paths.
+% Optional legacy title graphic (used only when no partners are enabled):
+% \titlegraphic{\flushright\includegraphics[height=1.3cm]{img/logos.png}\vspace{.5cm}}
\author{
\texorpdfstring{
- \begin{table}[]
- \begin{flushleft}
- \begin{tabular}{ll}
- & Vincent Lannurien~\inst{1} \\
- & Laurent Lemarchand~\inst{1} \\
- & Abdel Malek Toumi~\inst{2} \\
- & Jalil Boukhobza~\inst{2}
- \end{tabular}
- \end{flushleft}
- \end{table}
+ \begin{tabular}{@{}l@{}}
+ Vincent Lannurien~\inst{1} \\
+ Laurent Lemarchand~\inst{1} \\
+ Abdel Malek Toumi~\inst{2} \\
+ Jalil Boukhobza~\inst{2}
+ \end{tabular}
}
{
Vincent Lannurien \and
@@ -29,19 +37,13 @@
\institute{
\texorpdfstring{
- \begin{table}[]
- \centering
- \begin{flushleft}
- \begin{tabular}{L}
- \inst{1}~Lab-STICC, CNRS UMR 6285, Université de Bretagne Occidentale, Brest
- \inst{2}~Lab-STICC, CNRS UMR 6285, ENSTA, Institut Polytechnique de Paris, Brest \\
- \end{tabular}
- \end{flushleft}
- \end{table}
+ \raggedright
+ \inst{1}~Lab-STICC, CNRS, UMR 6285, Université de Bretagne Occidentale, Brest\par\medskip
+ \inst{2}~Lab-STICC, CNRS, UMR 6285, ENSTA, Institut Polytechnique de Paris, Brest
}
{
- Lab-STICC, CNRS UMR 6285, ENSTA, Institut Polytechnique de Paris, Brest \and
- Lab-STICC, CNRS UMR 6285, Université de Bretagne Occidentale, Brest
+ Lab-STICC, CNRS, UMR 6285, ENSTA, Institut Polytechnique de Paris, Brest \and
+ Lab-STICC, CNRS, UMR 6285, Université de Bretagne Occidentale, Brest
}
}