Text Editing & Formatting

Table to Spreadsheet

Convert rendered or Markdown tables into cells for Google Sheets or Excel.

OfficialOn-device

Download action

What Table to Spreadsheet does

Captures selected rendered tables locally from their copied HTML, or converts Markdown and tab-separated tables to spreadsheet rows. Removes the header divider, bold, italic, and code formatting while preserving the first row and empty cells. Markdown links become their full URLs. Supports escaped pipes and quoted cells containing tabs, quotes, or line breaks. Select one complete table, run the action, copy the result, then paste into a spreadsheet cell. Rendered-table capture briefly copies the source selection and restores the previous clipboard before producing the result.

CategoryText Editing & Formatting
Action typeJavaScript
Default resultCopy the result
InternetNot required

Action and outcome

Table to Spreadsheet example
ActionOutcome
Table to SpreadsheetSelected text: | **Name** | Website | | --- | --- | | **Example** | [Visit](https://example.com) |Name Website Example https://example.com

Use Table to Spreadsheet

Follow these steps to convert rendered or Markdown tables into cells for Google Sheets or Excel with ActionClip.

  1. Select the text you want to use.
  2. Choose Table to Spreadsheet from ActionClip.
  3. Review the extracted result, then copy it where you need it.

Requirements and privacy

  • Before you start: No setup required.
  • Your selected text: Runs locally in ActionClip’s JavaScript sandbox. Selected text does not leave your Mac.

Action script

The script below is included so you can inspect how the action works before downloading it.

JavaScript definition
function run(selected_text) {
  // Rendered tables may already arrive as spreadsheet clipboard text.
  if (selected_text.includes("\t") && !selected_text.trimStart().startsWith("|")) {
    const rows = [], row = [];
    let cell = "", quoted = false, closed = false, start = true;
    const finish = () => { row.push(cell); cell = ""; closed = false; start = true; };
    for (let index = 0; index < selected_text.length; index += 1) {
      const char = selected_text[index];
      if (quoted) {
        if (char === '"') {
          if (selected_text[index + 1] === '"') { cell += '"'; index += 1; }
          else { quoted = false; closed = true; }
        } else cell += char;
      } else if (char === "\t" || char === "\r" || char === "\n") {
        finish();
        if (char !== "\t") { rows.push(row.splice(0)); }
        if (char === "\r" && selected_text[index + 1] === "\n") index += 1;
      } else if (closed) {
        throw new Error("Invalid quoted spreadsheet cell. Select the table again and retry.");
      } else if (start && char === '"') { quoted = true; start = false; }
      else { cell += char; start = false; }
    }
    if (quoted) throw new Error("Incomplete quoted spreadsheet cell. Select the table again and retry.");
    if (row.length || cell.length || !/[\r\n]$/.test(selected_text)) { finish(); rows.push(row); }
    const width = rows[0].length;
    if (rows.some((values) => values.length !== width)) throw new Error("The selected rows have different numbers of columns.");
    const quote = (value) => /[\t\r\n"]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
    return rows.map((values) => values.map(quote).join("\t")).join("\n");
  }
  const unescape = (text) => text.replace(/\\([^\w\s])/g, "$1");
  const entities = (text) => text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (match, code) => {
    const names = {amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " "};
    if (code[0] !== "#") return names[code.toLowerCase()];
    const value = code[1].toLowerCase() === "x" ? parseInt(code.slice(2), 16) : parseInt(code.slice(1), 10);
    return value > 0 && value <= 0x10ffff && !(value >= 0xd800 && value <= 0xdfff)
      ? String.fromCodePoint(value) : match;
  });

  // Read inline Markdown links without truncating URLs containing parentheses.
  function linkAt(text, start) {
    if (text[start] !== "[") return null;
    let depth = 1, index = start + 1;
    for (; index < text.length && depth; index += 1) {
      if (text[index] === "\\") { index += 1; continue; }
      if (text[index] === "[") depth += 1;
      if (text[index] === "]") depth -= 1;
    }
    if (depth || text[index] !== "(") return null;
    index += 1;
    while (/\s/.test(text[index] || "") && index < text.length) index += 1;
    let url = "";
    if (text[index] === "<") {
      const end = text.indexOf(">", index + 1);
      if (end < 0) return null;
      url = text.slice(index + 1, end);
      index = end + 1;
    } else {
      depth = 0;
      for (; index < text.length; index += 1) {
        const char = text[index];
        if (char === "\\" && index + 1 < text.length) { url += char + text[++index]; continue; }
        if ((char === ")" || /\s/.test(char)) && depth === 0) break;
        if (char === "(") depth += 1;
        if (char === ")") depth -= 1;
        url += char;
      }
      if (depth) return null;
    }
    while (/\s/.test(text[index] || "") && index < text.length) index += 1;
    if (text[index] === '"' || text[index] === "'") {
      const quote = text[index++];
      while (index < text.length && text[index] !== quote) {
        if (text[index] === "\\") index += 1;
        index += 1;
      }
      if (text[index++] !== quote) return null;
      while (/\s/.test(text[index] || "") && index < text.length) index += 1;
    }
    return text[index] === ")" ? {end: index + 1, url: entities(unescape(url))} : null;
  }

  function codeAt(text, start) {
    if (text[start] !== "`") return null;
    const fence = text.slice(start).match(/^`+/)[0];
    let end = text.indexOf(fence, start + fence.length);
    while (end >= 0 && (text[end - 1] === "`" || text[end + fence.length] === "`")) {
      end = text.indexOf(fence, end + fence.length);
    }
    return end < 0 ? null : {end: end + fence.length, value: text.slice(start + fence.length, end)};
  }

  function splitRow(line) {
    const cells = [];
    let cell = "", separators = 0;
    for (let index = 0; index < line.length;) {
      const char = line[index];
      if (char === "\\" && index + 1 < line.length) { cell += line.slice(index, index + 2); index += 2; continue; }
      const token = codeAt(line, index) || linkAt(line, index);
      if (token) { cell += line.slice(index, token.end); index = token.end; continue; }
      if (char === "|") { cells.push(cell.trim()); cell = ""; separators += 1; }
      else cell += char;
      index += 1;
    }
    cells.push(cell.trim());
    if (!separators) throw new Error("Select a Markdown table with columns separated by | characters.");
    if (line.startsWith("|")) cells.shift();
    if (cells[cells.length - 1] === "" && line.endsWith("|") && cells.length > 1) cells.pop();
    return cells;
  }

  function plainCell(text) {
    let output = "";
    for (let index = 0; index < text.length;) {
      if (text[index] === "\\" && index + 1 < text.length) {
        output += unescape(text.slice(index, index + 2)); index += 2; continue;
      }
      const code = codeAt(text, index);
      if (code) { output += code.value; index = code.end; continue; }
      const isImage = text[index] === "!" && text[index + 1] === "[";
      const link = linkAt(text, index + (isImage ? 1 : 0));
      if (link) { output += link.url; index = link.end; continue; }
      const autolink = text.slice(index).match(/^<(https?:\/\/[^>]+|mailto:[^>]+)>/i);
      if (autolink) { output += entities(autolink[1]); index += autolink[0].length; continue; }
      const html = text.slice(index).match(/^<\/?(?:br|strong|b|em|i|s|del|code)(?:\s*\/?)>/i);
      if (html) { if (/^<br/i.test(html[0])) output += "\n"; index += html[0].length; continue; }
      const marker = ["**", "__", "~~", "*", "_"].find((value) => text.startsWith(value, index));
      if (marker && (marker !== "_" || index === 0 || !/\w/.test(text[index - 1]))) {
        const end = text.indexOf(marker, index + marker.length);
        const inner = end < 0 ? "" : text.slice(index + marker.length, end);
        if (inner && inner === inner.trim()) { output += plainCell(inner); index = end + marker.length; continue; }
      }
      output += text[index++];
    }
    return entities(output);
  }

  let lines = selected_text.replace(/\r\n|[\r\u2028\u2029]/g, "\n").trim().split("\n");
  // Accept a table copied with its enclosing Markdown code fence.
  const fence = (lines[0] || "").trim().match(/^(`{3,}|~{3,})(?:markdown|md|text)?\s*$/i);
  if (fence && lines.length > 1 && lines[lines.length - 1].trim() === fence[1]) lines = lines.slice(1, -1);
  const rows = lines.filter((line) => line.trim()).map((line) => splitRow(line.trim()));
  if (!rows.length) throw new Error("Select a Markdown table first.");
  if (rows.length > 1 && rows[1].every((value) => /^:?-{3,}:?$/.test(value))) rows.splice(1, 1);
  const width = rows[0].length;
  if (!width || rows.some((row) => row.length !== width)) {
    throw new Error("The table rows have different numbers of columns. Escape literal pipes as \\| and select one complete table.");
  }
  const quote = (text) => /[\t\r\n"]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
  return rows.map((row) => row.map((cell) => quote(plainCell(cell))).join("\t")).join("\n");
}