Repository navigation
Expand file tree
/
Copy pathReferenceShortener.java
More file actions
548 lines (485 loc) · 27.6 KB
/
Copy pathReferenceShortener.java
File metadata and controls
548 lines (485 loc) · 27.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
///usr/bin/env jbang "$0" "$@" ; exit $?
//DESCRIPTION Shortens a LaTeX paper's references until it fits a page count
//JAVA 25
//RUNTIME_OPTIONS --enable-native-access=ALL-UNNAMED
//FILES tinylog.properties=tinylog.properties
//FILES META-INF/native-image/reference-shortener/reflect-config.json=native-image/reflect-config.json
// raw is for the patched icu4j (https://github.com/unicode-org/icu/pull/2127) referenced by jablib's POM
//REPOS mavenlocal,mavencentral,mavencentralsnapshots=https://central.sonatype.com/repository/maven-snapshots/,raw=https://raw.githubusercontent.com/JabRef/jabref/refs/heads/main/jablib/lib/
//DEPS org.jabref:jablib:6.0-SNAPSHOT
// see https://github.com/gradlex-org/extra-java-module-info/issues/237 why e-adr is included here
//DEPS io.github.adr:e-adr:2.0.0
//DEPS info.picocli:picocli:4.7.7
// Generates GraalVM reflection config for the picocli annotations at compile time (native image).
// -proc:full is required since JDK 23 disabled implicit annotation processing.
//DEPS info.picocli:picocli-codegen:4.7.7
//COMPILE_OPTIONS -proc:full
// Colored terminal output; bundles jansi — see main() for the jansi/picocli native-image workaround.
//DEPS com.github.tomas-langer:chalk:1.0.2
//NATIVE_OPTIONS --no-fallback --enable-url-protocols=https -H:IncludeResources=journals/.*|tinylog.properties
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jabref.logic.cleanup.AbbreviateJournalCleanup;
import org.jabref.logic.cleanup.DoiCleanup;
import org.jabref.logic.cleanup.FieldFormatterCleanup;
import org.jabref.logic.exporter.AtomicFileWriter;
import org.jabref.logic.exporter.BibDatabaseWriter;
import org.jabref.logic.formatter.bibtexfields.CleanupUrlFormatter;
import org.jabref.logic.formatter.bibtexfields.ClearFormatter;
import org.jabref.logic.formatter.bibtexfields.ShortenDOIFormatter;
import org.jabref.logic.formatter.minifier.MinifyNameListFormatter;
import org.jabref.logic.importer.ParserResult;
import org.jabref.logic.importer.fileformat.BibtexImporter;
import org.jabref.logic.journals.AbbreviationType;
import org.jabref.logic.journals.JournalAbbreviationLoader;
import org.jabref.logic.journals.JournalAbbreviationRepository;
import org.jabref.logic.preferences.JabRefCliPreferences;
import org.jabref.logic.texparser.DefaultLatexParser;
import org.jabref.model.database.BibDatabase;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.field.StandardField;
import org.jabref.model.texparser.LatexParserResult;
import org.jabref.model.util.DummyFileUpdateMonitor;
import com.github.tomaslanger.chalk.Chalk;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
/// Shortens the reference list cited by a LaTeX document until the compiled paper reaches a target
/// page count, recompiling with latexmk after each cleanup and stopping as soon as the target is met.
/// Cleanups are applied in this order:
///
/// 1. Clear the `location` field (and its BibTeX equivalent `address`) — few styles print it, so it is the cheapest information to lose.
/// 2. Normalize DOIs — a cleanup any well-formed paper wants anyway, not an information-losing trade-off.
/// 3. Clean up URLs — unwrap redirect wrappers and decode escapes; also a cleanup any paper wants.
/// 4. Minify author lists to the first author ("et al.").
/// 5. Clear the `month` field.
/// 6. Abbreviate journal names.
/// 7. Shorten DOIs to their `10/xxx` form (via shortdoi.org, needs the network) — the very last resort, since not everyone likes short DOIs.
///
/// The referenced `.bib` file(s) are rewritten with the smallest set of cleanups that reaches the target.
///
/// Built on jablib, JabRef's core library.
@Command(name = "reference-shortener",
mixinStandardHelpOptions = true,
// a plain version string (not an IVersionProvider) keeps the native image free of extra reflection
version = "reference-shortener 0.2.0",
description = "Shortens a LaTeX paper's references (via latexmk) until it fits a page count.")
public class ReferenceShortener implements Callable<Integer> {
@Parameters(index = "0", paramLabel = "FILE.tex", description = "LaTeX document whose cited references should be shortened.")
Path texFile;
@Option(names = "--pages", description = "Target page count. Default: one page fewer than the current length.")
Integer pages;
@Option(names = "--output", description = "Write the shortened .bib here instead of overwriting the referenced file (single .bib only).")
Path outputFile;
// getInstance() is deprecation-marked to restrict it to application entry points — which this is
// (the same sanctioned usage as the jablib-examples jbang scripts).
private final JabRefCliPreferences preferences = JabRefCliPreferences.getInstance();
public static void main(String[] args) {
// chalk pulls in jansi, whose Windows-console native init crashes inside a native image.
// "passthrough" makes jansi emit ANSI without touching that native path; it must be set
// before any jansi class loads.
System.setProperty("jansi.passthrough", "true");
// Chalk does not strip ANSI on its own, so decide here: honor NO_COLOR and only colorize a
// real terminal (System.console() is null when the output is piped or redirected).
boolean color = System.getenv("NO_COLOR") == null && System.console() != null;
Chalk.setColorEnabled(color);
// picocli otherwise auto-probes jansi (calcIsJansiConsoleInstalled) for its own help output,
// which trips the same native crash. Force its ANSI to our decision so it never probes.
System.setProperty("picocli.ansi", Boolean.toString(color));
System.exit(new CommandLine(new ReferenceShortener()).execute(args));
}
/// Signals a user-facing failure; the message is printed to stderr and the exit code returned.
static final class AbortException extends Exception {
final int exitCode;
AbortException(String message, int exitCode) {
super(message);
this.exitCode = exitCode;
}
}
/// Persists the current entry state, compiles the document, and returns its page count.
@FunctionalInterface
interface PageCounter {
int measure() throws AbortException;
}
/// A single shortening escalation level. `apply` mutates the loaded entries in place.
record Step(String description, Runnable apply) {
}
record Result(int baselinePages, int finalPages, int targetPages, List<String> appliedSteps, boolean reachedTarget) {
}
/// Pairs a loaded database with the staged file it is compiled from and the cited entries to shorten.
private record LoadedBib(BibDatabase database, Path stagedPath, List<BibEntry> citedEntries) {
}
@Override
public Integer call() {
try {
return run();
} catch (AbortException e) {
System.err.println(Chalk.on(e.getMessage()).red());
return e.exitCode;
}
}
private Integer run() throws AbortException {
if (pages != null && pages < 1) {
throw new AbortException("--pages must be at least 1, was " + pages, CommandLine.ExitCode.USAGE);
}
if (!Files.isRegularFile(texFile)) {
throw new AbortException("Not a readable file: " + texFile, CommandLine.ExitCode.USAGE);
}
// Work on a copy so the loop never touches the user's tree until a validated result exists,
// and so latexmk's aux/pdf/log artifacts land in a throwaway directory. The directory is
// created before the try so the finally removes it even if the copy itself fails partway.
Path workDir = createStagingDirectory();
try {
copyDirectoryContents(texFile.toAbsolutePath().getParent(), workDir);
return shorten(workDir);
} finally {
deleteRecursivelyQuietly(workDir);
}
}
private Integer shorten(Path workDir) throws AbortException {
Path workTex = workDir.resolve(texFile.getFileName());
LatexParserResult parsed = new DefaultLatexParser().parse(workTex)
.orElseThrow(() -> new AbortException("Could not read " + texFile, CommandLine.ExitCode.USAGE));
if (parsed.getBibFiles().isEmpty()) {
throw new AbortException("No bibliography file found for " + texFile, CommandLine.ExitCode.USAGE);
}
Set<String> citedKeys = parsed.getCitations().keySet();
List<LoadedBib> loadedBibs = loadBibs(parsed.getBibFiles(), citedKeys);
// --output can only name one destination, so reject multi-.bib papers before compiling or
// mutating anything — silently falling back to in-place edits would defeat the point of the
// flag (avoiding in-place modification).
if (outputFile != null && loadedBibs.size() != 1) {
throw new AbortException(
"--output supports a single .bib, but '%s' references %d".formatted(texFile, loadedBibs.size()),
CommandLine.ExitCode.USAGE);
}
List<Step> steps = buildSteps(loadedBibs);
LatexCompiler compiler = new LatexCompiler(workTex);
if (compiler.usesDocker()) {
System.out.println(Chalk.on("No local TeX found; compiling with the Island of TeX Docker image.").gray());
}
PageCounter measure = () -> {
for (LoadedBib bib : loadedBibs) {
saveDatabase(bib.database(), bib.stagedPath());
}
return compiler.compileAndCountPages();
};
Result result = runShortener(steps, measure,
pages == null ? OptionalInt.empty() : OptionalInt.of(pages));
return report(workDir, loadedBibs, result);
}
/// Runs the escalation loop: applies steps one at a time, re-measuring after each, and stops as
/// soon as the page count reaches the target. When no target is given, the target is one page
/// shorter than the baseline — but never below 1, so a single-page paper is left untouched.
static Result runShortener(List<Step> steps, PageCounter measure, OptionalInt targetPages) throws AbortException {
int baseline = measure.measure();
int target = targetPages.orElse(Math.max(1, baseline - 1));
List<String> applied = new ArrayList<>();
int currentPages = baseline;
for (Step step : steps) {
if (currentPages <= target) {
break;
}
step.apply().run();
applied.add(step.description());
currentPages = measure.measure();
}
return new Result(baseline, currentPages, target, applied, currentPages <= target);
}
private List<Step> buildSteps(List<LoadedBib> loadedBibs) {
JournalAbbreviationRepository journalRepository = JournalAbbreviationLoader.loadBuiltInRepository();
// `location` is the biblatex field; `address` is its BibTeX equivalent — clear whichever is present.
FieldFormatterCleanup clearLocation = new FieldFormatterCleanup(StandardField.LOCATION, new ClearFormatter());
FieldFormatterCleanup clearAddress = new FieldFormatterCleanup(StandardField.ADDRESS, new ClearFormatter());
FieldFormatterCleanup cleanupUrls = new FieldFormatterCleanup(StandardField.URL, new CleanupUrlFormatter());
FieldFormatterCleanup minifyAuthors = new FieldFormatterCleanup(StandardField.AUTHOR, new MinifyNameListFormatter());
FieldFormatterCleanup clearMonth = new FieldFormatterCleanup(StandardField.MONTH, new ClearFormatter());
FieldFormatterCleanup shortenDois = new FieldFormatterCleanup(StandardField.DOI, new ShortenDOIFormatter());
// Position = precedence: the loop stops at the first step that reaches the target, so the
// least painful losses come first. The location/address goes right away (few styles print it),
// followed by the normalizations any well-formed paper wants anyway (DOI, URL). Author
// minification is the biggest single reducer; `month` is the next-cheapest drop, then journal
// abbreviation. DOI shortening (the 10/xxx form) is the very last resort: it needs the network
// and not everyone likes short DOIs, so it only runs if nothing else reached the target.
return List.of(
new Step("clear the location field",
() -> loadedBibs.forEach(bib -> bib.citedEntries().forEach(entry -> {
clearLocation.cleanup(entry);
clearAddress.cleanup(entry);
}))),
new Step("clean up DOIs",
() -> loadedBibs.forEach(bib -> bib.citedEntries().forEach(new DoiCleanup()::cleanup))),
new Step("clean up URLs",
() -> loadedBibs.forEach(bib -> bib.citedEntries().forEach(cleanupUrls::cleanup))),
new Step("shorten authors to the first author",
() -> loadedBibs.forEach(bib -> bib.citedEntries().forEach(minifyAuthors::cleanup))),
new Step("remove the month field",
() -> loadedBibs.forEach(bib -> bib.citedEntries().forEach(clearMonth::cleanup))),
new Step("abbreviate journal names",
() -> loadedBibs.forEach(bib -> bib.citedEntries()
.forEach(new AbbreviateJournalCleanup(bib.database(), journalRepository, AbbreviationType.DEFAULT, false)::cleanup))),
new Step("shorten DOIs to the short form",
() -> shortenDoisQuietly(shortenDois, loadedBibs)));
}
/// Applies short-DOI shortening, tolerating failure: the short form is fetched from shortdoi.org,
/// so if the network is unavailable (offline, service down, or unsupported in a native image) the
/// step is skipped rather than failing the whole run. Per-DOI lookup failures are already handled
/// inside the formatter (it returns the original); this guards against transport-level errors.
private static void shortenDoisQuietly(FieldFormatterCleanup shortenDois, List<LoadedBib> loadedBibs) {
try {
loadedBibs.forEach(bib -> bib.citedEntries().forEach(shortenDois::cleanup));
} catch (Exception e) {
System.err.println(Chalk.on("Warning: skipped DOI shortening (needs access to shortdoi.org): " + e.getMessage()).yellow());
}
}
private Integer report(Path workDir, List<LoadedBib> loadedBibs, Result result) throws AbortException {
if (result.appliedSteps().isEmpty()) {
System.out.println(Chalk.on("'%s' already fits within %d pages; nothing to shorten.".formatted(texFile, result.baselinePages())).cyan());
return CommandLine.ExitCode.OK;
}
writeBack(workDir, loadedBibs);
String appliedSteps = String.join(", ", result.appliedSteps());
if (result.reachedTarget()) {
System.out.println(Chalk.on("Shortened '%s' from %d to %d pages by applying: %s.".formatted(
texFile, result.baselinePages(), result.finalPages(), appliedSteps)).green());
return CommandLine.ExitCode.OK;
}
System.err.println(Chalk.on("Could not reach the target of %d pages; best effort is %d pages after applying: %s.".formatted(
result.targetPages(), result.finalPages(), appliedSteps)).yellow());
return CommandLine.ExitCode.OK;
}
private void writeBack(Path workDir, List<LoadedBib> loadedBibs) throws AbortException {
Path sourceRoot = texFile.toAbsolutePath().getParent();
for (LoadedBib bib : loadedBibs) {
// outputFile is only reached with exactly one bib (validated earlier); otherwise map the
// staged copy back onto the user's tree, preserving its subdirectory.
Path destination = outputFile != null
? outputFile
: resolveWriteBackDestination(sourceRoot, workDir, bib.stagedPath());
saveDatabase(bib.database(), destination);
System.out.println(Chalk.on("Saved " + destination).gray());
}
}
/// Maps a staged `.bib` back to the user's source tree, preserving the path (e.g. subdirectory)
/// it had relative to the staging root — the LaTeX parser resolves and keeps subdirectories, so
/// writing only the basename would target the wrong file. Refuses paths that escape the source
/// directory via `..`.
static Path resolveWriteBackDestination(Path sourceRoot, Path workDir, Path stagedBib) throws AbortException {
Path relative = workDir.relativize(stagedBib);
Path destination = sourceRoot.resolve(relative).normalize();
if (!destination.startsWith(sourceRoot.normalize())) {
throw new AbortException(
"Refusing to write '%s' outside the project directory '%s'".formatted(destination, sourceRoot),
CommandLine.ExitCode.SOFTWARE);
}
return destination;
}
private List<LoadedBib> loadBibs(List<Path> bibFiles, Set<String> citedKeys) throws AbortException {
BibtexImporter importer = new BibtexImporter(preferences.getImportFormatPreferences(), new DummyFileUpdateMonitor());
List<LoadedBib> loadedBibs = new ArrayList<>();
for (Path bibFile : bibFiles) {
ParserResult parserResult;
try {
parserResult = importer.importDatabase(bibFile);
} catch (IOException e) {
throw new AbortException("Could not read " + bibFile + ": " + e.getMessage(), CommandLine.ExitCode.SOFTWARE);
}
BibDatabase database = parserResult.getDatabase();
// No cite keys, or the \nocite{*} wildcard -> every entry may be printed, so shorten all.
boolean shortenAll = citedKeys.isEmpty() || citedKeys.contains("*");
List<BibEntry> citedEntries = database.getEntries().stream()
.filter(entry -> shortenAll
|| entry.getCitationKey().map(citedKeys::contains).orElse(false))
.toList();
loadedBibs.add(new LoadedBib(database, bibFile, citedEntries));
}
return loadedBibs;
}
private void saveDatabase(BibDatabase database, Path target) throws AbortException {
try (AtomicFileWriter writer = new AtomicFileWriter(target, StandardCharsets.UTF_8)) {
BibDatabaseContext context = new BibDatabaseContext(database);
new BibDatabaseWriter(writer, context, preferences).writeDatabase(context);
} catch (IOException e) {
throw new AbortException("Could not write " + target + ": " + e.getMessage(), CommandLine.ExitCode.SOFTWARE);
}
}
private static Path createStagingDirectory() throws AbortException {
try {
return Files.createTempDirectory("reference-shortener");
} catch (IOException e) {
throw new AbortException("Could not create a staging directory: " + e.getMessage(), CommandLine.ExitCode.SOFTWARE);
}
}
private static void copyDirectoryContents(Path source, Path target) throws AbortException {
try (var paths = Files.walk(source)) {
for (Path path : (Iterable<Path>) paths::iterator) {
Path destination = target.resolve(source.relativize(path));
if (Files.isDirectory(path)) {
Files.createDirectories(destination);
} else {
Files.createDirectories(destination.getParent());
Files.copy(path, destination);
}
}
} catch (IOException e) {
throw new AbortException("Could not stage '%s' for compilation: %s".formatted(source, e.getMessage()), CommandLine.ExitCode.SOFTWARE);
}
}
/// Best-effort recursive delete of the staging directory. Never throws: a leftover artifact
/// (for example a Docker-created file with different ownership) must not fail an otherwise
/// successful run.
private static void deleteRecursivelyQuietly(Path directory) {
try (var paths = Files.walk(directory)) {
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
// per-file failure: reported via the existence warning below
}
});
} catch (IOException e) {
// walk failure: reported via the existence warning below
}
if (Files.exists(directory)) {
System.err.println(Chalk.on("Warning: could not fully remove staging directory " + directory).yellow());
}
}
/// Compiles a LaTeX document with `latexmk` (which drives the pdflatex/bibtex/biber passes
/// itself, and re-runs them when the `.bib` changes) and reports the resulting page count. The
/// count is read from the pdflatex `.log` (`Output written on X.pdf (N pages, ...)`) rather than
/// by parsing the PDF, so no PDF library is needed. Prefers a local `latexmk`; falls back to the
/// Island of TeX `texlive/texlive` Docker image when no local TeX distribution is present.
static class LatexCompiler {
/// pdflatex writes this line once per run; the last occurrence reflects the final pass.
private static final Pattern OUTPUT_PAGES = Pattern.compile("Output written on \\S+\\.pdf \\((\\d+) pages?");
private static final String DOCKER_IMAGE = "texlive/texlive";
private static final int TIMEOUT_SECONDS = 300;
private final Path workingDir;
private final String texFileName;
private final boolean useDocker;
LatexCompiler(Path texFile) {
this.workingDir = texFile.toAbsolutePath().getParent();
this.texFileName = texFile.getFileName().toString();
this.useDocker = !isLocalLatexmkAvailable();
}
boolean usesDocker() {
return useDocker;
}
/// Compiles the document and returns the page count of the produced PDF.
int compileAndCountPages() throws AbortException {
int exitCode = runLatexmk();
return pageCountOrFail(exitCode, readLog(), texFileName);
}
/// Turns a compile run into a page count, or fails. A non-zero latexmk exit is a hard
/// failure even when the log still carries a page count: pdflatex writes a PDF in
/// nonstopmode despite errors, and shortening must not proceed on top of a broken compile.
static int pageCountOrFail(int exitCode, String log, String texFileName) throws AbortException {
if (exitCode != 0) {
throw new AbortException(
"LaTeX compilation of '%s' failed (latexmk exit %d). Run latexmk manually to see the error.".formatted(texFileName, exitCode),
CommandLine.ExitCode.SOFTWARE);
}
OptionalInt parsedPages = parsePageCount(log);
if (parsedPages.isEmpty()) {
throw new AbortException(
"latexmk reported success but produced no page count for '%s'".formatted(texFileName),
CommandLine.ExitCode.SOFTWARE);
}
return parsedPages.getAsInt();
}
private int runLatexmk() throws AbortException {
try {
Process process = new ProcessBuilder(buildCommand())
.directory(workingDir.toFile())
.redirectErrorStream(true)
.start();
// Drain the output so the process cannot block on a full pipe buffer.
process.getInputStream().readAllBytes();
if (!process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
process.destroyForcibly();
throw new AbortException("latexmk timed out compiling '%s'".formatted(texFileName), CommandLine.ExitCode.SOFTWARE);
}
return process.exitValue();
} catch (IOException e) {
throw new AbortException("Could not run latexmk (install a TeX distribution or Docker): " + e.getMessage(), CommandLine.ExitCode.SOFTWARE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AbortException("Interrupted while compiling '%s'".formatted(texFileName), CommandLine.ExitCode.SOFTWARE);
}
}
private List<String> buildCommand() {
if (useDocker) {
List<String> command = new ArrayList<>(List.of("docker", "run", "--rm"));
// Without --user, the bind-mounted artifacts (pdf/log/aux) are created as the image's
// default user (often root), which the invoking user then cannot read back or clean up.
unixUserFlag().ifPresent(user -> {
command.add("--user");
command.add(user);
});
command.addAll(List.of(
"-v", workingDir + ":/work", "-w", "/work",
DOCKER_IMAGE,
"latexmk", "-pdf", "-interaction=nonstopmode", texFileName));
return command;
}
return List.of("latexmk", "-pdf", "-interaction=nonstopmode", texFileName);
}
/// The invoking user's `uid:gid` for Docker's `--user`, read from the (self-created) working
/// directory. Empty on non-Unix filesystems, where Docker manages bind-mount ownership itself.
private Optional<String> unixUserFlag() {
try {
Object uid = Files.getAttribute(workingDir, "unix:uid");
Object gid = Files.getAttribute(workingDir, "unix:gid");
return Optional.of(uid + ":" + gid);
} catch (IOException | UnsupportedOperationException e) {
return Optional.empty();
}
}
/// Read with Latin-1: TeX logs are not guaranteed UTF-8, and Latin-1 maps every byte without
/// throwing while leaving the ASCII "Output written on" line intact.
private String readLog() {
Path log = workingDir.resolve(texFileName.replaceFirst("\\.tex$", "") + ".log");
try {
return Files.exists(log) ? Files.readString(log, StandardCharsets.ISO_8859_1) : "";
} catch (IOException e) {
return "";
}
}
static OptionalInt parsePageCount(String log) {
Matcher matcher = OUTPUT_PAGES.matcher(log);
int parsedPages = -1;
while (matcher.find()) {
parsedPages = Integer.parseInt(matcher.group(1));
}
return parsedPages < 0 ? OptionalInt.empty() : OptionalInt.of(parsedPages);
}
private static boolean isLocalLatexmkAvailable() {
String path = System.getenv("PATH");
if (path == null) {
return false;
}
for (String dir : path.split(File.pathSeparator)) {
if (!dir.isBlank()
&& (Files.isExecutable(Path.of(dir, "latexmk")) || Files.isExecutable(Path.of(dir, "latexmk.exe")))) {
return true;
}
}
return false;
}
}
}