fix: merge multiline cells and strip leftover indentation

Group physical data lines into logical rows by border lines so word-wrapped cells merge instead of producing empty rows. Expand the selection to whole lines before replacing so the converted table is inserted flush-left.
This commit is contained in:
Vesa Vänskä 2026-05-20 13:04:08 +03:00
parent 65f7894fab
commit a4370e95d1
No known key found for this signature in database
6 changed files with 235 additions and 17 deletions

View file

@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest";
import type { Editor, EditorPosition } from "obsidian";
import { convertSelection } from "../convert";
// Minimal in-memory Editor standing in for Obsidian's Editor. It implements
// only the methods convertSelection touches, backed by an array of lines.
class FakeEditor {
private lines: string[];
private from: EditorPosition;
private to: EditorPosition;
constructor(text: string, from: EditorPosition, to: EditorPosition) {
this.lines = text.split("\n");
this.from = from;
this.to = to;
}
getValue(): string {
return this.lines.join("\n");
}
getLine(line: number): string {
return this.lines[line];
}
getCursor(which: "from" | "to"): EditorPosition {
return which === "from" ? this.from : this.to;
}
setSelection(from: EditorPosition, to: EditorPosition): void {
this.from = from;
this.to = to;
}
getSelection(): string {
const { from, to } = this;
if (from.line === to.line) {
return this.lines[from.line].slice(from.ch, to.ch);
}
const first = this.lines[from.line].slice(from.ch);
const middle = this.lines.slice(from.line + 1, to.line);
const last = this.lines[to.line].slice(0, to.ch);
return [first, ...middle, last].join("\n");
}
replaceSelection(text: string): void {
const { from, to } = this;
const before = this.lines[from.line].slice(0, from.ch);
const after = this.lines[to.line].slice(to.ch);
const replaced = (before + text + after).split("\n");
this.lines = [
...this.lines.slice(0, from.line),
...replaced,
...this.lines.slice(to.line + 1),
];
}
}
describe("convertSelection", () => {
it("inserts the Markdown table flush-left despite leftover indentation", () => {
const doc =
" ┌──────┬──────┐\n" +
" │ A │ B │\n" +
" ├──────┼──────┤\n" +
" │ 1 │ 2 │\n" +
" └──────┴──────┘";
// Selection starts at the box character, leaving the 2-space indent behind.
const editor = new FakeEditor(doc, { line: 0, ch: 2 }, { line: 4, ch: 17 });
convertSelection(editor as unknown as Editor);
expect(editor.getValue()).toBe(
"| A | B |\n" + "| - | - |\n" + "| 1 | 2 |"
);
});
it("leaves the document untouched when the selection is not a table", () => {
const doc = "just regular text\nover two lines";
const editor = new FakeEditor(doc, { line: 0, ch: 0 }, { line: 1, ch: 14 });
convertSelection(editor as unknown as Editor);
expect(editor.getValue()).toBe(doc);
});
});

View file

