Newer
Older
gradle-lectures / buildSrc / src / main / kotlin / lectures.gradle.kts
/***********************************************************************
 * CONFIGURABLE SETTINGS
 *
 * Override the following values in each lecture's build.gradle.kts.
 * In most cases simply adding a modified copy of the original line
 * should do what you need. Most collections are sets to avoid accidental
 * duplication. You can append to an existing set value by doing something
 * like this (e.g.):
 * 
 * extra["latexFlags"] = (extra["latexFlags"] as Set<String>) + "--barf"
 ***********************************************************************/

/***********************************************************************
 * Supported targets (unnumbered documents).
 * Remove any targets that don't exist for this lecture.
 * New targets must be registered in the lecture's build.gradle.kts.
 * The target list for numbered documents is automatically derived from
 * this (see below).
 */
val docTargets by extra(mutableSetOf<String>(
    "slides",
    "handout",
    "notes",
    "examples"
))

/***********************************************************************
 * Command line options for LaTeX. This is a set, so duplicates will be
 * automatically eliminated.
 */
val latexFlags by extra(mutableSetOf<String>(
    "-shell-escape",
    "-synctex=1",
    "-interaction=nonstopmode",
    "-halt-on-error",
    "-file-line-error"
))

/***********************************************************************
 * Standard document names (unnumbered). The keys are mostly the Gradle
 * target names in docTargets, plus "content".
 */
val docNames by extra(mutableMapOf<String, String>(
    "content" to "lecture_content",
    "slides" to "lecture_slides",
    "handout" to "lecture_handout",
    "notes" to "lecture_notes",
    "examples" to "lecture_examples"
))

/***********************************************************************
 * Lecture number, derived from the project name (assumes the project
 * folder is named something like "01_Lecture_Title").
 */
val lectureNum by extra(project.name.substring(0, 2))

/***********************************************************************
 * Name of subdirectory in which images are kept.
 */
val imageDir by extra(layout.projectDirectory.dir("images"))

/***********************************************************************
 * Name of subdirectory to which numbered PDFs will be written. Originally
 * These were written into the project directory, but it seemed to completely
 * confuse Gradle into thinking that all the input files in the project
 * has been touched and it yelled about implicit task dependencies. See
 * <https://docs.gradle.org/7.3.1/userguide/validation_problems.html#implicit_dependency>.
 */
val pdfDir by extra(layout.projectDirectory.dir("pdfs"))

/***********************************************************************
 * Images and  files used by the slides, handout, and notes documents.
 * Images will be automatically mapped to imageDir (above).
 */
val slidesImages by extra(mutableSetOf<String>())
val slidesFiles by extra(mutableSetOf<String>(
    "${docNames["content"]}.tex",
    "paper_init.tex",
    "lecturedates.tex",
    "doc_init.tex"
))

/***********************************************************************
 * Images and files used by the examples document.
 * Images will be automatically mapped to imageDir (above).
 */
val examplesImages by extra(mutableSetOf<String>())
val examplesFiles by extra(mutableSetOf<String>(
    "${docNames["examples"]}.tex",
    "paper_init.tex",
    "lecturedates.tex",
    "doc_init.tex"
))

/***********************************************************************
 * Images that are generated from another format.
 * Key is the target image, value is the source image.
 * All images will be mapped to imageDir (above).
 */
val generatedImages by extra(mutableMapOf<String, String>())

/***********************************************************************
 * Files and directories to be removed by the clean task.
 * Directories are separate because FileTree only stores files :(.
 */
val cleanFiles by extra(mutableSetOf<String>(
    "*.aux",
    "*.dvi",
    "*.fdb_latexmk",
    "*.fls",
    "*.head",
    "*.listing",
    "*.log",
    "*.nav",
    "*.out",
    "*.pyg",
    "*.snm",
    "*.synctex.gz",
    "*.tmp",
    "*.toc",
    "*.vrb",
    "*.xdv"
))
// These are regular expressions, NOT glob patterns.
val cleanDirs by extra(mutableSetOf<String>(
    """_minted.*""",
))

/***********************************************************************
 * END OF CONFIGURABLE SETTINGS
 ***********************************************************************/

/***********************************************************************
 * Supported targets (numbered documents).
 * Automatically derived from docTargets.
 */
val numberedTargets by extra(docTargets.map { it + ".numbered" })

/***********************************************************************
 * Standard document names (unnumbered).
 * Automatically derived from docNames.
 */
val numberedNames by extra(docNames.mapValues { pair -> pair.value.replace("_", "_${lectureNum}_") })

/***********************************************************************
 * Document dependencies.
 * Automatically derived from slidesImages, slidesFiles, examplesImages,
 * and examplesFiles. Images are mapped to imageDir.
 * The slides, handout, and notes will always share the same dependencies.
 * Only the examples will be different.
 */
