Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
38 changes: 25 additions & 13 deletions src/resources/extensions/quarto/placeholder/placeholder.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions tests/docs/smoke-all/2024/05/14/placeholder-test.qmd
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
61 changes: 61 additions & 0 deletions tests/smoke/render/placeholder-typst-text.test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
const svg =
`<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>`;
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 });
}
});
Loading