From 4544cd48c673d54796094aedd865d98dd6c1584f Mon Sep 17 00:00:00 2001 From: Jason Shellen Date: Wed, 26 Aug 2026 16:09:49 -0700 Subject: [PATCH] Hide tiny images instead of rez-ing them up as hero/card artwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small images — favicons, site logos, avatars — were being scaled up as the article hero and as Rundown/dash card images whenever they were the best candidate found. Fixes #115: - New isTinyImage() helper; the hero and all card images check natural dimensions on load (<200px wide or <100px tall) and fall back to their existing no-image treatment instead of scaling up - listFiles' body-image fallback skips favicon/apple-touch-icon URLs at pick time so they never become card artwork Covered by a Playwright spec (canvas-generated 32x32 hidden, 400x250 kept) and jest tests for the pick-time filter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AdXEsWQTadb9qbTQjLkMqn --- src/viewer-html.ts | 2 +- src/viewer.test.ts | 47 +++++++++++++++++++++++++++++++++--- src/viewer.ts | 2 ++ tests/e2e/hero-image.spec.ts | 44 +++++++++++++++++++++++++++++++++ viewer/02-utils.js | 8 ++++++ viewer/04-article.js | 17 ++++++++----- 6 files changed, 109 insertions(+), 11 deletions(-) diff --git a/src/viewer-html.ts b/src/viewer-html.ts index 683d4ea..b293bc3 100644 --- a/src/viewer-html.ts +++ b/src/viewer-html.ts @@ -1,3 +1,3 @@ // Auto-generated by scripts/embed-viewer.ts — do not edit // Source: viewer.html + viewer.css + viewer/*.js + @fontsource/* woff2 -export const VIEWER_HTML = "\n\n\n\n\nPull Read\n\n\n\n\n\n\n\n\n\n\n\n\n\nSkip to content\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n
\n\n
\n \n Pull Read\n \n
\n
\n\n
\n \n\n \n\n
\n
\n Sources\n \n
\n
\n
\n
\n\n
\n
\n
\n
\n
\n \n
\n \n \n
\n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n
\n\n
\n\n
\n
\n
\n\n\n\n\n"; +export const VIEWER_HTML = "\n\n\n\n\nPull Read\n\n\n\n\n\n\n\n\n\n\n\n\n\nSkip to content\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n
\n\n
\n \n Pull Read\n \n
\n
\n\n
\n \n\n \n\n
\n
\n Sources\n \n
\n
\n
\n
\n\n
\n
\n
\n
\n
\n \n
\n \n \n
\n \n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n
\n \n
\n
\n\n
\n\n
\n
\n
\n\n\n\n\n"; diff --git a/src/viewer.test.ts b/src/viewer.test.ts index e660675..5c9535c 100644 --- a/src/viewer.test.ts +++ b/src/viewer.test.ts @@ -1,7 +1,7 @@ // ABOUTME: Tests for viewer module helpers // ABOUTME: Covers reprocessFile, parseFrontmatter, sync progress, and XSS sanitization -import { reprocessFile, parseFrontmatter } from './viewer'; +import { reprocessFile, parseFrontmatter, listFiles } from './viewer'; import { setOutputPath, resetWriteGuard } from './writer'; import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs'; import { join } from 'path'; @@ -318,6 +318,43 @@ bookmarked: 2025-01-15T00:00:00Z }); }); +describe('listFiles body-image fallback', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `pullread-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + function writeArticle(body: string): void { + writeFileSync(join(testDir, 'article.md'), `--- +title: "No Thumbnail" +url: https://example.com/a +domain: example.com +bookmarked: 2026-08-26T00:00:00Z +--- + +${body} +`); + } + + test('skips favicon-like URLs and picks the next real image (#115)', () => { + writeArticle( + '![](https://example.com/favicon.ico)\n\n' + + '![](https://example.com/apple-touch-icon-180.png)\n\n' + + '![](https://cdn.example.com/photos/story-lead.jpg)' + ); + const files = listFiles(testDir); + expect(files[0].image).toBe('https://cdn.example.com/photos/story-lead.jpg'); + }); + + test('leaves image empty when only favicon-like images exist', () => { + writeArticle('![](https://example.com/favicon-32x32.png)'); + const files = listFiles(testDir); + expect(files[0].image).toBe(''); + }); +}); + describe('sync progress', () => { const rootDir = join(__dirname, '..'); @@ -848,9 +885,11 @@ describe('For You section rendering', () => { const fnMatch = article.match(/function buildSectionRundownHtml\b[\s\S]*?^}/m); expect(fnMatch).toBeTruthy(); const fnBody = fnMatch![0]; - // The onerror should call dashCardInitialHtml as a function (runtime call pattern) - // NOT pre-render it and embed raw HTML with unescaped quotes in the attribute - expect(fnBody).toMatch(/onerror="this\.outerHTML=dashCardInitialHtml\(/); + // The fallback should call dashCardInitialHtml as a function at runtime + // (shared by onload's tiny-image check and onerror — #115), NOT pre-render + // it and embed raw HTML with unescaped quotes in the attribute + expect(fnBody).toMatch(/compactFallback = 'this\.outerHTML=dashCardInitialHtml\(/); + expect(fnBody).toMatch(/onerror="' \+ compactFallback/); expect(fnBody).not.toContain("dashCardInitialHtml(a.domain, 80).replace"); }); }); diff --git a/src/viewer.ts b/src/viewer.ts index 1e6b2a1..95e50a2 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -478,6 +478,8 @@ export function listFiles(outputPath: string, includeSummaries = false): FileMet // Skip tracking pixels, badges, tiny icons, and feeds.feedburner if (/[?&](w|width|sz)=\d{1,2}(&|$)/.test(src)) continue; if (/\/(pixel|beacon|track|spacer|blank|badge|icon)\b/i.test(src)) continue; + // Skip favicons/touch icons — too small to be card artwork (#115) + if (/favicon|apple-touch-icon|\/favicons\//i.test(src)) continue; if (/feeds\.feedburner\.com/i.test(src)) continue; if (/\.(gif|svg)(\?|$)/.test(src) && !/\d{3,}/.test(src)) continue; image = imgMatch[1]; diff --git a/tests/e2e/hero-image.spec.ts b/tests/e2e/hero-image.spec.ts index ad02a84..8aa7cd2 100644 --- a/tests/e2e/hero-image.spec.ts +++ b/tests/e2e/hero-image.spec.ts @@ -44,6 +44,50 @@ test('hero image survives dedup; body duplicate is removed', async ({ page }) => await expect(otherImg).toHaveCount(1); }); +// #115: tiny images (favicons/logos shipped as og:image) must not be +// rez'd up as the hero — the hero hides itself once dimensions are known. +test('tiny hero image is hidden instead of scaled up (#115)', async ({ page }) => { + await page.goto('/'); + await page.waitForFunction(() => typeof (window as any).renderArticle === 'function'); + + const render = (dataUrl: string) => `--- +title: "Tiny Hero" +url: https://example.com/tiny +bookmarked: 2026-08-26T10:00:00.000Z +domain: example.com +image: ${dataUrl} +source: extracted +--- + +Body text. +`; + + // 32x32 (favicon-sized) → hero hidden + await page.evaluate((fm) => { + const c = document.createElement('canvas'); + c.width = 32; c.height = 32; + (window as any).renderArticle(fm.replace('__URL__', c.toDataURL()), 'tiny.md'); + }, render('__URL__')); + await page.waitForFunction(() => { + const hero = document.querySelector('.article-hero') as HTMLElement | null; + return hero !== null && hero.style.display === 'none'; + }); + + // 400x250 (real artwork) → hero stays visible + await page.evaluate((fm) => { + const c = document.createElement('canvas'); + c.width = 400; c.height = 250; + (window as any).renderArticle(fm.replace('__URL__', c.toDataURL()), 'big.md'); + }, render('__URL__')); + await page.waitForFunction(() => { + const img = document.querySelector('.article-hero img') as HTMLImageElement | null; + return img !== null && img.complete && img.naturalWidth === 400; + }); + const heroVisible = await page.evaluate(() => + (document.querySelector('.article-hero') as HTMLElement).style.display !== 'none'); + expect(heroVisible).toBe(true); +}); + test('launch highlights Explore in the sidebar nav (#110)', async ({ page }) => { await page.goto('/'); await page.waitForSelector('.sidebar-nav-item'); diff --git a/viewer/02-utils.js b/viewer/02-utils.js index c1f027f..a9148e2 100644 --- a/viewer/02-utils.js +++ b/viewer/02-utils.js @@ -95,6 +95,14 @@ function stripTags(s) { return s ? s.replace(/<[^>]+>/g, '').trim() : s; } +// True when a loaded image is too small to be real artwork — favicons, logos, +// avatars (#115). Blowing these up as a hero/card image looks blurry; callers +// should fall back to their no-image treatment instead. 200px covers standard +// favicon sizes up to apple-touch-icon (180x180). +function isTinyImage(img) { + return img.naturalWidth > 0 && (img.naturalWidth < 200 || img.naturalHeight < 100); +} + function escapeHtml(s) { return String(s) .replace(/&/g, '&') diff --git a/viewer/04-article.js b/viewer/04-article.js index 1379c79..7670ddd 100644 --- a/viewer/04-article.js +++ b/viewer/04-article.js @@ -219,7 +219,7 @@ function buildRundownTab(engagement, mc) { var feat = arts[0]; html += '