MediaWiki:Gadget-figureSource.js

Revision as of 22:20, 28 July 2026 by Wikilah admin (talk | contribs)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* Gadget-figureSource — click-to-source provenance for archived documents.
 *
 * Two modes, keyed on the page:
 *
 * ARCHIVE PAGES (any page with a published figures sidecar,
 * Data:<page>.figures.json): every located figure becomes clickable. Click →
 * an anchored popover showing a CROP of the source PDF with the figure's
 * stored rectangle boxed in coral, plus its tags (row label, column header,
 * caption, p./t. address). The wiki row stays visible beside the popover —
 * the review gesture is "does the wiki cell match the source?", so nothing
 * may cover the cell being compared.
 *
 * Bot:PDF viewer: the full-page stage — ?doc=<archive page>&fig=<figure id>
 * renders the whole PDF page with the box, prev/next paging. Deep-linkable:
 * a figure's evidence is a URL.
 *
 * Data contract: Data:<page>.figures.json (by_id figures — see
 * tools/doc_archive/output/figures.py). Boxes are PDF points, top-left
 * origin; the sidecar's `loc` fields are DOM-computable (table ordinal +
 * expanded grid r/c; chunk + in-order prose occurrences). The mirrored PDF
 * is File:<page with "/"->"-">.pdf, served same-origin via Special:FilePath.
 * NEVER guesses: a figure the sidecar could not locate gets an honest
 * "no source location" popover, and a DOM cell whose text disagrees with
 * the sidecar entry is skipped with a console warning.
 */