@ -87,6 +87,18 @@ describe("formatMarkdownTable", () => {
expect(lines[3]).toContain("x ");
});
it("does not backtick a merged multiline cell containing spaces", () => {
const table: ParsedTable = {
headers: ["Surface", "Net change"],
rows: [["Per-template list", "fixed via ShowableScope"]],
};
const result = formatMarkdownTable(table);
expect(result).not.toContain("`fixed via ShowableScope`");
expect(result).toContain("fixed via ShowableScope");
});
it("handles empty cells", () => {
const table: ParsedTable = {
headers: ["A", "B"],

View file

@ -89,6 +89,48 @@ describe("parseTable", () => {
expect(result).toBeNull();
});
it("merges multiline cells into one logical row per separator", () => {
const input = `┌────────────┬─────────────────────────┐
Surface Before (Apr 15 this
PR)
Main list Yes
Per-tmpl Yes (the visible
bug)
`;
const result = parseTable(input);
expect(result).not.toBeNull();
expect(result!.headers).toEqual(["Surface", "Before (Apr 15 this PR)"]);
expect(result!.rows).toEqual([
["Main list", "✅ Yes"],
["Per-tmpl", "✅ Yes (the visible bug)"],
]);
});
it("merges a row whose first physical line is empty in some columns", () => {
const input = `┌────────────┬─────────────┐
Surface Net change
Main list unchanged
fixed via
Muokkaa privileges
demotion
`;
const result = parseTable(input);
expect(result).not.toBeNull();
expect(result!.headers).toEqual(["Surface", "Net change"]);
expect(result!.rows).toEqual([
["Main list", "unchanged"],
["Muokkaa", "fixed via privileges demotion"],
]);
});
it("handles a three-column table", () => {
const input = `┌──────┬──────┬──────┐
A B C

24
src/convert.ts Normal file
View file

@ -0,0 +1,24 @@
import type { Editor } from "obsidian";
import { parseTable } from "./parser";
import { formatMarkdownTable } from "./formatter";
export function convertSelection(editor: Editor): void {
const selection = editor.getSelection();
if (!selection) return;
const table = parseTable(selection);
if (!table) return;
// Expand the selection to whole lines so any leftover indentation — before
// the table on the first line, or on a partially selected last line — is
// replaced too. Otherwise the Markdown table is inserted indented.
const from = editor.getCursor("from");
const to = editor.getCursor("to");
const lastLine = to.ch === 0 && to.line > from.line ? to.line - 1 : to.line;
editor.setSelection(
{ line: from.line, ch: 0 },
{ line: lastLine, ch: editor.getLine(lastLine).length }
);
editor.replaceSelection(formatMarkdownTable(table));
}

View file

@ -1,6 +1,5 @@
import { Editor, Menu, Plugin } from "obsidian";
import { parseTable } from "./parser";
import { formatMarkdownTable } from "./formatter";
import { convertSelection } from "./convert";
const TABLE_HINT = /[│┌┬┐├┼┤└┴┘─]/;
@ -8,16 +7,6 @@ function looksLikeTable(text: string): boolean {
return TABLE_HINT.test(text) || /\+[-=]+\+/.test(text);
}
function convertSelection(editor: Editor): void {
const selection = editor.getSelection();
if (!selection) return;
const table = parseTable(selection);
if (!table) return;
editor.replaceSelection(formatMarkdownTable(table));
}
export default class TableBeautifierPlugin extends Plugin {
onload() {
this.addCommand({

View file

@ -20,6 +20,11 @@ function isDataRow(line: string): boolean {
return true;
}
function isBorderLine(line: string): boolean {
const trimmed = line.trim();
return trimmed.length > 0 && BORDER_LINE.test(trimmed);
}
function splitCells(line: string): string[] {
const trimmed = line.trim();
@ -34,20 +39,81 @@ function splitCells(line: string): string[] {
return cells.map((cell) => cell.trim());
}
// A logical row may be word-wrapped across several physical lines. Merge them
// column by column, joining the non-empty fragments with a single space.
function mergeGroup(group: string[]): string[] {
const lineCells = group.map(splitCells);
const columnCount = Math.max(...lineCells.map((cells) => cells.length));
const merged: string[] = [];
for (let col = 0; col < columnCount; col++) {
const fragments = lineCells
.map((cells) => cells[col] ?? "")
.filter((fragment) => fragment.length > 0);
merged.push(fragments.join(" "));
}
return merged;
}
// Split physical data lines into groups separated by border lines. Each group
// is the physical lines belonging to one logical row.
function groupDataLines(lines: string[]): { groups: string[][]; hasBorder: boolean } {
const groups: string[][] = [];
let current: string[] = [];
let hasBorder = false;
const flush = () => {
if (current.length > 0) {
groups.push(current);
current = [];
}
};
for (const line of lines) {
if (isDataRow(line)) {
current.push(line);
} else if (isBorderLine(line)) {
hasBorder = true;
flush();
} else {
flush();
}
}
flush();
return { groups, hasBorder };
}
export function parseTable(text: string): ParsedTable | null {
const lines = text.split("\n");
const { groups, hasBorder } = groupDataLines(lines);
const dataRows = lines.filter(isDataRow);
// Turn groups into logical rows (one string[] per row).
let logicalRows: string[][];
if (dataRows.length < 2) return null;
if (!hasBorder) {
// No borders to delimit rows — each physical data line is its own row.
const dataLines = groups.flat();
logicalRows = dataLines.map((line) => splitCells(line));
} else {
let rowGroups = groups;
const allCells = dataRows.map(splitCells);
// Borders present but only one data group (header group + one data group):
// the table has no per-row separators, so each physical line is a row.
if (rowGroups.length === 2 && rowGroups[1].length > 1) {
rowGroups = [rowGroups[0], ...rowGroups[1].map((line) => [line])];
}
const columnCount = allCells[0].length;
logicalRows = rowGroups.map(mergeGroup);
}
if (logicalRows.length < 2) return null;
const columnCount = logicalRows[0].length;
if (columnCount === 0) return null;
// Validate consistent column count
const consistentRows = allCells.filter((row) => row.length === columnCount);
const consistentRows = logicalRows.filter((row) => row.length === columnCount);
if (consistentRows.length < 2) return null;
return {