From cd8a32a5e7dbbf8242001c9fcee158de0a307153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Tue, 6 Oct 2026 15:53:52 +0200 Subject: [PATCH] Show Retina screenshots at their intended size macOS saves Retina screenshots at 144 DPI and we rendered them at twice the size they had on screen. We now scale PNGs with a 2x or 3x density down; everything else, including 300 DPI print exports, stays 1:1. --- src/components/Markdown.svelte | 14 ++++-- src/lib/embeds.ts | 35 +++++++++++++ tests/unit/embeds.test.ts | 90 +++++++++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 6 deletions(-) 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(); + }); +});