diff --git a/src/components/Markdown.svelte b/src/components/Markdown.svelte index 7f2859067..e7a1511e4 100644 --- a/src/components/Markdown.svelte +++ b/src/components/Markdown.svelte @@ -4,7 +4,7 @@ import { toDom } from "hast-util-to-dom"; import { mount, tick, unmount } from "svelte"; - import { embedPreviewKind } from "@app/lib/embeds"; + import { embedPreviewKind, retinaWidth } from "@app/lib/embeds"; import { parseFrontmatter } from "@app/lib/frontmatter"; import { invoke } from "@app/lib/invoke"; import { @@ -73,10 +73,15 @@ function createEmbedPreview( mimeType: string | null, url: string, + content: Uint8Array, ): HTMLElement | undefined { const kind = embedPreviewKind(mimeType); if (kind === "image") { const element = document.createElement("img"); + const width = retinaWidth(content); + if (width) { + element.style.width = `${width}px`; + } element.setAttribute("src", url); element.style.display = "block"; return element; @@ -308,10 +313,9 @@ } let preview = embedPreviews.get(href); if (!preview) { - const url = URL.createObjectURL( - new Blob([Buffer.from(content)]), - ); - const element = createEmbedPreview(mimeType, url); + const bytes = Buffer.from(content); + const url = URL.createObjectURL(new Blob([bytes])); + const element = createEmbedPreview(mimeType, url, bytes); if (!element) { URL.revokeObjectURL(url); console.warn( diff --git a/src/lib/embeds.ts b/src/lib/embeds.ts index bf8d112a8..20d716864 100644 --- a/src/lib/embeds.ts +++ b/src/lib/embeds.ts @@ -11,3 +11,38 @@ export function embedPreviewKind( if (mimeType?.startsWith("audio")) return "audio"; return undefined; } + +const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +function chunkType(bytes: Uint8Array, offset: number): string { + return String.fromCharCode(...bytes.subarray(offset, offset + 4)); +} + +export function retinaWidth(bytes: Uint8Array): number | undefined { + if ( + bytes.length < 24 || + !pngSignature.every((byte, i) => bytes[i] === byte) || + chunkType(bytes, 12) !== "IHDR" + ) { + return undefined; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint32(16); + + let offset = 8; + while (offset + 8 <= bytes.length) { + const length = view.getUint32(offset); + const type = chunkType(bytes, offset + 4); + if (type === "pHYs" && length === 9 && offset + 17 <= bytes.length) { + const x = view.getUint32(offset + 8); + const y = view.getUint32(offset + 12); + const metre = bytes[offset + 16] === 1; + if (!metre || x !== y) return undefined; + const scale = Math.round((x * 0.0254) / 72); + return scale === 2 || scale === 3 ? Math.round(width / scale) : undefined; + } + if (type === "IDAT" || type === "IEND") return undefined; + offset += 12 + length; + } + return undefined; +} diff --git a/tests/unit/embeds.test.ts b/tests/unit/embeds.test.ts index 8f2f5942a..59e06cd95 100644 --- a/tests/unit/embeds.test.ts +++ b/tests/unit/embeds.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { basename, embedPreviewKind } from "@app/lib/embeds"; +import { basename, embedPreviewKind, retinaWidth } from "@app/lib/embeds"; describe("basename", () => { test.each([ @@ -22,3 +22,91 @@ test.each([ ])("a %j embed previews as %j", (mimeType, expected) => { expect(embedPreviewKind(mimeType)).toBe(expected); }); + +function u32(n: number): number[] { + return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]; +} + +function bytesOf(text: string): number[] { + return Array.from(text, c => c.charCodeAt(0)); +} + +function chunk(type: string, data: number[]): number[] { + return [...u32(data.length), ...bytesOf(type), ...data, 0, 0, 0, 0]; +} + +const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +function ihdr(width: number): number[] { + return chunk("IHDR", [...u32(width), ...u32(100), 8, 6, 0, 0, 0]); +} + +function png(width: number, ...chunks: number[][]): Uint8Array { + return new Uint8Array([ + ...signature, + ...ihdr(width), + ...chunks.flat(), + ...chunk("IEND", []), + ]); +} + +function pHYs(x: number, y: number = x, unit: number = 1): number[] { + return chunk("pHYs", [...u32(x), ...u32(y), unit]); +} + +const retina = 5669; + +describe("retinaWidth", () => { + test("halves a 144 DPI PNG", () => { + expect(retinaWidth(png(3680, pHYs(retina)))).toBe(1840); + }); + + test("divides a 216 DPI PNG by three", () => { + expect(retinaWidth(png(3000, pHYs(8504)))).toBe(1000); + }); + + test("finds pHYs after other chunks", () => { + const text = chunk("tEXt", bytesOf("a\0b")); + expect(retinaWidth(png(3680, text, pHYs(retina)))).toBe(1840); + }); + + test("reads bytes at an offset in a larger buffer", () => { + const bytes = png(3680, pHYs(retina)); + const buffer = new Uint8Array(bytes.length + 3); + buffer.set(bytes, 3); + expect(retinaWidth(buffer.subarray(3))).toBe(1840); + }); + + test.each([ + ["72 DPI", png(3680, pHYs(2835))], + ["300 DPI", png(3680, pHYs(11811))], + ["a density of zero", png(3680, pHYs(0))], + ["the maximum density", png(3680, pHYs(0xffffffff))], + ["no pHYs chunk", png(3680)], + ["an unknown unit", png(3680, pHYs(retina, retina, 0))], + ["non-square pixels", png(3680, pHYs(retina, 2835))], + ["pHYs after IDAT", png(3680, chunk("IDAT", [0]), pHYs(retina))], + [ + "a pHYs chunk of the wrong length", + png(3680, chunk("pHYs", [...u32(retina), ...u32(retina), 1, 0])), + ], + ["a truncated pHYs chunk", png(3680, pHYs(retina)).subarray(0, 45)], + [ + "no IHDR chunk first", + new Uint8Array([...signature, ...pHYs(retina), ...ihdr(3680)]), + ], + ["a truncated IHDR chunk", png(3680, pHYs(retina)).subarray(0, 18)], + ])("ignores a PNG with %s", (_, bytes) => { + expect(retinaWidth(bytes)).toBeUndefined(); + }); + + test.each([ + ["empty", new Uint8Array()], + [ + "a JPEG", + new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...new Array(30).fill(0)]), + ], + ])("ignores %s input", (_, bytes) => { + expect(retinaWidth(bytes)).toBeUndefined(); + }); +});