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.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 13 additions & 8 deletions src/core/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}
}
Expand Down
58 changes: 58 additions & 0 deletions src/core/jpeg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* jpeg.ts
*
* 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;
}

// 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");
}
32 changes: 8 additions & 24 deletions src/core/png.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array> = [];

Expand All @@ -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");
}

Expand Down Expand Up @@ -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;
}
51 changes: 51 additions & 0 deletions tests/unit/core/image-size.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading