mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808e97c9ec | ||
|
|
f54bf1c0db | ||
|
|
868bed0ed9 | ||
|
|
cce828e3d7 | ||
|
|
654191dcd4 | ||
|
|
c0d538fba2 | ||
|
|
e0ac6170f4 |
@@ -8,6 +8,3 @@ server-ce/test
|
||||
services/web/scripts/plan-prices
|
||||
services/web/scripts/translations
|
||||
services/web/scripts/ukamf
|
||||
|
||||
# local LanguageTool ngrams data (deploy-only, not part of the image)
|
||||
tools/languagetools_server_compose
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# replacement for quay.io/sharelatex/pdftocairo:24.02 (clsitex PDF→JPEG)
|
||||
#
|
||||
# ALIASES: pdftocairo-ol:25.12.0
|
||||
#
|
||||
# alpine:3.24 with poppler (25.12.0, >= 24.02 required by CLSI).
|
||||
#
|
||||
# Why: CLSI convertPDFToJPEG runs `pdftocairo -jpeg ...` against this image
|
||||
# for preview (794px, q90) and thumbnail (190px, q50) JPEGs of compiled PDFs.
|
||||
#
|
||||
# The SaaS image is Ubuntu-based; alpine 3.24's poppler 25.12.0 covers exactly
|
||||
# the flags CLSI uses (-jpeg -jpegopt -singlefile -scale-to-x -scale-to-y), and
|
||||
# the image is tiny.
|
||||
#
|
||||
# images/pandoc-amd64/Dockerfile is the sibling (pandoc/core + zip). Same pattern:
|
||||
# ENTRYPOINT [] because CLSI docker-runs with an explicit command (no
|
||||
# --entrypoint override).
|
||||
|
||||
FROM alpine:3.24
|
||||
|
||||
ENTRYPOINT []
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache poppler-utils \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
@@ -1,51 +0,0 @@
|
||||
# replacement for quay.io/sharelatex/png2pdf:2026-06-24 (clsitex PNG→PDF)
|
||||
#
|
||||
# Engine: img2pdf (josch) — lossless. For PNG input the IDAT streams are
|
||||
# embedded verbatim into the PDF (no re-encode); the alpha channel becomes
|
||||
# a PDF soft mask (/SMask), also lossless. That is the mechanism of the
|
||||
# whole feature: pdfTeX copies PDF image streams cheaply, while PNGs with
|
||||
# alpha/gamma/palette/interlace are re-decoded on every compile, which is
|
||||
# what made them "slow" and what fills CLSI's slow-PNG list.
|
||||
#
|
||||
# The SaaS tool is proprietary. The Go port (go/services/clsitex/png2pdf)
|
||||
# pins the exact container contract this image fulfils:
|
||||
# ENTRYPOINT + ["--in-place", "--", "<relpath>", ...] CWD=/compile
|
||||
# stdout: one "Converted <f> to PDF" line per file (counted by Go)
|
||||
# exit non-zero on first failure (CLSI falls back to the original PNGs)
|
||||
#
|
||||
# Why git@commit instead of `pip install img2pdf==0.6.3`:
|
||||
# 0.6.3 (2025-11-06) is the last PyPI release, and it PRE-DATES the
|
||||
# parse_png quadratic-rewrite fix (2025-11-16, "my 311MB ARCHD at 600dpi
|
||||
# with 17638 IDAT records went from many minutes to 320ms"). The slow-PNG
|
||||
# list is populated by BIG PNGs (min 1MB, no upper bound), so we pin main
|
||||
# at this commit: it is still reproducible (exact hash) and carries the
|
||||
# fix plus the JPEG2000/ICC and pikepdf-writer-close follow-ups.
|
||||
#
|
||||
# pikepdf + pillow have musllinux cp314 wheels for this python version, so
|
||||
# no compiler is needed; git is needed only for the pinned checkout.
|
||||
|
||||
FROM python:3.14-alpine
|
||||
|
||||
ARG IMG2PDF_REF=0fafa2c312be50c68a07e2331a89bf4adbd274ed
|
||||
|
||||
RUN apk update \
|
||||
&& apk add --no-cache git \
|
||||
&& pip install --no-cache-dir --disable-pip-version-check \
|
||||
"git+https://gitlab.mister-muffin.de/josch/img2pdf.git@${IMG2PDF_REF}#egg=img2pdf" \
|
||||
pikepdf==10.15.0 \
|
||||
pillow==12.3.0 \
|
||||
packaging \
|
||||
&& rm -rf /root/.cache/pip /usr/src/python/lib*
|
||||
|
||||
# shim: arg parsing, leading-dash path handling (paths derive from URL
|
||||
# paths and start with '-'), temp-file + atomic replace (input must survive
|
||||
# a failed conversion — CLSI renames the untouched original as fallback),
|
||||
# and the "Converted <f> to PDF" stdout line Go counts.
|
||||
COPY server-ce/png2pdf/png2pdf.sh /usr/local/bin/png2pdf
|
||||
RUN chmod 0755 /usr/local/bin/png2pdf
|
||||
|
||||
# The Go runner passes the arg list ["--in-place", "--", ...] as the container
|
||||
# command, never a binary name — see the pdftocairo image (ENTRYPOINT []) for
|
||||
# the contrast: that image provides the binary NAME in its command, this one
|
||||
# relies on the image's own ENTRYPOINT.
|
||||
ENTRYPOINT ["/usr/local/bin/png2pdf"]
|
||||
+4
-36
@@ -14,30 +14,23 @@ export OVERLEAF_TAG ?= sharelatex/sharelatex:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_
|
||||
export OVERLEAF_PANDOC_BRANCH ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)
|
||||
export OVERLEAF_PANDOC_LATEST ?= sharelatex/sharelatex-pandoc
|
||||
export OVERLEAF_PANDOC_TAG ?= sharelatex/sharelatex-pandoc:$(BRANCH_NAME_TAG_SAFE)-$(MONOREPO_REVISION)
|
||||
export OVERLEAF_PDFTOCAIRO ?= sharelatex/pdftocairo:24.02
|
||||
export OVERLEAF_PNG2PDF ?= sharelatex/png2pdf:2026-06-24
|
||||
|
||||
|
||||
all: build-base build-community build-pandoc build-pdftocairo build-png2pdf
|
||||
all: build-base build-community build-pandoc
|
||||
|
||||
clean:
|
||||
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_TAG) $(OVERLEAF_PDFTOCAIRO) $(OVERLEAF_PNG2PDF)
|
||||
-docker rmi --force $(OVERLEAF_BASE_TAG) $(OVERLEAF_TAG) $(OVERLEAF_PANDOC_TAG)
|
||||
|
||||
refresh-cache: refresh-cache-branch
|
||||
refresh-cache-branch:
|
||||
docker inspect $(OVERLEAF_BASE_BRANCH) > /dev/null && docker pull $(OVERLEAF_BASE_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_BRANCH) > /dev/null && docker pull $(OVERLEAF_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_PANDOC_BRANCH) > /dev/null && docker pull $(OVERLEAF_PANDOC_BRANCH) || true
|
||||
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
|
||||
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
|
||||
|
||||
refresh-cache: refresh-cache-latest
|
||||
refresh-cache-latest:
|
||||
docker inspect $(OVERLEAF_BASE_LATEST) > /dev/null && docker pull $(OVERLEAF_BASE_LATEST) || true
|
||||
docker inspect $(OVERLEAF_LATEST) > /dev/null && docker pull $(OVERLEAF_LATEST) || true
|
||||
docker inspect $(OVERLEAF_PANDOC_LATEST) > /dev/null && docker pull $(OVERLEAF_PANDOC_LATEST) || true
|
||||
docker inspect $(OVERLEAF_PDFTOCAIRO) > /dev/null && docker pull $(OVERLEAF_PDFTOCAIRO) || true
|
||||
docker inspect $(OVERLEAF_PNG2PDF) > /dev/null && docker pull $(OVERLEAF_PNG2PDF) || true
|
||||
|
||||
build-base:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
@@ -52,6 +45,7 @@ build-base:
|
||||
--tag $(OVERLEAF_BASE_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
|
||||
build-community:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
@@ -80,30 +74,6 @@ build-pandoc:
|
||||
--tag $(OVERLEAF_PANDOC_BRANCH) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-pdftocairo:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PDFTOCAIRO) \
|
||||
--file Dockerfile-pdftocairo \
|
||||
--tag $(OVERLEAF_PDFTOCAIRO) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
build-png2pdf:
|
||||
cp .dockerignore $(MONOREPO_ROOT)
|
||||
docker build \
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1 \
|
||||
--progress=plain \
|
||||
--build-arg OVERLEAF_BASE_TAG \
|
||||
--label "com.overleaf.ce.revision=$(MONOREPO_REVISION)" \
|
||||
--cache-from $(OVERLEAF_PNG2PDF) \
|
||||
--file Dockerfile-png2pdf \
|
||||
--tag $(OVERLEAF_PNG2PDF) \
|
||||
$(MONOREPO_ROOT)
|
||||
|
||||
push:
|
||||
docker push $(OVERLEAF_BASE_TAG)
|
||||
docker push $(OVERLEAF_BASE_BRANCH)
|
||||
@@ -111,8 +81,6 @@ push:
|
||||
docker push $(OVERLEAF_BRANCH)
|
||||
docker push $(OVERLEAF_PANDOC_TAG)
|
||||
docker push $(OVERLEAF_PANDOC_BRANCH)
|
||||
docker push $(OVERLEAF_PDFTOCAIRO)
|
||||
docker push $(OVERLEAF_PNG2PDF)
|
||||
|
||||
SHELLCHECK_OPTS = \
|
||||
--shell=bash \
|
||||
@@ -134,5 +102,5 @@ shellcheck_fix:
|
||||
done
|
||||
|
||||
.PHONY: all \
|
||||
build-base build-community build-pandoc build-pdftocairo build-png2pdf\
|
||||
build-base build-community build-pandoc\
|
||||
shellcheck shellcheck_fix
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/sh
|
||||
# CLSI "png2pdf" container contract shim (engine: img2pdf 0.6.3, josch).
|
||||
# Replaces the proprietary quay.io/sharelatex/png2pdf:2026-06-24.
|
||||
#
|
||||
# Invoked by the CLSI docker runner (go/services/clsitex/dockerrunner.go, go/services/clsitex/png2pdf/png2pdf.go):
|
||||
# args: ["--in-place", "--", "<relpath1>", ...] CWD=/compile
|
||||
# the docker runner does NOT supply a binary name in the command, so the
|
||||
# image ENTRYPOINT is this script.
|
||||
#
|
||||
# Paths: each arg is a conversion path CLSI downloaded, RELATIVE to the
|
||||
# mounted cache dir (CWD /compile). Real CLSI derives it from the file
|
||||
# URL path (u.Path: '/'->'-') + mtime + ".opt<10-hex cacheKey>",
|
||||
# e.g. ".-uploads-filestore-a1b2c3d4-0-1-1756600000000.opta1b2c3d4a1bc"
|
||||
# (leading dash, no directory separator).
|
||||
#
|
||||
# "IN-PLACE" means "rewrite each input PATH as a PDF". CLSI then renames
|
||||
# each path to its own ".opt" cache entry and copies it into the compile
|
||||
# dir (urlcache.CommitConversion) — PER FILE and UNCONDITIONALLY (sync.go:
|
||||
# a conversion error only logs a warning). So each converted file lands
|
||||
# in the cache even in a partially-failed run; an unconverted file is
|
||||
# committed as the original-PNG fallback.
|
||||
#
|
||||
# Contract (go/services/clsitex/png2pdf/png2pdf.go):
|
||||
# - stdout: one "Converted <f> to PDF" per converted file (Go counts
|
||||
# lines matching ^Converted .+ to PDF$ -> stats.png2pdf).
|
||||
# - exit non-zero if anything fails: CLSI logs a warning and commits
|
||||
# each file's current on-disk state (converted or original).
|
||||
#
|
||||
# Why img2pdf: LOSSLESS. PNG IDAT is embedded verbatim into the PDF (no
|
||||
# re-encode); the alpha channel becomes a PDF /SMask, also lossless.
|
||||
# That is the mechanism of the feature: pdfTeX copies PDF image streams
|
||||
# cheaply, whereas PNGs with alpha/gamma/palette/interlace are re-decoded
|
||||
# from the PNG on every compile — what made them "slow". Without
|
||||
# --imgsize the image keeps its pHYs DPI, falling back to 96dpi (the same
|
||||
# default pdfLaTeX itself uses).
|
||||
#
|
||||
# Failure handling: convert to a temp + atomic replace, and CONTINUE with
|
||||
# the remaining files after a failure (their valid conversions are
|
||||
# committed by CLSI even though the run is reported failed). A failed
|
||||
# file stays exactly the original PNG CLSI downloaded.
|
||||
|
||||
set -u
|
||||
|
||||
# argv is exactly [--in-place]... ["--"] <files...>. The loop terminates:
|
||||
# every iteration either shifts, breaks, or exits.
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--) shift ; break ;;
|
||||
--in-place) shift ;;
|
||||
*) echo "png2pdf: unexpected argument: $1" >&2 ; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "png2pdf: no input files (usage: --in-place -- <png>...)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
rc=0
|
||||
for file in "$@"; do
|
||||
# Resolve against CWD so a leading '-' (or '.') never lands in an argv
|
||||
# slot of img2pdf/mv, where it would be parsed as an option.
|
||||
case "$file" in
|
||||
/ | /*) abs="$file" ;;
|
||||
*) abs="$(pwd)/$file" ;;
|
||||
esac
|
||||
tmp="${abs}.tmp"
|
||||
|
||||
if ! img2pdf --output "$tmp" "$abs"; then
|
||||
echo "png2pdf: conversion failed for $file" >&2
|
||||
rm -f -- "$tmp"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
if ! mv -- "$tmp" "$abs"; then
|
||||
echo "png2pdf: failed to replace $file" >&2
|
||||
rm -f -- "$tmp"
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
echo "Converted $file to PDF"
|
||||
done
|
||||
exit "$rc"
|
||||
@@ -1,18 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Grant the service user access to the Docker socket group, same as the CLSI
|
||||
# run script. Needed for CONVERTER=pdftocairo-sandbox (filestore launches the
|
||||
# sibling conversion container through /var/run/docker.sock). Idempotent: a
|
||||
# fresh container may have no such group yet, and CLSI's run script does the
|
||||
# same thing, so just append to the membership if it exists.
|
||||
if [ -e '/var/run/docker.sock' ]; then
|
||||
DOCKER_GROUP=$(stat -c '%g' /var/run/docker.sock)
|
||||
if ! getent group dockeronhost >/dev/null; then
|
||||
groupadd --non-unique --gid "${DOCKER_GROUP}" dockeronhost
|
||||
fi
|
||||
usermod -aG dockeronhost www-data
|
||||
fi
|
||||
|
||||
source /etc/overleaf/env.sh
|
||||
export LISTEN_ADDRESS=127.0.0.1
|
||||
|
||||
|
||||
@@ -48,20 +48,7 @@ const DockerRunner = {
|
||||
image = `${Settings.texliveImageNameOveride}/${img}`
|
||||
}
|
||||
|
||||
if (
|
||||
Settings.path.sandboxedCompilesHostDirCache &&
|
||||
directory.startsWith(Settings.path.clsiCacheDir + Path.sep)
|
||||
) {
|
||||
// In: directory = '<clsiCacheDir>/projectId'
|
||||
// sandboxedCompilesHostDirCache = '/host/cache'
|
||||
// Out: directory = '/host/cache/projectId'
|
||||
// (png2pdf & friends operate on CLSI's per-project cache dir, which
|
||||
// is not under the compiles dir)
|
||||
directory = Path.join(
|
||||
Settings.path.sandboxedCompilesHostDirCache,
|
||||
Path.basename(directory)
|
||||
)
|
||||
} else if (compileGroup === 'synctex-output') {
|
||||
if (compileGroup === 'synctex-output') {
|
||||
// In: directory = '/overleaf/services/clsi/output/projectId-userId/generated-files/buildId'
|
||||
// directory.split('/').slice(-3) === 'projectId-userId/generated-files/buildId'
|
||||
// sandboxedCompilesHostDirOutput = '/host/output'
|
||||
@@ -106,35 +93,32 @@ const DockerRunner = {
|
||||
// logOptions = _.clone(options)
|
||||
// logOptions?.HostConfig?.SecurityOpt = "secomp used, removed in logging"
|
||||
logger.debug({ projectId }, 'running docker container')
|
||||
// Bounded retry: a permanent container create/start failure (e.g.
|
||||
// "unable to find user" on a FROM-scratch image) must not busy-loop
|
||||
// forever — surface the error to the caller after a couple of attempts.
|
||||
let retriesLeft = 2
|
||||
const runAttempt = () => {
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
(error, output) => {
|
||||
if (error && error.statusCode === 500 && retriesLeft > 0) {
|
||||
retriesLeft -= 1
|
||||
logger.debug(
|
||||
{ err: error, projectId, retriesLeft },
|
||||
'error running container so destroying and retrying'
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
(error, output) => {
|
||||
if (error && error.statusCode === 500) {
|
||||
logger.debug(
|
||||
{ err: error, projectId },
|
||||
'error running container so destroying and retrying'
|
||||
)
|
||||
DockerRunner.destroyContainer(name, null, true, error => {
|
||||
if (error != null) {
|
||||
return callback(error)
|
||||
}
|
||||
DockerRunner._runAndWaitForContainer(
|
||||
options,
|
||||
volumes,
|
||||
timeout,
|
||||
callback
|
||||
)
|
||||
DockerRunner.destroyContainer(name, null, true, err => {
|
||||
if (err != null) {
|
||||
return callback(err)
|
||||
}
|
||||
runAttempt()
|
||||
})
|
||||
} else {
|
||||
callback(error, output)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
callback(error, output)
|
||||
}
|
||||
)
|
||||
}
|
||||
runAttempt()
|
||||
}
|
||||
)
|
||||
|
||||
// pass back the container name to allow it to be killed
|
||||
return name
|
||||
|
||||
@@ -172,15 +172,7 @@ if ((process.env.DOCKER_RUNNER || process.env.SANDBOXED_COMPILES) === 'true') {
|
||||
wordcount: { 'HostConfig.AutoRemove': true },
|
||||
synctex: { 'HostConfig.AutoRemove': true },
|
||||
'synctex-output': { 'HostConfig.AutoRemove': true },
|
||||
// The pdftocairo image is also built FROM scratch with no passwd file,
|
||||
// so the default named user (www-data) fails at container create with
|
||||
// "unable to find user ... no matching entries in passwd file". Run it
|
||||
// as the numeric uid/gid of this clsi process (which owns the files it
|
||||
// converts), same as the png2pdf image below.
|
||||
conversions: {
|
||||
'HostConfig.AutoRemove': true,
|
||||
User: `${process.getuid()}:${process.getgid()}`,
|
||||
},
|
||||
conversions: { 'HostConfig.AutoRemove': true },
|
||||
// The png2pdf image is built FROM scratch and has no 'tex' user, so run
|
||||
// it as the same uid/gid as this clsi process, which owns the cache files
|
||||
// it converts in place.
|
||||
|
||||
@@ -2,7 +2,6 @@ import metrics from '@overleaf/metrics'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { callbackify } from 'node:util'
|
||||
import SafeExec from './SafeExec.js'
|
||||
import SandboxConverterRunner from './SandboxConverterRunner.js'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { ConversionError } = Errors
|
||||
@@ -23,9 +22,6 @@ export default {
|
||||
}
|
||||
|
||||
async function convert(sourcePath, requestedFormat) {
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, requestedFormat, 1500)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
const width = 1500
|
||||
return await _convert(sourcePath, requestedFormat, [
|
||||
@@ -53,9 +49,6 @@ async function convert(sourcePath, requestedFormat) {
|
||||
}
|
||||
|
||||
async function thumbnail(sourcePath) {
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, 'png', 700)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
const width = 700
|
||||
return await _convert(sourcePath, 'png', [
|
||||
@@ -88,9 +81,6 @@ async function thumbnail(sourcePath) {
|
||||
|
||||
async function preview(sourcePath) {
|
||||
const width = 1000
|
||||
if (Settings.converter === 'pdftocairo-sandbox') {
|
||||
return await _convertInSandbox(sourcePath, 'png', width)
|
||||
}
|
||||
if (Settings.converter === 'pdftocairo') {
|
||||
return await _convert(sourcePath, 'png', [
|
||||
'pdftocairo',
|
||||
@@ -119,50 +109,6 @@ async function preview(sourcePath) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pdftocairo conversion inside a sandboxed sibling container (no
|
||||
* network, all caps dropped, memory cap — see SandboxConverterRunner.js).
|
||||
*
|
||||
* Keeps the same contract as the local path: the result is written to
|
||||
* `${sourcePath}.${requestedFormat}`.
|
||||
*
|
||||
* @param {string} sourcePath
|
||||
* @param {string} requestedFormat 'png' or 'jpg'
|
||||
* @param {number} width output width in px
|
||||
* @return {Promise<{outputHostPath: string}>}
|
||||
*/
|
||||
async function _convertInSandbox(sourcePath, requestedFormat, width) {
|
||||
if (!APPROVED_FORMATS.includes(requestedFormat)) {
|
||||
throw new ConversionError('invalid format requested', { format: requestedFormat })
|
||||
}
|
||||
const ext = requestedFormat
|
||||
const workDir = '/work'
|
||||
// poppler appends the device extension to the output argument, so the
|
||||
// argument must end WITHOUT an extension; the result lands at
|
||||
// /work/input.pdf.<png|jpg> next to the input.
|
||||
const device = requestedFormat === 'png' ? '-png' : '-jpeg'
|
||||
const result = await SandboxConverterRunner.runConversion({
|
||||
sourcePath,
|
||||
containerArgs: [
|
||||
'pdftocairo',
|
||||
device,
|
||||
'-singlefile',
|
||||
'-scale-to-x',
|
||||
width.toString(),
|
||||
'-scale-to-y',
|
||||
'-1', // maintain aspect ratio
|
||||
`${workDir}/input.pdf`,
|
||||
`${workDir}/input.pdf`,
|
||||
],
|
||||
workDir,
|
||||
outputBase: 'input.pdf',
|
||||
outputExt: ext,
|
||||
destPath: `${sourcePath}.${requestedFormat}`,
|
||||
})
|
||||
// Same contract as the local converters: return the destination path string.
|
||||
return result.outputHostPath
|
||||
}
|
||||
|
||||
async function _convert(sourcePath, requestedFormat, command) {
|
||||
if (!APPROVED_FORMATS.includes(requestedFormat)) {
|
||||
throw new ConversionError('invalid format requested', {
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
// SandboxConverterRunner.js
|
||||
//
|
||||
// Runs a PDF→image conversion (pdftocairo) in a throwaway sibling container,
|
||||
// the same isolation profile CLSI's DockerRunner gives its conversion
|
||||
// containers (see services/clsi/app/js/DockerRunner.mjs and the `conversions`
|
||||
// / `png2pdf` compile groups in services/clsi/config/settings.defaults.cjs):
|
||||
//
|
||||
// - no network access (HostConfig.NetworkMode: 'none')
|
||||
// - all capabilities dropped (HostConfig.CapDrop: ['ALL'])
|
||||
// - no new privileges (SecurityOpt: 'no-new-privileges')
|
||||
// - hard memory cap
|
||||
// - numeric uid:gid of the overleaf service user (the scratch-based
|
||||
// converter images have no passwd entries, so named users fail)
|
||||
// - container is always removed afterwards
|
||||
//
|
||||
// Implemented as a minimal Docker Engine API client over the unix socket so
|
||||
// that no new npm dependency is required (this service is installed with
|
||||
// Yarn PnP; cross-service packages such as dockerode are not resolvable
|
||||
// here).
|
||||
//
|
||||
// IMPORTANT (bind mounts): the daemon runs on the Docker host, so the
|
||||
// bind source MUST be a daemon-host-visible path (in this deployment:
|
||||
// under the overleaf `./data` volume), never a container-internal path
|
||||
// such as /tmp. The caller passes a `hostDir` that satisfies this.
|
||||
|
||||
import http from 'node:http'
|
||||
import Path from 'node:path'
|
||||
import Fs from 'node:fs'
|
||||
import crypto from 'node:crypto'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import Errors from './Errors.js'
|
||||
|
||||
const { ConversionError } = Errors
|
||||
|
||||
const API_VERSION = 'v1.43' // conservative; the engine API is backwards compatible
|
||||
|
||||
/**
|
||||
* One request against the Docker Engine API on a unix socket.
|
||||
*
|
||||
* @param {string} socketPath
|
||||
* @param {string} method
|
||||
* @param {string} apiPath e.g. `/v1.43/containers/create?name=xyz`
|
||||
* @param {object} [opts]
|
||||
* @param {object|string} [opts.body] JSON body
|
||||
* @param {number} [opts.timeoutMs] hard deadline for the whole request
|
||||
* @param {Buffer|null} [opts.expectRaw] pass a truthy value to keep the body
|
||||
* as a Buffer (used for the log stream)
|
||||
* @return {Promise<{status: number, headers: object, body: any, raw: Buffer}>}
|
||||
*/
|
||||
function request(socketPath, method, apiPath, { body, timeoutMs = 60000, raw = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined || body === null ? null : JSON.stringify(body)
|
||||
let settled = false
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath,
|
||||
method,
|
||||
path: apiPath,
|
||||
headers: data
|
||||
? {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(data),
|
||||
}
|
||||
: {},
|
||||
},
|
||||
(res) => {
|
||||
const chunks = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
const buf = Buffer.concat(chunks)
|
||||
if (raw) return resolve({ status: res.statusCode, headers: res.headers, body: buf, raw: buf })
|
||||
let parsed = buf
|
||||
if (buf.length && (buf[0] === 0x7b || buf[0] === 0x5b)) {
|
||||
try {
|
||||
parsed = JSON.parse(buf.toString('utf8'))
|
||||
} catch (e) {
|
||||
// non-JSON body (e.g. plain text) — keep buffer
|
||||
parsed = buf
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: buf })
|
||||
})
|
||||
}
|
||||
)
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
req.destroy()
|
||||
reject(new Error(`docker api ${method} ${apiPath} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
timer.unref?.()
|
||||
req.on('error', (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
if (data) req.write(data)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** Decode the framed (TTY=false) docker log stream. */
|
||||
function decodeLogStream(buf) {
|
||||
if (!buf || !buf.length) return ''
|
||||
// 8-byte header per frame: [stream(1)] [size_be32(4)] [reserved(3)] + payload
|
||||
// (older daemons use a 4-byte header; try the 8-byte layout first)
|
||||
let out = ''
|
||||
let i = 0
|
||||
while (i + 8 <= buf.length) {
|
||||
const size = buf.readUInt32BE(i + 4)
|
||||
if (i + 8 + size > buf.length) break
|
||||
if (buf[i] === 1 || buf[i] === 2) out += buf.subarray(i + 8, i + 8 + size).toString('utf8')
|
||||
i += 8 + size
|
||||
}
|
||||
return out || buf.toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort sweep of leftover `filestore-convert-*` containers from crashes
|
||||
* or service restarts mid-run (a container that was created but never started
|
||||
* or removed otherwise would leak forever).
|
||||
*/
|
||||
async function _sweepLeftovers(socketPath) {
|
||||
let res
|
||||
try {
|
||||
res = await request(
|
||||
socketPath,
|
||||
'GET',
|
||||
`/${API_VERSION}/containers/json?all=1&limit=200&filters=%7B%22name%22%3A%5B%22filestore-convert-%22%5D%7D`,
|
||||
{ timeoutMs: 10000 }
|
||||
)
|
||||
} catch (err) {
|
||||
return // not fatal
|
||||
}
|
||||
if (res.status !== 200 || !Array.isArray(res.body)) return
|
||||
for (const c of res.body) {
|
||||
if (typeof c.Names?.[0] === 'string' && c.Names[0].startsWith('/filestore-convert-')) {
|
||||
const name = c.Names[0].slice(1)
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
|
||||
logger.warn({ name }, 'sandbox conversion: removed leftover container')
|
||||
break
|
||||
} catch (err) {
|
||||
if (attempt === 0) continue
|
||||
logger.warn({ err, name }, 'sandbox conversion: failed to remove leftover container')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a conversion in a sandboxed container.
|
||||
*
|
||||
* Layout contract (mirrors CLSI's conversion runner):
|
||||
* host: <hostDir>/<runId>/input.pdf <- the source file (copied in)
|
||||
* host: <hostDir>/<runId>/<outputBase>.<ext> <- produced by the container
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.sourcePath absolute path to the source PDF (must be
|
||||
* inside a daemon-host-visible directory — the bind source is derived from it)
|
||||
* @param {string} opts.containerArgs full argv inside the container, e.g.
|
||||
* ['pdftocairo','-png','-singlefile','-scale-to-x','700','-scale-to-y','-1',
|
||||
* '/work/input.pdf','/work/output']
|
||||
* @param {string} opts.workDir basename mapping used in `containerArgs`; the
|
||||
* source file is placed at <workDir>/input.pdf inside the container
|
||||
* @param {string} opts.outputBase container-side output basename (without ext)
|
||||
* @param {string} opts.outputExt 'png' or 'jpg'
|
||||
* @param {string} opts.destPath absolute host path where the result must end up
|
||||
* @return {Promise<{container: string, exitCode: number, outputHostPath: string, durationMs: number}>}
|
||||
*/
|
||||
export async function runConversion(opts) {
|
||||
const {
|
||||
sourcePath,
|
||||
containerArgs,
|
||||
workDir = '/work',
|
||||
outputBase = 'output',
|
||||
outputExt = 'png',
|
||||
destPath,
|
||||
} = opts
|
||||
|
||||
const sandbox = Settings.sandboxConversion || {}
|
||||
const image = sandbox.image || 'sharelatex/pdftocairo:24.02'
|
||||
const socketPath = sandbox.dockerSocketPath || '/var/run/docker.sock'
|
||||
const hostDir = sandbox.hostDir // daemon-host side of the scratch dir
|
||||
const containerDir = sandbox.containerDir // overleaf-container side
|
||||
const memoryBytes = sandbox.memoryBytes || 512 * 1024 * 1024
|
||||
const timeoutMs = sandbox.timeoutMs || 40000
|
||||
|
||||
if (!hostDir || !containerDir) {
|
||||
throw new ConversionError(
|
||||
'sandbox conversion is not configured: set CONVERSION_SANDBOX_HOST_DIR (Docker-daemon side) and CONVERSION_SANDBOX_DIR (overleaf-container side) to the two views of the same directory'
|
||||
)
|
||||
}
|
||||
|
||||
const runId = crypto.randomBytes(8).toString('hex')
|
||||
const runName = `filestore-convert-${runId}`
|
||||
const runDir = Path.join(containerDir, runId) // path used by THIS process (container side)
|
||||
const hostRunDir = Path.join(hostDir, runId) // same dir from the daemon side (bind source)
|
||||
const inputHostPath = Path.join(runDir, 'input.pdf')
|
||||
const outputHostPath = Path.join(runDir, `${outputBase}.${outputExt}`)
|
||||
|
||||
const t0 = Date.now()
|
||||
let containerCreated = false
|
||||
try {
|
||||
// Guard against leftover containers from an interrupted previous run.
|
||||
await _sweepLeftovers(socketPath)
|
||||
|
||||
Fs.mkdirSync(runDir, { mode: 0o770 })
|
||||
Fs.copyFileSync(sourcePath, inputHostPath)
|
||||
|
||||
// Container-absolute path of the source file, for argv remapping sanity
|
||||
const sourceInContainer = Path.join(workDir, 'input.pdf')
|
||||
if (!containerArgs.some((a) => a === sourceInContainer)) {
|
||||
throw new ConversionError(
|
||||
`sandbox argv does not reference ${sourceInContainer} — refusing to run`
|
||||
)
|
||||
}
|
||||
|
||||
// Create (the converter images are scratch-based: no ENTRYPOINT, so a bare
|
||||
// Cmd list is what CLSI runs successfully too).
|
||||
const createBody = {
|
||||
Image: image,
|
||||
Cmd: containerArgs,
|
||||
WorkingDir: workDir,
|
||||
// scratch-based images have no passwd entries — run as the numeric
|
||||
// uid:gid of this service (it owns the files on both sides of the bind).
|
||||
User: `${process.getuid()}:${process.getgid()}`,
|
||||
HostConfig: {
|
||||
Binds: [`${hostRunDir}:${workDir}:rw`],
|
||||
CapDrop: ['ALL'],
|
||||
SecurityOpt: ['no-new-privileges'],
|
||||
NetworkMode: 'none',
|
||||
Memory: memoryBytes,
|
||||
MemorySwap: 0,
|
||||
PidsLimit: 64,
|
||||
},
|
||||
}
|
||||
const created = await request(socketPath, 'POST', `/${API_VERSION}/containers/create?name=${runName}`, {
|
||||
body: createBody,
|
||||
timeoutMs: 60000,
|
||||
})
|
||||
if (created.status >= 300 || !created.body?.Id) {
|
||||
const msg = created.body?.message || JSON.stringify(created.body) || `status ${created.status}`
|
||||
throw new ConversionError(`failed to create sandbox container: ${msg}`)
|
||||
}
|
||||
containerCreated = true
|
||||
logger.info({ image, runName, hostDir: hostRunDir, containerDir: runDir }, 'sandbox conversion: container created')
|
||||
|
||||
await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/start`, { timeoutMs: 60000 })
|
||||
|
||||
// Wait with a real deadline; kill on breach (the /wait call is a blocking
|
||||
// HTTP call that carries no data, so it gets its own generous budget).
|
||||
let exitCode = -1
|
||||
let killed = false
|
||||
try {
|
||||
// NOTE: this daemon (Docker 29 / API ≥1.44) requires POST for /wait; GET
|
||||
// returns 404. (dockerode does the same, which is how CLSI waits.)
|
||||
const waited = await request(socketPath, 'POST', `/${API_VERSION}/containers/${runName}/wait`, {
|
||||
timeoutMs: timeoutMs + 15000,
|
||||
})
|
||||
exitCode = waited.body?.StatusCode ?? -1
|
||||
if (exitCode === -1 && waited.status !== 200) {
|
||||
throw new Error(`unexpected /wait status ${waited.status}: ${waited.raw?.toString?.('utf8') || 'no body'}`)
|
||||
}
|
||||
} catch (waitErr) {
|
||||
killed = true
|
||||
await _killAndRemove(socketPath, runName)
|
||||
throw new ConversionError(`sandbox conversion timed out (${timeoutMs}ms): ${waitErr.message}`)
|
||||
}
|
||||
|
||||
let logs = ''
|
||||
try {
|
||||
const logsRes = await request(
|
||||
socketPath,
|
||||
'GET',
|
||||
`/${API_VERSION}/containers/${runName}/logs?stdout=1&stderr=1&stdin=0&follow=1`,
|
||||
{ raw: true, timeoutMs: 10000 }
|
||||
)
|
||||
logs = decodeLogStream(logsRes.raw)
|
||||
} catch (err) {
|
||||
logger.warn({ err, runName }, 'sandbox conversion: could not read container logs')
|
||||
}
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new ConversionError(
|
||||
`sandbox conversion failed (exit ${exitCode}${killed ? ', killed' : ''})`,
|
||||
{ stderr: logs.trim().slice(-4000), exitCode }
|
||||
)
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = Fs.statSync(outputHostPath)
|
||||
} catch (err) {
|
||||
throw new ConversionError(`sandbox conversion produced no output file`, { stderr: logs.trim().slice(-4000) })
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new ConversionError('sandbox conversion output is not a regular file', {
|
||||
stderr: logs.trim().slice(-4000),
|
||||
})
|
||||
}
|
||||
|
||||
Fs.copyFileSync(outputHostPath, destPath)
|
||||
logger.info(
|
||||
{ runName, sizeBytes: stat.size, durationMs: Date.now() - t0 },
|
||||
'sandbox conversion completed'
|
||||
)
|
||||
return { container: runName, exitCode, outputHostPath: destPath, durationMs: Date.now() - t0 }
|
||||
} finally {
|
||||
// Best-effort cleanup: container + scratch dir (the dir holds the original
|
||||
// PDF — do not leave it behind).
|
||||
if (containerCreated) {
|
||||
await _killAndRemove(socketPath, runName).catch((e) => {
|
||||
logger.warn({ err: e, runName }, 'sandbox conversion: cleanup failed')
|
||||
})
|
||||
}
|
||||
Fs.rmSync(runDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function _killAndRemove(socketPath, name) {
|
||||
try {
|
||||
// POST /kill (GET is not served by this daemon); 409 = not running → fine
|
||||
await request(socketPath, 'POST', `/${API_VERSION}/containers/${name}/kill`, { timeoutMs: 10000 })
|
||||
} catch (err) {
|
||||
// not running → fine
|
||||
}
|
||||
try {
|
||||
await request(socketPath, 'DELETE', `/${API_VERSION}/containers/${name}?force=1`, { timeoutMs: 10000 })
|
||||
} catch (err) {
|
||||
// already gone → fine
|
||||
}
|
||||
}
|
||||
|
||||
export default { runConversion }
|
||||
@@ -99,29 +99,8 @@ const settings = {
|
||||
|
||||
enableConversions: process.env.ENABLE_CONVERSIONS === 'true',
|
||||
|
||||
// PDF→image conversion backend (template gallery previews/thumbnails):
|
||||
// 'imagemagick' – local, via ImageMagick `convert` (default; binary required)
|
||||
// 'pdftocairo' – local, via the `pdftocairo` binary (poppler)
|
||||
// 'pdftocairo-sandbox' – runs `pdftocairo` in a throwaway sibling container:
|
||||
// no network, CapDrop ALL, no-new-privileges, memory
|
||||
// cap (see app/js/SandboxConverterRunner.js).
|
||||
// requires a Docker socket + a daemon-host-visible
|
||||
// scratch dir (CONVERSION_SANDBOX_HOST_DIR).
|
||||
converter: process.env.CONVERTER || 'imagemagick',
|
||||
|
||||
sandboxConversion: {
|
||||
image: process.env.CONVERSION_SANDBOX_IMAGE || 'sharelatex/pdftocairo:24.02',
|
||||
dockerSocketPath: process.env.CONVERSION_DOCKER_SOCKET || '/var/run/docker.sock',
|
||||
// The scratch dir has two views of the same path (the overleaf container
|
||||
// mounts ./data): `containerDir` for this process, `hostDir` for the Docker
|
||||
// daemon's bind mount. Both must be set when sandbox conversion is used.
|
||||
hostDir: process.env.CONVERSION_SANDBOX_HOST_DIR || null,
|
||||
containerDir: process.env.CONVERSION_SANDBOX_DIR || null,
|
||||
memoryBytes:
|
||||
parseInt(process.env.CONVERSION_SANDBOX_MEMORY_BYTES, 10) || 512 * 1024 * 1024,
|
||||
timeoutMs: parseInt(process.env.CONVERSION_SANDBOX_TIMEOUT_MS, 10) || 40000,
|
||||
},
|
||||
|
||||
gracefulShutdownDelayInMs:
|
||||
parseInt(process.env.GRACEFUL_SHUTDOWN_DELAY_SECONDS ?? '30', 10) * 1000,
|
||||
}
|
||||
|
||||
@@ -1199,8 +1199,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
timeout: options.timeout,
|
||||
imageName: project.imageName,
|
||||
draft: Boolean(options.draft),
|
||||
// CE: premium-compile (alpha/priority) gate removed
|
||||
png2pdf: Boolean(options.png2pdf),
|
||||
// enable for premium compiles only
|
||||
png2pdf: Boolean(options.png2pdf) && hasPremiumCompiles,
|
||||
// enable for premium compiles on an image that has a checkpointing build
|
||||
enableCheckpoint:
|
||||
Boolean(options.checkpointing) &&
|
||||
|
||||
@@ -453,8 +453,7 @@ const _CompileController = {
|
||||
enablePng2Pdf,
|
||||
enableCheckpointCompiles,
|
||||
} = await _getSplitTestOptions(req, res)
|
||||
// CE: saas feature gate removed so png2pdf / compile-from-history options are sent
|
||||
if (true) {
|
||||
if (Features.hasFeature('saas')) {
|
||||
options.compileFromClsiCache = true
|
||||
options.populateClsiCache = true
|
||||
options.compileFromHistory = compileFromHistory
|
||||
|
||||
@@ -604,58 +604,6 @@ const EditorController = {
|
||||
)
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// LanguageTool settings (project-wide), mirrored to all clients
|
||||
// in the room (see use-project-wide-settings-socket-listener).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
setLanguageToolEnabled(projectId, enabled, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolEnabled(projectId, enabled, function (
|
||||
err
|
||||
) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolEnabledUpdated',
|
||||
enabled
|
||||
)
|
||||
callback()
|
||||
})
|
||||
},
|
||||
|
||||
setLanguageToolPicky(projectId, picky, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolPicky(projectId, picky, function (err) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolPickyUpdated',
|
||||
picky
|
||||
)
|
||||
callback()
|
||||
})
|
||||
},
|
||||
|
||||
setLanguageToolLanguage(projectId, language, callback) {
|
||||
ProjectOptionsHandler.setLanguageToolLanguage(
|
||||
projectId,
|
||||
language,
|
||||
function (err) {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
EditorRealTimeController.emitToRoom(
|
||||
projectId,
|
||||
'languageToolLanguageUpdated',
|
||||
language
|
||||
)
|
||||
callback()
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
setPublicAccessLevel(projectId, newAccessLevel, callback) {
|
||||
async.series(
|
||||
[
|
||||
@@ -799,13 +747,6 @@ EditorController.promises = {
|
||||
setImageName: promisify(EditorController.setImageName),
|
||||
setPng2pdf: promisify(EditorController.setPng2pdf),
|
||||
setSpellCheckLanguage: promisify(EditorController.setSpellCheckLanguage),
|
||||
setLanguageToolEnabled: promisify(
|
||||
EditorController.setLanguageToolEnabled
|
||||
),
|
||||
setLanguageToolPicky: promisify(EditorController.setLanguageToolPicky),
|
||||
setLanguageToolLanguage: promisify(
|
||||
EditorController.setLanguageToolLanguage
|
||||
),
|
||||
setPublicAccessLevel: promisify(EditorController.setPublicAccessLevel),
|
||||
setRootDoc: promisify(EditorController.setRootDoc),
|
||||
setMainBibliographyDoc: promisify(EditorController.setMainBibliographyDoc),
|
||||
|
||||
@@ -109,9 +109,6 @@ const updateProjectSettingsSchema = z.object({
|
||||
rootDocId: zz.objectId().optional(),
|
||||
spellCheckLanguage: z.string().optional(),
|
||||
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
|
||||
languageToolEnabled: z.boolean().optional(),
|
||||
languageToolPicky: z.boolean().optional(),
|
||||
languageToolLanguage: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -130,9 +127,6 @@ const updateProjectSettingsFallbackSchema = z.object({
|
||||
rootDocId: zz.objectId().optional(),
|
||||
spellCheckLanguage: z.string().optional(),
|
||||
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
|
||||
languageToolEnabled: z.boolean().optional(),
|
||||
languageToolPicky: z.boolean().optional(),
|
||||
languageToolLanguage: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -234,27 +228,6 @@ const _ProjectController = {
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolEnabled != null) {
|
||||
await EditorController.promises.setLanguageToolEnabled(
|
||||
projectId,
|
||||
body.languageToolEnabled
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolPicky != null) {
|
||||
await EditorController.promises.setLanguageToolPicky(
|
||||
projectId,
|
||||
body.languageToolPicky
|
||||
)
|
||||
}
|
||||
|
||||
if (body.languageToolLanguage != null) {
|
||||
await EditorController.promises.setLanguageToolLanguage(
|
||||
projectId,
|
||||
body.languageToolLanguage
|
||||
)
|
||||
}
|
||||
|
||||
if (body.rootDocId != null) {
|
||||
await EditorController.promises.setRootDoc(projectId, body.rootDocId)
|
||||
}
|
||||
|
||||
@@ -127,29 +127,6 @@ const ProjectOptionsHandler = {
|
||||
const update = { referenceFormat: newReferenceFormat }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// LanguageTool settings (project-wide; shared across collaborators).
|
||||
// Unset values mean the client defaults: enabled=true, picky=true,
|
||||
// language='auto' — i.e. picky level is the default selection.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
async setLanguageToolEnabled(projectId, enabled) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolEnabled: Boolean(enabled) }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
async setLanguageToolPicky(projectId, picky) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolPicky: Boolean(picky) }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
|
||||
async setLanguageToolLanguage(projectId, language) {
|
||||
const conditions = { _id: projectId }
|
||||
const update = { languageToolLanguage: language || 'auto' }
|
||||
return Project.updateOne(conditions, update, {})
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
@@ -167,14 +144,5 @@ export default {
|
||||
ProjectOptionsHandler.setHistoryRangesSupport
|
||||
),
|
||||
setReferenceFormat: callbackify(ProjectOptionsHandler.setReferenceFormat),
|
||||
setLanguageToolEnabled: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolEnabled
|
||||
),
|
||||
setLanguageToolPicky: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolPicky
|
||||
),
|
||||
setLanguageToolLanguage: callbackify(
|
||||
ProjectOptionsHandler.setLanguageToolLanguage
|
||||
),
|
||||
promises: ProjectOptionsHandler,
|
||||
}
|
||||
|
||||
@@ -425,7 +425,6 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
linkedInInsightsPartnerId: Settings.analytics?.linkedIn?.partnerId,
|
||||
githubSyncEnabled: !!Settings.githubSync?.clientID && !!Settings.githubSync?.clientSecret,
|
||||
zoteroEnabled: !!Settings.zotero?.clientKey && !!Settings.zotero?.clientSecret,
|
||||
languageToolAvailable: !!Settings.languageToolURL,
|
||||
enablePandocConversions: Settings.enablePandocConversions,
|
||||
mixpanelLabsToken:
|
||||
Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken,
|
||||
|
||||
@@ -1034,12 +1034,7 @@ module.exports = {
|
||||
),
|
||||
],
|
||||
contactUsModal: [],
|
||||
sourceEditorExtensions: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-extension'
|
||||
),
|
||||
],
|
||||
sourceEditorExtensions: [],
|
||||
sourceEditorVisualExtensions: [],
|
||||
sourceEditorComponents: [],
|
||||
pdfLogEntryHeaderActionComponents: [],
|
||||
@@ -1180,12 +1175,7 @@ module.exports = {
|
||||
],
|
||||
referenceSearchSetting: [],
|
||||
settingsModalEditorTabSections: [],
|
||||
settingsModalSpellcheckSections: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/languagetool/frontend/js/languagetool-section'
|
||||
),
|
||||
],
|
||||
settingsModalSpellcheckSections: [],
|
||||
editorFloatingMenuActions: [],
|
||||
errorLogsComponents: [],
|
||||
referenceIndices: [
|
||||
@@ -1208,7 +1198,6 @@ module.exports = {
|
||||
'symbol-palette',
|
||||
'reference-picker',
|
||||
'track-changes',
|
||||
'languagetool',
|
||||
'authentication/ldap',
|
||||
'authentication/saml',
|
||||
'authentication/oidc',
|
||||
|
||||
@@ -13,15 +13,6 @@ type ProjectSettingsSetterContextValue = {
|
||||
setSpellCheckLanguage: (
|
||||
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
|
||||
) => void
|
||||
setLanguageToolEnabled: (
|
||||
languageToolEnabled: ProjectSettings['languageToolEnabled']
|
||||
) => void
|
||||
setLanguageToolPicky: (
|
||||
languageToolPicky: ProjectSettings['languageToolPicky']
|
||||
) => void
|
||||
setLanguageToolLanguage: (
|
||||
languageToolLanguage: ProjectSettings['languageToolLanguage']
|
||||
) => void
|
||||
setReferenceFormat: (
|
||||
referenceFormat: ProjectSettings['referenceFormat']
|
||||
) => Promise<void>
|
||||
@@ -81,12 +72,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setSpellCheckLanguage,
|
||||
referenceFormat,
|
||||
setReferenceFormat,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
} = useProjectWideSettings()
|
||||
|
||||
const {
|
||||
@@ -150,12 +135,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setRootDocId,
|
||||
spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
autoComplete,
|
||||
setAutoComplete,
|
||||
autoPairDelimiters,
|
||||
@@ -214,12 +193,6 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
|
||||
setRootDocId,
|
||||
spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
autoComplete,
|
||||
setAutoComplete,
|
||||
autoPairDelimiters,
|
||||
|
||||
-42
@@ -53,33 +53,6 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolEnabled = useCallback(
|
||||
(languageToolEnabled: ProjectSettings['languageToolEnabled']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolEnabled })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolPicky = useCallback(
|
||||
(languageToolPicky: ProjectSettings['languageToolPicky']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolPicky })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
const setLanguageToolLanguage = useCallback(
|
||||
(languageToolLanguage: ProjectSettings['languageToolLanguage']) => {
|
||||
if (project) {
|
||||
updateProject({ languageToolLanguage })
|
||||
}
|
||||
},
|
||||
[project, updateProject]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// data is not available on initial mounting
|
||||
const dataAvailable = !!project
|
||||
@@ -90,9 +63,6 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
socket.on('png2pdfUpdated', setPng2pdf)
|
||||
socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage)
|
||||
socket.on('referenceFormatUpdated', setReferenceFormat)
|
||||
socket.on('languageToolEnabledUpdated', setLanguageToolEnabled)
|
||||
socket.on('languageToolPickyUpdated', setLanguageToolPicky)
|
||||
socket.on('languageToolLanguageUpdated', setLanguageToolLanguage)
|
||||
return () => {
|
||||
socket.removeListener('compilerUpdated', setCompiler)
|
||||
socket.removeListener('imageNameUpdated', setImageName)
|
||||
@@ -102,15 +72,6 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
setSpellCheckLanguage
|
||||
)
|
||||
socket.removeListener('referenceFormatUpdated', setReferenceFormat)
|
||||
socket.removeListener(
|
||||
'languageToolEnabledUpdated',
|
||||
setLanguageToolEnabled
|
||||
)
|
||||
socket.removeListener('languageToolPickyUpdated', setLanguageToolPicky)
|
||||
socket.removeListener(
|
||||
'languageToolLanguageUpdated',
|
||||
setLanguageToolLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -121,8 +82,5 @@ export default function useProjectWideSettingsSocketListener() {
|
||||
setReferenceFormat,
|
||||
setPng2pdf,
|
||||
setSpellCheckLanguage,
|
||||
setLanguageToolEnabled,
|
||||
setLanguageToolPicky,
|
||||
setLanguageToolLanguage,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ProjectSettings } from '../utils/api'
|
||||
import useRootDocId from './use-root-doc-id'
|
||||
import useSaveProjectSettings from './use-save-project-settings'
|
||||
import useSetSpellCheckLanguage from './use-set-spell-check-language'
|
||||
import useSetLanguageToolSetting from './use-set-language-tool'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
@@ -50,9 +49,6 @@ export default function useProjectWideSettings() {
|
||||
|
||||
const { setRootDocId, rootDocId } = useRootDocId()
|
||||
const setSpellCheckLanguage = useSetSpellCheckLanguage()
|
||||
const setLanguageToolEnabled = useSetLanguageToolSetting('languageToolEnabled')
|
||||
const setLanguageToolPicky = useSetLanguageToolSetting('languageToolPicky')
|
||||
const setLanguageToolLanguage = useSetLanguageToolSetting('languageToolLanguage')
|
||||
|
||||
return {
|
||||
compiler: project?.compiler,
|
||||
@@ -67,11 +63,5 @@ export default function useProjectWideSettings() {
|
||||
setRootDocId,
|
||||
spellCheckLanguage: project?.spellCheckLanguage,
|
||||
setSpellCheckLanguage,
|
||||
languageToolEnabled: project?.languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky: project?.languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage: project?.languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { type ProjectSettings } from '../utils/api'
|
||||
import useSaveProjectSettings from './use-save-project-settings'
|
||||
|
||||
type LanguageToolSettingKey =
|
||||
| 'languageToolEnabled'
|
||||
| 'languageToolPicky'
|
||||
| 'languageToolLanguage'
|
||||
|
||||
/**
|
||||
* Setter for the LanguageTool project settings (enabled / picky / language),
|
||||
* following the `useSet*` hook pattern of this directory.
|
||||
*/
|
||||
export default function useSetLanguageToolSetting(
|
||||
key: LanguageToolSettingKey
|
||||
) {
|
||||
const saveProjectSettings = useSaveProjectSettings()
|
||||
|
||||
return useCallback(
|
||||
(newValue: ProjectSettings[LanguageToolSettingKey]) => {
|
||||
saveProjectSettings(key, newValue).catch(() => {})
|
||||
},
|
||||
[key, saveProjectSettings]
|
||||
)
|
||||
}
|
||||
@@ -15,11 +15,6 @@ export interface ProjectSettings {
|
||||
name: string
|
||||
mainBibliographyDocId?: string
|
||||
referenceFormat?: 'bibtex' | 'biblatex'
|
||||
// LanguageTool grammar check (project-wide; defaults when unset:
|
||||
// enabled=true, picky=true → level=picky, language='auto')
|
||||
languageToolEnabled?: boolean
|
||||
languageToolPicky?: boolean
|
||||
languageToolLanguage?: string
|
||||
}
|
||||
|
||||
type SaveUserSettings = Partial<
|
||||
|
||||
@@ -55,7 +55,6 @@ import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
|
||||
import { useOnlineUsersContext } from '@/features/ide-react/context/online-users-context'
|
||||
import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
|
||||
import { SearchQuery } from '@codemirror/search'
|
||||
@@ -110,33 +109,6 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
|
||||
const hunspellManager = useHunspell(spellCheckLanguage)
|
||||
|
||||
// ── LanguageTool grammar check (project-wide settings) ─────────────────────
|
||||
// Stored on the project (shared by all collaborators). Defaults when unset:
|
||||
// enabled + picky ON → level=picky. Values are handed to the CodeMirror
|
||||
// extension through `createExtensions` below, and every change is
|
||||
// announced with a window event the extension listens for.
|
||||
const languageTool = {
|
||||
available:
|
||||
getMeta('ol-ExposedSettings')?.languageToolAvailable === true,
|
||||
// off for read-only views (like spell-checking)
|
||||
enabled:
|
||||
(permissions.write || permissions.trackedWrite) &&
|
||||
project?.languageToolEnabled !== false,
|
||||
picky: project?.languageToolPicky !== false,
|
||||
language: project?.languageToolLanguage || 'auto',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('lt:settings-changed', { detail: languageTool })
|
||||
)
|
||||
}, [
|
||||
languageTool.available,
|
||||
languageTool.enabled,
|
||||
languageTool.picky,
|
||||
languageTool.language,
|
||||
])
|
||||
|
||||
const { showVisual: visual, trackChanges } = useEditorPropertiesContext()
|
||||
|
||||
const { referenceKeys, searchLocalReferences } = useReferencesContext()
|
||||
@@ -390,7 +362,6 @@ function useCodeMirrorScope(view: EditorView) {
|
||||
settings: settingsRef.current,
|
||||
phrases: phrasesRef.current,
|
||||
spelling: spellingRef.current,
|
||||
languageTool,
|
||||
showVisual: showVisualRef.current,
|
||||
previewByPath: previewByPathRef.current,
|
||||
currentDocFolder: dirnameRef.current(currentDocument.doc_id),
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
# LanguageTool module
|
||||
|
||||
Grammar and style checking in the Overleaf source editor, powered by an
|
||||
external [LanguageTool](https://languagetool.org) server (API v2).
|
||||
|
||||
Based on the upstream PR:
|
||||
<https://github.com/yu-i-i/overleaf-cep/pull/179> (commit 8337e00), adapted
|
||||
for ext-ce-v6.3.1 with:
|
||||
|
||||
- **Picky level as the default** and an on/off "Picky mode" toggle in the
|
||||
project settings (the deployed LT fork, `erikvl87/languagetool`, supports
|
||||
`default` + `picky`; other levels return HTTP 400).
|
||||
- **Project-wide settings** (stored on the project document, shared by all
|
||||
collaborators, synced over the editor socket):
|
||||
- `languageToolEnabled` — grammar check on/off (default: on)
|
||||
- `languageToolPicky` — picky level on/off (default: **on** → `level=picky`)
|
||||
- `languageToolLanguage` — LT language code, e.g. `en-US`, `de` (default: `auto`)
|
||||
- **Dark/light theme support**: tooltip + underlines use the Overleaf themed
|
||||
CSS variables (`--bg-primary-themed`, `--content-primary-themed`, ...)
|
||||
with light-mode fallbacks (fixes the dark-theme tooltip bug from the PR).
|
||||
|
||||
## Server (Overleaf) environment
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| ----------------------- | ----------------- | --------------------------------------- |
|
||||
| `LANGUAGE_TOOL_ENABLED` | _unset_ | must be exactly `true` to activate |
|
||||
| `LANGUAGE_TOOL_HOST` | `languagetool` | host of the LT service |
|
||||
| `LANGUAGE_TOOL_PORT` | `8010` | LT API port |
|
||||
| `LANGUAGE_TOOL_URL` | derived | overrides host+port when set |
|
||||
|
||||
The overleaf container needs network access to the LT service. In this
|
||||
deployment the overleaf service joins the external
|
||||
`languagetools_server_compose_default` network (see
|
||||
`/data_1/docker/compose/overleaf/compose.yaml`) and talks to
|
||||
`http://languagetool:8010`.
|
||||
|
||||
## Endpoints (authenticated)
|
||||
|
||||
- `GET /languagetool/languages` — LT language list (used by the settings UI)
|
||||
- `POST /languagetool/check` — JSON body `{ language, level?, text? | data? }`;
|
||||
proxied to LT `/v2/check` with a 100 KB cap and LaTeX-noise `disabledRules`.
|
||||
**Exempt from CSRF** (`webRouter.csrf.disableDefaultCsrfProtection` in the
|
||||
router): it is a stateless, login-gated query — and long-lived editor tabs
|
||||
outlive session rotations, where the page's meta token would stale out and
|
||||
403 every check. The client still sends `X-CSRF-Token`; it passes when the
|
||||
session is fresh.
|
||||
|
||||
## Frontend
|
||||
|
||||
- `frontend/js/languagetool-extension.ts` — CodeMirror 6 extension
|
||||
(wired via the `sourceEditorExtensions` slot in
|
||||
`services/web/config/settings.defaults.js`). Wavy underlines + hover
|
||||
tooltip with replacement buttons; LaTeX-aware annotation via the Lezer
|
||||
syntax tree (regex fallback); debounced 2 s; aborts stale requests;
|
||||
filters TYPOS while the Hunspell spellchecker is active.
|
||||
- `frontend/js/languagetool-section.tsx` +
|
||||
`frontend/js/components/languagetool-settings.tsx` — Settings modal,
|
||||
Spelling and Language → **LanguageTool** (wired via the
|
||||
`settingsModalSpellcheckSections` slot).
|
||||
|
||||
Settings reach the editor through `options.languageTool`
|
||||
(`use-codemirror-scope.ts`) and the `lt:settings-changed` window event.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
index.mjs module entry (env-gated router)
|
||||
app/src/LanguageToolRouter.mjs
|
||||
app/src/LanguageToolController.mjs
|
||||
frontend/js/languagetool-extension.ts
|
||||
frontend/js/languagetool-section.tsx
|
||||
frontend/js/components/languagetool-settings.tsx
|
||||
frontend/js/utils/latex-to-annotations.ts
|
||||
```
|
||||
|
||||
Core Overleaf changes (see git diff):
|
||||
|
||||
- `services/web/config/settings.defaults.js`
|
||||
(`sourceEditorExtensions`, `settingsModalSpellcheckSections`,
|
||||
`moduleImportSequence`)
|
||||
- `services/web/app/src/infrastructure/ExpressLocals.mjs`
|
||||
(exposed setting `languageToolAvailable`)
|
||||
- `services/web/types/exposed-settings.ts`
|
||||
- `services/web/app/src/Features/Project/ProjectController.mjs`
|
||||
(project-settings schema + update)
|
||||
- `services/web/app/src/Features/Project/ProjectOptionsHandler.mjs` (persist)
|
||||
- `services/web/app/src/Features/Editor/EditorController.mjs` (socket emit)
|
||||
- `services/web/frontend/js/features/ide-settings/...` (settings UI context,
|
||||
hooks, socket listener, `api.ts`)
|
||||
- `services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts`
|
||||
@@ -1,138 +0,0 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import fetch from 'node-fetch'
|
||||
|
||||
const MAX_TEXT_SIZE = 100_000 // 100 KB limit for safety
|
||||
|
||||
// Levels understood by the LanguageTool fork we deploy
|
||||
// (erikvl87/languagetool supports: default and picky).
|
||||
const ALLOWED_LEVELS = new Set([
|
||||
'default',
|
||||
'normal',
|
||||
'careful',
|
||||
'meticulous',
|
||||
'picky',
|
||||
])
|
||||
|
||||
const LanguageToolController = {
|
||||
|
||||
/**
|
||||
* GET /languagetool/languages
|
||||
* Returns the list of supported languages from the LT service
|
||||
*/
|
||||
async getLanguages(req, res, next) {
|
||||
try {
|
||||
const response = await fetch(`${Settings.languageToolURL}/v2/languages`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
{ status: response.status },
|
||||
'LanguageTool languages request failed'
|
||||
)
|
||||
return res.status(502).json({ error: 'LanguageTool request failed' })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
res.json(data)
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error fetching LanguageTool languages')
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /languagetool/check
|
||||
* Proxies text/annotation data to the LanguageTool service and returns matches.
|
||||
* Accepts JSON body: { language, level?, text?, data? }
|
||||
*
|
||||
* `level` is optional; when omitted the LanguageTool default level is used.
|
||||
* The client sends `picky` when the project's "picky mode" is enabled —
|
||||
* picky is the recommended default in this deployment.
|
||||
*/
|
||||
async check(req, res, next) {
|
||||
const { language = 'auto', level, text, data } = req.body
|
||||
|
||||
if (!text && !data) {
|
||||
return res.status(400).json({ error: 'text or data is required' })
|
||||
}
|
||||
|
||||
if (level && !ALLOWED_LEVELS.has(level)) {
|
||||
return res.status(400).json({ error: `unknown level: ${level}` })
|
||||
}
|
||||
|
||||
// Sanitize: reject oversized requests
|
||||
const contentStr =
|
||||
typeof data === 'string'
|
||||
? data
|
||||
: typeof data === 'object'
|
||||
? JSON.stringify(data)
|
||||
: text || ''
|
||||
|
||||
if (contentStr.length > MAX_TEXT_SIZE) {
|
||||
logger.warn(
|
||||
{ size: contentStr.length },
|
||||
'LanguageTool request exceeds size limit, truncating'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('language', language)
|
||||
|
||||
if (level) {
|
||||
params.set('level', level)
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const dataStr =
|
||||
typeof data === 'string' ? data : JSON.stringify(data)
|
||||
params.set('data', dataStr.slice(0, MAX_TEXT_SIZE))
|
||||
} else {
|
||||
params.set('text', (text || '').slice(0, MAX_TEXT_SIZE))
|
||||
}
|
||||
|
||||
// Disable rules that produce excessive false positives in LaTeX documents
|
||||
params.set(
|
||||
'disabledRules',
|
||||
'WHITESPACE_RULE,COMMA_PARENTHESIS_WHITESPACE,CONSECUTIVE_SPACES,DASH_RULE,UPPERCASE_SENTENCE_START'
|
||||
)
|
||||
|
||||
const response = await fetch(`${Settings.languageToolURL}/v2/check`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: params.toString(),
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
logger.error(
|
||||
{ status: response.status, level, body: body.slice(0, 500) },
|
||||
'LanguageTool check failed'
|
||||
)
|
||||
return res
|
||||
.status(502)
|
||||
.json({ error: 'LanguageTool check failed', serverStatus: response.status })
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
// On timeout or network error return empty results so the linter stays silent
|
||||
if (err.type === 'request-timeout' || err.name === 'FetchError') {
|
||||
logger.warn({ err }, 'LanguageTool check timed out or failed, returning empty results')
|
||||
return res.json({ matches: [] })
|
||||
}
|
||||
logger.error({ err }, 'Error checking text with LanguageTool')
|
||||
next(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default LanguageToolController
|
||||
@@ -1,31 +0,0 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import LanguageToolController from './LanguageToolController.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.debug({}, 'Init languagetool router')
|
||||
|
||||
// POST /languagetool/check is a stateless grammar query (login required,
|
||||
// no server state changes). Exempt it from CSRF: long-lived editor tabs
|
||||
// hold the page's meta token, and after an in-flight (re-)login the
|
||||
// browser's session has rotated, so the stale token would 403 every
|
||||
// check even though the request is fully authenticated.
|
||||
if (webRouter.csrf && webRouter.csrf.disableDefaultCsrfProtection) {
|
||||
webRouter.csrf.disableDefaultCsrfProtection('/languagetool/check', 'POST')
|
||||
}
|
||||
|
||||
// Authenticated endpoints
|
||||
webRouter.get(
|
||||
'/languagetool/languages',
|
||||
AuthenticationController.requireLogin(),
|
||||
LanguageToolController.getLanguages
|
||||
)
|
||||
|
||||
webRouter.post(
|
||||
'/languagetool/check',
|
||||
AuthenticationController.requireLogin(),
|
||||
LanguageToolController.check
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
/**
|
||||
* LanguageTool settings component — rendered inside the Settings modal
|
||||
* (Spelling and Language → LanguageTool section) via the
|
||||
* `settingsModalSpellcheckSections` slot.
|
||||
*
|
||||
* Persistence: all three settings are PROJECT settings — stored on the
|
||||
* project document so every collaborator sees the same values:
|
||||
* - languageToolEnabled (default: true)
|
||||
* - languageToolPicky (default: true → level=picky is the default)
|
||||
* - languageToolLanguage (default: 'auto')
|
||||
*
|
||||
* Changes are saved server-side through the project settings context and
|
||||
* synced to other clients over the project socket. The CodeMirror
|
||||
* extension is informed via the `lt:settings-changed` window event so this
|
||||
* component does not need to be inside the CodeMirrorViewContext.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useProjectSettingsContext } from '@/features/ide-settings/context/project-settings-context'
|
||||
import { getJSON } from '@/infrastructure/fetch-json'
|
||||
import DropdownSetting from '@/features/ide-settings/components/dropdown-setting'
|
||||
import ToggleSetting from '@/features/ide-settings/components/toggle-setting'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
interface LTLanguage {
|
||||
name: string
|
||||
code: string
|
||||
longCode: string
|
||||
}
|
||||
|
||||
export default function LanguageToolSettings() {
|
||||
const {
|
||||
languageToolEnabled,
|
||||
setLanguageToolEnabled,
|
||||
languageToolPicky,
|
||||
setLanguageToolPicky,
|
||||
languageToolLanguage,
|
||||
setLanguageToolLanguage,
|
||||
} = useProjectSettingsContext()
|
||||
|
||||
// Defaults: enabled + picky (picky level is the recommended default).
|
||||
const enabled = languageToolEnabled !== false
|
||||
const picky = languageToolPicky !== false
|
||||
const language = languageToolLanguage || 'auto'
|
||||
|
||||
const gtAvailable =
|
||||
getMeta('ol-ExposedSettings')?.languageToolAvailable === true
|
||||
|
||||
const [languages, setLanguages] = useState<LTLanguage[]>([])
|
||||
const [languagesLoading, setLanguagesLoading] = useState(false)
|
||||
|
||||
// ── Load language list when LT is available and grammar check is on ──
|
||||
useEffect(() => {
|
||||
if (!gtAvailable || !enabled || languages.length > 0) return
|
||||
|
||||
setLanguagesLoading(true)
|
||||
getJSON<LTLanguage[]>('/languagetool/languages')
|
||||
.then(langs => {
|
||||
// Sort alphabetically by display name for usability
|
||||
const sorted = [...langs].sort((a, b) => a.name.localeCompare(b.name))
|
||||
setLanguages(sorted)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLanguagesLoading(false))
|
||||
}, [gtAvailable, enabled, languages.length])
|
||||
|
||||
// ── Notify the CodeMirror extension of the effective settings ─────────
|
||||
const notify = useCallback(
|
||||
(detail: { enabled: boolean; picky: boolean; language: string }) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('lt:settings-changed', {
|
||||
detail: { ...detail, available: gtAvailable },
|
||||
})
|
||||
)
|
||||
},
|
||||
[gtAvailable]
|
||||
)
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
(newEnabled: boolean) => {
|
||||
setLanguageToolEnabled(newEnabled)
|
||||
notify({ enabled: newEnabled, picky, language })
|
||||
},
|
||||
[setLanguageToolEnabled, picky, language, notify]
|
||||
)
|
||||
|
||||
const handleTogglePicky = useCallback(
|
||||
(newPicky: boolean) => {
|
||||
setLanguageToolPicky(newPicky)
|
||||
notify({ enabled, picky: newPicky, language })
|
||||
},
|
||||
[setLanguageToolPicky, enabled, language, notify]
|
||||
)
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(newLanguage: string) => {
|
||||
setLanguageToolLanguage(newLanguage || 'auto')
|
||||
notify({ enabled, picky, language: newLanguage || 'auto' })
|
||||
},
|
||||
[setLanguageToolLanguage, enabled, picky, notify]
|
||||
)
|
||||
|
||||
// Don't render anything if LT is not configured on the server
|
||||
if (!gtAvailable) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToggleSetting
|
||||
id="languagetool-enabled"
|
||||
label="LanguageTool grammar check"
|
||||
description="Grammar and style checking powered by LanguageTool"
|
||||
checked={enabled}
|
||||
onChange={handleToggleEnabled}
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<ToggleSetting
|
||||
id="languagetool-picky"
|
||||
label="Picky mode"
|
||||
description="Picky level catches more (subtle) grammar and style issues; off uses LanguageTool's default level"
|
||||
checked={picky}
|
||||
onChange={handleTogglePicky}
|
||||
/>
|
||||
|
||||
<DropdownSetting
|
||||
id="languagetool-language"
|
||||
label="Language"
|
||||
options={[{ value: 'auto', label: 'Auto-detect' }]}
|
||||
optgroups={
|
||||
languages.length > 0
|
||||
? [
|
||||
{
|
||||
label: 'Languages',
|
||||
options: languages.map(l => ({
|
||||
value: l.longCode,
|
||||
label: l.name,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
onChange={handleLanguageChange}
|
||||
value={language}
|
||||
width="wide"
|
||||
loading={languagesLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
/**
|
||||
* LanguageTool CodeMirror 6 extension.
|
||||
*
|
||||
* Exported as a named `extension` function matching the `sourceEditorExtensions`
|
||||
* module-import slot signature: (options: Record<string, any>) => Extension
|
||||
*
|
||||
* Features:
|
||||
* - Grammar and style linting via the /languagetool/check backend proxy
|
||||
* - Uses a CUSTOM decoration + tooltip system instead of @codemirror/lint.
|
||||
* The Overleaf compile-log linter globally combines markerFilter (only
|
||||
* severity=error) and tooltipFilter (returns []) into the lint facet,
|
||||
* which hides LT grammar warnings. By managing our own StateField,
|
||||
* Decoration layer, and hoverTooltip we bypass those filters entirely.
|
||||
* - Debounced: checks 2 s after the last document change
|
||||
* - LaTeX-aware: uses the Lezer syntax tree to identify plain-text regions;
|
||||
* math, commands, comments, citation keys, labels, and package arguments
|
||||
* are sent as `markup` in the LT annotation format
|
||||
* - Falls back to regex-based parsing when the syntax tree is unavailable
|
||||
* - Filters TYPOS category when the Overleaf Hunspell spellchecker is active
|
||||
* to avoid duplicate underlines; tracks Hunspell state dynamically
|
||||
* - Cancels stale HTTP requests with AbortController
|
||||
* - Project-wide settings: `enabled` / `picky` / `language` arrive through
|
||||
* `options.languageTool` (from the project settings, default: enabled,
|
||||
* picky ON → level=picky) and live `lt:settings-changed` window events
|
||||
* dispatched by the settings UI / the source-editor scope.
|
||||
* - Theme aware: tooltip and underlines use the Overleaf themed CSS
|
||||
* variables (--*-themed) with light-mode fallbacks, so they render
|
||||
* correctly in both the dark and the light theme.
|
||||
*
|
||||
* All logic is self-contained — no imports from Overleaf core internals.
|
||||
*/
|
||||
|
||||
import {
|
||||
StateEffect,
|
||||
StateField,
|
||||
type EditorState,
|
||||
type Extension,
|
||||
} from '@codemirror/state'
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
hoverTooltip,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view'
|
||||
import { syntaxTree, ensureSyntaxTree } from '@codemirror/language'
|
||||
import type { SyntaxNodeRef } from '@lezer/common'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
latexToAnnotations,
|
||||
type LTAnnotation,
|
||||
} from './utils/latex-to-annotations'
|
||||
|
||||
// ─── LT types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface LTMatch {
|
||||
offset: number
|
||||
length: number
|
||||
message: string
|
||||
rule?: { id?: string; category?: { id?: string } }
|
||||
replacements?: Array<{ value: string }>
|
||||
}
|
||||
|
||||
/** Our own diagnostic type — independent of @codemirror/lint's Diagnostic. */
|
||||
interface LTDiagnostic {
|
||||
from: number
|
||||
to: number
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
source: string
|
||||
replacements: string[]
|
||||
}
|
||||
|
||||
/** Effective per-project settings (see languagetool-settings.tsx). */
|
||||
interface LTSettings {
|
||||
available: boolean
|
||||
enabled: boolean
|
||||
picky: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: LTSettings = {
|
||||
available: false,
|
||||
enabled: true,
|
||||
picky: true,
|
||||
language: 'auto',
|
||||
}
|
||||
|
||||
// ─── LaTeX node skip-list ─────────────────────────────────────────────────────
|
||||
// Mirrors the `noSpellCheckProp` definitions in the Overleaf LaTeX grammar
|
||||
// without importing the core module. These argument node types contain
|
||||
// non-prose content (citation keys, labels, package names, etc.) and must be
|
||||
// skipped so that LanguageTool does not report false positives on them.
|
||||
|
||||
/** Node types whose entire subtree is always non-prose. */
|
||||
const ALWAYS_SKIP = new Set([
|
||||
'BibKeyArgument',
|
||||
'BibliographyArgument',
|
||||
'BibliographyStyleArgument',
|
||||
'DocumentClassArgument',
|
||||
'LabelArgument',
|
||||
'PackageArgument',
|
||||
'RefArgument',
|
||||
])
|
||||
|
||||
/** Node types to skip only when they appear inside certain parent contexts. */
|
||||
const CONTEXTUAL_SKIP: Record<string, string[][]> = {
|
||||
OptionalArgument: [
|
||||
['DocumentClass'],
|
||||
['IncludeGraphics'],
|
||||
['LineBreak'],
|
||||
['UsePackage'],
|
||||
['FigureEnvironment', 'BeginEnv'],
|
||||
['ListEnvironment', 'BeginEnv'],
|
||||
],
|
||||
ShortTextArgument: [['Date'], ['SetLengthCommand']],
|
||||
TextArgument: [['TabularEnvironment', 'BeginEnv']],
|
||||
}
|
||||
|
||||
function shouldSkipNode(node: SyntaxNodeRef): boolean {
|
||||
if (ALWAYS_SKIP.has(node.type.name)) return true
|
||||
const contexts = CONTEXTUAL_SKIP[node.type.name]
|
||||
if (contexts) {
|
||||
return contexts.some(ctx => node.matchContext(ctx))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Syntax-tree walker ───────────────────────────────────────────────────────
|
||||
|
||||
interface TextSpan {
|
||||
from: number
|
||||
to: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all "Normal" text spans from a single document line using the Lezer
|
||||
* syntax tree. Nodes matching the skip-list above are pruned so their
|
||||
* children (including any nested Normal nodes) are never visited.
|
||||
*/
|
||||
function getNormalSpansFromLine(
|
||||
state: EditorState,
|
||||
lineFrom: number,
|
||||
lineTo: number
|
||||
): TextSpan[] {
|
||||
const tree = syntaxTree(state)
|
||||
const spans: TextSpan[] = []
|
||||
|
||||
tree.iterate({
|
||||
from: lineFrom,
|
||||
to: lineTo,
|
||||
enter(node: SyntaxNodeRef) {
|
||||
if (shouldSkipNode(node)) return false
|
||||
if (node.type.name === 'Normal') {
|
||||
spans.push({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
text: state.doc.sliceString(node.from, node.to),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
// ─── Annotation builder (syntax tree) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Emit a gap (everything between two Normal text spans) as whitespace → text
|
||||
* (so LanguageTool sees sentence boundaries) and non-whitespace → markup.
|
||||
*/
|
||||
function emitGap(annotations: LTAnnotation[], gap: string): void {
|
||||
const segments = gap.match(/\s+|\S+/g)
|
||||
if (!segments) return
|
||||
for (const segment of segments) {
|
||||
if (/^\s+$/.test(segment)) {
|
||||
annotations.push({ text: segment })
|
||||
} else {
|
||||
annotations.push({ markup: segment })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LT annotations from the Lezer syntax tree. Only "Normal" text nodes
|
||||
* are emitted as checkable `text`; everything else becomes `markup`.
|
||||
*
|
||||
* Invariant: annotations.map(a => a.text ?? a.markup).join('') === source
|
||||
*/
|
||||
function buildAnnotationsFromTree(view: EditorView): LTAnnotation[] {
|
||||
const { state } = view
|
||||
const doc = state.doc
|
||||
const annotations: LTAnnotation[] = []
|
||||
let lastEnd = 0
|
||||
|
||||
for (let i = 1; i <= doc.lines; i++) {
|
||||
const line = doc.line(i)
|
||||
const spans = getNormalSpansFromLine(state, line.from, line.to)
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.from > lastEnd) {
|
||||
emitGap(annotations, doc.sliceString(lastEnd, span.from))
|
||||
}
|
||||
annotations.push({ text: span.text })
|
||||
lastEnd = span.to
|
||||
}
|
||||
}
|
||||
|
||||
if (lastEnd < doc.length) {
|
||||
emitGap(annotations, doc.sliceString(lastEnd, doc.length))
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
// ─── State management ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// We deliberately do NOT use @codemirror/lint's `linter()` because its
|
||||
// `lintConfig` facet is a global combine: the compile-log linter's
|
||||
// `markerFilter` (only severity=error) and `tooltipFilter` (returns [])
|
||||
// suppress our grammar-warning underlines and tooltips. Instead we manage
|
||||
// our own StateField → DecorationSet pipeline and a standalone hoverTooltip.
|
||||
|
||||
const setLTDiagnosticsEffect = StateEffect.define<readonly LTDiagnostic[]>()
|
||||
|
||||
const ltDiagnosticsField = StateField.define<{
|
||||
diagnostics: readonly LTDiagnostic[]
|
||||
decorations: DecorationSet
|
||||
}>({
|
||||
create() {
|
||||
return { diagnostics: [], decorations: Decoration.none }
|
||||
},
|
||||
update({ diagnostics, decorations }, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setLTDiagnosticsEffect)) {
|
||||
const diags = effect.value
|
||||
if (diags.length === 0) {
|
||||
return { diagnostics: [], decorations: Decoration.none }
|
||||
}
|
||||
const marks = diags
|
||||
.filter(d => d.from < d.to && d.to <= tr.state.doc.length)
|
||||
.map(d =>
|
||||
Decoration.mark({
|
||||
class: `cm-lt-underline cm-lt-underline-${d.severity}`,
|
||||
}).range(d.from, d.to)
|
||||
)
|
||||
return {
|
||||
diagnostics: diags,
|
||||
decorations: Decoration.set(marks, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tr.docChanged) {
|
||||
const mapped = diagnostics
|
||||
.map(d => ({
|
||||
...d,
|
||||
from: tr.changes.mapPos(d.from, 1),
|
||||
to: tr.changes.mapPos(d.to, -1),
|
||||
}))
|
||||
.filter(d => d.from < d.to)
|
||||
return {
|
||||
diagnostics: mapped,
|
||||
decorations: decorations.map(tr.changes),
|
||||
}
|
||||
}
|
||||
return { diagnostics, decorations }
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f, val => val.decorations),
|
||||
})
|
||||
|
||||
// ─── Hover tooltip ────────────────────────────────────────────────────────────
|
||||
|
||||
const ltTooltip = hoverTooltip(
|
||||
(view, pos) => {
|
||||
const { diagnostics } = view.state.field(ltDiagnosticsField)
|
||||
const matches = diagnostics.filter(d => d.from <= pos && pos <= d.to)
|
||||
if (!matches.length) return null
|
||||
|
||||
const primary = matches[0]
|
||||
return {
|
||||
pos: primary.from,
|
||||
end: primary.to,
|
||||
above: true,
|
||||
// NB: this closure uses the guard's `view` (EditorView) — do NOT
|
||||
// shadow it, hoverTooltip passes the editor *state* to create().
|
||||
create() {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'cm-lt-tooltip'
|
||||
|
||||
for (const match of matches) {
|
||||
const entry = document.createElement('div')
|
||||
entry.className = 'cm-lt-tooltip-entry'
|
||||
|
||||
const msg = document.createElement('div')
|
||||
msg.className = 'cm-lt-tooltip-message'
|
||||
msg.textContent = match.message
|
||||
entry.appendChild(msg)
|
||||
|
||||
if (match.source) {
|
||||
const src = document.createElement('div')
|
||||
src.className = 'cm-lt-tooltip-source'
|
||||
src.textContent = match.source
|
||||
entry.appendChild(src)
|
||||
}
|
||||
|
||||
if (match.replacements.length) {
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'cm-lt-tooltip-actions'
|
||||
for (const rep of match.replacements) {
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 'cm-lt-tooltip-action'
|
||||
btn.textContent = rep
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
view.dispatch({
|
||||
changes: { from: match.from, to: match.to, insert: rep },
|
||||
})
|
||||
})
|
||||
actions.appendChild(btn)
|
||||
}
|
||||
entry.appendChild(actions)
|
||||
}
|
||||
|
||||
dom.appendChild(entry)
|
||||
}
|
||||
|
||||
return { dom }
|
||||
},
|
||||
}
|
||||
},
|
||||
{ hoverTime: 300 }
|
||||
)
|
||||
|
||||
// ─── Theme ────────────────────────────────────────────────────────────────────
|
||||
// Overleaf 6.x themes via body[data-theme] → semantic CSS variables
|
||||
// (--bg-primary-themed & friends, see
|
||||
// stylesheets/abstracts/themes-common-variables.scss). The light fallbacks
|
||||
// in var(...) keep the extension usable where the variables are undefined.
|
||||
|
||||
const ltTheme = EditorView.baseTheme({
|
||||
'.cm-lt-underline': {
|
||||
backgroundImage: 'none !important',
|
||||
textDecoration: 'underline wavy',
|
||||
textUnderlineOffset: '3px',
|
||||
},
|
||||
'.cm-lt-underline-warning': {
|
||||
textDecorationColor: 'var(--content-warning-themed, #E8A200)',
|
||||
},
|
||||
'.cm-lt-underline-error': {
|
||||
textDecorationColor: 'var(--content-danger-themed, #ff5252)',
|
||||
},
|
||||
'.cm-lt-tooltip': {
|
||||
color: 'var(--content-primary-themed, #333)',
|
||||
backgroundColor: 'var(--bg-primary-themed, #fff)',
|
||||
border: '1px solid var(--border-divider-themed, #ddd)',
|
||||
borderRadius: '4px',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
},
|
||||
'.cm-lt-tooltip-entry + .cm-lt-tooltip-entry': {
|
||||
marginTop: '8px',
|
||||
paddingTop: '8px',
|
||||
borderTop: '1px solid var(--border-divider-themed, #eee)',
|
||||
},
|
||||
'.cm-lt-tooltip-message': {
|
||||
marginBottom: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-source': {
|
||||
fontSize: '11px',
|
||||
color: 'var(--content-secondary-themed, #888)',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-actions': {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
},
|
||||
'.cm-lt-tooltip-action': {
|
||||
padding: '2px 8px',
|
||||
border: '1px solid var(--border-divider-themed, #ccc)',
|
||||
borderRadius: '3px',
|
||||
backgroundColor: 'var(--bg-secondary-themed, #f5f5f5)',
|
||||
color: 'var(--content-primary-themed, #333)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--bg-tertiary-themed, #e0e0e0)',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Module export ────────────────────────────────────────────────────────────
|
||||
|
||||
export const extension = (options: Record<string, any>): Extension => {
|
||||
/**
|
||||
* ViewPlugin that manages the full lifecycle:
|
||||
* 1. On mount — starts disabled until the first `lt:settings-changed`
|
||||
* event (dispatched by the source-editor scope with the project
|
||||
* settings) or the options snapshot confirms LT is available and
|
||||
* enabled for this project.
|
||||
* 2. On document changes — debounces a new check (2 s after last edit).
|
||||
* 3. Listens to `lt:settings-changed` window events carrying the
|
||||
* per-project `{ enabled, picky, language }` settings.
|
||||
* 4. Tracks Hunspell state dynamically via duck-typing on
|
||||
* setSpellCheckLanguageEffect payloads.
|
||||
* 5. Dispatches setLTDiagnosticsEffect to update the StateField, which
|
||||
* in turn drives the decoration layer and hover tooltip.
|
||||
*/
|
||||
const syncPlugin = ViewPlugin.define(view => {
|
||||
const initial: LTSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...(options?.languageTool ?? {}),
|
||||
}
|
||||
let ltServerAvailable = !!initial.available
|
||||
let hunspellActive = !!options?.spelling?.spellCheckLanguage
|
||||
let currentLanguage = initial.language || 'auto'
|
||||
let picky = initial.picky !== false
|
||||
let enabled = !!initial.available && (initial.enabled !== false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
|
||||
// ── Scheduling ──────────────────────────────────────────────────────
|
||||
|
||||
function scheduleCheck(delay = 2000) {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (!enabled) return
|
||||
timer = setTimeout(() => runCheck(), delay)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = null
|
||||
if (controller) controller.abort()
|
||||
controller = null
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
|
||||
}
|
||||
|
||||
// ── Core check logic ────────────────────────────────────────────────
|
||||
|
||||
async function runCheck() {
|
||||
if (!enabled) return
|
||||
|
||||
// Abort any in-flight request.
|
||||
if (controller) controller.abort()
|
||||
const ac = new AbortController()
|
||||
controller = ac
|
||||
|
||||
const source = view.state.doc.toString()
|
||||
if (source.trim().length < 20) {
|
||||
if (!ac.signal.aborted) {
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of([]) })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the Lezer syntax tree; fall back to regex-based parser.
|
||||
let annotations: LTAnnotation[]
|
||||
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 500)
|
||||
if (tree) {
|
||||
annotations = buildAnnotationsFromTree(view)
|
||||
} else {
|
||||
annotations = latexToAnnotations(source)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/languagetool/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': getMeta('ol-csrfToken') },
|
||||
body: JSON.stringify({
|
||||
language: currentLanguage,
|
||||
// picky mode ON (the default) → level=picky; OFF → level=default
|
||||
level: picky ? 'picky' : 'default',
|
||||
data: { annotation: annotations },
|
||||
}),
|
||||
signal: ac.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const result: { matches: LTMatch[] } = await response.json()
|
||||
|
||||
// Filter TYPOS when Hunspell is active (it handles typos better).
|
||||
let matches = result.matches
|
||||
if (hunspellActive) {
|
||||
matches = matches.filter(m => m.rule?.category?.id !== 'TYPOS')
|
||||
}
|
||||
|
||||
const docLen = view.state.doc.length
|
||||
const diagnostics: LTDiagnostic[] = matches
|
||||
.filter(m => m.offset >= 0 && m.offset + m.length <= docLen)
|
||||
.map(m => ({
|
||||
from: m.offset,
|
||||
to: m.offset + m.length,
|
||||
severity:
|
||||
m.rule?.category?.id === 'TYPOS'
|
||||
? ('error' as const)
|
||||
: ('warning' as const),
|
||||
message: m.message,
|
||||
source: `LanguageTool (${m.rule?.id ?? ''})${
|
||||
picky ? ' (picky)' : ''
|
||||
}`,
|
||||
replacements: (m.replacements ?? []).slice(0, 5).map(r => r.value),
|
||||
}))
|
||||
|
||||
if (!ac.signal.aborted) {
|
||||
view.dispatch({ effects: setLTDiagnosticsEffect.of(diagnostics) })
|
||||
}
|
||||
} catch {
|
||||
// AbortError or network failure — silently ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings-changed listener ───────────────────────────────────────
|
||||
//
|
||||
// Detail shape (from languagetool-settings.tsx / use-codemirror-scope):
|
||||
// { available?: boolean, enabled?: boolean, picky?: boolean, language?: string }
|
||||
|
||||
const applySettings = (detail: Partial<LTSettings>) => {
|
||||
if (detail.available != null) ltServerAvailable = !!detail.available
|
||||
const newLanguage = detail.language || currentLanguage || 'auto'
|
||||
const newPicky = detail.picky !== false
|
||||
const newEnabled = ltServerAvailable && (detail.enabled !== false)
|
||||
|
||||
const changed =
|
||||
newEnabled !== enabled || newPicky !== picky || newLanguage !== currentLanguage
|
||||
enabled = newEnabled
|
||||
picky = newPicky
|
||||
currentLanguage = newLanguage
|
||||
if (!enabled) {
|
||||
clearAll()
|
||||
} else if (changed) {
|
||||
scheduleCheck(100)
|
||||
}
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<Partial<LTSettings>>).detail
|
||||
if (!detail) return
|
||||
applySettings(detail)
|
||||
}
|
||||
|
||||
window.addEventListener('lt:settings-changed', handler)
|
||||
|
||||
// ── Initialisation ──────────────────────────────────────────────────
|
||||
if (enabled) {
|
||||
scheduleCheck(500)
|
||||
}
|
||||
|
||||
// ── ViewPlugin callbacks ────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged && enabled) {
|
||||
// Abort stale in-flight request and schedule a fresh check.
|
||||
if (controller) controller.abort()
|
||||
scheduleCheck()
|
||||
}
|
||||
|
||||
// Track Hunspell state changes (duck-typed from effects).
|
||||
for (const tr of update.transactions) {
|
||||
for (const effect of tr.effects) {
|
||||
const val = effect.value as Record<string, unknown> | null
|
||||
if (
|
||||
val !== null &&
|
||||
typeof val === 'object' &&
|
||||
'spellCheckLanguage' in val
|
||||
) {
|
||||
const newActive = !!(val.spellCheckLanguage as string)
|
||||
if (newActive !== hunspellActive) {
|
||||
hunspellActive = newActive
|
||||
if (enabled) scheduleCheck(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('lt:settings-changed', handler)
|
||||
if (timer) clearTimeout(timer)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
controller = null
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return [ltDiagnosticsField, ltTooltip, ltTheme, syncPlugin]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* LanguageTool settings section hook — registers the LanguageTool settings in
|
||||
* the Settings modal (Spelling and Language tab) through the
|
||||
* `settingsModalSpellcheckSections` slot.
|
||||
*
|
||||
* Signature required by the slot consumer: a default-exported function that
|
||||
* returns a SettingsSection (or null).
|
||||
*/
|
||||
|
||||
import type { SettingsSection } from '@/features/ide-settings/context/types'
|
||||
import LanguageToolSettings from './components/languagetool-settings'
|
||||
|
||||
export default function languageToolSection(): SettingsSection {
|
||||
return {
|
||||
key: 'languagetool',
|
||||
title: 'LanguageTool',
|
||||
settings: [
|
||||
{
|
||||
key: 'languagetool-settings',
|
||||
component: <LanguageToolSettings />,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* Converts LaTeX source to a LanguageTool annotation array.
|
||||
*
|
||||
* The invariant is:
|
||||
* annotations.map(a => a.text ?? a.markup).join('') === source
|
||||
*
|
||||
* This means that the character offsets returned by LanguageTool (which count
|
||||
* both text and markup characters) map directly to positions in the original
|
||||
* CodeMirror document — no offset remapping is needed.
|
||||
*
|
||||
* LaTeX commands, math environments, and comments are emitted as `markup`.
|
||||
* Regular text content is emitted as `text` and will be grammar-checked.
|
||||
*/
|
||||
|
||||
export interface LTAnnotation {
|
||||
text?: string
|
||||
markup?: string
|
||||
}
|
||||
|
||||
/** Math environments whose entire content should be treated as markup. */
|
||||
const MATH_ENVS = new Set([
|
||||
'equation',
|
||||
'equation*',
|
||||
'align',
|
||||
'align*',
|
||||
'alignat',
|
||||
'alignat*',
|
||||
'gather',
|
||||
'gather*',
|
||||
'multline',
|
||||
'multline*',
|
||||
'displaymath',
|
||||
'eqnarray',
|
||||
'eqnarray*',
|
||||
'flalign',
|
||||
'flalign*',
|
||||
'subequations',
|
||||
'math',
|
||||
'dmath',
|
||||
'dgroup',
|
||||
'dgroup*',
|
||||
])
|
||||
|
||||
export function latexToAnnotations(source: string): LTAnnotation[] {
|
||||
const annotations: LTAnnotation[] = []
|
||||
let textStart = 0 // start of the current un-emitted text segment
|
||||
let i = 0
|
||||
|
||||
/** Emit accumulated plain text up to `end`. */
|
||||
function flushText(end: number): void {
|
||||
if (end > textStart) {
|
||||
annotations.push({ text: source.slice(textStart, end) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `source[start..end)` as markup, flushing any buffered text first.
|
||||
* Also advances `textStart` to `end`.
|
||||
*/
|
||||
function addMarkup(start: number, end: number): void {
|
||||
flushText(start)
|
||||
if (end > start) {
|
||||
annotations.push({ markup: source.slice(start, end) })
|
||||
}
|
||||
textStart = end
|
||||
}
|
||||
|
||||
while (i < source.length) {
|
||||
const ch = source[i]
|
||||
|
||||
// ── LaTeX comments: % … (end of line) ──────────────────────────────────
|
||||
if (ch === '%') {
|
||||
let end = i + 1
|
||||
while (end < source.length && source[end] !== '\n') end++
|
||||
addMarkup(i, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Display math: $$…$$ ────────────────────────────────────────────────
|
||||
if (ch === '$' && i + 1 < source.length && source[i + 1] === '$') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '$' && source[j + 1] === '$') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Inline math: $…$ ───────────────────────────────────────────────────
|
||||
if (ch === '$') {
|
||||
const start = i
|
||||
let j = i + 1
|
||||
while (j < source.length && source[j] !== '$') {
|
||||
if (source[j] === '\\') j++ // skip one escaped character
|
||||
j++
|
||||
}
|
||||
const end = j < source.length ? j + 1 : j
|
||||
addMarkup(start, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// ── LaTeX commands ─────────────────────────────────────────────────────
|
||||
if (ch === '\\' && i + 1 < source.length) {
|
||||
const next = source[i + 1]
|
||||
|
||||
// \[…\] (display math)
|
||||
if (next === '[') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '\\' && source[j + 1] === ']') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// \(…\) (inline math)
|
||||
if (next === '(') {
|
||||
const start = i
|
||||
let j = i + 2
|
||||
while (j + 1 < source.length) {
|
||||
if (source[j] === '\\' && source[j + 1] === ')') {
|
||||
j += 2
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
addMarkup(start, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// \begin{envname}
|
||||
if (source.startsWith('begin{', i + 1)) {
|
||||
const closeBrace = source.indexOf('}', i + 7)
|
||||
if (closeBrace !== -1) {
|
||||
const envName = source.slice(i + 7, closeBrace)
|
||||
|
||||
if (MATH_ENVS.has(envName)) {
|
||||
// Entire math environment is markup
|
||||
const endTag = `\\end{${envName}}`
|
||||
const endIdx = source.indexOf(endTag, closeBrace + 1)
|
||||
const envEnd =
|
||||
endIdx !== -1 ? endIdx + endTag.length : source.length
|
||||
addMarkup(i, envEnd)
|
||||
i = envEnd
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-math environment: only mark up the \begin{...} tag itself
|
||||
addMarkup(i, closeBrace + 1)
|
||||
i = closeBrace + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// \end{…}
|
||||
if (source.startsWith('end{', i + 1)) {
|
||||
const closeBrace = source.indexOf('}', i + 5)
|
||||
const end = closeBrace !== -1 ? closeBrace + 1 : i + 5
|
||||
addMarkup(i, end)
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
// General alpha command: \commandname (optional trailing whitespace)
|
||||
if (/[a-zA-Z]/.test(next)) {
|
||||
let j = i + 1
|
||||
while (j < source.length && /[a-zA-Z]/.test(source[j])) j++
|
||||
// Absorb trailing horizontal whitespace into the markup token
|
||||
while (j < source.length && (source[j] === ' ' || source[j] === '\t'))
|
||||
j++
|
||||
addMarkup(i, j)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// Single non-letter command: \\, \%, \$, \{, \}, \&, \_, \#, etc.
|
||||
addMarkup(i, i + 2)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Curly braces (LaTeX grouping — always markup) ──────────────────────
|
||||
if (ch === '{' || ch === '}') {
|
||||
addMarkup(i, i + 1)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Regular text character ─────────────────────────────────────────────
|
||||
i++
|
||||
}
|
||||
|
||||
flushText(source.length)
|
||||
return annotations
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
let LanguageToolModule = {}
|
||||
if (process.env.LANGUAGE_TOOL_ENABLED?.toLowerCase() === 'true') {
|
||||
logger.debug({}, 'Enable LanguageTool module')
|
||||
|
||||
Settings.languageToolURL = process.env.LANGUAGE_TOOL_URL ||
|
||||
`http://${process.env.LANGUAGE_TOOL_HOST || 'languagetool'}:${process.env.LANGUAGE_TOOL_PORT || '8010'}`
|
||||
|
||||
const { default: LanguageToolRouter } = await import('./app/src/LanguageToolRouter.mjs')
|
||||
|
||||
LanguageToolModule = {
|
||||
router: LanguageToolRouter,
|
||||
}
|
||||
}
|
||||
|
||||
export default LanguageToolModule
|
||||
@@ -56,6 +56,5 @@ export type ExposedSettings = {
|
||||
linkedInInsightsPartnerId?: string
|
||||
githubSyncEnabled: boolean
|
||||
zoteroEnabled: boolean
|
||||
languageToolAvailable: boolean
|
||||
enablePandocConversions: boolean
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
.env
|
||||
@@ -1,155 +0,0 @@
|
||||
# LanguageTool server (Docker Compose)
|
||||
|
||||
Standalone [LanguageTool](https://languagetool.org) grammar-check server used by
|
||||
the Overleaf integration in `services/web/modules/languagetool`
|
||||
(Overleaf CE fork repo, see the module's `README.md`).
|
||||
|
||||
- **Image:** `erikvl87/languagetool:latest` (community server fork of LanguageTool;
|
||||
supports `level=default` and `level=picky` — other levels return HTTP 400)
|
||||
- **Container name:** `languagetool` (overleaf reaches it by this hostname on the shared network)
|
||||
- **Host port:** `127.0.0.1:32768` → container `8010` (pinned, stable across restarts)
|
||||
- **Compose project name:** `languagetools_server_compose` (directory name)
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tools/languagetools_server_compose/
|
||||
├── compose.yaml # top-level: `extends` the service in languagetool/compose.yaml
|
||||
├── up.sh # docker compose up -d
|
||||
├── down.sh # docker compose down
|
||||
├── up_languagetool.sh # docker compose up -d languagetool
|
||||
├── down_languagetool.sh # docker compose down languagetool
|
||||
├── logs_languagetool.sh # docker compose logs -f languagetool
|
||||
├── autoupdate_languagetool.sh # pull latest image, recreate container only if changed
|
||||
├── lt-test.tex # test document: sentences with verified expected detections
|
||||
└── languagetool/
|
||||
├── compose.yaml # actual service definition
|
||||
├── download_ngrams.sh # main n-gram set (de en es fr nl) → ngrams/
|
||||
├── download_ngrams_untested.sh # untested set (he it ru zh) → ngrams/untested/
|
||||
└── ngrams/ # n-gram data, mounted read-only into the container (gitignored)
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd tools/languagetools_server_compose
|
||||
|
||||
# 1. Populate n-gram data (one-time, ~45 GB disk; resumable, idempotent)
|
||||
./languagetool/download_ngrams.sh
|
||||
./languagetool/download_ngrams_untested.sh # optional: he/it/ru/zh
|
||||
|
||||
# 2. Start
|
||||
./up.sh
|
||||
|
||||
# 3. Verify (container must be healthy)
|
||||
docker ps --filter name=languagetool
|
||||
curl -s http://127.0.0.1:32768/v2/languages | head -c 200
|
||||
```
|
||||
|
||||
## N-gram data
|
||||
|
||||
LanguageTool loads per-language n-gram models from the directory given in
|
||||
`langtool_languageModel` (here: `/ngrams` ← `languagetool/ngrams/`).
|
||||
Every subdirectory named after a language code (e.g. `en/`, `de/`) is picked
|
||||
up — currently present: `de en es fr nl` (main) and `he it ru zh` (untested).
|
||||
|
||||
| Set | Languages | Size (extracted) | Zip sources |
|
||||
|---|---|---|---|
|
||||
| main | de en es fr nl | ~24 GB | `ngrams-<lang>-YYYYMMDD.zip` (~1.7–9 GB each) |
|
||||
| untested | he it ru zh | ~14 GB | `ngram-<lang>-YYYYMMDD.zip` (~0.2–2.3 GB each) |
|
||||
|
||||
- Downloads come from `https://languagetool.org/download/ngram-data/`
|
||||
(untested set: `.../ngram-data/untested/`).
|
||||
- Both scripts are idempotent: existing zips are skip-validated (`unzip -t`),
|
||||
downloads are resumable (`curl -C -`). Re-run safely after an interrupted transfer.
|
||||
- The downloaded zip archives stay in `ngrams/` (~21 GB total).
|
||||
Safe to `rm ngrams/*.zip` once extraction succeeded, to reclaim that space.
|
||||
- `an` (Aragonese) is **not** available on the server; it is kept commented
|
||||
out in `download_ngrams_untested.sh` in case it appears later.
|
||||
|
||||
> **Pitfall (cost us a debugging session):** if `ngrams/` does not exist on
|
||||
> the host, Docker creates an *empty* directory for the bind mount and the
|
||||
> server runs **without any n-gram models** — spelling detection
|
||||
> (`recieve`, `realy`, …) silently degrades while the API still answers.
|
||||
> Verify with `ls languagetool/ngrams/en` and a check request that
|
||||
> `MORFOLOGIK_RULE_EN_US` still fires.
|
||||
|
||||
## Service configuration (`languagetool/compose.yaml`)
|
||||
|
||||
| Setting | Value | Notes |
|
||||
|---|---|---|
|
||||
| `image` | `erikvl87/languagetool:latest` | community fork, HTTP REST API on 8010 |
|
||||
| `Java_Xms` / `Java_Xmx` | `512m` / `2g` | JVM heap |
|
||||
| `langtool_languageModel` | `/ngrams` | n-gram model directory (read-only bind mount) |
|
||||
| `ports` | `127.0.0.1:32768:8010` | host port pinned — do not reintroduce the ephemeral `127.0.0.1::8010` form |
|
||||
| `healthcheck` | `wget --spider http://localhost:8010/v2/languages` | 30s interval, 60s start period |
|
||||
| `restart` | `unless-stopped` | |
|
||||
|
||||
## Connecting Overleaf
|
||||
|
||||
The matching Overleaf side (CE fork v6.3.1) exposes the feature through
|
||||
`services/web/modules/languagetool/` — enabled via overleaf service env vars.
|
||||
In this deployment they live in `/data_1/docker/compose/overleaf/compose.yaml`:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
LANGUAGE_TOOL_ENABLED: "true"
|
||||
LANGUAGE_TOOL_HOST: languagetool # resolves on the shared docker network
|
||||
LANGUAGE_TOOL_PORT: "8010"
|
||||
```
|
||||
|
||||
- The overleaf container reaches LT through the shared network
|
||||
(`languagetools_server_compose_default`); from inside overleaf the host is
|
||||
`languagetool:8010`, from the host it is `127.0.0.1:32768`.
|
||||
- Overleaf checks `/v2/check` (POST, form-encoded `text`, `language`, `level`,
|
||||
plus `disabledRules`); client-side defaults: **picky level on**,
|
||||
spellcheck language = Overleaf default (`en-US`), and the checks are
|
||||
stored as **project settings** (shared with collaborators).
|
||||
- If `LANGUAGE_TOOL_ENABLED` is false or the proxy can't reach LT, Overleaf
|
||||
simply does not offer the feature (no client errors).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# basic
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
-d 'language=en-US' -d 'text=You can recieve a bonus.' -d 'level=picky'
|
||||
|
||||
# picky vs default (passive voice is picky-only on this build)
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
--data-urlencode 'language=en-US' --data-urlencode 'text=The error was detected by the test suite.' \
|
||||
-d 'level=default' # → no passive-voice match
|
||||
curl -s -X POST http://127.0.0.1:32768/v2/check \
|
||||
--data-urlencode 'language=en-US' --data-urlencode 'text=The error was detected by the test suite.' \
|
||||
-d 'level=picky' # → PASSIVE_VOICE_SIMPLE
|
||||
```
|
||||
|
||||
Editor test document: **`lt-test.tex`** in this directory.
|
||||
Verified expectations (2026-10-26, with the exact `disabledRules` the proxy sends):
|
||||
|
||||
| Picky mode | Underlines |
|
||||
|---|---|
|
||||
| **on** (Overleaf default) | **27** — section A (2 passive-voice) + section B (23) |
|
||||
| **off** | **25** — section A disappears, section B stays |
|
||||
|
||||
See the file header for the per-sentence rule annotations.
|
||||
|
||||
## Update / rollback
|
||||
|
||||
```bash
|
||||
./autoupdate_languagetool.sh # pull erikvl87/languagetool:latest, recreate only if image changed
|
||||
docker compose down && docker compose up -d # manual restart
|
||||
docker compose logs -f languagetool # tail logs
|
||||
```
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
- **Overleaf can't reach LT** — from the overleaf container:
|
||||
`wget -O- http://languagetool:8010/v2/languages`. If DNS fails, the overleaf
|
||||
service is not attached to the `languagetools_server_compose_default` network
|
||||
(see the two `networks` blocks above; both must carry the same name).
|
||||
- **Port changed after a recreate** — always use the pinned form
|
||||
`"127.0.0.1:32768:8010"`; the old `"127.0.0.1::8010"` form picks a random
|
||||
host port per container creation.
|
||||
- **No spelling detections** — n-gram mount empty, see the pitfall above.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_NAME="languagetool"
|
||||
IMAGE_NAME="erikvl87/languagetool:latest"
|
||||
YAML_DIRECTORY_NAME="${COMPOSE_DIRECTORY:-$(pwd)}"
|
||||
|
||||
# Get current image ID (short format)
|
||||
CURRENT_IMAGE=$(docker inspect --format='{{.Image}}' "${SERVICE_NAME}" 2>/dev/null | cut -d: -f2 | cut -c1-12) || true
|
||||
|
||||
# Pull the latest image
|
||||
echo "Pulling latest image..."
|
||||
docker pull "${IMAGE_NAME}"
|
||||
|
||||
# Get new image ID (short format)
|
||||
NEW_IMAGE=$(docker images "${IMAGE_NAME}" --format '{{.ID}}')
|
||||
|
||||
echo "Current image: $CURRENT_IMAGE"
|
||||
echo "Latest image: $NEW_IMAGE"
|
||||
|
||||
# Compare and restart only if different
|
||||
if [ -z "$CURRENT_IMAGE" ]; then
|
||||
echo "Container not found, starting it up..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
elif [ "$CURRENT_IMAGE" != "$NEW_IMAGE" ]; then
|
||||
echo "New image detected, restarting container..."
|
||||
cd "${YAML_DIRECTORY_NAME}"
|
||||
docker compose down "${SERVICE_NAME}"
|
||||
docker compose up -d "${SERVICE_NAME}"
|
||||
echo "Update completed!"
|
||||
else
|
||||
echo "Already running the latest version, no restart needed."
|
||||
fi
|
||||
@@ -1,7 +0,0 @@
|
||||
services:
|
||||
languagetool:
|
||||
extends:
|
||||
file: ./languagetool/compose.yaml
|
||||
service: languagetool
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
docker compose down
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
docker compose down languagetool
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ngrams
|
||||
@@ -1,21 +0,0 @@
|
||||
services:
|
||||
languagetool:
|
||||
image: erikvl87/languagetool:latest
|
||||
container_name: languagetool
|
||||
hostname: languagetool
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
Java_Xms: 512m
|
||||
Java_Xmx: 2g
|
||||
langtool_languageModel: /ngrams
|
||||
ports:
|
||||
- "127.0.0.1:32768:8010"
|
||||
volumes:
|
||||
- ./ngrams:/ngrams:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --quiet --spider --timeout=5 http://localhost:8010/v2/languages 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download & extract LanguageTool n-gram data (main set: de en es fr nl)
|
||||
# into ngrams/ next to this script.
|
||||
#
|
||||
# Sources: https://languagetool.org/download/ngram-data/
|
||||
# ngrams-de-20150819.zip (~1.7 GB)
|
||||
# ngrams-en-20150817.zip (~9.0 GB)
|
||||
# ngrams-es-20150915.zip (~1.7 GB)
|
||||
# ngrams-fr-20150913.zip (~1.8 GB)
|
||||
# ngrams-nl-20181229.zip (~1.2 GB)
|
||||
#
|
||||
# Usage: ./download_ngrams.sh
|
||||
# Re-running is safe: already-downloaded & valid zips are skipped (download is
|
||||
# resumable via curl -C).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NGRAMS_DIR="$BASE/ngrams"
|
||||
BASE_URL="https://languagetool.org/download/ngram-data"
|
||||
|
||||
# lang -> zip filename (verified against server directory listing)
|
||||
declare -a FILES=(
|
||||
"ngrams-de-20150819.zip"
|
||||
"ngrams-en-20150817.zip"
|
||||
"ngrams-es-20150915.zip"
|
||||
"ngrams-fr-20150913.zip"
|
||||
"ngrams-nl-20181229.zip"
|
||||
)
|
||||
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
|
||||
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$NGRAMS_DIR"
|
||||
echo "Target dir: $NGRAMS_DIR"
|
||||
|
||||
failed=0
|
||||
for zip in "${FILES[@]}"; do
|
||||
url="$BASE_URL/$zip"
|
||||
dest="$NGRAMS_DIR/$zip"
|
||||
|
||||
echo "=================================================================="
|
||||
echo "$zip"
|
||||
|
||||
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
|
||||
echo " [skip] already downloaded and valid: $zip"
|
||||
else
|
||||
echo " [dl] $url"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
|
||||
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
|
||||
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
|
||||
rm -f "$dest" # 404/error body would corrupt the name for future runs
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " [extract] -> $NGRAMS_DIR"
|
||||
if ! unzip -o -q "$dest" -d "$NGRAMS_DIR"; then
|
||||
echo " [FAIL] extraction failed for $zip" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=================================================================="
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All done. Contents:"
|
||||
ls -lh "$NGRAMS_DIR"
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download & extract LanguageTool n-gram data (UNTESTED set) into ngrams/untested/
|
||||
# next to this script.
|
||||
#
|
||||
# Sources: https://languagetool.org/download/ngram-data/untested/
|
||||
# ngram-he-20150916.zip (~225 MB)
|
||||
# ngram-it-20150915.zip (~1.1 GB)
|
||||
# ngram-ru-20150914.zip (~2.3 GB)
|
||||
# ngram-zh-20150916.zip (~1.7 GB)
|
||||
#
|
||||
# NOTE: "an" (Aragonese) was requested but does NOT exist on the server
|
||||
# (checked ngram-data/, untested/, temp/, archive/ — only he/it/ru/zh are
|
||||
# offered). Kept commented out so it can be enabled if it appears later.
|
||||
#
|
||||
# Usage: ./download_ngrams_untested.sh
|
||||
# Re-running is safe: already-downloaded & valid zips are skipped (download is
|
||||
# resumable via curl -C).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
NGRAMS_DIR="$BASE/ngrams"
|
||||
UNTESTED_DIR="$NGRAMS_DIR"
|
||||
BASE_URL="https://languagetool.org/download/ngram-data/untested"
|
||||
|
||||
# zip filenames (verified against server directory listing)
|
||||
declare -a FILES=(
|
||||
"ngram-he-20150916.zip"
|
||||
"ngram-it-20150915.zip"
|
||||
"ngram-ru-20150914.zip"
|
||||
"ngram-zh-20150916.zip"
|
||||
# "ngram-an-*.zip (404 — not available on the server as of 2025/2026)"
|
||||
)
|
||||
|
||||
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 1; }
|
||||
command -v unzip >/dev/null || { echo "ERROR: unzip not found" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$UNTESTED_DIR"
|
||||
echo "Target dir: $UNTESTED_DIR"
|
||||
|
||||
failed=0
|
||||
for zip in "${FILES[@]}"; do
|
||||
url="$BASE_URL/$zip"
|
||||
dest="$UNTESTED_DIR/$zip"
|
||||
|
||||
echo "=================================================================="
|
||||
echo "$zip"
|
||||
|
||||
if [ -f "$dest" ] && unzip -t "$dest" >/dev/null 2>&1; then
|
||||
echo " [skip] already downloaded and valid: $zip"
|
||||
else
|
||||
echo " [dl] $url"
|
||||
if ! curl --fail --location --retry 3 --retry-delay 5 -C - \
|
||||
--connect-timeout 30 --progress-bar -o "$dest" "$url"; then
|
||||
echo " [FAIL] download failed for $url (partial file kept for resume: $dest)" >&2
|
||||
rm -f "$dest" # 404/error body would corrupt the name for future runs
|
||||
failed=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
echo " [extract] -> $UNTESTED_DIR"
|
||||
if ! unzip -o -q "$dest" -d "$UNTESTED_DIR"; then
|
||||
echo " [FAIL] extraction failed for $zip" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=================================================================="
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
echo "Finished with ERRORS — re-run to resume failed downloads." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All done. Contents:"
|
||||
ls -lh "$UNTESTED_DIR"
|
||||
@@ -1,2 +0,0 @@
|
||||
docker compose logs -f languagetool
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
% LanguageTool level test — ext-ce-v6.3.1 + erikvl87/languagetool
|
||||
%
|
||||
% Section A: expected to be flagged ONLY in Picky mode (level=picky).
|
||||
% Section B: expected to be flagged in BOTH modes (default and picky).
|
||||
%
|
||||
% All sentences below were verified live against the running server
|
||||
% (2026-10-26), with the exact disabledRules the Overleaf proxy sends.
|
||||
%
|
||||
% Usage: open in Overleaf, wait ~2 s after the last edit,
|
||||
% hover a wavy underline for the message + one-click replacements.
|
||||
% Toggle Settings → Spelling and Language → LanguageTool → "Picky mode"
|
||||
% off/on and compare: section A marks should appear/disappear.
|
||||
|
||||
\documentclass{article}
|
||||
\begin{document}
|
||||
|
||||
\section*{A: Picky-only (missed at default level)}
|
||||
|
||||
% PASSIVE_VOICE_SIMPLE — passive voice
|
||||
The experiment was conducted by our group.
|
||||
|
||||
% PASSIVE_VOICE_SIMPLE — passive voice
|
||||
The error was detected by the test suite.
|
||||
|
||||
\section*{B: Both modes (default and picky)}
|
||||
|
||||
% EN_A_VS_AN — wrong article
|
||||
An sequence off independent variables.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
You can recieve a bonus for this.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
This is a realy interesting result.
|
||||
|
||||
% MORFOLOGIK_RULE_EN_US — spelling mistake
|
||||
He shoulda told us about the crash.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
The results shows a significant difference.
|
||||
|
||||
% BEEN_PART_AGREEMENT — possible agreement error
|
||||
A new paper was publish last week.
|
||||
|
||||
% THERE_S_MANY — possible agreement error
|
||||
There is three reasons for this choice.
|
||||
|
||||
% IT_IS — possible typo (missing apostrophe)
|
||||
Its time to finish the manuscript.
|
||||
|
||||
% AFFECT_EFFECT — commonly confused word
|
||||
This should have an affect on the outcome.
|
||||
|
||||
% LOOSE_LOSE — possible typo
|
||||
You will loose the key if you leave it here.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
You should of checked the configuration.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
It might of happened last night.
|
||||
|
||||
% MODAL_OF — possible typo
|
||||
We could of fixed the bug yesterday.
|
||||
|
||||
% INFORMATIONS — nonstandard word
|
||||
We need more informations about this topic.
|
||||
|
||||
% EN_A_VS_AN — wrong article (uncountable)
|
||||
He gave me a advice yesterday morning.
|
||||
|
||||
% ENGLISH_WORD_REPEAT_RULE — word repetition
|
||||
The the method was tested twice.
|
||||
|
||||
% ADMIT_ENJOY_VB — use 'ing'
|
||||
I look forward to hear from you again.
|
||||
|
||||
% TOO_TO — possible typo
|
||||
I want to too participate in this.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
Each of the students have passed the exam.
|
||||
|
||||
% AGREEMENT_SENT_START — agreement error
|
||||
The dog which barks is friendly.
|
||||
|
||||
% RB_RB_COMMA — intensifier comma
|
||||
We have a very very good result here.
|
||||
|
||||
% FREE_GIFT — redundant phrase
|
||||
This is a free gift for all of you.
|
||||
|
||||
% ADMIT_ENJOY_VB — use 'ing'
|
||||
The manual recommends to restart the server.
|
||||
|
||||
% ALLOW_TO — use a gerund
|
||||
The tool allows to configure the output.
|
||||
|
||||
% ALLOW_TO — use a gerund
|
||||
The server requires to restart after the change.
|
||||
|
||||
\end{document}
|
||||
@@ -1,2 +0,0 @@
|
||||
docker compose up -d
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
docker compose up -d languagetool
|
||||
|
||||
Reference in New Issue
Block a user