From 84a74ecbc549a7c60f86a362595cbcd611980d1a Mon Sep 17 00:00:00 2001 From: Aki Vehtari Date: Thu, 16 Apr 2026 09:26:13 +0300 Subject: [PATCH 1/2] fix table column alignment with bold/italic markup --- eca-table.el | 75 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/eca-table.el b/eca-table.el index d0f9ed9..8dd4cf6 100644 --- a/eca-table.el +++ b/eca-table.el @@ -98,15 +98,81 @@ Sets subtle background tints on header and even-row faces." found)) (defun eca-table--max-line-width (beg end) - "Return the maximum line width in the region between BEG and END." + "Return the maximum display line width between BEG and END. +Subtracts hidden markup characters so the result reflects +the visual width when `markdown-hide-markup' is active." (let ((max-width 0)) (save-excursion (goto-char beg) (while (< (point) end) - (setq max-width (max max-width (- (line-end-position) (line-beginning-position)))) + (let* ((raw (- (line-end-position) + (line-beginning-position))) + (hidden (eca-table--count-markup-chars + (buffer-substring-no-properties + (line-beginning-position) + (line-end-position))))) + (setq max-width + (max max-width (- raw hidden)))) (forward-line 1))) max-width)) +(defun eca-table--count-markup-chars (text) + "Count hidden bold/italic `*' delimiters in TEXT. +Matches **text** (4 chars) and *text* (2 chars)." + (let ((count 0) + (start 0)) + (while (string-match + "\\(\\*\\{1,2\\}\\)\\([^*].*?\\)\\1" + text start) + (setq count (+ count (* 2 (length + (match-string 1 text))))) + (setq start (match-end 0))) + count)) + +(defun eca-table--compensate-hidden-markup () + "Add padding overlays for hidden markup in table at point. +For each cell containing bold/italic delimiters, places an +overlay with an `after-string' of spaces to restore the +column width that `markdown-table-align' computed for the +raw (unhidden) text. Must be called after alignment." + (let ((tbl-beg (markdown-table-begin)) + (tbl-end (markdown-table-end))) + ;; Remove any previous compensation overlays + (dolist (ov (overlays-in tbl-beg tbl-end)) + (when (overlay-get ov 'eca-table-markup-pad) + (delete-overlay ov))) + (save-excursion + (goto-char tbl-beg) + (while (< (point) tbl-end) + (let ((line-beg (line-beginning-position)) + (line-end (line-end-position))) + ;; Skip separator rows (|---|---|) + (unless (string-match-p + "^|[-:|[:space:]]+|$" + (buffer-substring-no-properties + line-beg line-end)) + (goto-char line-beg) + (while (re-search-forward + "|\\([^|\n]+\\)" line-end t) + (let* ((cell-end (match-end 1)) + (cell-text + (buffer-substring-no-properties + (match-beginning 1) cell-end)) + (hidden + (eca-table--count-markup-chars + cell-text))) + (when (> hidden 0) + (let ((ov (make-overlay + (1- cell-end) cell-end))) + (overlay-put ov 'eca-table-markup-pad t) + (overlay-put + ov 'after-string + (propertize + (make-string hidden ?\s) + 'face 'markdown-table-face)) + (overlay-put ov 'evaporate t))))))) + (forward-line 1))))) + (defun eca-table--action-bar-string (truncated-p) "Build the action bar before-string. TRUNCATED-P indicates the current display mode." @@ -212,13 +278,16 @@ All changes are overlay-only — buffer text is untouched." ;; Public API ------------------------------------------------------------- (defun eca-table-align (from end) - "Align all markdown tables between FROM and END." + "Align all markdown tables between FROM and END. +After aligning each table, compensates for hidden bold/italic +markup so columns stay visually aligned." (save-excursion (goto-char from) (while (and (< (point) end) (re-search-forward markdown-table-line-regexp end t)) (when (markdown-table-at-point-p) (markdown-table-align) + (eca-table--compensate-hidden-markup) ;; Move past this table to avoid re-processing (goto-char (markdown-table-end)))))) From 308154d6f8b8fa35b5f4e11211f1fc0e2debb55e Mon Sep 17 00:00:00 2001 From: Aki Vehtari Date: Thu, 16 Apr 2026 09:42:08 +0300 Subject: [PATCH 2/2] eca-table markup test --- test/eca-table-markup-test.el | 201 ++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 test/eca-table-markup-test.el diff --git a/test/eca-table-markup-test.el b/test/eca-table-markup-test.el new file mode 100644 index 0000000..3453617 --- /dev/null +++ b/test/eca-table-markup-test.el @@ -0,0 +1,201 @@ +;;; eca-table-markup-test.el --- Test hidden-markup table compensation -*- lexical-binding: t; -*- + +;;; Commentary: +;; +;; Interactive test for table column alignment with hidden markup. +;; +;; Usage: +;; M-x eval-buffer (in this file) +;; M-x eca-table-markup-test +;; +;; This opens a test buffer in markdown-mode containing tables +;; with bold/italic cells, aligns them, hides markup via +;; `markdown-hide-markup', and applies the compensation from +;; `eca-table.el' to restore correct visual alignment. +;; +;; The buffer shows two sections for comparison: +;; 1. "BEFORE" — aligned + markup hidden (misaligned) +;; 2. "AFTER" — with compensation (aligned) +;; +;;; Code: + +(require 'markdown-mode) +(require 'eca-table) + +;; --------------------------------------------------------------- +;; Sample tables +;; --------------------------------------------------------------- + +(defconst eca-table-markup-test--tables + '(;; Table 1: bold header, bold and italic in data + "| **Column A** | **Column B** | **Column C** | +|---|---|---| +| 10 | **25** | 30 | +| 40 | 55 | *60* | +| 70 | 85 | 90 |" + + ;; Table 2: bold and italic in data cells + "| Name | Score | Notes | +|---|---|---| +| Alice | **100** | *excellent* | +| Bob | 75 | plain | +| Carol | **90** | good |" + + ;; Table 3: multiple bold spans in one cell + "| Key | Value | +|---|---| +| **a** and **b** | normal | +| plain | **highlighted** |" + + ;; Table 4: no markup (control — should be unchanged) + "| X | Y | Z | +|---|---|---| +| 1 | 2 | 3 | +| 4 | 5 | 6 |")) + +;; --------------------------------------------------------------- +;; Test harness +;; --------------------------------------------------------------- + +(defun eca-table-markup-test--insert-section + (title tables compensate) + "Insert TITLE and TABLES into the current buffer. +If COMPENSATE is non-nil, apply markup compensation after +alignment." + (let ((section-beg (point))) + (insert (format "## %s\n\n" title)) + (dolist (tbl tables) + (insert tbl) + (insert "\n\n")) + (let ((section-end (point))) + ;; Align tables in this section + (save-excursion + (goto-char section-beg) + (while (and (< (point) section-end) + (re-search-forward + markdown-table-line-regexp + section-end t)) + (when (markdown-table-at-point-p) + (markdown-table-align) + (setq section-end (point-max)) + (goto-char (markdown-table-end))))) + (font-lock-ensure) + ;; Optionally compensate + (when compensate + (save-excursion + (goto-char section-beg) + (while (and (< (point) (point-max)) + (re-search-forward + markdown-table-line-regexp + nil t)) + (when (markdown-table-at-point-p) + (eca-table--compensate-hidden-markup) + (goto-char (markdown-table-end))))))))) + +(defun eca-table-markup-test () + "Open a test buffer showing table alignment before/after fix." + (interactive) + (let ((buf (get-buffer-create "*eca-table-markup-test*"))) + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer) + (markdown-mode) + (setq-local markdown-hide-markup t) + (add-to-invisibility-spec 'markdown-markup) + (face-remap-add-relative + 'markdown-table-face '(:inherit fixed-pitch)) + (insert "# Table Markup Compensation Test\n\n") + (insert (propertize + (concat + "BEFORE: columns with markup are too narrow.\n" + "AFTER: compensation restores alignment.\n\n") + 'face 'font-lock-comment-face)) + (eca-table-markup-test--insert-section + "BEFORE (no compensation)" + eca-table-markup-test--tables + nil) + (insert "\n---\n\n") + (eca-table-markup-test--insert-section + "AFTER (with compensation)" + eca-table-markup-test--tables + t) + (font-lock-ensure) + (goto-char (point-min)))) + (switch-to-buffer buf))) + +;; --------------------------------------------------------------- +;; Unit-style checks +;; --------------------------------------------------------------- + +(defun eca-table-markup-test-run-checks () + "Run programmatic checks; return t if all pass. +Results are printed to *Messages*." + (interactive) + (let ((pass t)) + ;; Check 1: regex counter + (message "--- Check: markup counting ---") + (let ((cases '((" **25** " . 4) + (" *60* " . 2) + (" **a** and **b** " . 8) + (" normal " . 0) + (" 55 " . 0)))) + (dolist (c cases) + (let* ((input (car c)) + (expected (cdr c)) + (actual (eca-table--count-markup-chars + input))) + (if (= actual expected) + (message " PASS: %S -> %d" input actual) + (message " FAIL: %S -> %d (expected %d)" + input actual expected) + (setq pass nil))))) + ;; Check 2: compensation overlays + (message "--- Check: table compensation ---") + (with-temp-buffer + (markdown-mode) + (setq-local markdown-hide-markup t) + (add-to-invisibility-spec 'markdown-markup) + (insert + "| A | B |\n|---|---|\n| **x** | *y* |\n") + (goto-char (point-min)) + (markdown-table-align) + (font-lock-ensure) + (goto-char (point-min)) + (when (markdown-table-at-point-p) + (eca-table--compensate-hidden-markup) + (let* ((tbl-beg (markdown-table-begin)) + (tbl-end (markdown-table-end)) + (ovs (seq-filter + (lambda (ov) + (overlay-get ov 'eca-table-markup-pad)) + (overlays-in tbl-beg tbl-end)))) + (if (= (length ovs) 2) + (message " PASS: 2 compensation overlays") + (message " FAIL: expected 2, got %d" + (length ovs)) + (setq pass nil)) + (dolist (ov ovs) + (message " overlay at %d: pad=%d" + (overlay-start ov) + (length + (overlay-get ov 'after-string)))) + ;; Idempotency + (goto-char (point-min)) + (eca-table--compensate-hidden-markup) + (let ((ovs2 (seq-filter + (lambda (ov) + (overlay-get + ov 'eca-table-markup-pad)) + (overlays-in tbl-beg tbl-end)))) + (if (= (length ovs2) (length ovs)) + (message " PASS: idempotent (%d overlays)" + (length ovs2)) + (message " FAIL: not idempotent (%d -> %d)" + (length ovs) (length ovs2)) + (setq pass nil)))))) + (message (if pass "All checks PASSED" + "Some checks FAILED")) + pass)) + +(provide 'eca-table-markup-test) +;;; eca-table-markup-test.el ends here