val slideDependencies by extra(slidesImages.map { "${imageDir}/${it}" } + slidesFiles)
val docDependencies by extra(mapOf<String, List<String>>(
    "slides" to slideDependencies,
    "handout" to slideDependencies,
    "notes" to slideDependencies,
    "examples" to examplesImages.map { "${imageDir}/${it}" } + examplesFiles
))

/***********************************************************************
 * LaTeX plugin configuration.
 */
plugins {
    id("org.danilopianini.gradle-latex")

    // for debugging task dependencies
    // https://plugins.gradle.org/plugin/org.barfuin.gradle.taskinfo
    // id("org.barfuin.gradle.taskinfo") version "1.3.1"
}

latex {
    pdfLatexCommand.set("xelatex")
    // Generate LaTeX build tasks for each of the targets.
    docTargets.forEach { target ->
        "${docNames[target]}" {
            extraArguments = latexFlags
            watching = docDependencies[target] as List<String>
        }
    }
}

// Add image task dependencies to the generated LaTeX tasks.
// No need to check whether there are any generated images, Gradle does
// the right thing if the list is empty.
tasks.matching { it.name.startsWith("pdfLatex") }.forEach {
    it.dependsOn(tasks.matching { task -> task.name.startsWith("images.") })
}

/***********************************************************************
 * Tasks.
 */
defaultTasks("docs", "numbered")

// All unnumbered documents.
tasks.register("docs") {
    dependsOn(docTargets)
    description = "Build all documents."
}

// Generate tasks for each of the unnumbered document targets.
(docTargets as Set<String>).forEach {
    target -> tasks.register(target) {
        dependsOn("pdfLatex.${docNames[target]}")
        description = "Build ${target} document."
    }
}

// All numbered documents.
tasks.register("numbered") {
    dependsOn(numberedTargets)
    description = "Create numbered versions of all documents."
}

/* Generate tasks for each of the numbered document targets.
 * Slight oddity: numberedTargets is a List not a Set because it's
 * generated by a map() call. It's not normally configurable, so OK.
 */
numberedTargets.forEach {
    target -> tasks.register<Copy>(target) {
        val plainTarget = target.replace(".numbered", "")
        from(tasks.getByName("pdfLatex.${docNames[plainTarget]}"))
        into(pdfDir)
        // The LaTeX tasks include the .aux file in their outputs.
        // Exclude or they will also be copied into the PDFs directory.
        exclude("*.aux")
        rename { src ->  src.replace("_", "_${lectureNum}_") }
        description = "Build numbered version of ${plainTarget} document."
    }
}

// All generated images.
tasks.register("images") {
    dependsOn(tasks.matching { it.name.startsWith("images.") })
    description = "Generate images."
}

// Find folders matching regular expression.
// Adapted from <https://gist.github.com/Ethsaam/2b8dd748fcc25e6973bb>.
fun getListOfFolders(dir: Directory, pattern: String): Collection<File> {
    return dir.getAsFile().listFiles({ file -> file.isDirectory() && file.name.matches(Regex(pattern)) }).toList()
}

// Remove files specified in cleanFiles and directories specified
// in cleanDirs.
tasks.register<Delete>("clean") {
    description = "Clean up intermediate files."
    delete(fileTree(layout.projectDirectory) { include(cleanFiles) })
    cleanDirs.forEach { pattern ->
        delete(getListOfFolders(layout.projectDirectory, pattern))
    }
}

// Remove everything not under version control. Unfortunately this *doesn't*
// include the .gradle directory :(, but in hindsight that's not surprising.
tasks.register<Exec>("deepClean") {
    description = "Remove all files not in Git."
    commandLine("git", "clean", "-fXd")
}

/***********************************************************************
 * Per-file tasks to convert images.
 * By default these assume single-file inputs, e.g., foo.svg -> foo.pdf.
 * If you have a file that is dependent on multiple inputs (e.g.,
 * bar.pu -> foo.pu -> foo.pdf), register an independent task in the
 * lecture's build.gradle.kts by copying the corresponding registration
 * code below, substituting the actual filenames in place of the variable
 * references like key and generatedImages[key]. Also remove the file
 * from generatedImages to avoid a name clash. You can access properties
 * like imageDir via project.properties (these will probably need explicit
 * type casting).
 */

