diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 6dc3f66aae6..a2dff7ef3c2 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -89,6 +89,7 @@ All changes included in 1.10: ## Other fixes and improvements +- ([#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. - ([#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. - ([#14255](https://github.com/quarto-dev/quarto-cli/issues/14255)): Fix shortcodes inside inline and display math expressions not being resolved. - ([#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. @@ -98,4 +99,5 @@ All changes included in 1.10: - ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) - ([#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/`. - ([#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. -- ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment. +- ([#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. +- ([#14595](https://github.com/quarto-dev/quarto-cli/issues/14595)): Fix reload preview in code-server environment diff --git a/src/resources/filters/customnodes/shortcodes.lua b/src/resources/filters/customnodes/shortcodes.lua index 9e3ee4c933a..33ab5a883fb 100644 --- a/src/resources/filters/customnodes/shortcodes.lua +++ b/src/resources/filters/customnodes/shortcodes.lua @@ -331,7 +331,29 @@ function shortcodes_filter() end, Image = function(el) el = attr_handler(el) + local before = el.src el.src = apply_code_shortcode(el.src) + -- A shortcode used as an image source is parsed without an extension, + -- so Pandoc appends default_image_extension to it. Once the shortcode + -- resolves to a path that already carries its own extension, that + -- appended extension is spurious and doubles up -- e.g. diagram.png.png, + -- or diagram.png.svg in Typst where the default differs (#14583). Strip + -- the appended default extension, but only when the resolved path still + -- has an extension of its own, so the multi-format workflow (a shortcode + -- resolving to an extensionless path) keeps its appended extension. + if el.src ~= before then + local ext = PANDOC_READER_OPTIONS.default_image_extension + if ext and ext ~= "" then + local suffix = "." .. ext + if el.src:sub(-#suffix) == suffix then + local candidate = el.src:sub(1, -#suffix - 1) + local _, candidate_ext = pandoc.path.split_extension(candidate) + if candidate_ext ~= "" then + el.src = candidate + end + end + end + end return el end, Link = function(el) diff --git a/src/resources/filters/normalize/fixupdatauri.lua b/src/resources/filters/normalize/fixupdatauri.lua index 7fb5d628ee5..83da61a2d7e 100644 --- a/src/resources/filters/normalize/fixupdatauri.lua +++ b/src/resources/filters/normalize/fixupdatauri.lua @@ -2,16 +2,36 @@ -- Copyright (C) 2023 Posit Software, PBC -- https://github.com/quarto-dev/quarto-cli/issues/6568 -function normalize_fixup_data_uri_image_extension() +-- https://github.com/quarto-dev/quarto-cli/issues/6092 +function normalize_fixup_data_uri_image_extension() return { Image = function(img) + local ext = PANDOC_READER_OPTIONS.default_image_extension + -- Nothing was appended when there is no default extension. + if not ext or ext == "" then + return nil + end local src = img.src + local suffix = "." .. ext + + -- Data URIs never need an extension, but Pandoc appends one anyway (#6568). if src:sub(1, 5) == "data:" then - local l = PANDOC_READER_OPTIONS.default_image_extension:len() - if src:sub(-l-1) == ("." .. PANDOC_READER_OPTIONS.default_image_extension) then - img.src = src:sub(1, -l - 2) + if src:sub(-#suffix) == suffix then + img.src = src:sub(1, -#suffix - 1) return img end + return nil + end + + -- An http(s) URL whose path ends with a slash has no filename, so the + -- appended default extension is provably spurious -- e.g. + -- https://example.com/ is parsed as https://example.com/.png (#6092). + -- A URL with a non-empty last segment is indistinguishable from a real + -- file of that extension, so it is deliberately left untouched. + if src:sub(1, 4) == "http" and src:match("^https?://") + and src:sub(-#suffix - 1) == "/" .. suffix then + img.src = src:sub(1, -#suffix - 1) + return img end end } diff --git a/tests/docs/smoke-all/2026/06/29/14583-custom-ext.qmd b/tests/docs/smoke-all/2026/06/29/14583-custom-ext.qmd new file mode 100644 index 00000000000..afc693e3bd7 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/29/14583-custom-ext.qmd @@ -0,0 +1,31 @@ +--- +title: default-image-extension custom value +logo: assets/diagram.png +default-image-extension: jpg +format: html +_quarto: + tests: + html: + ensureFileRegexMatches: + - + # Both fixes must read the configured extension, not a hardcoded "png". + # Fix A strips the appended .jpg off a whole-shortcode source + - 'src="assets/diagram\.png"' + # Local file gets the configured .jpg appended (multi-format workflow) + - 'src="images/screenshot\.jpg"' + # Fix B strips the appended .jpg off the empty-filename URL + - 'src="https://example\.com/"' + - + # Fix B must have stripped the spurious .jpg from the URL + - 'src="https://example\.com/\.jpg"' + # Fix A must not leave a doubled extension on the shortcode + - 'diagram\.png\.jpg' +--- + +A non-default extension must flow through both fixes. + +![shortcode]({{< meta logo >}}) + +![local](images/screenshot) + +![empty url](https://example.com/) diff --git a/tests/docs/smoke-all/2026/06/29/14583-explicit-empty.qmd b/tests/docs/smoke-all/2026/06/29/14583-explicit-empty.qmd new file mode 100644 index 00000000000..71c54afde39 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/29/14583-explicit-empty.qmd @@ -0,0 +1,30 @@ +--- +title: default-image-extension explicitly empty +logo: assets/diagram.png +default-image-extension: "" +format: html +_quarto: + tests: + html: + ensureFileRegexMatches: + - + # With an empty default, Pandoc appends nothing, so the fix is a no-op + # and a local file without extension stays extensionless (user's choice) + - 'src="images/screenshot"' + # Shortcode resolves to its own value untouched + - 'src="assets/diagram\.png"' + # URL stays exactly as authored + - 'src="https://example\.com/"' + - + # No extension was appended, so none should appear + - 'src="images/screenshot\.' + - 'diagram\.png\.png' +--- + +User explicitly disables the default extension. Behavior must be unchanged. + +![shortcode]({{< meta logo >}}) + +![local](images/screenshot) + +![empty url](https://example.com/) diff --git a/tests/docs/smoke-all/2026/06/29/14583-typst.qmd b/tests/docs/smoke-all/2026/06/29/14583-typst.qmd new file mode 100644 index 00000000000..50fad66de63 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/29/14583-typst.qmd @@ -0,0 +1,22 @@ +--- +title: default-image-extension shortcode in Typst +withext: assets/diagram.png +format: typst +keep-typ: true +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - + # 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\.svg"\)' +--- + +Shortcode resolving to a `.png` path in a Typst document (default extension +`svg`) must not end up as `.png.svg`: + +![diagram]({{< meta withext >}}) diff --git a/tests/docs/smoke-all/2026/06/29/14583.qmd b/tests/docs/smoke-all/2026/06/29/14583.qmd new file mode 100644 index 00000000000..c17763e3bb0 --- /dev/null +++ b/tests/docs/smoke-all/2026/06/29/14583.qmd @@ -0,0 +1,82 @@ +--- +title: default-image-extension spurious append +logo: assets/diagram.png +imgdir: assets +noext: figures/plot +svgfile: figures/chart.svg +format: html +_quarto: + tests: + html: + ensureFileRegexMatches: + - + # #14583: a shortcode resolving to a path that already has an extension + # must not end up with a doubled extension + - 'src="assets/diagram\.png"' + # The doubled extension may be mismatched: a shortcode resolving to a + # .svg path in an HTML doc (default png) becomes .svg.png; the spurious + # .png must be stripped, leaving the real .svg + - 'src="figures/chart\.svg"' + # A shortcode resolving to an extensionless path keeps the appended + # default extension (the multi-format workflow) + - 'src="figures/plot\.png"' + # Fix B: provably-spurious empty-filename URL has the extension stripped + - 'src="https://example\.com/"' + # Guard: local file without extension still gets default appended + - 'src="images/screenshot\.png"' + # Guard: user-typed URL with real extension is left unchanged + - 'src="https://example\.com/logo\.png"' + # Guard: data URI is left intact (no spurious extension) + - 'src="data:image/png;base64,[^"]+"' + # Guard: a shortcode mid-path with a real extension keeps that extension + - 'src="assets/logo\.png"' + - + # No doubled extension survives the shortcode cases + - 'diagram\.png\.png' + - 'chart\.svg\.png' + # Empty-filename URL must not be left with the appended extension + - 'src="https://example\.com/\.png"' + # Narrow URL fix must not strip a real extension off a user-typed URL + - 'src="https://example\.com/logo"' + # Mid-path shortcode source must not have its real extension stripped + - 'src="assets/logo"' + # Data URI must not have a spurious extension appended + - 'base64,[^"]*\.png"' +--- + +Shortcode resolving to an extensioned path — must not double (issue #14583): + +![shortcode]({{< meta logo >}}) + +Shortcode resolving to a path whose extension differs from the format default +(here `.svg` in an HTML doc): the spurious default extension must be stripped: + +![mismatched ext]({{< meta svgfile >}}) + +Shortcode resolving to an extensionless path — multi-format append is kept: + +![extensionless]({{< meta noext >}}) + +User explicitly appends the default extension to a shortcode — preserved: + +![user explicit]({{< meta noext >}}.png) + +Empty-filename URL — embed/iframe syntax (issue #6092): + +![empty url](https://example.com/) + +Local file without extension — multi-format workflow must keep the append: + +![local](images/screenshot) + +User-typed URL with a real extension must stay untouched: + +![user png](https://example.com/logo.png) + +Data URI regression guard: + +![data uri](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==) + +Shortcode mid-path with a real extension must keep the extension: + +![mid-path]({{< meta imgdir >}}/logo.png) diff --git a/tests/docs/smoke-all/2026/06/29/assets/diagram.png b/tests/docs/smoke-all/2026/06/29/assets/diagram.png new file mode 100644 index 00000000000..08cd6f2bfd1 Binary files /dev/null and b/tests/docs/smoke-all/2026/06/29/assets/diagram.png differ