diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index e973e27bbb9..b3b5d8859be 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -139,3 +139,4 @@ All changes included in 1.10: - ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment - ([#14669](https://github.com/quarto-dev/quarto-cli/issues/14669)): Fix markdown output being deleted when `output-file` has an `.html` extension and an html format is paired with a markdown format. - ([#14687](https://github.com/quarto-dev/quarto-cli/issues/14687)): Fix SCSS color-variable export (`--quarto-scss-export-*`) being silently skipped when a theme rule places a declaration on the same line as the opening brace with no space after the colon (e.g. `.example {width:100px;}`). +- ([#14722](https://github.com/quarto-dev/quarto-cli/issues/14722)): Fix `{{< placeholder >}}` shortcode failing to render on non-SVG formats after an external image service was retired; placeholders now render locally with no network access. diff --git a/src/resources/extensions/quarto/placeholder/placeholder.lua b/src/resources/extensions/quarto/placeholder/placeholder.lua index 725be5bc254..f7cdb620069 100644 --- a/src/resources/extensions/quarto/placeholder/placeholder.lua +++ b/src/resources/extensions/quarto/placeholder/placeholder.lua @@ -25,21 +25,33 @@ return { if output_format == "svg" then result = svg64 else - local pcallresult, mt, contents = pcall(function() - local mt, contents = pandoc.mediabag.fetch("https://svg2png.deno.dev/" .. svg64) - return mt, contents + local ok, contents = pcall(function() + return pandoc.system.with_temporary_directory('placeholder', function(tmpdir) + local svg_in = pandoc.path.join({tmpdir, 'in.svg'}) + local typ_in = pandoc.path.join({tmpdir, 'in.typ'}) + local png_out = pandoc.path.join({tmpdir, 'out.png'}) + local sf = assert(io.open(svg_in, 'wb')) + sf:write(svg) + sf:close() + local tf = assert(io.open(typ_in, 'wb')) + tf:write('#set page(width: auto, height: auto, margin: 0pt)\n#image("in.svg")\n') + tf:close() + pandoc.pipe(quarto.paths.typst(), { + 'compile', '--root', tmpdir, '--format', 'png', '--ppi', '96', + '--ignore-system-fonts', typ_in, png_out + }, '') + local pf = assert(io.open(png_out, 'rb')) + local data = pf:read('*a') + pf:close() + return data + end) end) - if not pcallresult then - error("Error rendering placeholder") - error(contents) - return pandoc.Str("Error rendering placeholder") + if not ok or contents == nil or contents == '' then + -- Local, offline rasterization: a failure is a real bug, not a flake. Fail loudly. + -- Do NOT substitute other content (that silent substitution was the original bug). + fatal('placeholder: failed to rasterize SVG to PNG via typst (' .. tostring(contents) .. ')') end - if mt ~= "image/png" then - error("Expected image/png but got " .. mt) - error(contents) - return pandoc.Str("Error rendering placeholder") - end - result = "data:" .. mt .. ";base64," .. quarto.base64.encode(contents) + result = 'data:image/png;base64,' .. quarto.base64.encode(contents) end if context == "text" then diff --git a/tests/docs/smoke-all/2024/05/14/placeholder-test.qmd b/tests/docs/smoke-all/2024/05/14/placeholder-test.qmd index c8457e79c8f..ec8d6c77504 100644 --- a/tests/docs/smoke-all/2024/05/14/placeholder-test.qmd +++ b/tests/docs/smoke-all/2024/05/14/placeholder-test.qmd @@ -1,5 +1,13 @@ --- format: html +_quarto: + tests: + html: + # The placeholder shortcode must never leak its error string into the + # output as image content (regression: svg2png.deno.dev sunset, #14722). + ensureFileRegexMatches: + - ['data:image/png;base64'] # png placeholders rasterize locally via typst + - ['Error rendering placeholder'] # and never leak the error text --- ::: {#fig-placeholder-test} diff --git a/tests/smoke/render/placeholder-typst-text.test.ts b/tests/smoke/render/placeholder-typst-text.test.ts new file mode 100644 index 00000000000..7687307faec --- /dev/null +++ b/tests/smoke/render/placeholder-typst-text.test.ts @@ -0,0 +1,61 @@ +/* + * placeholder-typst-text.test.ts + * + * The `placeholder` shortcode rasterizes its generated SVG to PNG with the + * bundled Typst binary (#14722). That path only works if Typst renders the + * dimension label text using its embedded fonts (we pass + * `--ignore-system-fonts`). This contract test pins that assumption: a + * labelled SVG must produce a materially larger PNG than a blank one — if + * Typst silently dropped the text, both would be the same blank rectangle. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { assert } from "testing/asserts"; +import { join } from "path"; +import { unitTest } from "../../test.ts"; +import { typstBinaryPath } from "../../../src/core/typst.ts"; + +async function renderPng(label: string, dir: string): Promise { + const svg = + `${label}`; + await Deno.writeTextFile(join(dir, "in.svg"), svg); + await Deno.writeTextFile( + join(dir, "in.typ"), + `#set page(width: auto, height: auto, margin: 0pt)\n#image("in.svg")\n`, + ); + const png = join(dir, `${label.trim() || "blank"}.png`); + const { code } = await new Deno.Command(typstBinaryPath(), { + args: [ + "compile", + "--root", + dir, + "--format", + "png", + "--ppi", + "96", + "--ignore-system-fonts", + join(dir, "in.typ"), + png, + ], + }).output(); + assert(code === 0, "typst compile failed"); + return await Deno.readFile(png); +} + +unitTest("placeholder-typst-renders-label-text", async () => { + const dir = await Deno.makeTempDir(); + try { + const withText = await renderPng("120 x 60", dir); + const blank = await renderPng(" ", dir); + assert(withText[0] === 0x89 && withText[1] === 0x50, "not a PNG"); + // A rendered label yields a materially larger PNG than a blank rect; if + // Typst silently dropped the text, both would be the same blank rect. + assert( + withText.length > blank.length + 100, + `label did not render: ${withText.length}B vs blank ${blank.length}B`, + ); + } finally { + await Deno.remove(dir, { recursive: true }); + } +});