Skip to content

Commit bc2d42e

Browse files
authored
Fix {{< placeholder >}} shortcode failing on non-SVG output (#14724)
The `{{< placeholder >}}` shortcode fetched a rasterized PNG from the `svg2png.deno.dev` service for every non-SVG format. That service was permanently retired and now returns 404, so placeholders failed to render — a broken image on html, a hard LaTeX abort on pdf. Rasterize locally with the already-bundled Typst binary instead: the shortcode writes its SVG plus a one-line Typst wrapper to a temp dir, runs `typst compile --format png`, and returns a base64 PNG data URI. No network, no new bundled assets. On failure it aborts loudly rather than substituting placeholder text (that silent substitution was the original bug). The `format=svg` and Typst-output paths are unchanged. A contract test pins the assumption the fix relies on — that Typst renders the dimension label using its portable embedded fonts. Fixes #14722
1 parent d30cdbb commit bc2d42e

4 files changed

Lines changed: 95 additions & 13 deletions

File tree

news/changelog-1.10.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,4 @@ All changes included in 1.10:
139139
- ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment
140140
- ([#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.
141141
- ([#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;}`).
142+
- ([#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.

src/resources/extensions/quarto/placeholder/placeholder.lua

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,33 @@ return {
2525
if output_format == "svg" then
2626
result = svg64
2727
else
28-
local pcallresult, mt, contents = pcall(function()
29-
local mt, contents = pandoc.mediabag.fetch("https://svg2png.deno.dev/" .. svg64)
30-
return mt, contents
28+
local ok, contents = pcall(function()
29+
return pandoc.system.with_temporary_directory('placeholder', function(tmpdir)
30+
local svg_in = pandoc.path.join({tmpdir, 'in.svg'})
31+
local typ_in = pandoc.path.join({tmpdir, 'in.typ'})
32+
local png_out = pandoc.path.join({tmpdir, 'out.png'})
33+
local sf = assert(io.open(svg_in, 'wb'))
34+
sf:write(svg)
35+
sf:close()
36+
local tf = assert(io.open(typ_in, 'wb'))
37+
tf:write('#set page(width: auto, height: auto, margin: 0pt)\n#image("in.svg")\n')
38+
tf:close()
39+
pandoc.pipe(quarto.paths.typst(), {
40+
'compile', '--root', tmpdir, '--format', 'png', '--ppi', '96',
41+
'--ignore-system-fonts', typ_in, png_out
42+
}, '')
43+
local pf = assert(io.open(png_out, 'rb'))
44+
local data = pf:read('*a')
45+
pf:close()
46+
return data
47+
end)
3148
end)
32-
if not pcallresult then
33-
error("Error rendering placeholder")
34-
error(contents)
35-
return pandoc.Str("Error rendering placeholder")
49+
if not ok or contents == nil or contents == '' then
50+
-- Local, offline rasterization: a failure is a real bug, not a flake. Fail loudly.
51+
-- Do NOT substitute other content (that silent substitution was the original bug).
52+
fatal('placeholder: failed to rasterize SVG to PNG via typst (' .. tostring(contents) .. ')')
3653
end
37-
if mt ~= "image/png" then
38-
error("Expected image/png but got " .. mt)
39-
error(contents)
40-
return pandoc.Str("Error rendering placeholder")
41-
end
42-
result = "data:" .. mt .. ";base64," .. quarto.base64.encode(contents)
54+
result = 'data:image/png;base64,' .. quarto.base64.encode(contents)
4355
end
4456

4557
if context == "text" then

tests/docs/smoke-all/2024/05/14/placeholder-test.qmd

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
---
22
format: html
3+
_quarto:
4+
tests:
5+
html:
6+
# The placeholder shortcode must never leak its error string into the
7+
# output as image content (regression: svg2png.deno.dev sunset, #14722).
8+
ensureFileRegexMatches:
9+
- ['data:image/png;base64'] # png placeholders rasterize locally via typst
10+
- ['Error rendering placeholder'] # and never leak the error text
311
---
412

513
::: {#fig-placeholder-test}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* placeholder-typst-text.test.ts
3+
*
4+
* The `placeholder` shortcode rasterizes its generated SVG to PNG with the
5+
* bundled Typst binary (#14722). That path only works if Typst renders the
6+
* dimension label text using its embedded fonts (we pass
7+
* `--ignore-system-fonts`). This contract test pins that assumption: a
8+
* labelled SVG must produce a materially larger PNG than a blank one — if
9+
* Typst silently dropped the text, both would be the same blank rectangle.
10+
*
11+
* Copyright (C) 2026 Posit Software, PBC
12+
*/
13+
14+
import { assert } from "testing/asserts";
15+
import { join } from "path";
16+
import { unitTest } from "../../test.ts";
17+
import { typstBinaryPath } from "../../../src/core/typst.ts";
18+
19+
async function renderPng(label: string, dir: string): Promise<Uint8Array> {
20+
const svg =
21+
`<svg width="120" height="60" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60"><rect width="120" height="60" fill="#ddd"/><text x="50%" y="50%" font-family="sans-serif" font-size="12" fill="#000" text-anchor="middle">${label}</text></svg>`;
22+
await Deno.writeTextFile(join(dir, "in.svg"), svg);
23+
await Deno.writeTextFile(
24+
join(dir, "in.typ"),
25+
`#set page(width: auto, height: auto, margin: 0pt)\n#image("in.svg")\n`,
26+
);
27+
const png = join(dir, `${label.trim() || "blank"}.png`);
28+
const { code } = await new Deno.Command(typstBinaryPath(), {
29+
args: [
30+
"compile",
31+
"--root",
32+
dir,
33+
"--format",
34+
"png",
35+
"--ppi",
36+
"96",
37+
"--ignore-system-fonts",
38+
join(dir, "in.typ"),
39+
png,
40+
],
41+
}).output();
42+
assert(code === 0, "typst compile failed");
43+
return await Deno.readFile(png);
44+
}
45+
46+
unitTest("placeholder-typst-renders-label-text", async () => {
47+
const dir = await Deno.makeTempDir();
48+
try {
49+
const withText = await renderPng("120 x 60", dir);
50+
const blank = await renderPng(" ", dir);
51+
assert(withText[0] === 0x89 && withText[1] === 0x50, "not a PNG");
52+
// A rendered label yields a materially larger PNG than a blank rect; if
53+
// Typst silently dropped the text, both would be the same blank rect.
54+
assert(
55+
withText.length > blank.length + 100,
56+
`label did not render: ${withText.length}B vs blank ${blank.length}B`,
57+
);
58+
} finally {
59+
await Deno.remove(dir, { recursive: true });
60+
}
61+
});

0 commit comments

Comments
 (0)