diff --git a/configuration b/configuration index e70824da128..9c200155b20 100644 --- a/configuration +++ b/configuration @@ -13,12 +13,12 @@ export DENO=v2.7.14 # TODO figure out where 0.1.41 apple silicon libs are available export DENO_DOM=v0.1.41-alpha-artifacts -export PANDOC=3.8.3 -export DARTSASS=1.87.0 -export ESBUILD=0.25.10 -export TYPST=0.14.2 +export PANDOC=3.10 +export DARTSASS=1.101.0 +export ESBUILD=0.28.1 +export TYPST=0.15.0 export TYPST_GATHER=0.2.3 -export VERAPDF=1.28.2 +export VERAPDF=1.30.2 # NB: we can't put comments in the same line as export statements because it diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 7e945c91fba..b0d545dfd9b 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -11,6 +11,12 @@ All changes included in 1.10: ## Dependencies - ([#14291](https://github.com/quarto-dev/quarto-cli/issues/14291)): Update `deno` to v2.7.14 (fixes silent crash on Windows builds older than 16299). +- Update `pandoc` to 3.10. +- Update `typst` to 0.15.0. +- Update `typst-gather` to 0.2.3. +- Update `dart-sass` to 1.101.0. +- Update `esbuild` to 0.28.1. +- Update `veraPDF` to 1.30.2. ## Accessibility diff --git a/src/command/check/check.ts b/src/command/check/check.ts index dce5c9c8bbb..7d18d40e072 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -244,10 +244,10 @@ async function checkVersions(conf: CheckConfiguration) { // file is in an awkward format and it is not packaged // with our installers const versionConstraints: [string | undefined, string, string][] = [ - [pandocVersion, "3.8.3", "Pandoc"], - [sassVersion, "1.87.0", "Dart Sass"], + [pandocVersion, "3.10.0", "Pandoc"], + [sassVersion, "1.101.0", "Dart Sass"], [denoVersion, "2.7.14", "Deno"], - [typstVersion, "0.14.2", "Typst"], + [typstVersion, "0.15.0", "Typst"], ]; const checkData: [string | undefined, string, string][] = versionConstraints .map(([version, ver, name]) => [ diff --git a/src/core/brand/brand.ts b/src/core/brand/brand.ts index 8cd7b7ce486..af3063e85ff 100644 --- a/src/core/brand/brand.ts +++ b/src/core/brand/brand.ts @@ -39,7 +39,7 @@ import { LogoSpecifier, LogoSpecifierPathOptional, } from "../../resources/types/schema-types.ts"; -import { ensureLeadingSlash } from "../path.ts"; +import { ensureLeadingSlash, pathWithForwardSlashes } from "../path.ts"; type ProcessedBrandData = { color: Record; @@ -246,14 +246,21 @@ export class Brand { resolvePath(entry: BrandLogoResource) { const pathPrefix = relative(this.projectDir, this.brandDir); + // Always use forward slashes: this path can flow into writers (e.g. + // Typst 0.15+) that reject backslash path separators, while forward + // slashes work fine for the actual file resolution on Windows too. if (typeof entry === "string") { - return { path: isExternalPath(entry) ? entry : join(pathPrefix, entry) }; + return { + path: isExternalPath(entry) + ? entry + : pathWithForwardSlashes(join(pathPrefix, entry)), + }; } return { ...entry, path: isExternalPath(entry.path) ? entry.path - : join(pathPrefix, entry.path), + : pathWithForwardSlashes(join(pathPrefix, entry.path)), }; } diff --git a/src/core/handlers/base.ts b/src/core/handlers/base.ts index b0e6d63a00b..68969660bfe 100644 --- a/src/core/handlers/base.ts +++ b/src/core/handlers/base.ts @@ -79,7 +79,7 @@ import { ensureDirSync } from "../../deno_ral/fs.ts"; import { mappedStringFromFile } from "../mapped-text.ts"; import { error } from "../../deno_ral/log.ts"; import { withCriClient } from "../cri/cri.ts"; -import { normalizePath } from "../path.ts"; +import { normalizePath, pathWithForwardSlashes } from "../path.ts"; import { InvalidShortcodeError, isBlockShortcode, @@ -247,7 +247,10 @@ function makeHandlerContext( const pngName = `${prefix}${globalFigureCounter[prefix]}${extension}`; const tempName = join(context.figuresDir(), pngName); const baseDir = dirname(options.context.target.source); - const mdName = relative(baseDir, tempName); + // Always use forward slashes: this path is embedded as an image source + // and can flow into writers (e.g. Typst 0.15+) that reject backslash + // path separators, while forward slashes work fine on Windows too. + const mdName = pathWithForwardSlashes(relative(baseDir, tempName)); this.addSupporting(relative(baseDir, context.figuresDir())); diff --git a/src/resources/filters/modules/jog.lua b/src/resources/filters/modules/jog.lua index f3ab5c07302..a3547570c40 100644 --- a/src/resources/filters/modules/jog.lua +++ b/src/resources/filters/modules/jog.lua @@ -118,6 +118,9 @@ local function recurse (element, tp, jogger) elseif tp == 'pandoc TableHead' or tp == 'pandoc TableFoot' or tp == 'TableHead' or tp == 'TableFoot' then element.rows = jogger(element.rows) + elseif tp == 'pandoc TableBody' or tp == 'TableBody' then + element.head = jogger(element.head) + element.body = jogger(element.body) elseif tp == 'Blocks' or tp == 'Inlines' then local expected_itemtype = tp == 'Inlines' and 'Inline' or 'Block' local pos = 0 diff --git a/src/resources/filters/modules/mediabag.lua b/src/resources/filters/modules/mediabag.lua index 658b613b156..183527e8fae 100644 --- a/src/resources/filters/modules/mediabag.lua +++ b/src/resources/filters/modules/mediabag.lua @@ -4,6 +4,7 @@ -- helpers for working with the pandoc mediabag local filenames = require 'modules/filenames' +local path = require 'modules/path' -- A cache of image urls that we've resolved into the mediabag -- keyed by {url: mediabagpath} @@ -82,7 +83,9 @@ local function write_mediabag_entry(src) if contents == nil then return nil end local mediabagDir = param("mediabag-dir", nil) - local mediaFile = pandoc.path.join{mediabagDir, src} + -- This path is assigned to el.src, so it needs forward slashes. See + -- modules/path.lua. + local mediaFile = path.to_forward_slashes(pandoc.path.join{mediabagDir, src}) local file = _quarto.file.write(mediaFile, contents) if not file then diff --git a/src/resources/filters/modules/path.lua b/src/resources/filters/modules/path.lua new file mode 100644 index 00000000000..384434a7ae1 --- /dev/null +++ b/src/resources/filters/modules/path.lua @@ -0,0 +1,14 @@ +-- path.lua +-- Copyright (C) 2026 Posit Software, PBC + +-- Always use forward slashes: a path built with the native separator can +-- flow into writers (e.g. Typst 0.15+) that reject backslash path +-- separators, while forward slashes work fine for file resolution on +-- Windows too. +local function to_forward_slashes(path) + return path:gsub('\\', '/') +end + +return { + to_forward_slashes = to_forward_slashes +} diff --git a/src/resources/filters/quarto-post/typst-brand-yaml.lua b/src/resources/filters/quarto-post/typst-brand-yaml.lua index d4225c4904a..d530d8c75e7 100644 --- a/src/resources/filters/quarto-post/typst-brand-yaml.lua +++ b/src/resources/filters/quarto-post/typst-brand-yaml.lua @@ -1,3 +1,5 @@ +local path = require 'modules/path' + function render_typst_brand_yaml() if not _quarto.format.isTypstOutput() then return {} @@ -110,7 +112,8 @@ function render_typst_brand_yaml() local declImage = {} for name, image in pairs(brandLogo.images) do declImage[name] = { - path = quote_string(image.path):gsub('\\', '\\\\'), + -- Typst 0.15+ rejects backslash path separators. + path = path.to_forward_slashes(quote_string(image.path)), alt = quote_string(image.alt), } end @@ -121,7 +124,8 @@ function render_typst_brand_yaml() for _, size in pairs({'small', 'medium', 'large'}) do if brandLogo[size] then declLogo[size] = { - path = quote_string(brandLogo[size].path):gsub('\\', '\\\\'), + -- Typst 0.15+ rejects backslash path separators. + path = path.to_forward_slashes(quote_string(brandLogo[size].path)), alt = quote_string(brandLogo[size].alt), } end @@ -328,8 +332,8 @@ function render_typst_brand_yaml() imageFilename = _quarto.modules.mediabag.write_mediabag_entry(imageFilename) or imageFilename imageFilename = imageFilename and imageFilename:gsub('\\_', '_') else - -- backslashes need to be doubled for Windows - imageFilename = string.gsub(imageFilename, '\\', '\\\\') + -- Typst 0.15+ rejects backslash path separators. + imageFilename = path.to_forward_slashes(imageFilename) end logoOptions.path = pandoc.RawInline('typst', imageFilename) meta.logo = logoOptions diff --git a/src/resources/filters/quarto-post/typst.lua b/src/resources/filters/quarto-post/typst.lua index 10e822405e4..d89a31eea8e 100644 --- a/src/resources/filters/quarto-post/typst.lua +++ b/src/resources/filters/quarto-post/typst.lua @@ -8,6 +8,7 @@ local typst = require("modules/typst") _quarto.format.typst = typst +local path = require("modules/path") -- Helper to format marginalia shift parameter -- auto/true/false are unquoted, "avoid"/"ignore" are quoted strings @@ -252,8 +253,8 @@ function render_typst_fixups() -- Build image() parameters local params = {} - -- Source path (escape backslashes for Windows paths) - src = src:gsub('\\', '\\\\') + -- Typst 0.15+ rejects backslash path separators in image() calls. + src = path.to_forward_slashes(src) table.insert(params, '"' .. src .. '"') -- Alt text second (escape backslashes and quotes) @@ -274,6 +275,21 @@ function render_typst_fixups() return pandoc.RawInline("typst", "#box(image(" .. table.concat(params, ", ") .. "))") end + -- When caption-as-alt is deliberately suppressed (this Image is the + -- sole content of a Figure whose caption is rendered separately, see + -- layout/pandoc3_figure.lua) and no other alt text applies, clear the + -- caption before returning the bare Image. Otherwise Pandoc's own + -- Typst writer independently re-derives alt: from the Image's + -- caption, reintroducing the leak this suppression exists to prevent. + if no_caption_alt then + image.caption = pandoc.Inlines{} + end + + -- Typst 0.15+ rejects backslash path separators in image() calls, and + -- this bare-Image path (no alt text) is handed to Pandoc's own Typst + -- writer, which emits image.src verbatim. + image.src = path.to_forward_slashes(image.src) + return image end, Div = function(div) diff --git a/src/resources/filters/quarto-pre/bibliography-formats.lua b/src/resources/filters/quarto-pre/bibliography-formats.lua index da7c632f8da..0b9c687b37b 100644 --- a/src/resources/filters/quarto-pre/bibliography-formats.lua +++ b/src/resources/filters/quarto-pre/bibliography-formats.lua @@ -9,6 +9,18 @@ function bibliography_formats() doc.meta.references = pandoc.utils.references(doc) doc.meta.bibliography = nil return doc + elseif _quarto.format.isTypstOutput() and doc.meta.bibliography ~= nil + and quarto.doc.cite_method() ~= "citeproc" then + -- pandoc's Typst writer backslash-escapes any metadata string starting + -- with a literal dot (jgm/pandoc#11511). Rewriting each bibliography + -- path as a raw typst inline bypasses that escaping entirely. + -- + -- Skip this when citeproc is handling citations natively (cite-method + -- "citeproc", set when the user opts into `citeproc: true` for Typst): + -- pandoc's own citeproc reads doc.meta.bibliography to load the actual + -- bib file, so it must stay a plain path string here. + doc.meta.bibliography = quarto.utils.as_raw_metadata(doc.meta.bibliography) + return doc end end } diff --git a/src/resources/filters/quarto-pre/book-links.lua b/src/resources/filters/quarto-pre/book-links.lua index 41e19222d63..b080b88e2f3 100644 --- a/src/resources/filters/quarto-pre/book-links.lua +++ b/src/resources/filters/quarto-pre/book-links.lua @@ -1,6 +1,8 @@ -- book-links.lua -- Copyright (C) 2020-2022 Posit Software, PBC +local path = require 'modules/path' + function index_book_file_targets() if not param("single-file-book", false) then return {} @@ -53,7 +55,7 @@ function resolve_book_file_targets() package.config:sub(1,1) -- Paths are always using '/' separator (even on windows) - linkTarget = linkTarget:gsub("\\", "/") + linkTarget = path.to_forward_slashes(linkTarget) local sectionId = quarto_global_state.fileSectionIds[linkTarget]; if sectionId ~= nil then el.target = '#' .. sectionId diff --git a/src/resources/filters/quarto-pre/project-paths.lua b/src/resources/filters/quarto-pre/project-paths.lua index 8fb6d9053e9..c3fb306ba7e 100644 --- a/src/resources/filters/quarto-pre/project-paths.lua +++ b/src/resources/filters/quarto-pre/project-paths.lua @@ -2,11 +2,14 @@ -- Copyright (C) 2020-2022 Posit Software, PBC local constants = require("modules/constants") +local pathmodule = require("modules/path") local function resolveProjectPath(path) local offset = _quarto.projectOffset() if offset and path and startsWith(path, '/') then - return pandoc.path.join({offset, pandoc.text.sub(path, 2, #path)}) + -- This path can flow into writers (e.g. Typst 0.15+) that reject + -- backslash path separators, so it needs forward slashes. + return pathmodule.to_forward_slashes(pandoc.path.join({offset, pandoc.text.sub(path, 2, #path)})) else return nil end diff --git a/src/resources/formats/beamer/pandoc/beamer.template b/src/resources/formats/beamer/pandoc/beamer.template index 6e2c0ed80cf..bb88d82662f 100644 --- a/src/resources/formats/beamer/pandoc/beamer.template +++ b/src/resources/formats/beamer/pandoc/beamer.template @@ -125,7 +125,7 @@ $for(titlegraphic)$ $endfor$} $endif$ $if(logo)$ -\logo{\includegraphics{$logo$}} +\logo{\includegraphics$if(logooptions)$[$for(logooptions)$$logooptions$$sep$, $endfor$]$endif${$logo$}} $endif$ \begin{document} diff --git a/src/resources/formats/beamer/pandoc/hypersetup.latex b/src/resources/formats/beamer/pandoc/hypersetup.latex index ff67655576e..610ba2c8fd8 100644 --- a/src/resources/formats/beamer/pandoc/hypersetup.latex +++ b/src/resources/formats/beamer/pandoc/hypersetup.latex @@ -1,3 +1,7 @@ +% fallback for those not using the hyperref driver hyperxmp: +\makeatletter +\@ifundefined{xmpquote}{\newcommand{\xmpquote}[1]{#1}}{} +\makeatother \hypersetup{ $if(title-meta)$ pdftitle={$title-meta$}, @@ -12,7 +16,7 @@ $if(subject)$ pdfsubject={$subject$}, $endif$ $if(keywords)$ - pdfkeywords={$for(keywords)$$keywords$$sep$, $endfor$}, + pdfkeywords={$for(keywords)$\xmpquote{$keywords$}$sep$, $endfor$}, $endif$ $if(colorlinks)$ colorlinks=true, diff --git a/src/resources/formats/beamer/pandoc/title.tex b/src/resources/formats/beamer/pandoc/title.tex index a3163a911c2..ccbe5198f1f 100644 --- a/src/resources/formats/beamer/pandoc/title.tex +++ b/src/resources/formats/beamer/pandoc/title.tex @@ -18,5 +18,5 @@ $endfor$} $endif$ $if(logo)$ -\logo{\includegraphics{$logo$}} +\logo{\includegraphics$if(logooptions)$[$for(logooptions)$$logooptions$$sep$, $endfor$]$endif${$logo$}} $endif$ \ No newline at end of file diff --git a/src/resources/formats/html/pandoc/html.styles b/src/resources/formats/html/pandoc/html.styles index 800f63c7a6a..cac6172c5ed 100644 --- a/src/resources/formats/html/pandoc/html.styles +++ b/src/resources/formats/html/pandoc/html.styles @@ -106,6 +106,7 @@ div.abstract-title { } $endif$ code { + white-space: pre-wrap; font-family: $if(monofont)$$monofont$$else$Menlo, Monaco, Consolas, 'Lucida Console', monospace$endif$; $if(monobackgroundcolor)$ background-color: $monobackgroundcolor$; @@ -183,10 +184,13 @@ header { text-decoration: none; } $endif$ -code{white-space: pre-wrap;} span.smallcaps{font-variant: small-caps;} -div.columns{display: flex; gap: min(4vw, 1.5em);} -div.column{flex: auto; overflow-x: auto;} +div.columns{display: flex; gap: 1.5em;} +div.column{flex: auto;} +@media screen { +div.columns{gap: min(4vw, 1.5em);} +div.column{overflow-x: auto;} +} div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;} /* The extra [class] is a hack that increases specificity enough to override a similar rule in reveal.js */ diff --git a/src/resources/formats/html/pandoc/html.template b/src/resources/formats/html/pandoc/html.template index 26740d1b0ec..2f39bc95ec6 100644 --- a/src/resources/formats/html/pandoc/html.template +++ b/src/resources/formats/html/pandoc/html.template @@ -1,8 +1,8 @@ - + - + $for(author-meta)$ diff --git a/src/resources/formats/html/pandoc/styles.html b/src/resources/formats/html/pandoc/styles.html index ebcc014be1f..795fd311307 100644 --- a/src/resources/formats/html/pandoc/styles.html +++ b/src/resources/formats/html/pandoc/styles.html @@ -118,6 +118,7 @@ } $endif$ code { + white-space: pre-wrap; font-family: $if(monofont)$$monofont$$else$Menlo, Monaco, Consolas, 'Lucida Console', monospace$endif$; $if(monobackgroundcolor)$ background-color: $monobackgroundcolor$; @@ -190,10 +191,13 @@ text-decoration: none; } $endif$ -code{white-space: pre-wrap;} span.smallcaps{font-variant: small-caps;} -div.columns{display: flex; gap: min(4vw, 1.5em);} -div.column{flex: auto; overflow-x: auto;} +div.columns{display: flex; gap: 1.5em;} +div.column{flex: auto;} +@media screen { +div.columns{gap: min(4vw, 1.5em);} +div.column{overflow-x: auto;} +} div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;} ul.task-list{list-style: none;} ul.task-list li input[type="checkbox"] { diff --git a/src/resources/formats/html/pandoc/template.html b/src/resources/formats/html/pandoc/template.html index 3a7b1b9675f..5ae339e9211 100644 --- a/src/resources/formats/html/pandoc/template.html +++ b/src/resources/formats/html/pandoc/template.html @@ -1,5 +1,5 @@ - + diff --git a/src/resources/formats/pdf/pandoc/hypersetup.latex b/src/resources/formats/pdf/pandoc/hypersetup.latex index ff67655576e..610ba2c8fd8 100644 --- a/src/resources/formats/pdf/pandoc/hypersetup.latex +++ b/src/resources/formats/pdf/pandoc/hypersetup.latex @@ -1,3 +1,7 @@ +% fallback for those not using the hyperref driver hyperxmp: +\makeatletter +\@ifundefined{xmpquote}{\newcommand{\xmpquote}[1]{#1}}{} +\makeatother \hypersetup{ $if(title-meta)$ pdftitle={$title-meta$}, @@ -12,7 +16,7 @@ $if(subject)$ pdfsubject={$subject$}, $endif$ $if(keywords)$ - pdfkeywords={$for(keywords)$$keywords$$sep$, $endfor$}, + pdfkeywords={$for(keywords)$\xmpquote{$keywords$}$sep$, $endfor$}, $endif$ $if(colorlinks)$ colorlinks=true, diff --git a/src/resources/formats/pdf/pandoc/latex.template b/src/resources/formats/pdf/pandoc/latex.template index 6c70459554f..e993af1d77d 100644 --- a/src/resources/formats/pdf/pandoc/latex.template +++ b/src/resources/formats/pdf/pandoc/latex.template @@ -1,3 +1,4 @@ +$document-metadata.latex()$ $passoptions.latex()$ \documentclass[ $for(babel-otherlangs)$ diff --git a/src/resources/formats/revealjs/pandoc/revealjs.template b/src/resources/formats/revealjs/pandoc/revealjs.template index 5278dd14571..0fc4464f170 100644 --- a/src/resources/formats/revealjs/pandoc/revealjs.template +++ b/src/resources/formats/revealjs/pandoc/revealjs.template @@ -29,6 +29,9 @@ $if(theme)$ $else$ $endif$ +$if(highlight-js)$ + +$endif$ $for(css)$ $endfor$ @@ -87,6 +90,9 @@ $body$ $if(mathjax)$ $endif$ +$if(highlight-js)$ + +$endif$ $if(mathjax)$ +$endif$ +$if(highlight-js)$ + $endif$ @@ -313,6 +319,9 @@ plugins: [ $if(mathjax)$ RevealMath, +$endif$ +$if(highlight-js)$ + RevealHighlight, $endif$ RevealNotes, RevealSearch, diff --git a/src/resources/formats/typst/pandoc/template.typst b/src/resources/formats/typst/pandoc/template.typst index 648476620f5..1d90b1d9769 100644 --- a/src/resources/formats/typst/pandoc/template.typst +++ b/src/resources/formats/typst/pandoc/template.typst @@ -73,47 +73,48 @@ } } - block(below: 1em, width: 100%)[ - #if title != none { - align(center, block[ - #text(weight: "bold", size: 1.5em)[#title #if thanks != none { - footnote(thanks, numbering: "*") - counter(footnote).update(n => n - 1) - }] - #( - if subtitle != none { - parbreak() - text(weight: "bold", size: 1.25em)[#subtitle] - } - )]) - } - - #if authors != none and authors != [] { - let count = authors.len() - let ncols = calc.min(count, 3) - grid( - columns: (1fr,) * ncols, - row-gutter: 1.5em, - ..authors.map(author => align(center)[ - #author.name \ - #author.affiliation \ - #author.email - ]) - ) - } + if title != none { + place(top, float: true, scope: "parent", clearance: 4mm, block(below: 1em, width: 100%)[ + #if title != none { + align(center, block[ + #text(weight: "bold", size: 1.5em, hyphenate: false)[#title #if thanks != none { + footnote(thanks, numbering: "*") + counter(footnote).update(n => n - 1) + }] + #( + if subtitle != none { + parbreak() + text(weight: "bold", size: 1.25em, hyphenate: false)[#subtitle] + } + )]) + } - #if date != none { - align(center)[#block(inset: 1em)[ - #date - ]] - } + #if authors != none and authors != [] { + let count = authors.len() + let ncols = calc.min(count, 3) + grid( + columns: (1fr,) * ncols, + row-gutter: 1.5em, + ..authors.map(author => align(center)[ + #author.name \ + #author.affiliation \ + #author.email + ]) + ) + } - #if abstract != none { - block(inset: 2em)[ - #text(weight: "semibold")[#abstract-title] #h(1em) #abstract - ] - } - ] + #if date != none { + align(center)[#block(inset: 1em)[ + #date + ]] + } + #if abstract != none { + block(inset: 2em)[ + #text(weight: "semibold")[#abstract-title] #h(1em) #abstract + ] + } + ]) + } doc } diff --git a/src/resources/formats/typst/pandoc/typst.template b/src/resources/formats/typst/pandoc/typst.template index a499cd8db9f..9b161a4c873 100644 --- a/src/resources/formats/typst/pandoc/typst.template +++ b/src/resources/formats/typst/pandoc/typst.template @@ -1,13 +1,9 @@ #let horizontalrule = line(start: (25%,0%), end: (75%,0%)) -#show terms: it => { - it.children - .map(child => [ - #strong[#child.term] - #block(inset: (left: 1.5em, top: -0.4em))[#child.description] - ]) - .join() -} +#show terms.item: it => block(breakable: false)[ + #text(weight: "bold")[#it.term] + #block(inset: (left: 1.5em, top: -0.4em))[#it.description] +] #set table( inset: 6pt, diff --git a/src/resources/lua-types/pandoc/components.lua b/src/resources/lua-types/pandoc/components.lua index 7c838708590..06b1e65a777 100644 --- a/src/resources/lua-types/pandoc/components.lua +++ b/src/resources/lua-types/pandoc/components.lua @@ -198,6 +198,16 @@ number of row header columns. pandoc.TableBody = {} +--[[ +Creates a table body +]] +---@param body? pandoc.List List of `Row` +---@param head? pandoc.List Intermediate head (list of `Row`) +---@param row_head_columns? integer Number of columns taken up by the row head of each row of a `TableBody` +---@param attr? pandoc.Attr Table body attributes +---@return pandoc.TableBody +function pandoc.TableBody(body, head, row_head_columns, attr) end + --[[ Make a clone ]] diff --git a/src/resources/lua-types/pandoc/pandoc.lua b/src/resources/lua-types/pandoc/pandoc.lua index 0c33cb5e453..cea7c64e344 100644 --- a/src/resources/lua-types/pandoc/pandoc.lua +++ b/src/resources/lua-types/pandoc/pandoc.lua @@ -3,10 +3,10 @@ ---@module 'pandoc' pandoc = {} ----@type table +---@type table pandoc.readers = {} ----@type table +---@type table pandoc.writers = {} --[[ diff --git a/src/resources/lua-types/pandoc/version.lua b/src/resources/lua-types/pandoc/version.lua index ecf1c5394a8..f0d1aa8f3ae 100644 --- a/src/resources/lua-types/pandoc/version.lua +++ b/src/resources/lua-types/pandoc/version.lua @@ -27,6 +27,30 @@ pandoc.Version = {} function pandoc.types.Version(version_specifier) end +--[[ +A named source of text, e.g. a file name paired with its contents. +Used as an item in a `Sources` list. +]] +---@class pandoc.types.Source +---@field name string Name/path the text originated from +---@field text string The source text + +--[[ +A list of `Source` items, pairing input text with information on +where it originated (e.g. a file name). Used by Pandoc's text readers. +]] +---@class pandoc.types.Sources: pandoc.List + +---@alias sources_specifier string|pandoc.types.Source[]|pandoc.types.Sources + +--[[ +Creates a new Sources element, i.e., a list of `Source` items. +]] +---@param srcs sources_specifier +---@return pandoc.types.Sources +function pandoc.types.Sources(srcs) end + + --[[ Raise an error message if the version is older than the expected version; does nothing if actual is equal to or newer than the expected version. diff --git a/src/resources/pandoc/datadir/_utils.lua b/src/resources/pandoc/datadir/_utils.lua index 2ea4b0580c1..8a204fbaef4 100644 --- a/src/resources/pandoc/datadir/_utils.lua +++ b/src/resources/pandoc/datadir/_utils.lua @@ -607,6 +607,28 @@ local function walk(node, filter) return node:walk(filter) end +--- Rewrites a Meta scalar or list value so every entry becomes a `RawInline` +-- in the given format, verbatim -- bypassing that format's writer-side string +-- escaping entirely. Preserves the scalar-vs-list shape of the input. +-- Useful for metadata values (paths, etc.) that a pandoc writer's own +-- escaping would otherwise corrupt. `format` defaults to the current output +-- format (pandoc's `FORMAT` global) when omitted. +local function as_raw_metadata(value, format) + format = format or FORMAT + local function raw(str) + return pandoc.MetaInlines({ pandoc.RawInline(format, str) }) + end + if get_type(value) == "List" then + local result = pandoc.List() + for _, item in ipairs(value) do + result:insert(raw(pandoc.utils.stringify(item))) + end + return result + else + return raw(pandoc.utils.stringify(value)) + end +end + return { dump = dump, type = get_type, @@ -617,6 +639,7 @@ return { }, as_inlines = as_inlines, as_blocks = as_blocks, + as_raw_metadata = as_raw_metadata, is_empty_node = is_empty_node, match = match, walk = walk, diff --git a/src/resources/pandoc/datadir/init.lua b/src/resources/pandoc/datadir/init.lua index 8eb2be0ae76..bd19df840ff 100644 --- a/src/resources/pandoc/datadir/init.lua +++ b/src/resources/pandoc/datadir/init.lua @@ -971,6 +971,7 @@ quarto = { resolve_path_relative_to_document = resolvePath, as_inlines = utils.as_inlines, as_blocks = utils.as_blocks, + as_raw_metadata = utils.as_raw_metadata, is_empty_node = utils.is_empty_node, string_to_blocks = utils.string_to_blocks, string_to_inlines = utils.string_to_inlines, diff --git a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd index 6c1b98a8a77..00db55838c3 100644 --- a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd +++ b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd @@ -14,7 +14,7 @@ _quarto: - ['Proof content visible'] typst: ensureTypstFileRegexMatches: - - ['#emph\[Proof\]\. Proof content visible'] + - ['#emph\[Proof\]\\?\. Proof content visible'] - [] --- diff --git a/tests/docs/smoke-all/2026/06/29/14583-typst.qmd b/tests/docs/smoke-all/2026/06/29/14583-typst.qmd index 50fad66de63..fafd36670cf 100644 --- a/tests/docs/smoke-all/2026/06/29/14583-typst.qmd +++ b/tests/docs/smoke-all/2026/06/29/14583-typst.qmd @@ -11,9 +11,9 @@ _quarto: # Typst's default-image-extension is svg. A shortcode resolving to a # .png path becomes .png.svg; the spurious .svg must be stripped, # leaving the real .png (issue #14583, mismatched-extension case). - - 'image\("assets/diagram\.png"\)' + - 'image\("assets/diagram\.png"' - - - 'image\("assets/diagram\.png\.svg"\)' + - 'image\("assets/diagram\.png\.svg"' --- Shortcode resolving to a `.png` path in a Typst document (default extension diff --git a/tests/docs/smoke-all/crossrefs/float/asciidoc/asciidoc-float-caption-formatting-1.qmd b/tests/docs/smoke-all/crossrefs/float/asciidoc/asciidoc-float-caption-formatting-1.qmd index aa91f501746..439382f2d21 100644 --- a/tests/docs/smoke-all/crossrefs/float/asciidoc/asciidoc-float-caption-formatting-1.qmd +++ b/tests/docs/smoke-all/crossrefs/float/asciidoc/asciidoc-float-caption-formatting-1.qmd @@ -6,7 +6,7 @@ _quarto: asciidoc: ensureFileRegexMatches: - - - "\\.Customers _query_" + - "\\.Customers _{1,2}query_{1,2}" - "<>" - "\\[\\[lst-customers\\]\\]" --- diff --git a/tests/docs/smoke-all/typst/css-property-processing/default.qmd b/tests/docs/smoke-all/typst/css-property-processing/default.qmd index 2d554c374f3..9f8f44bd615 100644 --- a/tests/docs/smoke-all/typst/css-property-processing/default.qmd +++ b/tests/docs/smoke-all/typst/css-property-processing/default.qmd @@ -6,7 +6,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(255, 0, 255\)\); B\]' + - '\[#set text\(fill: rgb\(255, 0, 255\)\);\s*B\]' - [] --- diff --git a/tests/docs/smoke-all/typst/css-property-processing/translate.qmd b/tests/docs/smoke-all/typst/css-property-processing/translate.qmd index b5c57ed355a..3cfc8703b4d 100644 --- a/tests/docs/smoke-all/typst/css-property-processing/translate.qmd +++ b/tests/docs/smoke-all/typst/css-property-processing/translate.qmd @@ -8,7 +8,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(255, 0, 255\)\); B\]' + - '\[#set text\(fill: rgb\(255, 0, 255\)\);\s*B\]' - [] --- diff --git a/tests/docs/smoke-all/typst/gt-islands.qmd b/tests/docs/smoke-all/typst/gt-islands.qmd index 6f80c9ac663..61ca2751ad0 100644 --- a/tests/docs/smoke-all/typst/gt-islands.qmd +++ b/tests/docs/smoke-all/typst/gt-islands.qmd @@ -11,7 +11,7 @@ _quarto: tests: typst: ensureTypstFileRegexMatches: - - ['#set text\(size: 1\.25em , weight: "regular" , fill: rgb\("#333333"\)\); Large Landmasses of the World'] + - ['#set text\(size: 1\.25em , weight: "regular" , fill: rgb\("#333333"\)\);\s*Large Landmasses of the World'] - [] --- diff --git a/tests/docs/smoke-all/typst/pandas-cell-css-rules.qmd b/tests/docs/smoke-all/typst/pandas-cell-css-rules.qmd index ccf52d08534..6ccfcf0f6c5 100644 --- a/tests/docs/smoke-all/typst/pandas-cell-css-rules.qmd +++ b/tests/docs/smoke-all/typst/pandas-cell-css-rules.qmd @@ -16,8 +16,8 @@ _quarto: typst: ensureTypstFileRegexMatches: - [ - 'table.cell\(fill: rgb\("#ffc0cb"\)\)\[#set text\(fill: white\); 1.867558\]', - '\[#set text\(fill: rgb\("#ff413633"\)\); -0.151357\]' + 'table.cell\(fill: rgb\("#ffc0cb"\)\)\[#set text\(fill: white\);\s*1.867558\]', + '\[#set text\(fill: rgb\("#ff413633"\)\);\s*\\?-0.151357\]' ] - [] --- diff --git a/tests/docs/smoke-all/typst/typst-bibliography-leading-dot.qmd b/tests/docs/smoke-all/typst/typst-bibliography-leading-dot.qmd new file mode 100644 index 00000000000..834829c226e --- /dev/null +++ b/tests/docs/smoke-all/typst/typst-bibliography-leading-dot.qmd @@ -0,0 +1,22 @@ +--- +title: "Typst bibliography with leading-dot path" +format: + typst: + keep-typ: true +bibliography: ./refs.bib +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['\#bibliography\(\("\./refs\.bib"\)\)'] + - ['\\\.', '\\/'] + noErrors: default +--- + +Pandoc 3.10's Typst writer backslash-escapes any metadata string starting with a +literal dot (jgm/pandoc#11511, jgm/pandoc#11761). Quarto's `biblio.typ` partial +interpolates the raw `bibliography` metadata value into `#bibliography(...)`, so a +relative path with a leading dot (`./refs.bib`) previously compiled to invalid Typst +(`path must not contain a backslash`). This document exercises that path directly. + +See @Cronbach_1951 for a citation that must still resolve correctly. diff --git a/tests/smoke/lua-unit/lua-unit.test.ts b/tests/smoke/lua-unit/lua-unit.test.ts index 1553052f60f..5a4f61341b9 100644 --- a/tests/smoke/lua-unit/lua-unit.test.ts +++ b/tests/smoke/lua-unit/lua-unit.test.ts @@ -25,6 +25,8 @@ import { unitTest } from "../../test.ts"; // Explicit list, relative to tests/unit-lua/. Keep alphabetized. const LUA_TESTS: string[] = [ + "mediabag.test.lua", + "path.test.lua", "typst-css.test.lua", ]; diff --git a/tests/unit-lua/mediabag.test.lua b/tests/unit-lua/mediabag.test.lua new file mode 100644 index 00000000000..8a00d546c22 --- /dev/null +++ b/tests/unit-lua/mediabag.test.lua @@ -0,0 +1,57 @@ +-- Unit tests for src/resources/filters/modules/mediabag.lua +-- +-- Run via the Deno smoke test in tests/smoke/lua-unit/lua-unit.test.ts +-- (which sets LUA_PATH and invokes `quarto run`), or directly with: +-- LUA_PATH="tests/unit-lua/?.lua;src/resources/filters/modules/?.lua;;" \ +-- package/dist/bin/quarto run tests/unit-lua/mediabag.test.lua + +-- Mocks for filter-runtime globals ------------------------------------------- + +local param_values = {} +function param(name, default) + if param_values[name] ~= nil then return param_values[name] end + return default +end + +function warn(_msg) end + +_quarto = { + file = { + write = function(_path, _contents) return true end, + }, +} + +local lu = require('luaunit') +local mediabag = require('mediabag') + +-- Tests ---------------------------------------------------------------------- + +-- Regression test for Typst 0.15.0 hard-rejecting backslash path separators +-- in image() calls (quarto-cli-fpil). pandoc.path.join uses the host OS's +-- native separator, which is a backslash on Windows. Any path handed to +-- el.src must use forward slashes only, since it flows straight into +-- writers (e.g. Typst's image()) that may not accept native separators. +TestWriteMediabagEntry = {} + +function TestWriteMediabagEntry:setUp() + param_values = { ["mediabag-dir"] = "imgtest_files/mediabag" } + pandoc.mediabag.insert("png-base64,abc123.png", "image/png", "fake-bytes") +end + +function TestWriteMediabagEntry:tearDown() + pandoc.mediabag.empty() +end + +function TestWriteMediabagEntry:testResultHasNoBackslash() + local path = mediabag.write_mediabag_entry("png-base64,abc123.png") + lu.assertNotNil(path) + lu.assertNil(path:find('\\', 1, true), + 'write_mediabag_entry returned a path with a backslash: ' .. path) +end + +function TestWriteMediabagEntry:testResultJoinsDirAndFilename() + local path = mediabag.write_mediabag_entry("png-base64,abc123.png") + lu.assertEquals(path, "imgtest_files/mediabag/png-base64,abc123.png") +end + +os.exit(lu.LuaUnit.run()) diff --git a/tests/unit-lua/path.test.lua b/tests/unit-lua/path.test.lua new file mode 100644 index 00000000000..a71c1aeb7d8 --- /dev/null +++ b/tests/unit-lua/path.test.lua @@ -0,0 +1,40 @@ +-- Unit tests for src/resources/filters/modules/path.lua +-- +-- Run via the Deno smoke test in tests/smoke/lua-unit/lua-unit.test.ts +-- (which sets LUA_PATH and invokes `quarto run`), or directly with: +-- LUA_PATH="tests/unit-lua/?.lua;src/resources/filters/modules/?.lua;;" \ +-- package/dist/bin/quarto run tests/unit-lua/path.test.lua + +local lu = require('luaunit') +local path = require('path') + +-- Tests ---------------------------------------------------------------------- + +-- Regression test for Typst 0.15.0 hard-rejecting backslash path separators +-- (quarto-cli-ooky). Any path built with the host OS's native separator +-- (backslash on Windows) must be normalized to forward slashes before it can +-- flow into writers (e.g. Typst's image()) that reject native separators. +TestToForwardSlashes = {} + +function TestToForwardSlashes:testReplacesBackslashes() + lu.assertEquals( + path.to_forward_slashes("a\\b\\c.png"), + "a/b/c.png" + ) +end + +function TestToForwardSlashes:testLeavesForwardSlashesUnchanged() + lu.assertEquals( + path.to_forward_slashes("a/b/c.png"), + "a/b/c.png" + ) +end + +function TestToForwardSlashes:testMixedSeparators() + lu.assertEquals( + path.to_forward_slashes("../../..\\media/table.jpg"), + "../../../media/table.jpg" + ) +end + +os.exit(lu.LuaUnit.run())