#!/usr/bin/env perl
# Copyright (C) 2014--2026 Karl Wette
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
use strict;
use Cwd;
use File::Basename;
use File::Copy;
use File::Spec;
use File::Temp;
use FindBin qw($Script);
use Getopt::Long qw(:config no_ignore_case);
use Pod::Usage;
=pod
=head1 NAME
B - Make a submission-ready tarball of an academic paper manuscript.
=head1 SYNOPSIS
B B<--help>|B<-h>
B B<--version>|B<-v>
B [ B<--zip>|B<-z> ] [ B<--nodir>|B<-D> ] [ B<--base>|B<-b> I ] I
=head1 DESCRIPTION
B prepares an academic paper manuscript, whose main LaTeX file is I (with or without the I<.tex> extension), for submission. It performs the following tasks:
=over 4
=item *
Check that the manuscript can be compiled.
=item *
Identify source files (e.g. figures, bibliography) and copy then to a temporary directory.
=item *
Rename graphic files after the number of the figure/table that includes then, e.g. I.
Multiple graphic files per figure/table are named e.g. I, I etc.
=item *
Check that figures and tables are cited in order.
=item *
Insert LaTeX files included by I<\input> directly into the main LaTeX file.
=item *
Insert code listings included by I<\lstinputlisting> directly into the main LaTeX file.
=item *
Insert BibTex-generated bibliography directly into the main LaTeX file.
=item *
Strip out comments.
Lines between comment lines
% protect comments ...
and
% end protect comments ...
are not stripped for comments.
=item *
Copy auxiliary files to the tarball. Use a comment line of the form:
% add-to-tarball
=item *
Create an archive containing only the the main LaTeX file, required source files, and specified auxiliary files.
The archive is copied to the current directory.
=back
=head1 OPTIONS
=over 4
=item B<--zip>|B<-z>
Create a I<.zip> archive instead of a I<.tar.gz> archive (the default).
=item B<--nodir>|B<-D>
Do not put files into a subdirectory within the archive (the default); instead all files will be at the top level of the archive.
=item B<--base>|B<-b> I
Use I as the base name for the archive, instead of I without the I<.tex> extension (the default).
=back
=head1 AUTHOR
Karl Wette
Project homepage: https://github.com/kwwette/make-paper-tarball
=cut
# use latexmk to compile paper
sub run_latexmk {
my ($dir, $file, $config) = @_;
print "$Script: checking that '$file.tex' can be compiled in directory '$dir/'...\n";
my $status = system("cd '$dir' && latexmk -norc -silent -Werror -pdf '$file.tex' >/dev/null 2>/dev/null");
if ($status != 0) {
my $log = "$dir/$file.log";
open(my $logfile, "$log") or die "$Script: could not read from '$log': $!";
my $printlog = 0;
while (my $line = <$logfile>) {
chomp($line);
print STDERR "$Script: could not compile '$file.tex': $line\n";
}
close $logfile;
exit $status;
}
print "$Script: checking that '$file.tex' can be compiled in directory '$dir/'... yes\n";
}
# parse latexmk file database
sub parse_fdb {
my ($fdbfile, $srcfiles) = @_;
print "$Script: parsing '$fdbfile'...\n";
open (my $fh, $fdbfile) or die "$Script: could not open '$fdbfile': $!";
while (<$fh>) {
if (/^\s+"(.*?)"/) {
my $srcfile = File::Spec->canonpath($1);
# exclude absolute paths e.g. from system directories
if (File::Spec->file_name_is_absolute($srcfile)) {
next;
}
# exclude temporary files associated with LaTeX files
my ($srcname, $srcdir, $srcext) = fileparse($srcfile, qr/\.[^.]*$/);
my $texsrcfile = File::Spec->canonpath(File::Spec->catfile($srcdir, "$srcname.tex"));
if (-f $texsrcfile) {
$srcfile = $texsrcfile;
}
# store unique list of files
$srcfiles->{$srcfile} = $srcfile;
}
}
close $fh;
}
# copy source files
sub copy_src {
my ($srcfiles, $srcbasedir, $destbasedir) = @_;
foreach my $srcfile (keys %{$srcfiles}) {
my ($srcname, $srcdir) = fileparse($srcfile);
my $destdir = File::Spec->canonpath(File::Spec->catdir($destbasedir, $srcdir));
if (! -d $destdir) {
mkdir $destdir or die "$Script: could not create directory '$destdir/': $!";
}
my $src = File::Spec->catfile($srcbasedir, $srcfile);
my $dest = File::Spec->catfile($destdir, $srcfiles->{$srcfile});
copy($src, $dest) or die "$Script: could not copy source file '$src' to '$dest': $!";
print "$Script: copied source file '$src' to '$dest'\n";
}
}
# strip out comments from LaTeX file
sub strip_comments {
my ($line, $protect) = @_;
# parse line
$line =~ s/\s+$//;
my ($text, $comment);
if ($line =~ /^(|.*?[^%\\])%+\s*(.*)$/) {
$text = $1;
$comment = $2;
} else {
$text = $line;
$comment = "";
}
$text =~ s/\s+$//;
$text = undef if $text eq "";
# return line if no comment
return $line unless $comment ne "";
# protect comments between lines with special comments, e.g.:
# % protect comments (more text here is fine)
# \begin{lstlisting}
# ... text here is protected ...
# \end{lstlisting}
# % end protect comments (more text here is fine)
if ($comment =~ /^(end\s+)?protect\s+comments?/i) {
$$protect = $1 eq "" ? 1 : 0;
return $text;
} elsif ($$protect) {
return $line;
}
# return line without comments
return $text;
}
# handle help options
my $version = 0;
my $help = 0;
my $zip = 0;
my $nodir = 0;
my $tarballbase;
GetOptions(
"version|v" => \$version,
"help|h" => \$help,
"zip|z" => \$zip,
"nodir|D" => \$nodir,
"base|b=s" => \$tarballbase,
) or die "$Script: could not parse options";
if ($version) {
print '1.1' . "\n";
exit 1;
}
if ($help) {
pod2usage(-verbose => 2, -exitval => 1)
}
die "$Script: wrong number of arguments" if @ARGV != 1;
# get main LaTeX file name
my $texbase = $ARGV[0];
$texbase =~ s/\.tex$//;
my $texbasefile = "$texbase.tex";
die "$Script: $texbasefile is not a file" unless -f $texbasefile;
# get tarball base name
$tarballbase = $texbase unless defined($tarballbase);
# check that paper can be compiled
run_latexmk ".", $texbase;
# get figure/table order from labels
my %order;
my $auxord = 0;
print "$Script: parsing '$texbase.aux'...\n";
open(my $fh, "$texbase.aux") or die "$Script: could not read from '$texbase.aux': $!";
while (<$fh>) {
if (/^\\newlabel\{(fig|tab):(.*?)\}\{\{([[:digit:]]+[[:lower:]]*)\}/) {
my $type = $1;
my $label = $2;
my $num = $3;
if ($num =~ /^\d+$/) {
$order{$type}{$label} = ++$auxord;
}
}
}
close $fh;
# make directories
my $basedir = File::Temp->newdir( CLEANUP => 1 );
print "$Script: using temporary directory '$basedir'\n";
my $testdir = "$basedir/${tarballbase}_test";
mkdir $testdir or die "$Script: could not create directory '$testdir/': $!";
my $tarballdir = "$basedir/$tarballbase";
mkdir $tarballdir or die "$Script: could not create directory '$tarballdir/': $!";
# copy source files
my %srcfiles;
parse_fdb("$texbase.fdb_latexmk", \%srcfiles);
copy_src \%srcfiles, ".", $testdir;
# check that paper can be compiled in test directory
run_latexmk $testdir, $texbase;
# add LaTeX code to manuscript to extract file names and figure numbers of plots
my $testtexbasefile = File::Spec->catfile($testdir, $texbasefile);
print "$Script: extracting file names and figure numbers of plots...\n";
open (my $fh, ">$testtexbasefile") or die "$Script: could not open '$testtexbasefile': $!";
print $fh <<'EOF';
\RequirePackage{etoolbox}
\makeatletter
\newwrite\makepapertarball@out
\immediate\openout\makepapertarball@out=\jobname.makepapertarball_out
\gdef\makepapertarball@prefix{none}
\newcount\makepapertarball@figure
\makepapertarball@figure 0\relax
\AtBeginEnvironment{figure}{%
\global\advance\makepapertarball@figure 1\relax%
\gdef\makepapertarball@prefix{figure-\the\makepapertarball@figure}%
}
\AtEndEnvironment{figure}{%
\gdef\makepapertarball@prefix{none}%
}
\newcount\makepapertarball@table
\makepapertarball@table 0\relax
\AtBeginEnvironment{table}{%
\global\advance\makepapertarball@table 1\relax%
\gdef\makepapertarball@prefix{table-\the\makepapertarball@table}%
}
\AtEndEnvironment{table}{%
\gdef\makepapertarball@prefix{none}%
}
\AtBeginDocument{%
\let\makepapertarball@includegraphics\includegraphics%
\renewcommand{\includegraphics}[2][]{%
\immediate\write\makepapertarball@out{\makepapertarball@prefix:#2}%
\makepapertarball@includegraphics[#1]{#2}%
}%
}
\makeatother
EOF
open (my $fh2, $texbasefile) or die "$Script: could not open '$texbasefile': $!";
while (<$fh2>) {
print $fh $_;
}
close $fh2;
close $fh;
# compile paper in test directory
run_latexmk $testdir, $texbase;
# parse output names/numbers of graphics
my %graphicfiles;
my $outfile = File::Spec->catfile($testdir, "$texbase.makepapertarball_out");
open (my $fh, $outfile) or die "$Script: could not open '$outfile': $!";
while (<$fh>) {
chomp;
my ($prefix, $graphicfile) = split(":", $_, 2);
next if $prefix eq "none";
push @{$graphicfiles{$prefix}}, $graphicfile;
}
# rename graphics files by number of figure/table
foreach my $prefix (keys %graphicfiles) {
my $n = @{$graphicfiles{$prefix}};
my $i = 0;
foreach my $graphicfile (@{$graphicfiles{$prefix}}) {
my ($graphicname, $graphicdir, $graphicext) = fileparse($graphicfile, qr/\.[^.]*$/);
die "Unknown graphic file '$graphicfile'" unless defined($srcfiles{$graphicfile});
$i += 1;
if ($n > 1) {
$srcfiles{$graphicfile} = "${prefix}-plot-${i}${graphicext}";
} else {
$srcfiles{$graphicfile} = "${prefix}${graphicext}";
}
my $src = File::Spec->catfile($testdir, $graphicfile);
my $dest = File::Spec->catfile($testdir, $srcfiles{$graphicfile});
move($src, $dest) or die "$Script: could not move' source file '$src' to '$dest': $!";
print "$Script: moved source file '$src' to '$dest'\n";
}
}
# generate main LaTeX file
my %reforder;
my $texord = 0;
my $protect = 0;
my $testtexmainfile = File::Spec->catfile($testdir, "main.tex");
print "$Script: generating '$testtexmainfile' from '$texbasefile' and included files ...\n";
open(my $fhin, "$texbasefile") or die "$Script: could not read from '$texbasefile': $!";
open(my $fhout, ">$testtexmainfile") or die "$Script: could not write to '$testtexmainfile': $!";
while (my $line = <$fhin>) {
chomp($line);
# get figure/table reference order
while ($line =~ /\\(?:[a-z]*)ref\{(.*?):(.*?)\}/g) {
my $prefix = $1;
my $label = $2;
next unless defined($order{$prefix}{$label});
next if defined($reforder{$prefix}{$label});
$reforder{$prefix}{$label} = ++$texord;
}
# insert files included by \input
if ($line =~ /^(.*?)\\input\{(.*?)\}(.*?)$/) {
my $pre = $1;
my $inputfile = $2;
my $post = $3;
# insert file
print $fhout "$pre" if $pre =~ /\S/;
open(my $infile, "$inputfile") or die "$Script: could not read from '$inputfile': $!";
my $inprotect = 0;
while (my $inline = <$infile>) {
chomp($inline);
# strip out comments
$inline = strip_comments($inline, \$inprotect);
next if !defined($inline);
# otherwise print to output file
print $fhout "$inline\n";
}
close $infile;
print $fhout "$post\n" if $post =~ /\S/;
next;
}
# rename graphics files by number of figure/table
if ($line =~ /^(.*?)\\includegraphics(\[(?:[^\[\]]++|(?2))*\])?\{(.*?)\}(.*?)$/) {
my $pre = $1;
my $graphicopts = $2;
my $graphicfile = $3;
my $post = $4;
$graphicfile = File::Spec->canonpath($graphicfile);
die "Unknown graphic file '$graphicfile'" unless defined($srcfiles{$graphicfile});
print $fhout "${pre}\\includegraphics${graphicopts}\{$srcfiles{$graphicfile}\}${post}\n";
next;
}
# insert files included by \lstinputlisting
if ($line =~ /^\s*\\lstinputlisting(\[(?:[^\[\]]++|(?1))*\])?\{(.*?)\}\s*$/) {
my $lstopts = $1;
my $lstfile = $2;
# insert file
print $fhout "\\begin{lstlisting}$lstopts\n";
open(my $infile, "$lstfile") or die "$Script: could not read from '$lstfile': $!";
while (my $inline = <$infile>) {
chomp($inline);
print $fhout "$inline\n";
}
close $infile;
print $fhout "\\end{lstlisting}\n";
next;
}
# insert BibTeX-generated bibliography
next if $line =~ /^\s*\\bibliographystyle\{.*?\}\s*$/;
if ($line =~ /^\s*\\bibliography\{.*?\}\s*$/) {
# insert file
open(my $infile, "$texbase.bbl") or die "$Script: could not read from '$texbase.bbl': $!";
while (my $inline = <$infile>) {
chomp($inline);
print $fhout "$inline\n";
}
close $infile;
next;
}
# copy over auxiliary files
if ($line =~ /^%\s*add-to-tarball\s+(\S+)\s+(\S+)$/) {
my $srcfile = $1;
my $destfile = File::Spec->catfile($tarballdir, $2);
my $destdir = dirname($destfile);
mkdir $destdir;
copy("$srcfile", "$destfile") or die "$Script: could not copy auxiliary file '$srcfile' to '$destfile': $!";
print "$Script: copied auxiliary file '$srcfile' to '$destfile'";
}
# strip out comments
$line = strip_comments($line, \$protect);
next if !defined($line);
# otherwise print to output file
print $fhout "$line\n";
}
close $fhin;
close $fhout;
# check figures/tables are cited in order
print "$Script: checking figures/tables are cited in order...\n";
foreach my $prefix (keys(%order)) {
my @labels = sort { $order{$prefix}{$a} <=> $order{$prefix}{$b} } keys(%{$order{$prefix}});
my @refs = sort { $reforder{$prefix}{$a} <=> $reforder{$prefix}{$b} } keys(%{$reforder{$prefix}});
foreach my $label (@labels) {
my $ref = shift @refs;
if ($ref ne $label) {
if ((grep { $_ eq $label } @refs) > 0) {
die "$Script: figure '$prefix:$label' is cited out of order";
} else {
die "$Script: figure '$prefix:$label' is not cited";
}
}
}
}
# check that main.tex can be compiled
run_latexmk $testdir, "main";
# copy source files
my %tarballsrcfiles;
parse_fdb(File::Spec->catfile($testdir, "main.fdb_latexmk"), \%tarballsrcfiles);
copy_src \%tarballsrcfiles, $testdir, $tarballdir;
# create tarball
print "$Script: creating tarball...\n";
my $tarballfile;
my $tarballcmd;
if ($zip) {
$tarballfile = "$tarballbase.zip";
$tarballcmd = "zip --quiet --recurse-paths";
} else {
$tarballfile = "$tarballbase.tar.gz";
$tarballcmd = "tar --create --gzip --sort=name --file";
}
if ($nodir) {
system("cd '$basedir/$tarballbase' && $tarballcmd '../$tarballfile' *") == 0 or die "$Script: could not create tarball: $!";
} else {
system("cd '$basedir' && $tarballcmd '$tarballfile' '$tarballbase/'") == 0 or die "$Script: could not create tarball: $!";
}
copy("$basedir/$tarballfile", ".") or die "$Script: could not copy file '$basedir/$tarballfile' to '.': $!";
print "$Script: created tarball '$tarballfile' in current directory\n";