Skip to content
Open
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
6 changes: 4 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@
"content_scripts": [
{
"matches": [
"https://www.sec.gov/*"
"http://*/*",
"https://*/*"
],
"js": [
"src/content/plain-html-detection.js",
"src/content/sec-comment.js"
],
"run_at": "document_start"
"run_at": "document_idle"
},
{
"matches": [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/check-extension.mjs",
"test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs"
"test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/raw-metadata.test.mjs tests/plain-html-dark-mode.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs"
},
"dependencies": {
"pdfjs-dist": "6.3.289",
Expand Down
38 changes: 38 additions & 0 deletions src/content/plain-html-detection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Dark mode is limited to simple HTML documents; do not overwrite site design.
(() => {
function isPlainHtmlDocument(doc) {
if (!doc?.body || !doc.documentElement) return false;
if (doc.contentType?.toLowerCase() !== "text/html") return false;
if (!doc.body.textContent?.trim()) return false;
if (doc.querySelector(
'link[rel~="stylesheet"], style, script, nav, header, footer, aside, form, ' +
'iframe, canvas, video, audio, svg, button, input, select, textarea, ' +
'[role="application"], [contenteditable], #app, #root, [data-reactroot]'
)) return false;
if (doc.querySelectorAll('div, section, article').length > 12) return false;
if (doc.querySelectorAll('[style]').length > 16) return false;
if (doc.querySelectorAll('[class], [id]').length > 25) return false;
return true;
}
// EDGAR exhibits often use legacy inline CSS and <font> markup even though
// they are ordinary documents, not application-like websites.
function isRawEdgarHtmlDocument(doc, href) {
if (!doc?.body || !doc.documentElement) return false;
if (doc.contentType?.toLowerCase() !== "text/html") return false;
if (!doc.body.textContent?.trim()) return false;

let url;
try {
url = new URL(href);
} catch {
return false;
}

return url.protocol === "https:" &&
/^(?:www\.)?sec\.gov$/i.test(url.hostname) &&
/^\/Archives\/edgar\/data\/\d+\/[^/]+\/[^/]+\.html?$/i.test(url.pathname);
}

globalThis.pdfMagicIsRawEdgarHtmlDocument = isRawEdgarHtmlDocument;
globalThis.pdfMagicIsPlainHtmlDocument = isPlainHtmlDocument;
})();
30 changes: 25 additions & 5 deletions src/content/sec-comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
? "src/"
: "";
const SWEETIE_BOT_PATH = sourcePrefix + "assets/sweetie-bot.png";
const isCommentPage = /^\/comments\/.+\.html?$/i.test(window.location.pathname);
const isSecHost = /(^|\.)sec\.gov$/i.test(window.location.hostname);
const isCommentPage = isSecHost && /^\/comments\/.+\.html?$/i.test(window.location.pathname);
const isRawEdgarPage = globalThis.pdfMagicIsRawEdgarHtmlDocument?.(document, window.location.href) === true;
const isBasicHtml = isCommentPage || isRawEdgarPage || globalThis.pdfMagicIsPlainHtmlDocument?.(document);

const selectionStyles = `
::selection {
Expand All @@ -32,9 +35,9 @@
(document.head || document.documentElement).append(style);
}

applySelectionStyle();
if (isSecHost) applySelectionStyle();

if (!isCommentPage) {
if (!isBasicHtml) {
return;
}

Expand Down Expand Up @@ -94,6 +97,22 @@
}
`;

const rawEdgarDarkStyles = `
/* Legacy EDGAR HTML often uses <font color>, bgcolor and inline white
backgrounds. Override only those colors, not table sizing or images. */
body *:not(svg):not(svg *):not(canvas) {
color: inherit !important;
background-color: transparent !important;
}

body a,
body a:link,
body a:visited,
body a * {
color: var(--pdf-viewer-sec-link) !important;
}
`;

function commentPrompt(commentUrl) {
return [
"Display the SEC comment at the URL below with proper, faithful Markdown formatting.",
Expand Down Expand Up @@ -236,7 +255,7 @@

const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = darkStyles;
style.textContent = darkStyles + (isRawEdgarPage ? rawEdgarDarkStyles : "");
(document.head || document.documentElement).append(style);
}

Expand All @@ -249,7 +268,8 @@
const enabled = stored[SEC_COMMENT_DARK_MODE_KEY] !== false;
const darkModeActive = enabled && theme === "dark";
applyDarkMode(darkModeActive);
setCommentActionVisible(darkModeActive);
// SEC-specific ChatGPT formatting is not offered on unrelated websites.
setCommentActionVisible(darkModeActive && isCommentPage);
}

chrome.storage.onChanged.addListener((changes, areaName) => {
Expand Down
9 changes: 9 additions & 0 deletions src/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
<div id="tools-menu" class="tools-menu" role="menu" hidden>
<button id="rotate-left" class="tool-button" type="button" role="menuitem">↶ <span>Rotate left</span></button>
<button id="rotate-right" class="tool-button" type="button" role="menuitem">↷ <span>Rotate right</span></button>
<button id="view-raw-metadata" class="tool-button" type="button" role="menuitem" hidden>â–¤ <span>View metadata</span></button>
<div class="menu-separator" role="separator"></div>
<button id="print-pdf" class="tool-button" type="button" role="menuitem">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
Expand Down Expand Up @@ -291,6 +292,14 @@ <h2 id="cross-reference-title">Cross-referenced footnotes</h2>
</section>
</dialog>

<dialog id="raw-metadata-dialog" class="raw-metadata-dialog" aria-labelledby="raw-metadata-title">
<div class="raw-metadata-card">
<header class="raw-metadata-header"><h2 id="raw-metadata-title">Raw PDF metadata</h2>
<button id="raw-metadata-close" type="button" aria-label="Close metadata">×</button></header>
<p>Original PDF Info and XMP values, without Sections formatting or precedence.</p>
<div id="raw-metadata-content" class="raw-metadata-content"></div>
</div>
</dialog>
<dialog id="enhancer-setup-dialog" class="enhancer-setup-dialog" aria-labelledby="enhancer-setup-title">
<div class="enhancer-setup-card">
<button id="enhancer-setup-close" class="enhancer-setup-close" type="button" aria-label="Close PDF Enhancer setup">×</button>
Expand Down
66 changes: 66 additions & 0 deletions src/viewer/raw-metadata.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Source metadata is shown separately from the viewer's formatted Sections.
const DERIVED_KEYS = new Set([
"PDFFormatVersion", "Language", "EncryptFilterName", "IsLinearized",
"IsAcroFormPresent", "IsXFAPresent", "IsCollectionPresent", "IsSignaturesPresent"
]);
function serialize(value) {
const seen = new WeakSet();
return JSON.stringify(value, (_key, v) => {
if (typeof v === "bigint") return String(v);
if (v && typeof v === "object") {
if (seen.has(v)) return "[Circular]";
seen.add(v);
}
return v;
}, 2);
}
export function rawMetadataSections(payload) {
if (!payload) return [];
const info = payload.info && typeof payload.info === "object" ? payload.info : {};
let parsed, xml;
try { parsed = payload.metadata?.getAll?.(); } catch { /* unreadable XMP */ }
try { xml = payload.metadata?.getRaw?.(); } catch { /* unreadable XMP */ }
const hasActualInfo = Object.entries(info).some(([key,v]) =>
!DERIVED_KEYS.has(key) && v !== null && v !== undefined && v !== "");
const hasXmp = Boolean((parsed && Object.keys(parsed).length) || (typeof xml === "string" && xml.length));
if (!hasActualInfo && !hasXmp) return [];
const sections = [];
if (Object.keys(info).length) sections.push({ title: "PDF Info dictionary", text: serialize(info) });
if (parsed && Object.keys(parsed).length) sections.push({ title: "XMP parsed fields", text: serialize(parsed) });
if (typeof xml === "string" && xml.length) sections.push({ title: "Raw XMP XML", text: xml });
const response = {};
if (payload.contentDispositionFilename != null) response.contentDispositionFilename = payload.contentDispositionFilename;
if (payload.contentLength != null) response.contentLength = payload.contentLength;
if (Object.keys(response).length) sections.push({ title: "PDF response metadata", text: serialize(response) });
return sections;
}
export async function installRawMetadataViewer(pdfDocument, closeToolsMenu) {
const button = document.querySelector("#view-raw-metadata");
const dialog = document.querySelector("#raw-metadata-dialog");
const content = document.querySelector("#raw-metadata-content");
const close = document.querySelector("#raw-metadata-close");
if (!button || !dialog || !content || !close) return;
button.hidden = true;
let sections;
try { sections = rawMetadataSections(await pdfDocument.getMetadata()); } catch { return; }
if (!sections.length) return;
button.hidden = false;
button.addEventListener("click", () => {
closeToolsMenu();
const fragment = document.createDocumentFragment();
for (const { title, text } of sections) {
const section = document.createElement("section");
section.className = "raw-metadata-section";
const heading = document.createElement("h3");
heading.textContent = title;
const pre = document.createElement("pre");
pre.textContent = text; // Embedded metadata is never treated as HTML.
section.append(heading, pre);
fragment.append(section);
}
content.replaceChildren(fragment);
dialog.showModal();
});
close.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); });
}
4 changes: 2 additions & 2 deletions src/viewer/viewer-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ async function addSecCommentSetting() {

const label = document.createElement("label");
label.className = "tool-button tool-toggle";
label.title = "Darken SEC HTML comments when the viewer uses dark mode";
label.title = "Darken basic plaintext-style HTML pages (including SEC comments) when the viewer uses dark mode";

const text = document.createElement("span");
text.textContent = "Darken SEC HTML comments";
text.textContent = "Darken basic HTML pages";

const input = document.createElement("input");
input.id = "sec-comment-dark-mode";
Expand Down
27 changes: 27 additions & 0 deletions src/viewer/viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -1277,3 +1277,30 @@ input {
font-size: 25px;
cursor: pointer;
}

/* Raw source-level PDF metadata, kept independent of Sections. */
#view-raw-metadata[hidden] { display: none; }
.raw-metadata-dialog {
width: min(800px, calc(100vw - 28px));
max-height: 86dvh; padding: 0;
border: 1px solid var(--border); border-radius: 12px;
background: var(--toolbar-bg); color: var(--text);
box-shadow: 0 18px 60px rgb(0 0 0 / 40%);
}
.raw-metadata-dialog::backdrop { background: rgb(0 0 0 / 60%); }
.raw-metadata-card { padding: 18px; }
.raw-metadata-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.raw-metadata-header h2 { margin: 0; font-size: 19px; }
#raw-metadata-close { border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 26px; }
.raw-metadata-card > p { color: var(--muted); font-size: 13px; }
.raw-metadata-content { overflow: auto; max-height: 65dvh; }
.raw-metadata-section { margin: 16px 0; }
.raw-metadata-section h3 { margin: 0 0 7px; font-size: 13px; }
.raw-metadata-section pre {
margin: 0; max-height: 40dvh; overflow: auto;
white-space: pre-wrap; overflow-wrap: anywhere;
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
border: 1px solid var(--border); border-radius: 7px;
padding: 12px; background: var(--control-bg); color: var(--text);
user-select: text;
}
2 changes: 2 additions & 0 deletions src/viewer/viewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EventBus, PDFLinkService } from "../../node_modules/pdfjs-dist/web/pdf_
import { abandonPdfDocumentSession, publishPdfDocument } from "./pdf-document-session.js";
import { referenceUrlFromPdfMetadata, resolveDocumentReferenceUrl } from "./document-reference-url.js";
import { metadataField, pdfMetadataTitle } from "./pdf-metadata-title.js";
import { installRawMetadataViewer } from "./raw-metadata.js";
import { doiFromPdfMetadata, doiResolverUrl } from "./metadata-doi.js";
import { resolvePdfSource } from "./pdf-source.js";
import { readResumedPage, saveResumedPage } from "./page-resume.js";
Expand Down Expand Up @@ -1361,6 +1362,7 @@ function bindControls() {

rotateLeftButton.addEventListener("click", () => void rotatePages(-90));
rotateRightButton.addEventListener("click", () => void rotatePages(90));
void installRawMetadataViewer(pdfDocument, () => setToolsMenuOpen(false));
printButton.addEventListener("click", () => void printPdf());
downloadButton.addEventListener("click", () => void downloadPdf());

Expand Down
105 changes: 105 additions & 0 deletions tests/plain-html-dark-mode.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";

const detector = readFileSync(new URL("../src/content/plain-html-detection.js", import.meta.url), "utf8");
const handler = readFileSync(new URL("../src/content/sec-comment.js", import.meta.url), "utf8");
const settings = readFileSync(new URL("../src/viewer/viewer-settings.js", import.meta.url), "utf8");
const manifest = JSON.parse(readFileSync(new URL("../manifest.json", import.meta.url), "utf8"));
const ctx=vm.createContext({ URL });
vm.runInContext(detector,ctx);
function page({text="plain HTML text", type="text/html", complex=false, divs=0, styled=0, named=0}={}) {
return { contentType:type, documentElement:{}, body:{textContent:text},
querySelector:()=>complex ? {} : null,
querySelectorAll:(selector)=>Array(
selector==="div, section, article" ? divs : selector==="[style]" ? styled : selector==="[class], [id]" ? named : 0
).fill({}) };
}
test("plain HTML is eligible, but empty text and non-HTML are not",()=>{
assert.equal(ctx.pdfMagicIsPlainHtmlDocument(page()),true);
assert.equal(ctx.pdfMagicIsPlainHtmlDocument(page({text:" "})),false);
assert.equal(ctx.pdfMagicIsPlainHtmlDocument(page({type:"application/pdf"})),false);
});
test("modern website structure is excluded",()=>{
assert.equal(ctx.pdfMagicIsPlainHtmlDocument(page({complex:true})),false);
assert.equal(ctx.pdfMagicIsPlainHtmlDocument(page({divs:13})),false);
assert.equal(ctx.pdfMagicIsPlainHtmlDocument(page({styled:17})),false);
assert.equal(ctx.pdfMagicIsPlainHtmlDocument(page({named:26})),false);
});
test("coverage and old preference survive, SEC ChatGPT stays SEC-only",()=>{
assert.deepEqual(manifest.content_scripts[0].matches,["http://*/*","https://*/*"]);
assert.deepEqual(manifest.content_scripts[0].js,["src/content/plain-html-detection.js","src/content/sec-comment.js"]);
assert.match(handler,/setCommentActionVisible\(darkModeActive && isCommentPage\)/);
assert.match(settings,/Darken basic HTML pages/);
assert.match(settings,/pdf-viewer-sec-comment-dark-mode/);
});

test("raw SEC EDGAR HTML qualifies despite extensive legacy styling", () => {
const filing = "https://www.sec.gov/Archives/edgar/data/1761325/000119312522184158/filename1.htm";
const legacy = page({complex:true, divs:100, styled:200, named:200});
assert.equal(ctx.pdfMagicIsPlainHtmlDocument(legacy), false);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(legacy, filing), true);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(legacy, filing.replace(".htm", ".html")), true);
});

test("raw SEC EDGAR detection rejects lookalikes, other sites, and non-HTML", () => {
const filing = "https://www.sec.gov/Archives/edgar/data/1761325/000119312522184158/filename1.htm";
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page(), filing), true);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page(), filing.replace("www.sec.gov", "sec.gov.evil.example")), false);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page(), filing.replace("https:", "http:")), false);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page(), filing.replace(".htm", ".pdf")), false);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page(), "https://www.sec.gov/Archives/edgar/data/1761325/"), false);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page(), "https://www.sec.gov/ixviewer/doc.html"), false);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page({type:"application/pdf"}), filing), false);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page({text:" "}), filing), false);
assert.equal(ctx.pdfMagicIsRawEdgarHtmlDocument(page(), "not-a-url"), false);
});

