Path: blob/main/src/format/typst/format-typst.ts
12925 views
/*1* format-typst.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { join } from "../../deno_ral/path.ts";78import { RenderServices } from "../../command/render/types.ts";9import { ProjectContext } from "../../project/types.ts";10import { BookExtension } from "../../project/types/book/book-shared.ts";11import {12kBrand,13kCiteproc,14kColumns,15kDefaultImageExtension,16kFigFormat,17kFigHeight,18kFigWidth,19kLight,20kLogo,21kNumberSections,22kSectionNumbering,23kShiftHeadingLevelBy,24kVariant,25kWrap,26} from "../../config/constants.ts";27import {28Format,29FormatExtras,30FormatPandoc,31LightDarkBrand,32Metadata,33PandocFlags,34} from "../../config/types.ts";35import { formatResourcePath } from "../../core/resources.ts";36import { createFormat } from "../formats-shared.ts";37import { hasLevelOneHeadings as hasL1Headings } from "../../core/lib/markdown-analysis/level-one-headings.ts";38import {39BrandNamedLogo,40LogoLightDarkSpecifier,41} from "../../resources/types/schema-types.ts";42import {43brandWithAbsoluteLogoPaths,44fillLogoPaths,45resolveLogo,46} from "../../core/brand/brand.ts";47import { LogoLightDarkSpecifierPathOptional } from "../../resources/types/zod/schema-types.ts";4849const typstBookExtension: BookExtension = {50selfContainedOutput: true,51// multiFile defaults to false (single-file book)52};5354export function typstFormat(): Format {55return createFormat("Typst", "pdf", {56execute: {57[kFigWidth]: 5.5,58[kFigHeight]: 3.5,59[kFigFormat]: "svg",60},61pandoc: {62standalone: true,63[kDefaultImageExtension]: "svg",64[kWrap]: "none",65[kCiteproc]: false,66},67extensions: {68book: typstBookExtension,69},70resolveFormat: typstResolveFormat,71formatExtras: async (72_input: string,73markdown: string,74flags: PandocFlags,75format: Format,76_libDir: string,77_services: RenderServices,78_offset?: string,79_project?: ProjectContext,80): Promise<FormatExtras> => {81const pandoc: FormatPandoc = {};82const metadata: Metadata = {};8384// provide default section numbering if required85if (86(flags?.[kNumberSections] === true ||87format.pandoc[kNumberSections] === true)88) {89// number-sections imples section-numbering90if (!format.metadata?.[kSectionNumbering]) {91metadata[kSectionNumbering] = "1.1.a";92}93}9495// unless otherwise specified, pdfs with only level 2 or greater headings get their96// heading level shifted by -1.97const hasLevelOneHeadings = await hasL1Headings(markdown);98if (99!hasLevelOneHeadings &&100flags?.[kShiftHeadingLevelBy] === undefined &&101format.pandoc?.[kShiftHeadingLevelBy] === undefined102) {103pandoc[kShiftHeadingLevelBy] = -1;104}105106const brand = format.render.brand;107// For Typst, convert brand logo paths to project-absolute (with /)108// before merging with document logo metadata. Typst resolves / paths109// via --root which points to the project directory.110const typstBrand = brandWithAbsoluteLogoPaths(brand);111const logoSpec = format112.metadata[kLogo] as LogoLightDarkSpecifierPathOptional;113const sizeOrder: BrandNamedLogo[] = [114"small",115"medium",116"large",117];118// temporary: if document logo has object or light/dark objects119// without path, do our own findLogo to add the path120// typst is the exception not needing path but we'll probably deprecate this121const logo = fillLogoPaths(typstBrand, logoSpec, sizeOrder);122format.metadata[kLogo] = resolveLogo(typstBrand, logo, sizeOrder);123// force columns to wrap and move any 'columns' setting to metadata124const columns = format.pandoc[kColumns];125if (columns) {126pandoc[kColumns] = undefined;127metadata[kColumns] = columns;128}129130// Provide a template and partials131// For Typst books, a book extension overrides these partials132const templateDir = formatResourcePath("typst", join("pandoc", "quarto"));133134const templateContext = {135template: join(templateDir, "template.typ"),136partials: [137"numbering.typ",138"definitions.typ",139"typst-template.typ",140"page.typ",141"typst-show.typ",142"notes.typ",143"biblio.typ",144].map((partial) => join(templateDir, partial)),145};146147// Postprocessor to fix Skylighting code block styling (issue #14126).148// Pandoc's generated Skylighting function uses block(fill: bgcolor, blocks)149// which lacks width, inset, and radius. We surgically fix this in the .typ150// output. If brand monospace-block has a background-color, we also override151// the bgcolor value.152const brandData = (format.render[kBrand] as LightDarkBrand | undefined)153?.[kLight];154const monospaceBlock = brandData?.processedData?.typography?.[155"monospace-block"156];157let brandBgColor = (monospaceBlock && typeof monospaceBlock !== "string")158? monospaceBlock["background-color"] as string | undefined159: undefined;160// Resolve palette color names (e.g. "code-bg" → "#1e1e2e")161if (brandBgColor && brandData?.data?.color?.palette) {162const palette = brandData.data.color.palette as Record<string, string>;163let resolved = brandBgColor;164while (palette[resolved]) {165resolved = palette[resolved];166}167brandBgColor = resolved;168}169170return {171pandoc,172metadata,173templateContext,174postprocessors: [175skylightingPostProcessor(brandBgColor),176],177};178},179});180}181182// Fix Skylighting code block styling in .typ output (issue #14126).183// The Pandoc-generated Skylighting function uses block(fill: bgcolor, blocks)184// which lacks width, inset, and radius. This postprocessor matches the entire185// Skylighting function by its distinctive signature and patches only within it.186// When brand provides a monospace-block background-color, also overrides the187// bgcolor value. This is a temporary workaround until the fix is upstreamed188// to the Skylighting library.189function skylightingPostProcessor(brandBgColor?: string) {190// Match the entire #let Skylighting(...) = { ... } function.191// The signature is stable and generated by Skylighting's Typst backend.192const skylightingFnRe =193/(#let Skylighting\(fill: none, number: false, start: 1, sourcelines\) = \{[\s\S]*?\n\})/;194195return async (output: string) => {196const content = Deno.readTextFileSync(output);197198const match = skylightingFnRe.exec(content);199if (!match) {200// No Skylighting function found — document may not have code blocks,201// or upstream changed the function signature. Nothing to patch.202return;203}204205let fn = match[1];206207// Fix block() call: add width, inset, radius208fn = fn.replace(209"block(fill: bgcolor, blocks)",210"block(fill: bgcolor, width: 100%, inset: 8pt, radius: 2pt, blocks)",211);212213// Override bgcolor with brand monospace-block background-color214if (brandBgColor) {215fn = fn.replace(216/let bgcolor = rgb\("[^"]*"\)/,217`let bgcolor = rgb("${brandBgColor}")`,218);219}220221if (fn !== match[1]) {222Deno.writeTextFileSync(output, content.replace(match[1], fn));223}224};225}226227function typstResolveFormat(format: Format) {228// Pandoc citeproc with typst output requires adjustment229// https://github.com/jgm/pandoc/commit/e89a3edf24a025d5bb0fe8c4c7a8e6e0208fa846230if (231format.pandoc?.[kCiteproc] === true &&232!format.pandoc.to?.includes("-citations") &&233!format.render[kVariant]?.includes("-citations")234) {235// citeproc: false is the default, so user setting it to true means they want to use236// Pandoc's citeproc which requires `citations` extensions to be disabled (e.g typst-citations)237// This adds the variants for them if not set already238format.render[kVariant] = [format.render?.[kVariant], "-citations"].join(239"",240);241}242}243244245