This commit is contained in:
2026-10-16 16:59:18 +02:00
parent 750d82eb32
commit bb2d72a050
65 changed files with 3273 additions and 443 deletions

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -1,9 +1,16 @@
{
"name": "LaTeX Dev Container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"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": {
@@ -26,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,15 +125,17 @@
]
}
],
"latex-workshop.latex.recipe.default": "lastUsed"
"latex-workshop.latex.recipe.default": "lastUsed",
// 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"
"jgclark.vscode-todo-highlight"
]
}
},
"remoteUser": "vscode",
"updateRemoteUserUID": true,
"features": {}
}
}

View File

@@ -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"""<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011">
<Metadata>
<Identity Language="en-US" Id="{ident}" Version="{version}" Publisher="{publisher}" />
<DisplayName>{escape(pkg["displayName"])}</DisplayName>
<Description xml:space="preserve">{escape(pkg["description"])}</Description>
<Tags>{escape(",".join(pkg.get("categories", [])))}</Tags>
<Categories>{escape(",".join(pkg.get("categories", [])))}</Categories>
<GalleryFlags>Public</GalleryFlags>
<Properties>
<Property Id="Microsoft.VisualStudio.Code.Engine" Value="{pkg["engines"]["vscode"]}" />
<Property Id="Microsoft.VisualStudio.Code.ExtensionKind" Value="{kinds}" />
</Properties>
</Metadata>
<Installation>
<InstallationTarget Id="Microsoft.VisualStudio.Code" />
</Installation>
<Dependencies />
<Assets>
<Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true" />
</Assets>
</PackageManifest>
"""
# 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(
['<?xml version="1.0" encoding="utf-8"?>',
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
*[f' <Default Extension="{ext}" ContentType="{ctype}" />' for ext, ctype in sorted(defaults.items())],
'</Types>', ''])
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

View File

@@ -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"
}
]
}
}

View File

@@ -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() {} }

View File

@@ -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`."
}
}
}
}
}