test("raw EDGAR filing colors follow the saved viewer theme and opt-out", async () => {
const filing = "https://www.sec.gov/Archives/edgar/data/1761325/000119312522184158/filename1.htm";
const doc = page({complex:true, styled:200, named:200});
const nodes = new Map();
const listeners = [];
const stored = {"pdf-viewer-theme":"dark", "pdf-viewer-sec-comment-dark-mode":true};
doc.head = {append(node) {nodes.set(node.id, node);}};
doc.createElement = () => ({
id:"", textContent:"",
remove() {nodes.delete(this.id);}
});
doc.getElementById = (id) => nodes.get(id) || null;
const chrome = {
runtime:{getManifest:()=>({background:{service_worker:"src/background.js"}})},
storage:{
local:{get:async()=>({...stored})},
onChanged:{addListener:(callback)=>listeners.push(callback)}
}
};
const context = vm.createContext({URL, document:doc, window:{location:new URL(filing)}, chrome});
vm.runInContext(detector, context);
vm.runInContext(handler, context);
const flush = () => new Promise(resolve => setImmediate(resolve));
await flush();

const darkId = "pdf-viewer-sec-comment-dark-style";
assert.ok(nodes.has(darkId));
assert.match(nodes.get(darkId).textContent, /body \*:not\(svg\):not\(svg \*\):not\(canvas\)/);
assert.match(nodes.get(darkId).textContent, /body a:visited/);
assert.equal(nodes.has("pdf-viewer-sec-chatgpt"), false);

stored["pdf-viewer-theme"] = "light";
listeners[0]({"pdf-viewer-theme":{newValue:"light"}}, "local");
await flush();
assert.equal(nodes.has(darkId), false);

stored["pdf-viewer-theme"] = "dark";
stored["pdf-viewer-sec-comment-dark-mode"] = false;
listeners[0]({"pdf-viewer-theme":{newValue:"dark"}}, "local");
await flush();
assert.equal(nodes.has(darkId), false);

stored["pdf-viewer-sec-comment-dark-mode"] = true;
listeners[0]({"pdf-viewer-sec-comment-dark-mode":{newValue:true}}, "local");
await flush();
assert.equal(nodes.has(darkId), true);
});
Loading
Loading