// Generate per-file tasks to convert PlantUML to PDF.
generatedImages.filterValues {
        it.matches(Regex(""".*\.(pu|puml|plantuml)$"""))
    }.filterKeys {
        it.matches(Regex(""".*\.pdf$"""))
    }.keys
    .forEach { key ->
        val dst = imageDir.file(key)
        val src = imageDir.file(generatedImages[key].toString())
        tasks.register<Exec>("images.${key}") {
            val cropped = imageDir.file(key.replace(".pdf", "-crop.pdf"))
            // set working directory to imageDir so that relative includes work
            workingDir(imageDir)
            inputs.files(src)
            outputs.files(dst)
            // Direct PDF from PlantUML is problematic, go via SVG instead.
            commandLine(
                "bash",
                "-c",
                "plantuml -tsvg -pipe < ${src} | rsvg-convert --format pdf > ${dst} && pdfcrop ${dst} ${cropped} && mv -f ${cropped} ${dst}"
            )
            description = "Generate ${key} from ${generatedImages[key]}."
        }
        // Every PDF generation will have a different hash, so only run the
        // task if the input is newer than the output.
        tasks.getByPath("images.${key}").onlyIf { src.getAsFile().lastModified() > dst.getAsFile().lastModified() }
    }

// Generate per-file tasks to convert SVG to PDF.
generatedImages.filterValues {
        it.matches(Regex(""".*\.svg$"""))
    }.filterKeys {
        it.matches(Regex(""".*\.pdf$"""))
    }.keys
    .forEach { key ->
        val dst = imageDir.file(key)
        val src = imageDir.file(generatedImages[key].toString())
        tasks.register<Exec>("images.${key}") {
            val cropped = imageDir.file(key.replace(".pdf", "-crop.pdf"))
            inputs.files(src)
            outputs.files(dst)
            commandLine(
                "bash",
                "-c",
                "rsvg-convert --format pdf ${src} > ${dst} && pdfcrop ${dst} ${cropped} && mv -f ${cropped} ${dst}"
            )
            description = "Generate ${key} from ${generatedImages[key]}."
        }
        // Every PDF generation will have a different hash, so only run the
        // task if the input is newer than the output.
        tasks.getByPath("images.${key}").onlyIf { src.getAsFile().lastModified() > dst.getAsFile().lastModified() }
    }

// Generate per-file tasks to convert R to PDF.
generatedImages.filterValues {
        it.matches(Regex(""".*\.R$"""))
    }.filterKeys {
        it.matches(Regex(""".*\.pdf$"""))
    }.keys
    .forEach { key ->
        val dst = imageDir.file(key)
        val src = imageDir.file(generatedImages[key].toString())
        tasks.register<Exec>("images.${key}") {
            val cropped = imageDir.file(key.replace(".pdf", "-crop.pdf"))
            inputs.files(src)
            outputs.files(dst)
            commandLine(
                "bash",
                "-c",
                "R --no-echo --file=${src} --args ${dst} && pdfcrop ${dst} ${cropped} && mv -f ${cropped} ${dst}"
            )
            description = "Generate ${key} from ${generatedImages[key]}."
        }
        // Every PDF generation will have a different hash, so only run the
        // task if the input is newer than the output.
        tasks.getByPath("images.${key}").onlyIf { src.getAsFile().lastModified() > dst.getAsFile().lastModified() }
    }

// Debugging information.
tasks.register("debug") {
    description = "Print debugging information."
    doLast {
        println("project name = <${project.name}>")
        println("project directory = <${layout.projectDirectory}>")
        // println("project.properties = <${project.properties}>")
        println("docTargets = <${docTargets}>")
        println("numberedTargets = <${numberedTargets}>")
        println("lectureNum = <${lectureNum}>")
        println("imageDir = <${imageDir}>")
        println("docNames = <${docNames}>")
        println("numberedNames = <${numberedNames}>")
        println("slidesImages = <${slidesImages}>")
        println("slidesFiles = <${slidesFiles}>")
        println("examplesImages = <${examplesImages}>")
        println("examplesFiles = <${examplesFiles}>")
        println("docDependencies = <${docDependencies}>")
        println("generatedImages = <${generatedImages}>")
        println("latexFlags = <${latexFlags}>")
        println("latex.terminalEmulator = <${project.latex.terminalEmulator.get()}>")
        println("latex.waitTime = <${project.latex.waitTime.get()}>")
        println("latex.waitUnit = <${project.latex.waitUnit.get()}>")
        println("latex.pdfLatexCommand = <${project.latex.pdfLatexCommand.get()}>")
        println("latex.bibTexCommand = <${project.latex.bibTexCommand.get()}>")
        println("latex.inkscapeCommand = <${project.latex.inkscapeCommand.get()}>")
        println("latex.gitLatexdiffCommand = <${project.latex.gitLatexdiffCommand.get()}>")
        // tasks.matching { it.name.startsWith("pdfLatex") }.forEach {
        //     println("${it}, ${it.property("artifact")}")
        // }
        // tasks.forEach {
        //     println(it)
        //     println("inputs: ${it.getInputs().getFiles().getFiles()}")
        //     println("outputs: ${it.getOutputs().getFiles().getFiles()}")
        // }
    }
}