diff --git a/css/propaganda.css b/css/propaganda.css
index 9cbfd9c..c57fe3b 100644
--- a/css/propaganda.css
+++ b/css/propaganda.css
@@ -622,6 +622,154 @@
color: #fff;
}
+/* ─── Page Container (canvas + text layer) ───────────── */
+.prop-page-container {
+ position: relative;
+ display: inline-block;
+}
+
+.prop-page-container canvas {
+ display: block;
+ max-width: 100%;
+ box-shadow: 0 0 30px rgba(0, 0, 0, 0.6);
+}
+
+/* ─── Text Layer (transparent overlay for selection/search) */
+.prop-text-layer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ overflow: hidden;
+ opacity: 0.25;
+ line-height: 1;
+ pointer-events: all;
+}
+
+.prop-text-layer > span {
+ position: absolute;
+ color: transparent;
+ white-space: pre;
+ pointer-events: all;
+ cursor: text;
+}
+
+.prop-text-layer > span::selection {
+ background: rgba(0, 204, 51, 0.35);
+}
+
+/* ─── Search Bar ─────────────────────────────────────── */
+.prop-search-bar {
+ max-height: 0;
+ overflow: hidden;
+ background: rgba(17, 17, 17, 0.98);
+ border-bottom: 1px solid transparent;
+ transition: max-height 0.3s ease, border-color 0.3s ease, padding 0.3s ease;
+ padding: 0 1rem;
+}
+
+.prop-search-bar.active {
+ max-height: 60px;
+ padding: 0.5rem 1rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.prop-search-inner {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+}
+
+.prop-search-input {
+ flex: 1;
+ min-width: 120px;
+ max-width: 300px;
+ padding: 0.35rem 0.6rem;
+ background: rgba(5, 5, 5, 0.9);
+ border: 1px solid var(--border);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ letter-spacing: 1px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.prop-search-input:focus {
+ border-color: var(--status-green);
+ box-shadow: 0 0 6px rgba(0, 204, 51, 0.2);
+}
+
+.prop-search-input::placeholder {
+ color: var(--text-muted);
+ opacity: 0.5;
+ letter-spacing: 2px;
+}
+
+.prop-search-btn {
+ padding: 0.3rem 0.5rem;
+ background: rgba(30, 30, 30, 0.8);
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ line-height: 1;
+ letter-spacing: 1px;
+}
+
+.prop-search-btn:hover {
+ border-color: var(--status-green);
+ color: #fff;
+ background: rgba(0, 204, 51, 0.1);
+}
+
+.prop-search-btn.active {
+ border-color: var(--status-green);
+ color: var(--status-green);
+ background: rgba(0, 204, 51, 0.15);
+}
+
+.prop-search-close {
+ color: var(--mil-red);
+ font-size: 0.8rem;
+}
+
+.prop-search-close:hover {
+ border-color: var(--mil-red);
+ background: rgba(139, 0, 0, 0.15);
+ color: #ff4444;
+}
+
+.prop-search-info {
+ color: var(--text-muted);
+ font-size: 0.7rem;
+ letter-spacing: 1px;
+ min-width: 70px;
+ text-align: center;
+ white-space: nowrap;
+}
+
+.prop-search-info.no-match {
+ color: var(--mil-red);
+}
+
+/* ─── Search Highlights ──────────────────────────────── */
+.prop-search-hl {
+ background: rgba(0, 204, 51, 0.3) !important;
+ color: transparent !important;
+ border-radius: 1px;
+ box-shadow: 0 0 4px rgba(0, 204, 51, 0.4);
+}
+
+.prop-search-hl-active {
+ background: rgba(255, 170, 0, 0.5) !important;
+ box-shadow: 0 0 8px rgba(255, 170, 0, 0.6), 0 0 2px rgba(255, 170, 0, 0.8);
+}
+
+
/* ─── Classification Banner ──────────────────────────── */
.prop-classification {
text-align: center;
@@ -717,6 +865,14 @@
.prop-breadcrumb {
font-size: 0.7rem;
}
+ .prop-search-input {
+ min-width: 80px;
+ max-width: 180px;
+ }
+ .prop-search-info {
+ min-width: 50px;
+ font-size: 0.65rem;
+ }
}
@media (min-width: 1400px) {
diff --git a/js/propaganda.js b/js/propaganda.js
index 1eee7aa..7b312ec 100644
--- a/js/propaganda.js
+++ b/js/propaganda.js
@@ -327,9 +327,20 @@
+
⬇ DOWNLOAD
+
+
+
+
+
+
+
+
+
+
@@ -358,6 +369,13 @@
let pdfScale = 1.5;
let pdfRendering = false;
let pdfPending = null;
+ let pdfAllText = [];
+ let pdfSearchActive = false;
+ let pdfSearchQuery = '';
+ let pdfSearchCaseSensitive = false;
+ let pdfSearchMatches = [];
+ let pdfSearchCurrentIdx = -1;
+
async function initPdfViewer(url) {
const canvasWrap = document.getElementById('pdfCanvasWrap');
@@ -382,10 +400,14 @@
loadingEl.remove();
- // Create canvas
+ // Create page container with canvas
+ const pageContainer = document.createElement('div');
+ pageContainer.id = 'pdfPageContainer';
+ pageContainer.className = 'prop-page-container';
const canvas = document.createElement('canvas');
canvas.id = 'pdfCanvas';
- canvasWrap.appendChild(canvas);
+ pageContainer.appendChild(canvas);
+ canvasWrap.appendChild(pageContainer);
updatePageInfo();
renderPdfPage();
@@ -413,6 +435,12 @@
pdfScale = 1.5;
queueRender();
});
+
+ // Extract all page text for search
+ extractAllText();
+
+ // Initialize search controls
+ initSearchControls();
} catch (e) {
console.error('PDF load failed:', e);
canvasWrap.innerHTML = `
@@ -442,7 +470,21 @@
canvas.height = viewport.height;
canvas.width = viewport.width;
+ // Update page container size to match viewport
+ const pageContainer = document.getElementById('pdfPageContainer');
+ if (pageContainer) {
+ pageContainer.style.width = viewport.width + 'px';
+ pageContainer.style.height = viewport.height + 'px';
+ }
+
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
+
+ // Render text layer for search and text selection
+ await renderPageTextLayer(page, viewport);
+
+ // Highlight search matches on this page
+ highlightCurrentPageMatches();
+
pdfRendering = false;
updatePageInfo();
@@ -460,6 +502,285 @@
}
}
+ // ─── Text Layer Rendering ────────────────────────────
+ async function renderPageTextLayer(page, viewport) {
+ const existing = document.getElementById('pdfTextLayer');
+ if (existing) existing.remove();
+
+ const pageContainer = document.getElementById('pdfPageContainer');
+ if (!pageContainer) return;
+
+ const textContent = await page.getTextContent();
+ const textLayerDiv = document.createElement('div');
+ textLayerDiv.id = 'pdfTextLayer';
+ textLayerDiv.className = 'prop-text-layer';
+ textLayerDiv.style.width = viewport.width + 'px';
+ textLayerDiv.style.height = viewport.height + 'px';
+
+ // Try pdf.js built-in renderTextLayer, fallback to manual
+ if (typeof pdfjsLib.renderTextLayer === 'function') {
+ try {
+ const task = pdfjsLib.renderTextLayer({
+ textContentSource: textContent,
+ container: textLayerDiv,
+ viewport: viewport,
+ });
+ await task.promise;
+ } catch (err) {
+ console.warn('renderTextLayer failed, using fallback:', err);
+ buildTextLayerFallback(textContent, viewport, textLayerDiv);
+ }
+ } else {
+ buildTextLayerFallback(textContent, viewport, textLayerDiv);
+ }
+
+ // Tag each span with its item index for search highlighting
+ let itemIdx = 0;
+ for (const child of textLayerDiv.childNodes) {
+ if (child.nodeName === 'SPAN') {
+ child.dataset.itemIdx = itemIdx;
+ itemIdx++;
+ }
+ }
+
+ pageContainer.appendChild(textLayerDiv);
+ }
+
+ function buildTextLayerFallback(textContent, viewport, container) {
+ textContent.items.forEach((item, idx) => {
+ if (!item.str) return;
+ const span = document.createElement('span');
+ span.textContent = item.str;
+ const tx = item.transform;
+ const fontHeight = Math.sqrt(tx[2] * tx[2] + tx[3] * tx[3]);
+ const s = viewport.scale;
+ span.style.fontSize = (fontHeight * s) + 'px';
+ span.style.fontFamily = 'sans-serif';
+ span.style.left = (tx[4] * s) + 'px';
+ span.style.top = (viewport.height - tx[5] * s - fontHeight * s) + 'px';
+ span.style.position = 'absolute';
+ span.style.color = 'transparent';
+ span.style.whiteSpace = 'pre';
+ container.appendChild(span);
+ });
+ }
+
+ // ─── Text Extraction for Search ──────────────────────
+ async function extractAllText() {
+ pdfAllText = [];
+ if (!pdfDoc) return;
+ for (let i = 1; i <= pdfDoc.numPages; i++) {
+ const page = await pdfDoc.getPage(i);
+ const tc = await page.getTextContent();
+ let fullText = '';
+ const itemOffsets = [];
+ tc.items.forEach((item, idx) => {
+ const start = fullText.length;
+ fullText += item.str;
+ itemOffsets.push({ start: start, end: fullText.length, itemIdx: idx });
+ if (item.hasEOL) fullText += '\n';
+ });
+ pdfAllText.push({ pageNum: i, text: fullText, items: tc.items, itemOffsets: itemOffsets });
+ }
+ console.log('PROPAGANDA: Extracted text from ' + pdfAllText.length + ' pages');
+ }
+
+ // ─── Search Controls Init ────────────────────────────
+ function initSearchControls() {
+ const searchInput = document.getElementById('pdfSearchInput');
+ const searchPrev = document.getElementById('pdfSearchPrev');
+ const searchNext = document.getElementById('pdfSearchNext');
+ const searchClose = document.getElementById('pdfSearchClose');
+ const searchToggle = document.getElementById('pdfSearchToggle');
+ const searchCaseBtn = document.getElementById('pdfSearchCaseBtn');
+ if (!searchInput) return;
+
+ searchToggle.addEventListener('click', () => toggleSearchBar());
+ searchClose.addEventListener('click', () => closeSearchBar());
+
+ let searchTimeout = null;
+ searchInput.addEventListener('input', () => {
+ clearTimeout(searchTimeout);
+ searchTimeout = setTimeout(() => {
+ pdfSearchQuery = searchInput.value;
+ performSearch();
+ }, 200);
+ });
+
+ searchInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (e.shiftKey) prevMatch(); else nextMatch();
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ closeSearchBar();
+ }
+ });
+
+ searchPrev.addEventListener('click', () => prevMatch());
+ searchNext.addEventListener('click', () => nextMatch());
+
+ searchCaseBtn.addEventListener('click', () => {
+ pdfSearchCaseSensitive = !pdfSearchCaseSensitive;
+ searchCaseBtn.classList.toggle('active', pdfSearchCaseSensitive);
+ performSearch();
+ });
+
+ // Ctrl+F shortcut — only when PDF viewer is present
+ document.addEventListener('keydown', (e) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
+ if (document.getElementById('pdfViewerContainer')) {
+ e.preventDefault();
+ toggleSearchBar(true);
+ }
+ }
+ });
+ }
+
+ function toggleSearchBar(forceOpen) {
+ const searchBar = document.getElementById('pdfSearchBar');
+ const searchInput = document.getElementById('pdfSearchInput');
+ if (!searchBar) return;
+ if (forceOpen || !pdfSearchActive) {
+ searchBar.classList.add('active');
+ pdfSearchActive = true;
+ setTimeout(() => searchInput && searchInput.focus(), 50);
+ } else {
+ closeSearchBar();
+ }
+ }
+
+ function closeSearchBar() {
+ const searchBar = document.getElementById('pdfSearchBar');
+ if (!searchBar) return;
+ searchBar.classList.remove('active');
+ pdfSearchActive = false;
+ pdfSearchQuery = '';
+ pdfSearchMatches = [];
+ pdfSearchCurrentIdx = -1;
+ const searchInput = document.getElementById('pdfSearchInput');
+ if (searchInput) searchInput.value = '';
+ updateSearchInfo();
+ clearHighlights();
+ }
+
+ function performSearch() {
+ pdfSearchMatches = [];
+ pdfSearchCurrentIdx = -1;
+ const query = pdfSearchQuery.trim();
+ if (!query || query.length === 0) {
+ updateSearchInfo();
+ clearHighlights();
+ return;
+ }
+ const cs = pdfSearchCaseSensitive;
+ const q = cs ? query : query.toLowerCase();
+ pdfAllText.forEach(pageData => {
+ const text = cs ? pageData.text : pageData.text.toLowerCase();
+ let pos = 0;
+ while ((pos = text.indexOf(q, pos)) !== -1) {
+ const matchStart = pos;
+ const matchEnd = pos + q.length;
+ const affectedItems = pageData.itemOffsets.filter(
+ io => io.end > matchStart && io.start < matchEnd
+ );
+ pdfSearchMatches.push({
+ page: pageData.pageNum,
+ charStart: matchStart,
+ charEnd: matchEnd,
+ itemIndices: affectedItems.map(io => io.itemIdx)
+ });
+ pos += 1;
+ }
+ });
+ pdfSearchCurrentIdx = pdfSearchMatches.length > 0 ? 0 : -1;
+ updateSearchInfo();
+ if (pdfSearchMatches.length > 0) {
+ goToMatch(0);
+ } else {
+ clearHighlights();
+ }
+ }
+
+ function goToMatch(idx) {
+ if (idx < 0 || idx >= pdfSearchMatches.length) return;
+ pdfSearchCurrentIdx = idx;
+ const match = pdfSearchMatches[idx];
+ if (match.page !== pdfPage) {
+ pdfPage = match.page;
+ queueRender();
+ } else {
+ highlightCurrentPageMatches();
+ }
+ updateSearchInfo();
+ }
+
+ function nextMatch() {
+ if (pdfSearchMatches.length === 0) return;
+ goToMatch((pdfSearchCurrentIdx + 1) % pdfSearchMatches.length);
+ }
+
+ function prevMatch() {
+ if (pdfSearchMatches.length === 0) return;
+ goToMatch((pdfSearchCurrentIdx - 1 + pdfSearchMatches.length) % pdfSearchMatches.length);
+ }
+
+ function highlightCurrentPageMatches() {
+ clearHighlights();
+ const textLayer = document.getElementById('pdfTextLayer');
+ if (!textLayer) return;
+ const pageMatches = pdfSearchMatches.filter(m => m.page === pdfPage);
+ if (pageMatches.length === 0) return;
+
+ pageMatches.forEach(match => {
+ const globalIdx = pdfSearchMatches.indexOf(match);
+ const isActive = globalIdx === pdfSearchCurrentIdx;
+ match.itemIndices.forEach(itemIdx => {
+ const span = textLayer.querySelector('span[data-item-idx="' + itemIdx + '"]');
+ if (span) {
+ span.classList.add('prop-search-hl');
+ if (isActive) {
+ span.classList.add('prop-search-hl-active');
+ const canvasWrap = document.getElementById('pdfCanvasWrap');
+ if (canvasWrap) {
+ const sr = span.getBoundingClientRect();
+ const wr = canvasWrap.getBoundingClientRect();
+ const offset = sr.top - wr.top - wr.height / 3;
+ canvasWrap.scrollBy({ top: offset, behavior: 'smooth' });
+ }
+ }
+ }
+ });
+ });
+ }
+
+ function clearHighlights() {
+ const tl = document.getElementById('pdfTextLayer');
+ if (!tl) return;
+ tl.querySelectorAll('.prop-search-hl').forEach(el => {
+ el.classList.remove('prop-search-hl', 'prop-search-hl-active');
+ });
+ }
+
+ function updateSearchInfo() {
+ const info = document.getElementById('pdfSearchInfo');
+ if (!info) return;
+ if (pdfSearchMatches.length === 0) {
+ if (pdfSearchQuery && pdfSearchQuery.trim().length > 0) {
+ info.textContent = 'NO MATCHES';
+ info.className = 'prop-search-info no-match';
+ } else {
+ info.textContent = '';
+ info.className = 'prop-search-info';
+ }
+ } else {
+ info.textContent = (pdfSearchCurrentIdx + 1) + ' OF ' + pdfSearchMatches.length;
+ info.className = 'prop-search-info';
+ }
+ }
+
+
// ─── Not Found / Empty ────────────────────────────────
function renderNotFound(msg) {
ROOT.innerHTML = `
@@ -489,6 +810,12 @@
pdfPage = 1;
pdfRendering = false;
pdfPending = null;
+ pdfAllText = [];
+ pdfSearchActive = false;
+ pdfSearchQuery = '';
+ pdfSearchMatches = [];
+ pdfSearchCurrentIdx = -1;
+
const hash = (location.hash || '').replace(/^#\/?/, '');
const parts = hash.split('/').filter(Boolean);