Skip to content

Commit ecb482c

Browse files
authored
fix: strip spurious default-image-extension from shortcode and empty-URL image sources (#14634)
Pandoc appends default-image-extension to any extensionless image source at parse time, with no URL guard (confirmed against Pandoc HEAD source). This breaks two cases: - A shortcode used as an image source is parsed without an extension, so Pandoc appends the default. When the shortcode later resolves to a path that already has its own extension, the result carries a doubled extension (e.g. logo.png.png, or logo.png.svg in Typst where the default differs) (#14583). - An extensionless URL ending in a slash gets the extension appended, breaking embed/iframe syntax (#6092). The shortcode case is fixed after the shortcode resolves, where the resolved path is finally known: the appended default extension is stripped only when the resolved path already carries an extension of its own, so the multi-format workflow (a shortcode resolving to an extensionless path) keeps its appended extension. The URL case is fixed in the normalize stage, only for the provably-spurious empty-filename form; a URL with a non-empty last segment is indistinguishable from a real file after parsing and is left untouched.
1 parent 894f43f commit ecb482c

8 files changed

Lines changed: 214 additions & 5 deletions

File tree

news/changelog-1.10.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ All changes included in 1.10:
9393

9494
## Other fixes and improvements
9595

96+
- ([#6092](https://github.com/quarto-dev/quarto-cli/issues/6092)): Fix the `default-image-extension` being appended to an extensionless URL ending in a slash (e.g. `![](https://example.com/)`), which broke the embed/iframe image syntax.
9697
- ([#6651](https://github.com/quarto-dev/quarto-cli/issues/6651)): Fix dart-sass compilation failing in enterprise environments where `.bat` files are blocked by group policy.
9798
- ([#14255](https://github.com/quarto-dev/quarto-cli/issues/14255)): Fix shortcodes inside inline and display math expressions not being resolved.
9899
- ([#14342](https://github.com/quarto-dev/quarto-cli/issues/14342)): Work around TOCTOU race in Deno's `expandGlobSync` that can cause unexpected exceptions to be raised while traversing directories during project initialization.
@@ -102,4 +103,5 @@ All changes included in 1.10:
102103
- ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels)
103104
- ([#14529](https://github.com/quarto-dev/quarto-cli/issues/14529)): Fix bundled Julia engine path leaking into rendered YAML metadata and pandoc log output when running an installed Quarto. The internal subtree-engine filter only matched the source-tree share-path layout (`resources/extension-subtrees/`) and missed installed layouts where the path is `share/extension-subtrees/`.
104105
- ([#14582](https://github.com/quarto-dev/quarto-cli/issues/14582)): Fix format detection for extension formats (e.g. `acm-pdf`) in project preview, manuscript notebooks, MECA bundles, and website format ordering.
105-
- ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment.
106+
- ([#14583](https://github.com/quarto-dev/quarto-cli/issues/14583)): Fix a shortcode used as an image source (e.g. `![]({{< meta logo >}})`) getting the `default-image-extension` appended, producing a doubled extension once the shortcode resolves.
107+
- ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment

src/resources/filters/customnodes/shortcodes.lua

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,29 @@ function shortcodes_filter()
331331
end,
332332
Image = function(el)
333333
el = attr_handler(el)
334+
local before = el.src
334335
el.src = apply_code_shortcode(el.src)
336+
-- A shortcode used as an image source is parsed without an extension,
337+
-- so Pandoc appends default_image_extension to it. Once the shortcode
338+
-- resolves to a path that already carries its own extension, that
339+
-- appended extension is spurious and doubles up -- e.g. diagram.png.png,
340+
-- or diagram.png.svg in Typst where the default differs (#14583). Strip
341+
-- the appended default extension, but only when the resolved path still
342+
-- has an extension of its own, so the multi-format workflow (a shortcode
343+
-- resolving to an extensionless path) keeps its appended extension.
344+
if el.src ~= before then
345+
local ext = PANDOC_READER_OPTIONS.default_image_extension
346+
if ext and ext ~= "" then
347+
local suffix = "." .. ext
348+
if el.src:sub(-#suffix) == suffix then
349+
local candidate = el.src:sub(1, -#suffix - 1)
350+
local _, candidate_ext = pandoc.path.split_extension(candidate)
351+
if candidate_ext ~= "" then
352+
el.src = candidate
353+
end
354+
end
355+
end
356+
end
335357
return el
336358
end,
337359
Link = function(el)

src/resources/filters/normalize/fixupdatauri.lua

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,36 @@
22
-- Copyright (C) 2023 Posit Software, PBC
33

44
-- https://github.com/quarto-dev/quarto-cli/issues/6568
5-
function normalize_fixup_data_uri_image_extension()
5+
-- https://github.com/quarto-dev/quarto-cli/issues/6092
6+
function normalize_fixup_data_uri_image_extension()
67
return {
78
Image = function(img)
9+
local ext = PANDOC_READER_OPTIONS.default_image_extension
10+
-- Nothing was appended when there is no default extension.
11+
if not ext or ext == "" then
12+
return nil
13+
end
814
local src = img.src
15+
local suffix = "." .. ext
16+
17+
-- Data URIs never need an extension, but Pandoc appends one anyway (#6568).
918
if src:sub(1, 5) == "data:" then
10-
local l = PANDOC_READER_OPTIONS.default_image_extension:len()
11-
if src:sub(-l-1) == ("." .. PANDOC_READER_OPTIONS.default_image_extension) then
12-
img.src = src:sub(1, -l - 2)
19+
if src:sub(-#suffix) == suffix then
20+
img.src = src:sub(1, -#suffix - 1)
1321
return img
1422
end
23+
return nil
24+
end
25+
26+
-- An http(s) URL whose path ends with a slash has no filename, so the
27+
-- appended default extension is provably spurious -- e.g.
28+
-- https://example.com/ is parsed as https://example.com/.png (#6092).
29+
-- A URL with a non-empty last segment is indistinguishable from a real
30+
-- file of that extension, so it is deliberately left untouched.
31+
if src:sub(1, 4) == "http" and src:match("^https?://")
32+
and src:sub(-#suffix - 1) == "/" .. suffix then
33+
img.src = src:sub(1, -#suffix - 1)
34+
return img
1535
end
1636
end
1737
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
title: default-image-extension custom value
3+
logo: assets/diagram.png
4+
default-image-extension: jpg
5+
format: html
6+
_quarto:
7+
tests:
8+
html:
9+
ensureFileRegexMatches:
10+
-
11+
# Both fixes must read the configured extension, not a hardcoded "png".
12+
# Fix A strips the appended .jpg off a whole-shortcode source
13+
- 'src="assets/diagram\.png"'
14+
# Local file gets the configured .jpg appended (multi-format workflow)
15+
- 'src="images/screenshot\.jpg"'
16+
# Fix B strips the appended .jpg off the empty-filename URL
17+
- 'src="https://example\.com/"'
18+
-
19+
# Fix B must have stripped the spurious .jpg from the URL
20+
- 'src="https://example\.com/\.jpg"'
21+
# Fix A must not leave a doubled extension on the shortcode
22+
- 'diagram\.png\.jpg'
23+
---
24+
25+
A non-default extension must flow through both fixes.
26+
27+
![shortcode]({{< meta logo >}})
28+
29+
![local](images/screenshot)
30+
31+
![empty url](https://example.com/)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
title: default-image-extension explicitly empty
3+
logo: assets/diagram.png
4+
default-image-extension: ""
5+
format: html
6+
_quarto:
7+
tests:
8+
html:
9+
ensureFileRegexMatches:
10+
-
11+
# With an empty default, Pandoc appends nothing, so the fix is a no-op
12+
# and a local file without extension stays extensionless (user's choice)
13+
- 'src="images/screenshot"'
14+
# Shortcode resolves to its own value untouched
15+
- 'src="assets/diagram\.png"'
16+
# URL stays exactly as authored
17+
- 'src="https://example\.com/"'
18+
-
19+
# No extension was appended, so none should appear
20+
- 'src="images/screenshot\.'
21+
- 'diagram\.png\.png'
22+
---
23+
24+
User explicitly disables the default extension. Behavior must be unchanged.
25+
26+
![shortcode]({{< meta logo >}})
27+
28+
![local](images/screenshot)
29+
30+
![empty url](https://example.com/)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
title: default-image-extension shortcode in Typst
3+
withext: assets/diagram.png
4+
format: typst
5+
keep-typ: true
6+
_quarto:
7+
tests:
8+
typst:
9+
ensureTypstFileRegexMatches:
10+
-
11+
# Typst's default-image-extension is svg. A shortcode resolving to a
12+
# .png path becomes .png.svg; the spurious .svg must be stripped,
13+
# leaving the real .png (issue #14583, mismatched-extension case).
14+
- 'image\("assets/diagram\.png"\)'
15+
-
16+
- 'image\("assets/diagram\.png\.svg"\)'
17+
---
18+
19+
Shortcode resolving to a `.png` path in a Typst document (default extension
20+
`svg`) must not end up as `.png.svg`:
21+
22+
![diagram]({{< meta withext >}})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
title: default-image-extension spurious append
3+
logo: assets/diagram.png
4+
imgdir: assets
5+
noext: figures/plot
6+
svgfile: figures/chart.svg
7+
format: html
8+
_quarto:
9+
tests:
10+
html:
11+
ensureFileRegexMatches:
12+
-
13+
# #14583: a shortcode resolving to a path that already has an extension
14+
# must not end up with a doubled extension
15+
- 'src="assets/diagram\.png"'
16+
# The doubled extension may be mismatched: a shortcode resolving to a
17+
# .svg path in an HTML doc (default png) becomes .svg.png; the spurious
18+
# .png must be stripped, leaving the real .svg
19+
- 'src="figures/chart\.svg"'
20+
# A shortcode resolving to an extensionless path keeps the appended
21+
# default extension (the multi-format workflow)
22+
- 'src="figures/plot\.png"'
23+
# Fix B: provably-spurious empty-filename URL has the extension stripped
24+
- 'src="https://example\.com/"'
25+
# Guard: local file without extension still gets default appended
26+
- 'src="images/screenshot\.png"'
27+
# Guard: user-typed URL with real extension is left unchanged
28+
- 'src="https://example\.com/logo\.png"'
29+
# Guard: data URI is left intact (no spurious extension)
30+
- 'src="data:image/png;base64,[^"]+"'
31+
# Guard: a shortcode mid-path with a real extension keeps that extension
32+
- 'src="assets/logo\.png"'
33+
-
34+
# No doubled extension survives the shortcode cases
35+
- 'diagram\.png\.png'
36+
- 'chart\.svg\.png'
37+
# Empty-filename URL must not be left with the appended extension
38+
- 'src="https://example\.com/\.png"'
39+
# Narrow URL fix must not strip a real extension off a user-typed URL
40+
- 'src="https://example\.com/logo"'
41+
# Mid-path shortcode source must not have its real extension stripped
42+
- 'src="assets/logo"'
43+
# Data URI must not have a spurious extension appended
44+
- 'base64,[^"]*\.png"'
45+
---
46+
47+
Shortcode resolving to an extensioned path — must not double (issue #14583):
48+
49+
![shortcode]({{< meta logo >}})
50+
51+
Shortcode resolving to a path whose extension differs from the format default
52+
(here `.svg` in an HTML doc): the spurious default extension must be stripped:
53+
54+
![mismatched ext]({{< meta svgfile >}})
55+
56+
Shortcode resolving to an extensionless path — multi-format append is kept:
57+
58+
![extensionless]({{< meta noext >}})
59+
60+
User explicitly appends the default extension to a shortcode — preserved:
61+
62+
![user explicit]({{< meta noext >}}.png)
63+
64+
Empty-filename URL — embed/iframe syntax (issue #6092):
65+
66+
![empty url](https://example.com/)
67+
68+
Local file without extension — multi-format workflow must keep the append:
69+
70+
![local](images/screenshot)
71+
72+
User-typed URL with a real extension must stay untouched:
73+
74+
![user png](https://example.com/logo.png)
75+
76+
Data URI regression guard:
77+
78+
![data uri](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==)
79+
80+
Shortcode mid-path with a real extension must keep the extension:
81+
82+
![mid-path]({{< meta imgdir >}}/logo.png)
70 Bytes
Loading

0 commit comments

Comments
 (0)