From 5e6bcbe994d874fb95ec621c9880a922ef4b7aee Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 13 Oct 2026 19:17:24 +0200 Subject: [PATCH 1/3] Read JPEG image sizes for feed and open-graph metadata imageSize only measured files whose name ended in ".png", so a JPEG (the usual format for blog thumbnails and the preview images Quarto auto-discovers) gave listing feeds and og:image/twitter:image no height or width. The extension check was also case-sensitive, so "feed.PNG" was skipped. imageSize now picks the reader from the file's header bytes: PNG as before, JPEG via a small marker scan to the first start-of-frame segment (baseline, progressive and lossless alike), no dependency. Other formats (SVG, GIF, WebP) still return no size; SVG often has no pixel size and social platforms reject it as og:image anyway. Sniffing content also means a WebP saved as ".jpg" is no longer misread, and a non-PNG file named ".png" now gets no size instead of an error. Checked against Pillow on every PNG and JPEG tracked in the repo (298 files): identical sizes. --- src/core/image.ts | 21 +++++++----- src/core/jpeg.ts | 54 ++++++++++++++++++++++++++++++ src/core/png.ts | 32 +++++------------- tests/unit/core/image-size.test.ts | 51 ++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 32 deletions(-) create mode 100644 src/core/jpeg.ts create mode 100644 tests/unit/core/image-size.test.ts diff --git a/src/core/image.ts b/src/core/image.ts index ec3cab85fa8..f0a66d0fce7 100644 --- a/src/core/image.ts +++ b/src/core/image.ts @@ -6,23 +6,28 @@ import { existsSync } from "../deno_ral/fs.ts"; import { extname } from "../deno_ral/path.ts"; -import PngImage from "./png.ts"; +import PngImage, { isPng } from "./png.ts"; +import { isJpeg, jpegSize } from "./jpeg.ts"; +// Pixel size of a PNG or JPEG file, detected from its header bytes rather +// than its extension. Other formats have no size. export function imageSize(path: string) { if (path !== undefined) { - if (path.endsWith(".png")) { - if (existsSync(path)) { - const imageData = Deno.readFileSync(path); - try { + if (existsSync(path, { isFile: true })) { + const imageData = Deno.readFileSync(path); + try { + if (isPng(imageData)) { const png = new PngImage(imageData); return { height: png.height, width: png.width, }; - } catch (error) { - if (!(error instanceof Error)) throw error; - throw new Error(`Error reading file ${path}\n${error.message}`); + } else if (isJpeg(imageData)) { + return jpegSize(imageData); } + } catch (error) { + if (!(error instanceof Error)) throw error; + throw new Error(`Error reading file ${path}\n${error.message}`); } } } diff --git a/src/core/jpeg.ts b/src/core/jpeg.ts new file mode 100644 index 00000000000..9b39bf765f3 --- /dev/null +++ b/src/core/jpeg.ts @@ -0,0 +1,54 @@ +/* + * jpeg.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +export function isJpeg(bytes: Uint8Array) { + return bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF; +} + +// Reads the pixel size from the first start-of-frame (SOFn) segment, which +// holds it for baseline, progressive and lossless JPEGs alike. +export function jpegSize(bytes: Uint8Array) { + if (!isJpeg(bytes)) { + throw new Error("Not a jpeg file"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let pos = 2; + while (pos + 4 <= bytes.length) { + if (bytes[pos] !== 0xFF) { + throw new Error("Invalid jpeg marker"); + } + const marker = bytes[pos + 1]; + if (marker === 0xFF) { + // fill byte before a marker + pos++; + continue; + } + if (marker === 0x01 || (marker >= 0xD0 && marker <= 0xD8)) { + // markers without a length or payload + pos += 2; + continue; + } + if (marker === 0xD9 || marker === 0xDA) { + // end of image or start of scan data, with no frame header before it + break; + } + const length = view.getUint16(pos + 2); + // SOF0-SOF15, except DHT (C4), JPG (C8) and DAC (CC) + const isFrame = marker >= 0xC0 && marker <= 0xCF && + marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC; + if (isFrame) { + if (pos + 9 > bytes.length) { + break; + } + return { + height: view.getUint16(pos + 5), + width: view.getUint16(pos + 7), + }; + } + pos += 2 + length; + } + throw new Error("No jpeg frame header found"); +} diff --git a/src/core/png.ts b/src/core/png.ts index 3d453adafd9..8f3322fa089 100644 --- a/src/core/png.ts +++ b/src/core/png.ts @@ -9,6 +9,13 @@ * */ +// https://www.w3.org/TR/png-3/#5PNG-file-signature +const kPngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + +export function isPng(bytes: Uint8Array) { + return kPngSignature.every((byte, i) => bytes[i] === byte); +} + export default class PngImage { public content: Array = []; @@ -23,9 +30,7 @@ export default class PngImage { private interlaceMethod = -1; constructor(bytes: Uint8Array, sizeOnly = true) { - const magicNumberBytes = bytes.slice(0, 8); - const magicNumber = bytesToString(magicNumberBytes); - if (magicNumber !== "13780787113102610") { + if (!isPng(bytes)) { throw new Error("Not a png file"); } @@ -157,24 +162,3 @@ function bytesToUint32(byteArray: Uint8Array, start = 0, count?: number) { return value; } - -function bytesToString(byteArray: Uint8Array, start = 0, count?: number) { - if (count === undefined) { - count = byteArray.length; - } - - let result = ""; - for (let i = start; i < start + count; i++) { - const byte = byteArray[i]; - - if (byte === 0) { - result += "00"; - } else if (byte < 10) { - result += `0${byte.toString()}`; - } else { - result += byte.toString(); - } - } - - return result; -} diff --git a/tests/unit/core/image-size.test.ts b/tests/unit/core/image-size.test.ts new file mode 100644 index 00000000000..163c01eaea3 --- /dev/null +++ b/tests/unit/core/image-size.test.ts @@ -0,0 +1,51 @@ +/* + * image-size.test.ts + * + * imageSize (src/core/image.ts) reads the pixel size of an image file from + * its header bytes, whatever the file extension. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assertEquals } from "testing/asserts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { imageSize } from "../../../src/core/image.ts"; +import { docs, withTempDir } from "../../utils.ts"; + +// deno-lint-ignore require-await +unitTest("imageSize - baseline JPEG", async () => { + assertEquals( + imageSize(docs("reveal/mini/images/kitten-300-450.jpeg")), + { height: 450, width: 300 }, + ); +}); + +// deno-lint-ignore require-await +unitTest("imageSize - progressive JPEG", async () => { + assertEquals( + imageSize(docs("blog/posts/welcome/thumbnail.jpg")), + { height: 427, width: 640 }, + ); +}); + +unitTest("imageSize - PNG with an uppercase extension", async () => { + await withTempDir((dir) => { + const png = join(dir, "feed.PNG"); + Deno.copyFileSync(docs("listings-feed-image/feed.png"), png); + assertEquals(imageSize(png), { height: 400, width: 600 }); + }); +}); + +// deno-lint-ignore require-await +unitTest("imageSize - SVG has no size", async () => { + assertEquals( + imageSize(docs("smoke-all/2024/01/26/simple-svg.svg")), + undefined, + ); +}); + +// deno-lint-ignore require-await +unitTest("imageSize - unknown format has no size", async () => { + assertEquals(imageSize(docs("listings-feed-image/index.qmd")), undefined); +}); From c52ec3d96b6395624cd98e714ce46dbd4ac6c42c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 13 Oct 2026 19:18:50 +0200 Subject: [PATCH 2/3] Reference the JPEG spec marker table in jpeg.ts --- src/core/jpeg.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/core/jpeg.ts b/src/core/jpeg.ts index 9b39bf765f3..9e2f9b533b8 100644 --- a/src/core/jpeg.ts +++ b/src/core/jpeg.ts @@ -4,6 +4,10 @@ * Copyright (C) 2026 Posit Software, PBC */ +// Marker codes: ITU-T T.81 (JPEG), Annex B, Table B.1 +// https://www.w3.org/Graphics/JPEG/itu-t81.pdf + +// Start-of-image marker (FFD8) followed by the start of the next marker export function isJpeg(bytes: Uint8Array) { return bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF; } From 16cf14c90598fff73ee7471a623d44dc5f6f82a1 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 14 Oct 2026 11:44:52 +0200 Subject: [PATCH 3/3] Add changelog entry for JPEG image sizes --- news/changelog-1.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 045a446b1af..00f3c83ffb5 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -84,6 +84,7 @@ All changes included in 1.11: - ([#15022](https://github.com/quarto-dev/quarto-cli/issues/15022)): Fix rendering a listing with a `feed` failing on Windows with `os error 123` when a listing item's `image` or the feed `image` is a web URL. The image URL is used as is in the feed and no local size lookup is attempted. (author: @Masad791) - ([#15032](https://github.com/quarto-dev/quarto-cli/pull/15032)): Fix the listing feed `image` missing its height and width when a website is rendered from a directory other than the project directory, or when the image path starts with `/`. - ([#15044](https://github.com/quarto-dev/quarto-cli/issues/15044)): Fix a YAML file listed next to a folder in a listing's `contents` being left out, or another file being used, when a website is rendered from a directory other than the listing page's directory. +- ([#15050](https://github.com/quarto-dev/quarto-cli/pull/15050)): Support JPEG images when adding image height and width to the listing feed `image`, feed item media, and `og:image` and `twitter:image` metadata. Image files are now detected by content rather than file extension, so an uppercase `.PNG` is measured too. ## Lua API