Path: blob/main/src/command/render/output-typst.ts
12925 views
/*1* output-typst.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import {7dirname,8isAbsolute,9join,10normalize,11relative,12resolve,13} from "../../deno_ral/path.ts";14import {15copySync,16ensureDirSync,17existsSync,18safeRemoveSync,19} from "../../deno_ral/fs.ts";20import {21builtinSubtreeExtensions,22inputExtensionDirs,23readExtensions,24readSubtreeExtensions,25} from "../../extension/extension.ts";26import { projectScratchPath } from "../../project/project-scratch.ts";27import { resourcePath } from "../../core/resources.ts";2829import {30kFontPaths,31kKeepTyp,32kOutputExt,33kOutputFile,34kPdfStandard,35kVariant,36pdfStandardEnv,37} from "../../config/constants.ts";38import { error, warning } from "../../deno_ral/log.ts";39import { ErrorEx } from "../../core/lib/error.ts";40import { Format } from "../../config/types.ts";41import { writeFileToStdout } from "../../core/console.ts";42import { dirAndStem, expandPath } from "../../core/path.ts";43import { kStdOut, replacePandocOutputArg } from "./flags.ts";44import { OutputRecipe, RenderOptions } from "./types.ts";45import { normalizeOutputPath } from "./output-shared.ts";46import {47typstCompile,48TypstCompileOptions,49validateRequiredTypstVersion,50} from "../../core/typst.ts";51import { runAnalyze, toTomlPath } from "../../core/typst-gather.ts";52import { asArray } from "../../core/array.ts";53import { ProjectContext } from "../../project/types.ts";54import { validatePdfStandards } from "../../core/verapdf.ts";5556export interface NeededPackage {57namespace: string;58name: string;59version: string;60}6162// Collect all package source directories (built-in + extensions)63async function collectPackageSources(64input: string,65projectDir: string,66): Promise<string[]> {67const sources: string[] = [];6869// 1. Built-in packages70const builtinPackages = resourcePath("formats/typst/packages");71if (existsSync(builtinPackages)) {72sources.push(builtinPackages);73}7475// 2. Extension packages76const extensionDirs = inputExtensionDirs(input, projectDir);77const subtreePath = builtinSubtreeExtensions();78for (const extDir of extensionDirs) {79const extensions = extDir === subtreePath80? await readSubtreeExtensions(extDir)81: await readExtensions(extDir);82for (const ext of extensions) {83const packagesDir = join(ext.path, "typst/packages");84if (existsSync(packagesDir)) {85sources.push(packagesDir);86}87}88}8990return sources;91}9293// Build the TOML config string for typst-gather analyze94export function buildAnalyzeToml(95typstInput: string,96packageSources: string[],97): string {98const discoverPath = toTomlPath(typstInput);99const cachePaths = packageSources.map((p) => `"${toTomlPath(p)}"`).join(", ");100101return [102`discover = ["${discoverPath}"]`,103`package-cache = [${cachePaths}]`,104].join("\n");105}106107// Run typst-gather analyze on the .typ file to determine needed packages108async function analyzeNeededPackages(109typstInput: string,110packageSources: string[],111): Promise<NeededPackage[] | null> {112const tomlConfig = buildAnalyzeToml(typstInput, packageSources);113114try {115const result = await runAnalyze(tomlConfig);116return result.imports.map(({ namespace, name, version }) => ({117namespace,118name,119version,120}));121} catch {122// Fallback: if analyze fails, stage everything (current behavior)123warning("typst-gather analyze failed; staging all packages as fallback");124return null;125}126}127128// Stage only the needed packages from source dirs into the cache dir.129// Last write wins — extensions (listed after built-in) override built-in packages.130export function stageSelectedPackages(131sources: string[],132cacheDir: string,133needed: NeededPackage[] | null,134): void {135if (needed === null) {136stageAllPackages(sources, cacheDir);137return;138}139140for (const pkg of needed) {141const relPath = join(pkg.namespace, pkg.name, pkg.version);142const destPath = join(cacheDir, relPath);143144for (const source of sources) {145const srcPath = join(source, relPath);146if (existsSync(srcPath)) {147ensureDirSync(dirname(destPath));148copySync(srcPath, destPath, { overwrite: true });149}150}151}152}153154// Fallback: copy all packages from all sources. Last write wins at the155// package directory level. Built-in listed first, extensions after.156export function stageAllPackages(sources: string[], cacheDir: string): void {157for (const source of sources) {158for (const nsEntry of Deno.readDirSync(source)) {159if (!nsEntry.isDirectory) continue;160const nsSrc = join(source, nsEntry.name);161const nsDest = join(cacheDir, nsEntry.name);162ensureDirSync(nsDest);163for (const pkgEntry of Deno.readDirSync(nsSrc)) {164const pkgSrc = join(nsSrc, pkgEntry.name);165const pkgDest = join(nsDest, pkgEntry.name);166copySync(pkgSrc, pkgDest, { overwrite: true });167}168}169}170}171172// Stage typst packages to .quarto/typst-packages/173// First stages built-in packages, then extension packages (which can override)174async function stageTypstPackages(175input: string,176typstInput: string,177projectDir?: string,178): Promise<string | undefined> {179if (!projectDir) {180return undefined;181}182183const packageSources = await collectPackageSources(input, projectDir);184if (packageSources.length === 0) {185return undefined;186}187188const neededPackages = await analyzeNeededPackages(189typstInput,190packageSources,191);192193const cacheDir = projectScratchPath(projectDir, "typst/packages");194stageSelectedPackages(packageSources, cacheDir, neededPackages);195196return cacheDir;197}198199export function useTypstPdfOutputRecipe(200format: Format,201) {202return format.pandoc.to === "typst" &&203format.render[kOutputExt] === "pdf";204}205206export function typstPdfOutputRecipe(207input: string,208finalOutput: string,209options: RenderOptions,210format: Format,211project?: ProjectContext,212): OutputRecipe {213// calculate output and args for pandoc (this is an intermediate file214// which we will then compile to a pdf and rename to .typ)215const [inputDir, inputStem] = dirAndStem(input);216const output = inputStem + ".typ";217let args = options.pandocArgs || [];218const pandoc = { ...format.pandoc };219if (options.flags?.output) {220args = replacePandocOutputArg(args, output);221} else {222pandoc[kOutputFile] = output;223}224225// when pandoc is done, we need to run the pdf generator and then copy the226// output to the user's requested destination227const complete = async () => {228// input file is pandoc's output229const typstInput = join(inputDir, output);230231// run typst232await validateRequiredTypstVersion();233const pdfOutput = join(inputDir, inputStem + ".pdf");234const typstOptions: TypstCompileOptions = {235quiet: options.flags?.quiet,236fontPaths: (asArray(format.metadata?.[kFontPaths]) as string[]).map(237(p) => isAbsolute(p) ? p : resolve(inputDir, p),238),239pdfStandard: normalizePdfStandardForTypst(240asArray(241format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard] ??242pdfStandardEnv(),243),244),245};246if (project?.dir) {247typstOptions.rootDir = project.dir;248249// Stage extension typst packages250const packagePath = await stageTypstPackages(251input,252typstInput,253project.dir,254);255if (packagePath) {256typstOptions.packagePath = packagePath;257}258}259const result = await typstCompile(260typstInput,261pdfOutput,262typstOptions,263);264if (!result.success) {265if (result.stderr) {266error(result.stderr);267}268throw new ErrorEx("Error", "Typst compilation failed", false, false);269}270271// Validate PDF against specified standards using verapdf (if available)272const pdfStandards = asArray(273format.render?.[kPdfStandard] ?? format.metadata?.[kPdfStandard] ??274pdfStandardEnv(),275) as string[];276if (pdfStandards.length > 0) {277await validatePdfStandards(pdfOutput, pdfStandards, {278quiet: options.flags?.quiet,279});280}281282// keep typ if requested283if (!format.render[kKeepTyp]) {284safeRemoveSync(typstInput);285}286287// copy (or write for stdout) compiled pdf to final output location288if (finalOutput) {289if (finalOutput === kStdOut) {290writeFileToStdout(pdfOutput);291safeRemoveSync(pdfOutput);292} else {293const outputPdf = expandPath(finalOutput);294295if (normalize(pdfOutput) !== normalize(outputPdf)) {296// ensure the target directory exists297ensureDirSync(dirname(outputPdf));298Deno.renameSync(pdfOutput, outputPdf);299}300}301302// final output needs to either absolute or input dir relative303// (however it may be working dir relative when it is passed in)304return normalizeOutputPath(typstInput, finalOutput);305} else {306return normalizeOutputPath(typstInput, pdfOutput);307}308};309310const pdfOutput = finalOutput311? finalOutput === kStdOut312? undefined313: normalizeOutputPath(input, finalOutput)314: normalizeOutputPath(input, join(inputDir, inputStem + ".pdf"));315316// return recipe317const recipe: OutputRecipe = {318output,319keepYaml: false,320args,321format: { ...format, pandoc },322complete,323finalOutput: pdfOutput ? relative(inputDir, pdfOutput) : undefined,324};325326// if we have some variant declared, resolve it327// (use for opt-out citations extension)328if (format.render?.[kVariant]) {329const to = format.pandoc.to;330const variant = format.render[kVariant];331332recipe.format = {333...recipe.format,334pandoc: {335...recipe.format.pandoc,336to: `${to}${variant}`,337},338};339}340341return recipe;342}343344// Typst-supported PDF standards345const kTypstSupportedStandards = new Set([346"1.4",347"1.5",348"1.6",349"1.7",350"2.0",351"a-1b",352"a-1a",353"a-2b",354"a-2u",355"a-2a",356"a-3b",357"a-3u",358"a-3a",359"a-4",360"a-4f",361"a-4e",362"ua-1",363]);364365function normalizePdfStandardForTypst(standards: unknown[]): string[] {366const result: string[] = [];367for (const s of standards) {368// Convert to string - YAML may parse versions like 2.0 as integer 2369let str: string;370if (typeof s === "number") {371// Handle YAML numeric parsing: integer 2 -> "2.0", float 1.4 -> "1.4"372str = Number.isInteger(s) ? `${s}.0` : String(s);373} else if (typeof s === "string") {374str = s;375} else {376continue;377}378// Normalize: lowercase, remove any "pdf" prefix379const normalized = str.toLowerCase().replace(/^pdf[/-]?/, "");380if (kTypstSupportedStandards.has(normalized)) {381result.push(normalized);382} else {383warning(384`PDF standard '${s}' is not supported by Typst and will be ignored`,385);386}387}388return result;389}390391392