#! /usr/bin/env perl ################################################ # Prepare a LaTeX package for upload to CTAN # # By Scott Pakin # ################################################ use Cwd; use File::Basename; use File::Copy::Recursive qw(fcopy); use File::Find; use File::Path; use File::Spec; use File::stat; use File::Temp qw(tempdir); use Getopt::Long; use Pod::Usage; use warnings; use strict; # Define some global variables. our $VERSION = "1.9.1"; # ctanify version number my $progname = basename $0; # Name of this program my $pkgname; # Base name of the package to create my $miscify = 0; # 1=replace singletons with misc; 0=don't my $autoinclude = 1; # 1=automatically include files named in .ins my $skipdroppings = 1; # 1=skip "dropping" files (e.g., "README~") my $unixify = 1; # 1=make text files use Unix line endings my $maketds = 1; # 1=embed a .tds.zip file; 0=don't my @manifest; # List of files to include my %file2tds; # Map from specific filenames to TDS directories my @tdsdirlist; # Contents of the TDS tree my @pkgdirlist; # Contents of the package tree my @tdsonly; # Files to include only in the TDS tree my $tdsdir = ""; # Absolute directory of the TDS tree my $pkgdir = ""; # Absolute directory of the package directory my $zipname; # Name of the TDS zip file my $tarname; # Name of the package tar file my $tdsoutdir; # Directory name where to output TDS tree my $texmacros = "latex"; # TeX macro package # Associate file extensions with TDS directories. "%s" is replaced # with the package name. my %ext2tds = ("README" => "doc/latex/%s", "afm" => "fonts/afm/public/%s", "bat" => "scripts/%s", "bbx" => "tex/latex/%s", "bib" => "bibtex/bib/%s", "bst" => "bibtex/bst/%s", "cbx" => "tex/latex/%s", "cls" => "tex/latex/%s", "dbx" => "tex/latex/%s", "dtx" => "source/latex/%s", "dvi" => "doc/latex/%s", "fd" => "tex/latex/%s", "ins" => "source/latex/%s", "map" => "fonts/map/dvips/%s", "md" => "doc/latex/%s", "mf" => "fonts/source/public/%s", "mp" => "metapost/%s", "ofm" => "fonts/ofm/public/%s", "ovf" => "fonts/ovf/public/%s", "ovp" => "fonts/ovp/public/%s", "pdf" => "doc/latex/%s", "pfb" => "fonts/type1/public/%s", "pfm" => "fonts/type1/public/%s", "ps" => "doc/latex/%s", "py" => "scripts/%s", "sh" => "scripts/%s", "sty" => "tex/latex/%s", "tfm" => "fonts/tfm/public/%s", "txt" => "doc/latex/%s", "vf" => "fonts/vf/public/%s"); # Specify a list of filename regexps to ignore. my @ignore_re = ('~$', '\bCVS\b', '\b\.svn\b'); # Specify a list of file extensions that are considered "text" files. # This script will convert their line endings to Unix style (a single # linefeed character). my %text_ext = map {($_ => 1)} qw(afm bib bst cls dtx fd ins ltx md mf mp sty tex txt); # Define a subroutine that returns the size in bytes of a file, # aborting on error. sub filesize ($) { my $finfo = lstat($_[0]) || die "${progname}: Failed to stat $_ ($!)\n"; return $finfo->size; } # Define a subroutine that runs a command and aborts on error. sub run_command ($;$) { my $command = $_[0]; my $ignore_errors = defined $_[1] ? $_[1] : 0; my $retval = system $command; if (!$ignore_errors && $retval>>8) { die "${progname}: Command \"$command\" failed\n"; } } # Define a subroutine that Unix-ifies the line endings in text files. # This subroutine is called from File::Find so it does not accept # arguments via the @_ variable. my %reported_line_endings; # Files we converted and told the user about sub use_unix_line_endings { # Determine if the file is a text file. my $fname = $_; return if !-f $fname; my $fext = ($fname =~ m|([^./]+)$| && $1); return if !defined $text_ext{$fext}; # Make it use Unix line endings. Note that we don't bother # checking if we're already on a Unix (or Unix-like) system. my $oldsize = filesize $fname; open(TEXTFILE, "<$fname") || die "${progname}: Failed to open $File::Find::name ($!)\n"; my @entirefile = map {s/[\n\r]+$/\n/; $_} ; close TEXTFILE; open(BINARYFILE, ">$fname") || die "${progname}: Failed to open $File::Find::name ($!)\n"; binmode BINARYFILE; print BINARYFILE @entirefile; close BINARYFILE; my $newsize = filesize $fname; if ($newsize != $oldsize && !defined $reported_line_endings{$fname}) { # Don't frighten the user when he sees a different file size # in the tar file from the original in the filesystem my $fullname = $File::Find::name; $fullname =~ s,^$tdsdir/,,; $fullname =~ s,^$pkgdir/,,; warn "${progname}: Modified $fullname to use Unix line endings (use --no-unixify to prevent this)\n"; $reported_line_endings{$fname} = 1; } } ########################################################################### # Parse the command line. my $wanthelp = 0; my $wantversion = 0; GetOptions("p|pkgname=s" => \$pkgname, "m|misc!" => \$miscify, "k|skip!" => \$skipdroppings, "u|unixify!" => \$unixify, "t|tdsonly=s" => \@tdsonly, "a|auto!" => \$autoinclude, "d|tdsdir=s" => \$tdsoutdir, "T|tex=s" => \$texmacros, "s|tds!" => \$maketds, "V|version" => \$wantversion, "h|help" => \$wanthelp) || pod2usage(-verbose => 0, -exitval => 1); pod2usage(-verbose => 1, -exitval => 0) if $wanthelp; if ($wantversion) { print "ctanify version $VERSION\n"; exit 0; } pod2usage(-verbose => 0, -exitval => 1) if !@ARGV; foreach my $fname (@ARGV) { # Let the user specify "=" to place files # explicitly in the TDS tree. if ($fname =~ /^(.*)=([^=]+)$/) { foreach my $exp_fname (glob $1) { $file2tds{$exp_fname} = $2; push @manifest, $exp_fname; } } else { push @manifest, glob $fname; } } @tdsonly = map {File::Spec->abs2rel($_)} map {glob} @tdsonly; # Replace "latex" with something else if --tex was specified. if ($texmacros ne "latex") { foreach my $ext (keys %ext2tds) { $ext2tds{$ext} =~ s,\blatex\b,$texmacros,g; } } # Determine the package name if not explicitly specified. if (!defined $pkgname) { my @mainfiles = (grep(/\.ins$/, @manifest), grep(/\.sty$/, @manifest)); if (!@mainfiles) { die "${progname}: Please either list a .ins or .sty file or specify --pkgname\n"; } $pkgname = basename $mainfiles[0]; $pkgname =~ s/\.[^.]+$//; } # Parse each .ins file to find more files to include. if ($autoinclude) { foreach my $fname (grep /\.ins$/, @manifest) { # Read the entire file. local $/ = undef; open(INSFILE, "<$fname") || die "${progname}: Failed to open $fname ($!)\n"; my $insfile = ; close INSFILE; $insfile =~ s/\%.*?\n//g; # Add all source files (\from) to the manifest. while ($insfile =~ /\\from\{([^\}]+)\}/g) { my $fname_exp = $1; $fname_exp =~ s/\\jobname\b/$pkgname/g; push @manifest, $fname_exp; } # Add all generated files (\file) to the manifest but also to # the list of TDS-only inclusions. while ($insfile =~ /\\file\{([^\}]+)\}/g) { my $fname_exp = $1; $fname_exp =~ s/\\jobname\b/$pkgname/g; push @manifest, $fname_exp; push @tdsonly, $fname_exp; } } } # Skip "dropping" files and files beginning with ".". if ($skipdroppings) { my @newmanifest; CHECK_DROPPING: foreach my $fname (@manifest) { if (substr($fname, 0, 1) eq ".") { warn "${progname}: Excluding $fname (use --no-skip to force inclusion)\n"; next CHECK_DROPPING; } foreach my $dropping_re (@ignore_re) { if ($fname =~ $dropping_re) { warn "${progname}: Excluding $fname (use --no-skip to force inclusion)\n"; next CHECK_DROPPING; } } push @newmanifest, $fname; } @manifest = @newmanifest; } # Map each file to a TDS directory. foreach my $fname (@manifest) { next if defined $file2tds{$fname}; my $fext = ($fname =~ m|([^./]+)$| && $1); if ($fname =~ /\.tex$/ && $pkgname eq basename $fname, ".tex") { # .tex -- treat as documentation. $file2tds{$fname} = sprintf $ext2tds{"README"}, $pkgname; } elsif (defined $ext2tds{$fext}) { # Most files -- determine the directory based on the file extension. $file2tds{$fname} = sprintf $ext2tds{$fext}, $pkgname; } elsif ($fext =~ /^(\d+)[a-z]*$/i) { # Man page (e.g., "ls.1" or "File::Temp.3pm"). $file2tds{$fname} = "doc/man/man$1"; } else { # Read the file to determine if it's a Unix script (starts with "#!"). open(SCRIPT, "<", $fname) || die "${progname}: Failed to open $fname ($!)\n"; my $shebang; read(SCRIPT, $shebang, 2); close SCRIPT; if ($shebang eq "#!") { $file2tds{$fname} = "scripts/$pkgname"; } else { warn "${progname}: Not including $fname in the TDS tree (unknown extension)\n"; } } } # Create a working directory and populate it with TDS files. my $workdir = tempdir("$progname-XXXXXX", TMPDIR => 1, CLEANUP => 1); $tdsdir = $tdsoutdir ? $tdsoutdir : "$workdir/texmf"; mkdir $tdsdir || die "${progname}: Failed to create $tdsdir ($!)\n"; foreach my $fname (@manifest) { # Create a TDS subdirectory. my $subdir = $file2tds{$fname}; next if !defined $subdir; mkpath "$tdsdir/$subdir"; # Copy the specified file into the subdirectory. fcopy($fname, "$tdsdir/$subdir/" . basename $fname) || die "${progname}: Failed to copy $fname ($!)\n"; } if ($miscify) { # Replace package directories containing a single file with "misc". my %renamings; find(sub { return if $File::Find::name =~ m|\btexmf/fonts\b|; if (-d) { my @children = glob "$_/*"; if ($#children == 0 && -f $children[0] && $_ eq $pkgname) { $renamings{$File::Find::name} = $File::Find::dir . "/misc"; } } }, $tdsdir); while (my ($oldname, $newname) = each %renamings) { rename $oldname, $newname; } } if ($unixify) { # Make all text files use Unix line endings. find(\&use_unix_line_endings, $tdsdir); } # Complain if the doc directory contains PostScript or DVI files. my @bad_docs; find(sub { return if !-f; push @bad_docs, $_ if $File::Find::name =~ m,\bdoc/[^/]+/$pkgname/.*\.(ps|dvi)$,; }, $tdsdir); if (@bad_docs) { my $bad_docs = join ", ", @bad_docs; warn "${progname}: CTAN prefers having only PDF documentation (re: $bad_docs)\n"; } # We have no more work to do if we're simply creating a TDS directory. exit 0 if $tdsoutdir; # Store a listing of the TDS directory. my $prevdir = getcwd(); chdir $tdsdir || die "${progname}: Failed to switch to $tdsdir ($!)\n"; find(sub { return if !-f; my $fsize = filesize $_; push @tdsdirlist, [substr($File::Find::name, 2), $fsize]; }, "."); # Archive and remove the TDS directory. $zipname = "$pkgname.tds.zip"; my $zip_exclusions = $skipdroppings ? "-x __MACOSX -x .DS_Store" : ""; run_command "zip -q -r -9 -y -m $workdir/$zipname . $zip_exclusions", 1; my $zipsize; if (-f "$workdir/$zipname") { $zipsize = filesize "$workdir/$zipname"; } else { $zipname = ""; } $zipname = "" if !$maketds; chdir $prevdir || die "${progname}: Failed to switch to $prevdir ($!)\n"; rmdir $tdsdir || die "${progname}: Failed to remove $tdsdir ($!)\n"; # Copy all files named in the file manifest to the package directory. $pkgdir = "$workdir/$pkgname"; mkdir $pkgdir || die "${progname}: Failed to create $pkgdir ($!)\n"; my %tdsonly = map {($_ => 1)} @tdsonly; foreach my $fname (@manifest) { my $relname = File::Spec->abs2rel($fname); next if defined $tdsonly{$relname}; my ($namepart, $pathpart, $suffixpart) = fileparse($relname); mkpath "$pkgdir/$pathpart"; my $targetfile = "$pkgdir/$pathpart/$namepart$suffixpart"; fcopy($fname, $targetfile) || die "${progname}: Failed to copy $fname ($!)\n"; } if ($unixify) { # Make all text files use Unix line endings. find(\&use_unix_line_endings, $pkgdir); } # Store a listing of the package directory. $prevdir = getcwd(); chdir $pkgdir || die "${progname}: Failed to switch to $pkgdir ($!)\n"; find(sub { return if !-f; my $fsize = filesize $_; push @pkgdirlist, [substr($File::Find::name, 2), $fsize]; }, "."); chdir $prevdir || die "${progname}: Failed to switch to $prevdir ($!)\n"; # Tar up the package directory. $tarname = "$pkgname.tar.gz"; my $tar_exclusions = $skipdroppings ? "--exclude=__MACOSX --exclude=.DS_Store" : ""; run_command "tar -czf $tarname -C $workdir $tar_exclusions $pkgname $zipname"; # Output a listing of what we tarred up. printf "\n%8d %s\n\n", filesize($tarname), $tarname; foreach my $fname_size (sort {$a->[0] cmp $b->[0]} @pkgdirlist) { my ($fname, $fsize) = @$fname_size; printf " %8d %s/%s\n", $fsize, $pkgname, $fname; } if ($zipname ne "") { printf " %8d %s\n\n", $zipsize, $zipname; foreach my $fname_size (sort {$a->[0] cmp $b->[0]} @tdsdirlist) { my ($fname, $fsize) = @$fname_size; printf " %8d %s\n", $fsize, $fname; } } print "\n"; ########################################################################### __END__ =head1 NAME ctanify - Prepare a package for upload to CTAN =head1 SYNOPSIS ctanify [B<--pkgname>=I] [B<-->[B]B] [B<--tdsonly>=I ...] [B<-->[B]B] [B<-->[B]B] [B<--tdsdir>=I ...] [B<--tex>=I] [B<-->[B]B] [B<-->[B]B] I[=I] ... ctanify [B<--help>] ctanify [B<--version>] =head1 DESCRIPTION B is intended for developers who have a LaTeX package that they want to distribute via the Comprehensive TeX Archive Network (CTAN). Given a list of filenames, B creates a tarball (a F<.tar.gz> file) with the files laid out in CTAN's preferred structure. The tarball additionally contains a ZIP (F<.zip>) file with copies of all files laid out in the standard TeX Directory Structure (TDS), which facilitates inclusion of the package in the TeX Live distribution. =head1 OPTIONS B accepts the following command-line options: =over 5 =item B<-h>, B<--help> Output basic usage information and exit. =item B<-V>, B<--version> Output B's version number and exit. =item B<-p> I, B<--pkgname>=I Specify explicitly a package name. Normally, B uses the base name of the first F<.ins> or F<.sty> file listed as the package name. The package name forms the base name of the tarball that B produces. =item B<--noauto> Do not automatically add files to the tarball. Normally, B automatically includes all files mentioned in a F<.ins> file. =item B<-t> I, B<--tdsonly>=I Specify a subset of the files named on the command line to include only in the TDS ZIP file, not in the CTAN package directory. Wildcards are allowed (quoted if necessary), and B<--tdsonly> can be used multiple times on the same command line. =back At least one filename must be specified on the command line. B automatically places files in the TDS tree based on their extension, but this can be overridden by specifying explicitly a target TDS directory using the form I=I. Wildcards are allowed for the filespec (quoted if necessary). =head1 ADDITIONAL OPTIONS The following options are unlikely to be necessary in ordinary usage. They are provided for special circumstances that may arise. =over 5 =item B<-d> I, B<--tdsdir>=I Instead of creating a tarball for CTAN, merely create the package TDS tree rooted in directory I. =item B<-T> I, B<--tex>=I Assert that the files being packaged for CTAN target a TeX macro package other than LaTeX. Some common examples of I are C, C, and C. =item B<-nou>, B<--no-unixify> Store text files unmodified instead of converting their end-of-line character to Unix format (a single linefeed character with no carriage-return character), even though CTAN prefers receiving all files with Unix-format end-of-line characters. =item B<-nok>, B<--no-skip> Force B to include files such as Unix hidden files, Emacs backup files, and version-control metadata files, all of which CTAN dislikes receiving. =item B<-m>, B<--miscify> Rename directories containing a single file to C. (For example, rename C to C.) This was common practice in the past but is now strongly discouraged. =item B<-nos>, B<--no-tds> Do not embed a .tds.zip file in the generated tarball. =back =head1 DIAGNOSTICS =over 5 =item C (No such file or directory)> This message is typically caused by a F<.ins> file that generates I but that has not already been run through F or F to actually produce I. B does not automatically run F or F; this needs to be done manually by the user. See L for more information. =item C to use Unix line endings (use --no-unixify to prevent this)> For consistency, CTAN stores all text files with Unix-style line endings (a single linefeed character with no carriage-return character). To help in this effort, B automatically replaces non-Unix-style line endings. The preceding merely message notifies the user that he should not be alarmed to see a different size for I in the tarball versus the original I on disk (which B never modifies). If there's a good reason to preserve the original line endings (and there rarely is), the B<--no-unixify> option can be used to prevent B from altering any files when storing them in the tarball. =item C (use --no-skip to force inclusion)> B normally ignores files--even when specified explicitly on the command line--that CTAN prefers not receiving. These include files whose names start with "F<.>" (Unix hidden files), end in "F<~>" (Emacs automatic backups), or that come from a F or F<.svn> directory (version-control metadata files). If there's a good reason to submit such files to CTAN (and there rarely is), the B<--no-skip> option can be used to prevent B from ignoring them. =item C)> Because of the popularity of the PDF format, CTAN wants to have as much documentation as possible distributed in PDF. The preceding message asks the user to replace any PostScript or DVI documentation with PDF if possible. (B will still include PostScript and DVI documentation in the tarball; the preceding message is merely a polite request.) =item C in the TDS tree (unknown extension)> B places files in the TDS tree based on a table of file extensions. For example, all F<.sty> files are placed in F>. If B does not know where to put a file it does not put it anywhere. See the last paragraph of L for an explanation of how to specify explicitly a file's target location in the TDS tree. For common file extensions that happen to be absent from B's table, consider also notifying B's author at the address shown below under L. =back =head1 EXAMPLES =head2 The Common Case Normally, all that's needed is to tell B the name of the F<.ins> file (or F<.sty> if the package does not use DocStrip) and the prebuilt documentation, if any: $ ctanify mypackage.ins mypackage.pdf README 490347 mypackage.tar.gz 1771 mypackage/README 15453 mypackage/mypackage.dtx 1957 mypackage/mypackage.ins 277683 mypackage/mypackage.pdf 246935 mypackage.tds.zip 1771 doc/latex/mypackage/README 277683 doc/latex/mypackage/mypackage.pdf 15453 source/latex/mypackage/mypackage.dtx 1957 source/latex/mypackage/mypackage.ins 1725 tex/latex/mypackage/mypackage.sty B outputs the size in bytes of the resulting tarball, each file within it, and each file within the contained ZIP file. In the preceding example, notice how B automatically performed all of the following operations: =over 5 =item * including F (found by parsing F) in both the F directory and the ZIP file, =item * including F (found by parsing F) in the ZIP file but, because it's a generated file, not in the F directory, and =item * placing all files into appropriate TDS directories (documentation, source, main package) within the ZIP file. =back Consider what it would take to manually produce an equivalent F file. B is definitely a simpler, quicker alternative. =head2 Advanced Usage B assumes that PostScript files are documentation and therefore stores them under F/> in the TDS tree within the ZIP File. Suppose, however, that a LaTeX package uses a set of PostScript files to control B's output. In this case, B must be told to include those PostScript files in the package directory, not the documentation directory. $ ctanify mypackage.ins "mypackage*.ps=tex/latex/mypackage" =head1 FILES =over 5 =item F B is written in Perl and needs a Perl installation to run. =item F, F B requires the GNU F and F programs to create a compressed tarball (F<.tar.gz>). =item F B uses a F program to archive the TDS tree within the main tarball. =back =head1 CAVEATS B does not invoke F or F on its own, S process a F<.ins> file. The reason is that B does not know in the general case how to produce all of a package's generated files. It was deemed better to do nothing than to risk overwriting existing F<.sty> (or other) files or to include outdated generated files in the tarball. In short, before running B you should manually process any F<.ins> files and otherwise generate any files that should be sent to CTAN. B has been tested only on Linux. It may work on S. I've been told that it works on Windows when run using Cygwin. Volunteers willing to help port B to other platforms are extremely welcome. =head1 SEE ALSO tar(1), zip(1), latex(1), Guidelines for uploading TDS-Packaged materials to CTAN (L), A Directory Structure for TeX Files (L), =head1 AUTHOR Scott Pakin, I =head1 COPYRIGHT AND LICENSE Copyright 2017 Scott Pakin This work may be distributed and/or modified under the conditions of the LaTeX Project Public License, either S of this license or (at your option) any later version. The latest version of this license is in =over 4 L =back and S or later is part of all distributions of LaTeX version 2008/05/04 or later.