(function () {
    'use strict';

    var PDFJS = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
    var PDFJS_WORKER = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
    var VIEWER_PAGE = 'Bot:PDF viewer';
    var RENDER_SCALE = 1.6;          // offscreen page render (points -> px)
    var CROP_MARGIN_X = 110;         // context around the box, PDF points
    var CROP_MARGIN_Y = 16;

    var coral = (getComputedStyle(document.documentElement)
        .getPropertyValue('--ed-accent') || '#f07662').trim() || '#f07662';

    // ── zero-dependency stand-ins for mw.util / mw.loader ──────────────────
    // Deliberate: on this wiki mediawiki.util can hang at "loading" under the
    // page's heavy module soup (VE, popups, SMW), and a gadget waits FOREVER
    // for a declared dependency (found live: the gadget sat at "loaded" while
    // its dependency never resolved). This gadget therefore depends on
    // nothing beyond the startup config, which is always present before
    // gadgets execute.
    var SCRIPT = mw.config.get('wgScript');            // "/w/index.php"
    var ARTICLE = mw.config.get('wgArticlePath');      // "/wiki/$1"
    function esc(s) {
        return String(s).replace(/[&<>"']/g, function (ch) {
            return '&#' + ch.charCodeAt(0) + ';';
        });
    }
    function rawUrl(title) {
        return SCRIPT + '?title=' + encodeURIComponent(title) + '&action=raw';
    }
    function pageUrl(title, params) {
        if (!params) {
            return ARTICLE.replace('$1', encodeURIComponent(title)
                .replace(/%2F/g, '/').replace(/%3A/g, ':'));
        }
        var q = Object.keys(params).map(function (k) {
            return k + '=' + encodeURIComponent(params[k]);
        }).join('&');
        return SCRIPT + '?title=' + encodeURIComponent(title) + '&' + q;
    }
    function addStyle(css) {
        var el = document.createElement('style');
        el.textContent = css;
        document.head.appendChild(el);
    }
    function getScript(url) {
        return new Promise(function (resolve, reject) {
            var el = document.createElement('script');
            el.src = url;
            el.onload = resolve;
            el.onerror = reject;
            document.head.appendChild(el);
        });
    }

    // ── shared: pdf.js + page-canvas cache ─────────────────────────────────
    var pdfjsReady = null;
    function loadPdfJs() {
        if (!pdfjsReady) {
            pdfjsReady = getScript(PDFJS).then(function () {
                window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER;
                return window.pdfjsLib;
            });
        }
        return pdfjsReady;
    }

    function mirrorUrl(pageTitle) {
        var file = pageTitle.replace(/\//g, '-') + '.pdf';
        return pageUrl('Special:FilePath/' + file);
    }

    function PdfDoc(url) {                      // one per document, pages cached
        this.url = url;
        this.doc = null;
        this.pages = {};                        // pageno -> {canvas, viewport}
    }
    PdfDoc.prototype.page = function (n) {
        var self = this;
        return loadPdfJs().then(function (lib) {
            self.doc = self.doc || lib.getDocument(self.url).promise;
            return self.doc;
        }).then(function (doc) {
            if (self.pages[n]) { return self.pages[n]; }
            return doc.getPage(n).then(function (pg) {
                var viewport = pg.getViewport({ scale: RENDER_SCALE });
                var canvas = document.createElement('canvas');
                canvas.width = viewport.width;
                canvas.height = viewport.height;
                return pg.render({ canvasContext: canvas.getContext('2d'),
                                   viewport: viewport }).promise
                    .then(function () {
                        self.pages[n] = { canvas: canvas, viewport: viewport };
                        return self.pages[n];
                    });
            });
        });
    };

    // ── archive mode: map sidecar locators onto the DOM ────────────────────
    // Mirror of finmodel wiki_cells.expand_table: dense grid honoring
    // colspan/rowspan, so sidecar (r, c) means the same thing here.
    function expandTable(table) {
        var grid = [];                          // r -> c -> cell element
        var pending = {};                       // c -> [element, rowsLeft]
        var trs = table.querySelectorAll('tr');
        for (var r = 0; r < trs.length; r++) {
            var row = [], c = 0;
            var cells = trs[r].children;
            for (var i = 0; i < cells.length; i++) {
                while (pending[c]) {
                    row[c] = pending[c][0];
                    if (--pending[c][1] === 0) { delete pending[c]; }
                    c++;
                }
                var cell = cells[i];
                if (cell.tagName !== 'TD' && cell.tagName !== 'TH') { continue; }
                var cs = parseInt(cell.getAttribute('colspan') || '1', 10);
                var rs = parseInt(cell.getAttribute('rowspan') || '1', 10);
                for (var k = 0; k < cs; k++) {
                    row[c] = cell;
                    if (rs > 1) { pending[c] = [cell, rs - 1]; }
                    c++;
                }
            }
            while (pending[c]) {
                row[c] = pending[c][0];
                if (--pending[c][1] === 0) { delete pending[c]; }
                c++;
            }
            grid.push(row);
        }
        return grid;
    }

    function cellText(el) {
        var clone = el.cloneNode(true);
        // rendered {{fn ref}} sups + hidden footnote bodies are not cell text
        clone.querySelectorAll('sup.ed-fn-ref, .ed-fn-body').forEach(function (s) {
            s.remove();
        });
        return clone.textContent.replace(/\s+/g, ' ').trim();
    }

    // The chunk a node belongs to = the nearest PRECEDING ed-chunk anchor.
    function chunkOf(node) {
        var anchors = document.querySelectorAll('span.ed-chunk[id]');
        var best = null;
        for (var i = 0; i < anchors.length; i++) {
            var pos = anchors[i].compareDocumentPosition(node);
            /* anchor precedes node */
            if (pos & Node.DOCUMENT_POSITION_FOLLOWING) { best = anchors[i]; }
        }
        if (!best) { return null; }
        var m = best.id.match(/-c(\d+)$/);
        return m ? parseInt(m[1], 10) : null;
    }

    // Wrap the chunk's prose figures IN SIDECAR ORDER with a moving cursor —
    // reproduces the builder's in-chunk ordering without re-implementing its
    // materiality rules in JS.
    function tagProse(entries, tag) {
        var byChunk = {};
        entries.forEach(function (e) {
            (byChunk[e.loc.chunk] = byChunk[e.loc.chunk] || []).push(e);
        });
        Object.keys(byChunk).forEach(function (chunk) {
            var list = byChunk[chunk].sort(function (a, b) {
                return a.loc.n - b.loc.n;
            });
            var anchor = document.querySelector(
                'span.ed-chunk[id$="-c' + chunk + '"]');
            if (!anchor) { return; }
            var root = anchor.closest('h6') || anchor;
            var walker = document.createTreeWalker(
                document.body, NodeFilter.SHOW_TEXT, null);
            walker.currentNode = root;
            var idx = 0, node;
            while (idx < list.length && (node = walker.nextNode())) {
                if (node.parentElement.closest('table, .fig-popover')) { continue; }
                if (chunkOf(node) !== parseInt(chunk, 10)) { break; }
                var at, from = 0;
                while (idx < list.length &&
                       (at = node.data.indexOf(list[idx].text, from)) !== -1) {
                    var range = document.createRange();
                    range.setStart(node, at);
                    range.setEnd(node, at + list[idx].text.length);
                    var span = document.createElement('span');
                    span.className = 'fig-src';
                    range.surroundContents(span);
                    tag(span, list[idx]);
                    idx++;
                    // continue in the tail text node created by the wrap
                    node = span.nextSibling;
                    if (!node || node.nodeType !== Node.TEXT_NODE) { break; }
                    walker.currentNode = span;
                    from = 0;
                }
            }
            if (idx < list.length) {
                console.warn('[figureSource] chunk ' + chunk + ': ' +
                            (list.length - idx) + ' prose figure(s) not found');
            }
        });
    }

    function buildArchiveMode(sidecar) {
        var byEl = new Map();
        var tag = function (el, entry) {
            el.classList.add('fig-src');
            byEl.set(el, entry);
        };

        // cells — sidecar table ordinal ↔ DOM .wikitable ordinal
        var tables = Array.prototype.filter.call(
            document.querySelectorAll('#mw-content-text table.wikitable'),
            function (t) { return !t.closest('.fig-popover'); });
        var grids = tables.map(expandTable);
        var located = sidecar.figures.concat(sidecar.unmatched || []);
        var skipped = 0;
        located.forEach(function (entry) {
            if (entry.loc.kind !== 'cell') { return; }
            var grid = grids[entry.loc.table];
            var el = grid && grid[entry.loc.r] && grid[entry.loc.r][entry.loc.c];
            if (!el) { skipped++; return; }
            if (cellText(el).indexOf(entry.text) === -1) {
                console.warn('[figureSource] t' + entry.loc.table + ' r' +
                            entry.loc.r + 'c' + entry.loc.c +
                            ': DOM says "' + cellText(el) + '", sidecar "' +
                            entry.text + '" — skipped');
                skipped++;
                return;
            }
            tag(el, entry);
        });

        // prose
        tagProse(located.filter(function (e) {
            return e.loc.kind === 'prose';
        }), tag);

        console.info('[figureSource] tagged ' + byEl.size + ' figure(s), ' +
               skipped + ' skipped');
        return byEl;
    }

    // ── the popover ────────────────────────────────────────────────────────
    function popoverEl() {
        var el = document.querySelector('.fig-popover');
        if (el) { return el; }
        el = document.createElement('div');
        el.className = 'fig-popover';
        document.body.appendChild(el);
        document.addEventListener('click', function (ev) {
            if (!el.contains(ev.target) &&
                !ev.target.closest('.fig-src')) { el.remove(); }
        });
        document.addEventListener('keydown', function (ev) {
            if (ev.key === 'Escape') { el.remove(); }
        });
        return el;
    }

    function tagsHtml(el, entry) {
        var parts = [];
        if (entry.loc.kind === 'cell') {
            var table = el.closest('table');
            var grid = expandTable(table);
            var label = grid[entry.loc.r] && grid[entry.loc.r][0];
            if (label && label !== el) { parts.push(cellText(label)); }
            var headers = [];
            for (var h = 0; h < grid.length; h++) {
                var rowEls = grid[h] || [];
                var isHeader = rowEls.length &&
                    rowEls.every(function (c) { return c && c.tagName === 'TH'; });
                if (!isHeader) { break; }
                var cell = rowEls[entry.loc.c];
                var t = cell && cellText(cell);
                if (t && headers.indexOf(t) === -1) { headers.push(t); }
            }
            if (headers.length) { parts.push(headers.join(' · ')); }
            var cap = table.querySelector('caption');
            if (cap) { parts.push(cellText(cap)); }
        } else {
            parts.push('chunk ' + entry.loc.chunk);
        }
        return parts.map(function (p) {
            return '<div class="fig-tag">' + esc(p) + '</div>';
        }).join('');
    }

    function showPopover(el, entry, pdf) {
        var pop = popoverEl();
        var address = entry.page
            ? 'p. ' + entry.page
            : (entry.reason === 'ambiguous'
                ? 'no source location (ambiguous ×' + entry.candidates + ')'
                : 'no source location (' + (entry.reason || 'unmatched') + ')');
        var openHref = pageUrl(VIEWER_PAGE, {
            doc: mw.config.get('wgPageName'), fig: entry.id || '' });
        pop.innerHTML =
            '<div class="fig-crop">' +
            (entry.rect ? '<canvas></canvas>' : '') + '</div>' +
            tagsHtml(el, entry) +
            '<div class="fig-foot"><span>' + esc(address) +
            '</span>' + (entry.id ? '<a href="' + openHref +
            '" target="_blank">open ↗</a>' : '') + '</div>';

        var r = el.getBoundingClientRect();
        pop.style.left = Math.min(r.left + window.scrollX,
            window.scrollX + document.documentElement.clientWidth - 430) + 'px';
        pop.style.top = (r.bottom + window.scrollY + 6) + 'px';

        if (!entry.rect) { return; }
        pdf.page(entry.page).then(function (pg) {
            var s = pg.viewport.scale;
            var x0 = Math.max(0, (entry.rect[0] - CROP_MARGIN_X) * s);
            var y0 = Math.max(0, (entry.rect[1] - CROP_MARGIN_Y) * s);
            var w = Math.min(pg.canvas.width - x0,
                             (entry.rect[2] - entry.rect[0] + 2 * CROP_MARGIN_X) * s);
            var h = Math.min(pg.canvas.height - y0,
                             (entry.rect[3] - entry.rect[1] + 2 * CROP_MARGIN_Y) * s);
            var cv = pop.querySelector('canvas');
            if (!cv) { return; }
            cv.width = w; cv.height = h;
            var ctx = cv.getContext('2d');
            ctx.drawImage(pg.canvas, x0, y0, w, h, 0, 0, w, h);
            ctx.strokeStyle = coral;
            ctx.lineWidth = 2;
            ctx.strokeRect((entry.rect[0]) * s - x0 - 2,
                           (entry.rect[1]) * s - y0 - 2,
                           (entry.rect[2] - entry.rect[0]) * s + 4,
                           (entry.rect[3] - entry.rect[1]) * s + 4);
        }, function () {
            pop.querySelector('.fig-crop').textContent =
                'mirror PDF not available';
        });
    }

    // ── viewer mode (Bot:PDF viewer) ───────────────────────────────────────
    function runViewer() {
        var q = new URLSearchParams(location.search);
        var doc = q.get('doc'), figId = q.get('fig');
        var root = document.querySelector('#mw-content-text');
        if (!doc) {
            root.textContent = 'Missing ?doc=<archive page title>.';
            return;
        }
        // NB: this wiki serves EMPTY 200s for missing action=raw pages —
        // existence must be tested on the body, never on r.ok.
        fetch(rawUrl('Data:' + doc + '.figures.json'))
            .then(function (r) { return r.text(); })
            .then(function (t) {
                if (!t) { throw new Error('no sidecar for ' + doc); }
                return JSON.parse(t);
            })
            .then(function (sidecar) {
                var fig = null;
                sidecar.figures.forEach(function (f) {
                    if (f.id === figId) { fig = f; }
                });
                var pdf = new PdfDoc(mirrorUrl(doc));
                var page = fig ? fig.page : parseInt(q.get('page') || '1', 10);
                root.innerHTML =
                    '<div class="fig-viewer-bar"><b>' + esc(doc) +
                    '</b> <a href="' + pageUrl(doc) +
                    '">← back</a><span class="fig-viewer-page"></span>' +
                    '<button class="fig-prev">‹ prev</button>' +
                    '<button class="fig-next">next ›</button></div>' +
                    '<div class="fig-stage"><div class="fig-page">' +
                    '<canvas></canvas><div class="fig-box"></div></div></div>';
                var render = function () {
                    pdf.page(page).then(function (pg) {
                        var cv = root.querySelector('canvas');
                        cv.width = pg.canvas.width;
                        cv.height = pg.canvas.height;
                        cv.getContext('2d').drawImage(pg.canvas, 0, 0);
                        root.querySelector('.fig-viewer-page').textContent =
                            ' page ' + page;
                        var box = root.querySelector('.fig-box');
                        if (fig && fig.page === page) {
                            var s = pg.viewport.scale;
                            box.style.display = 'block';
                            box.style.left = (fig.rect[0] * s - 3) + 'px';
                            box.style.top = (fig.rect[1] * s - 3) + 'px';
                            box.style.width = ((fig.rect[2] - fig.rect[0]) * s + 6) + 'px';
                            box.style.height = ((fig.rect[3] - fig.rect[1]) * s + 6) + 'px';
                            box.scrollIntoView({ block: 'center' });
                        } else {
                            box.style.display = 'none';
                        }
                    });
                };
                root.querySelector('.fig-prev').onclick = function () {
                    if (page > 1) { page--; render(); }
                };
                root.querySelector('.fig-next').onclick = function () {
                    page++; render();
                };
                render();
            })
            .catch(function (e) {
                root.textContent = 'Could not load: ' + e.message;
            });
    }

    // ── archive-mode bootstrap ─────────────────────────────────────────────
    function runArchive() {
        var title = mw.config.get('wgPageName').replace(/_/g, ' ');
        fetch(rawUrl('Data:' + title + '.figures.json'))
            .then(function (r) { return r.text(); })
            .then(function (t) {                 // empty 200 = no sidecar here
                var sidecar = t ? JSON.parse(t) : null;
                if (!sidecar || !sidecar.figures) { return; }
                var byEl = buildArchiveMode(sidecar);
                if (!byEl.size) { return; }
                var pdf = new PdfDoc(mirrorUrl(title));
                document.addEventListener('click', function (ev) {
                    var el = ev.target.closest('.fig-src');
                    if (!el || !byEl.has(el)) { return; }
                    ev.preventDefault();
                    showPopover(el, byEl.get(el), pdf);
                });
            });
    }

    addStyle(
        '.fig-src{cursor:pointer}' +
        '.fig-src:hover{text-decoration:underline;' +
        'text-decoration-color:' + coral + ';text-underline-offset:3px}' +
        '.fig-popover{position:absolute;z-index:200;width:420px;' +
        'background:#fff;border:1px solid #0d0d0d;padding:10px;' +
        'box-shadow:0 2px 10px rgba(0,0,0,.15)}' +
        '.fig-popover .fig-crop canvas{max-width:100%;display:block;' +
        'border:1px solid #ddd;margin-bottom:8px}' +
        '.fig-popover .fig-tag{font-size:13px;line-height:1.45}' +
        '.fig-popover .fig-foot{display:flex;justify-content:space-between;' +
        'margin-top:8px;font-size:12px;color:#555}' +
        '.fig-viewer-bar{display:flex;gap:14px;align-items:center;' +
        'padding:8px 0;border-bottom:1px solid #0d0d0d;margin-bottom:12px}' +
        '.fig-viewer-bar button{font:inherit;background:#fff;' +
        'border:1px solid #0d0d0d;padding:2px 12px;cursor:pointer}' +
        '.fig-stage{overflow:auto;text-align:center}' +
        // the page frame: ink border so the white page reads as an OBJECT on
        // the white wiki background; position:relative so the coral box is
        // canvas-anchored (absolute children resolve against the padding box,
        // so the border adds no offset) — centering via the stage no longer
        // drifts the box on wide windows
        '.fig-page{position:relative;display:inline-block;' +
        'border:1px solid #0d0d0d;background:#fff}' +
        '.fig-page canvas{display:block}' +
        '.fig-box{position:absolute;display:none;border:3px solid ' + coral +
        ';pointer-events:none}');

    function boot() {
        if (mw.config.get('wgPageName') === VIEWER_PAGE.replace(/ /g, '_')) {
            runViewer();
        } else if (mw.config.get('wgNamespaceNumber') === 0) {
            runArchive();
        }
    }
    // A dependency-free gadget can execute before the content is parsed.
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', boot);
    } else {
        boot();
    }
}());