Path: blob/main/src/command/render/output-typst.ts
6428 views
/*1* output-typst.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { dirname, join, normalize, relative } from "../../deno_ral/path.ts";7import {8copySync,9ensureDirSync,10existsSync,11safeRemoveSync,12} from "../../deno_ral/fs.ts";13import {14builtinSubtreeExtensions,15inputExtensionDirs,16readExtensions,17readSubtreeExtensions,18} from "../../extension/extension.ts";19import { projectScratchPath } from "../../project/project-scratch.ts";20import { resourcePath } from "../../core/resources.ts";2122import {23kFontPaths,24kKeepTyp,25kOutputExt,26kOutputFile,27kPdfStandard,28kVariant,29} from "../../config/constants.ts";30import { error, warning } from "../../deno_ral/log.ts";31import { Format } from "../../config/types.ts";32import { writeFileToStdout } from "../../core/console.ts";33import { dirAndStem, expandPath } from "../../core/path.ts";34import { kStdOut, replacePandocOutputArg } from "./flags.ts";35import { OutputRecipe, RenderOptions } from "./types.ts";36import { normalizeOutputPath } from "./output-shared.ts";37import {38typstCompile,39TypstCompileOptions,40validateRequiredTypstVersion,41} from "../../core/typst.ts";42import { asArray } from "../../core/array.ts";43import { ProjectContext } from "../../project/types.ts";44import { validatePdfStandards } from "../../core/verapdf.ts";4546// Stage typst packages to .quarto/typst-packages/47// First stages built-in packages, then extension packages (which can override)48async function stageTypstPackages(49input: string,50projectDir?: string,51): Promise<string | undefined> {52if (!projectDir) {53return undefined;54}5556const packageSources: string[] = [];5758// 1. Add built-in packages from quarto resources59const builtinPackages = resourcePath("formats/typst/packages");60if (existsSync(builtinPackages)) {61packageSources.push(builtinPackages);62}6364// 2. Add packages from extensions (can override built-in)65const extensionDirs = inputExtensionDirs(input, projectDir);66const subtreePath = builtinSubtreeExtensions();67for (const extDir of extensionDirs) {68// Use readSubtreeExtensions for subtree directory, readExtensions for others69const extensions = extDir === subtreePath70? await readSubtreeExtensions(extDir)71: await readExtensions(extDir);72for (const ext of extensions) {73const packagesDir = join(ext.path, "typst/packages");74if (existsSync(packagesDir)) {75packageSources.push(packagesDir);76}77}78}7980if (packageSources.length === 0) {81return undefined;82}8384// Stage to .quarto/typst/packages/85const cacheDir = projectScratchPath(projectDir, "typst/packages");8687// Copy contents of each source directory (merging namespaces like "preview", "local")88for (const source of packageSources) {89for (const entry of Deno.readDirSync(source)) {90const srcPath = join(source, entry.name);91const destPath = join(cacheDir, entry.name);92if (!existsSync(destPath)) {93copySync(srcPath, destPath);94} else if (entry.isDirectory) {95// Merge directory contents (e.g., merge packages within "preview" namespace)96for (const subEntry of Deno.readDirSync(srcPath)) {97const subSrcPath = join(srcPath, subEntry.name);98const subDestPath = join(destPath, subEntry.name);99if (!existsSync(subDestPath)) {100copySync(subSrcPath, subDestPath);101}102}103}104}105}106107return cacheDir;108}109110export function useTypstPdfOutputRecipe(111format: Format,112) {113return format.pandoc.to === "typst" &&114format.render[kOutputExt] === "pdf";115}116117export function typstPdfOutputRecipe(118input: string,119finalOutput: string,120options: RenderOptions,121format: Format,122project?: ProjectContext,123): OutputRecipe {124// calculate output and args for pandoc (this is an intermediate file125// which we will then compile to a pdf and rename to .typ)126const [inputDir, inputStem] = dirAndStem(input);127const output = inputStem + ".typ";128let args = options.pandocArgs || [];129const pandoc = { ...format.pandoc };130if (options.flags?.output) {131args = replacePandocOutputArg(args, output);132} else {133pandoc[kOutputFile] = output;134}135136// when pandoc is done, we need to run the pdf generator and then copy the137// output to the user's requested destination138const complete = async () => {139// input file is pandoc's output140const typstInput = join(inputDir, output);141142// run typst143await validateRequiredTypstVersion();144const pdfOutput = join(inputDir, inputStem + ".pdf");145const typstOptions: TypstCompileOptions = {146quiet: options.flags?.quiet,147fontPaths: asArray(format.metadata?.[kFontPaths]) as string[],148pdfStandard: normalizePdfStandardForTypst(149asArray(150format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard],151),152),153};154if (project?.dir) {155typstOptions.rootDir = project.dir;156157// Stage extension typst packages158const packagePath = await stageTypstPackages(input, project.dir);159if (packagePath) {160typstOptions.packagePath = packagePath;161}162}163const result = await typstCompile(164typstInput,165pdfOutput,166typstOptions,167);168if (!result.success) {169// Log the error so test framework can detect it via shouldError170if (result.stderr) {171error(result.stderr);172}173throw new Error("Typst compilation failed");174}175176// Validate PDF against specified standards using verapdf (if available)177const pdfStandards = asArray(178format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard],179) as string[];180if (pdfStandards.length > 0) {181await validatePdfStandards(pdfOutput, pdfStandards, {182quiet: options.flags?.quiet,183});184}185186// keep typ if requested187if (!format.render[kKeepTyp]) {188safeRemoveSync(typstInput);189}190191// copy (or write for stdout) compiled pdf to final output location192if (finalOutput) {193if (finalOutput === kStdOut) {194writeFileToStdout(pdfOutput);195safeRemoveSync(pdfOutput);196} else {197const outputPdf = expandPath(finalOutput);198199if (normalize(pdfOutput) !== normalize(outputPdf)) {200// ensure the target directory exists201ensureDirSync(dirname(outputPdf));202Deno.renameSync(pdfOutput, outputPdf);203}204}205206// final output needs to either absolute or input dir relative207// (however it may be working dir relative when it is passed in)208return normalizeOutputPath(typstInput, finalOutput);209} else {210return normalizeOutputPath(typstInput, pdfOutput);211}212};213214const pdfOutput = finalOutput215? finalOutput === kStdOut216? undefined217: normalizeOutputPath(input, finalOutput)218: normalizeOutputPath(input, join(inputDir, inputStem + ".pdf"));219220// return recipe221const recipe: OutputRecipe = {222output,223keepYaml: false,224args,225format: { ...format, pandoc },226complete,227finalOutput: pdfOutput ? relative(inputDir, pdfOutput) : undefined,228};229230// if we have some variant declared, resolve it231// (use for opt-out citations extension)232if (format.render?.[kVariant]) {233const to = format.pandoc.to;234const variant = format.render[kVariant];235236recipe.format = {237...recipe.format,238pandoc: {239...recipe.format.pandoc,240to: `${to}${variant}`,241},242};243}244245return recipe;246}247248// Typst-supported PDF standards249const kTypstSupportedStandards = new Set([250"1.4",251"1.5",252"1.6",253"1.7",254"2.0",255"a-1b",256"a-1a",257"a-2b",258"a-2u",259"a-2a",260"a-3b",261"a-3u",262"a-3a",263"a-4",264"a-4f",265"a-4e",266"ua-1",267]);268269function normalizePdfStandardForTypst(standards: unknown[]): string[] {270const result: string[] = [];271for (const s of standards) {272// Convert to string - YAML may parse versions like 2.0 as integer 2273let str: string;274if (typeof s === "number") {275// Handle YAML numeric parsing: integer 2 -> "2.0", float 1.4 -> "1.4"276str = Number.isInteger(s) ? `${s}.0` : String(s);277} else if (typeof s === "string") {278str = s;279} else {280continue;281}282// Normalize: lowercase, remove any "pdf" prefix283const normalized = str.toLowerCase().replace(/^pdf[/-]?/, "");284if (kTypstSupportedStandards.has(normalized)) {285result.push(normalized);286} else {287warning(288`PDF standard '${s}' is not supported by Typst and will be ignored`,289);290}291}292return result;293}294295296