diff --git a/Makefile b/Makefile
index 6bf5f71..a0d2c02 100644
--- a/Makefile
+++ b/Makefile
@@ -4,6 +4,11 @@ SHELL := /bin/bash
EMACS ?= emacs
BATCH = $(EMACS) --batch -Q -L .
+# Source files shipped as packages (each is its own Emacs package).
+SOURCES = markdown-table-wrap.el markdown-table-wrap-pretty.el
+# Test files (features, without the .el extension), each loaded with -l.
+TEST_FILES = markdown-table-wrap-test markdown-table-wrap-pretty-test
+
# Test selector: run a subset of tests by ERT pattern
# Example: make test SELECTOR=parse
SELECTOR ?=
@@ -33,7 +38,7 @@ test:
@set -o pipefail; \
OUTPUT=$$(mktemp); \
$(BATCH) -L test \
- -l markdown-table-wrap-test \
+ $(patsubst %,-l %,$(TEST_FILES)) \
$(if $(SELECTOR),--eval '(ert-run-tests-batch-and-exit "$(SELECTOR)")',-f ert-run-tests-batch-and-exit) \
>$$OUTPUT 2>&1; \
STATUS=$$?; \
@@ -50,18 +55,22 @@ compile:
@echo "=== Byte-compile ==="
@$(BATCH) \
--eval "(setq byte-compile-error-on-warn t)" \
- -f batch-byte-compile markdown-table-wrap.el
+ -f batch-byte-compile $(SOURCES)
lint: lint-checkdoc lint-package
lint-checkdoc:
@echo "=== Checkdoc ==="
- @OUTPUT=$$($(BATCH) \
- --eval "(require 'checkdoc)" \
- --eval "(setq sentence-end-double-space nil)" \
- --eval "(checkdoc-file \"markdown-table-wrap.el\")" 2>&1); \
- WARNINGS=$$(echo "$$OUTPUT" | grep -A1 "^Warning" | grep -v "^Warning\|^--$$"); \
- if [ -n "$$WARNINGS" ]; then echo "$$WARNINGS"; exit 1; else echo "OK"; fi
+ @STATUS=0; \
+ for f in $(SOURCES); do \
+ OUTPUT=$$($(BATCH) \
+ --eval "(require 'checkdoc)" \
+ --eval "(setq sentence-end-double-space nil)" \
+ --eval "(checkdoc-file \"$$f\")" 2>&1); \
+ WARNINGS=$$(echo "$$OUTPUT" | grep -A1 "^Warning" | grep -v "^Warning\|^--$$"); \
+ if [ -n "$$WARNINGS" ]; then echo "$$WARNINGS"; STATUS=1; fi; \
+ done; \
+ if [ $$STATUS -eq 0 ]; then echo "OK"; else exit 1; fi
lint-package:
@echo "=== Package-lint ==="
@@ -69,23 +78,28 @@ lint-package:
--eval "(require 'package)" \
--eval "(push '(\"melpa\" . \"https://melpa.org/packages/\") package-archives)" \
--eval "(package-initialize)" \
+ --eval "(package-refresh-contents)" \
--eval "(unless (package-installed-p 'package-lint) \
- (package-refresh-contents) \
(package-install 'package-lint))" \
--eval "(require 'package-lint)" \
--eval "(setq package-lint-main-file \"markdown-table-wrap.el\")" \
- -f package-lint-batch-and-exit markdown-table-wrap.el
+ -f package-lint-batch-and-exit $(SOURCES)
check-parens:
@echo "=== Check Parens ==="
@OUTPUT=$$($(BATCH) \
- --eval '(condition-case err \
- (with-current-buffer (find-file-noselect "markdown-table-wrap.el") \
- (check-parens) \
- (message "markdown-table-wrap.el OK")) \
- (user-error \
- (message "FAIL: %s" (error-message-string err)) \
- (kill-emacs 1)))' 2>&1); \
+ --eval '(let ((status 0)) \
+ (mapc (lambda (f) \
+ (condition-case err \
+ (with-current-buffer (find-file-noselect f) \
+ (check-parens) \
+ (message "%s OK" f)) \
+ (user-error \
+ (message "FAIL: %s: %s" f (error-message-string err)) \
+ (setq status 1)))) \
+ command-line-args-left) \
+ (kill-emacs status))' \
+ $(SOURCES) 2>&1); \
echo "$$OUTPUT" | grep -E "OK$$|FAIL:"; \
echo "$$OUTPUT" | grep -q "FAIL:" && exit 1 || true
diff --git a/README.org b/README.org
index 8fe48c6..d2af37b 100644
--- a/README.org
+++ b/README.org
@@ -8,6 +8,13 @@
Word-wrap GFM (GitHub Flavored Markdown) pipe tables to fit a
given character width. Inline markup survives wrapping.
+This repository also ships an OPTIONAL display companion,
+=markdown-table-wrap-pretty= (documented below), which renders tables
+as a wrapped, box-drawing view on top of the canonical raw text (like
+=org-latex-preview= for tables) and toggles between that view and the
+editable source. It is a separate package and changes nothing unless
+you load it.
+
#+begin_example
| Feature | Status | Notes |
|-----------|--------|-------------------------------|
@@ -149,6 +156,82 @@ Parse and measure once, render at each width:
- Unicode-aware (CJK, combining marks, VS16 emoji)
- Pure Elisp, no dependencies
+* Optional: pretty display of pipe tables
+
+=markdown-table-wrap-pretty= is a separate, opt-in package shipped in
+this repository. It renders markdown and org pipe tables as a wrapped,
+Unicode box-drawing "pretty" view ON TOP OF the canonical raw table
+text, and toggles between that view and the raw source in place — the
+table analogue of =org-latex-preview= or inline image previews.
+
+** Why display-only
+
+The buffer text is always the canonical raw pipe table; nothing is
+rewritten. =markdown-table-wrap= wraps a long cell into multiple
+=| ... |= rows, but pipe-table syntax has no row-spanning, so a real
+renderer (cmark-gfm, pandoc, GitHub, org HTML export) would see those
+as independent data rows. Mutating the buffer looks right in Emacs but
+is semantically broken the moment the text leaves Emacs.
+=markdown-table-wrap-pretty= keeps the buffer canonical — export,
+copy, share, and push always see a real table — and makes the pretty
+rendering a disposable display layer.
+
+** Commands
+
+| Command | Action |
+|---|---|
+| =markdown-table-wrap-pretty-toggle= | Point-aware toggle: on a table toggles it; off-table toggles all; a region toggles tables in it; a prefix arg forces pretty or raw. |
+| =markdown-table-wrap-pretty-buffer= | Force pretty on every table in the buffer. |
+| =markdown-table-wrap-pretty-region= | Force pretty on every table in the region. |
+| =markdown-table-wrap-pretty-mode= | Buffer-local minor mode: wires the resize re-render hook and optional default-pretty per major mode. |
+
+Editing a pretty table auto-reveals the raw source (via
+=modification-hooks=, the =org-latex-preview= pattern — not
+=read-only=, which would break =undo=), and toggling back re-renders
+fresh from the canonical source.
+
+** Customization
+
+| Option | Default | Meaning |
+|---|---|---|
+| =markdown-table-wrap-pretty-prettify= | =t= | Unicode box-drawing borders; =nil= uses plain ASCII pipes. |
+| =markdown-table-wrap-pretty-auto-rewrap-on-resize= | =t= | Re-render pretty tables on window resize (lossless). |
+| =markdown-table-wrap-pretty-rewrap-idle-delay= | =0.3= | Seconds to debounce resize re-render (=0= is immediate). |
+| =markdown-table-wrap-pretty-default-on-major-modes= | =nil= | Modes where tables start pretty when the minor mode is on. |
+
+** Installation and keys
+
+Load the package (it depends on =markdown-table-wrap=):
+
+#+begin_src elisp
+(require 'markdown-table-wrap-pretty)
+#+end_src
+
+The package ships no default key. A consistent cross-mode choice is
+=C-c C-x C-k=:
+
+#+begin_src elisp
+(define-key markdown-mode-map (kbd "C-c C-x C-k")
+ #'markdown-table-wrap-pretty-toggle)
+(define-key org-mode-map (kbd "C-c C-x C-k")
+ #'markdown-table-wrap-pretty-toggle)
+#+end_src
+
+Or, with =use-package=, turn it on per major mode:
+
+#+begin_src elisp
+(use-package markdown-table-wrap-pretty
+ :after (markdown-mode org)
+ :hook (markdown-mode . markdown-table-wrap-pretty-mode)
+ :hook (org-mode . markdown-table-wrap-pretty-mode))
+#+end_src
+
+Detection is universal and line-based, code-fence guarded, with no
+tree-sitter dependency, so it works in =markdown-mode=, =gfm-mode=,
+=md-ts-mode=, and =org-mode=. Org =|---+---|= separators and width
+cookies are normalized for rendering only; the raw buffer keeps them.
+=#+TBLFM:= formula lines stay visible and untouched while pretty.
+
* API
** Primary functions
@@ -180,8 +263,11 @@ The package also exposes =markdown-table-wrap-parse=,
and =markdown-table-wrap-inside-code-fence-p=. See their docstrings
for details.
-All public functions are pure (except =inside-code-fence-p=). No
-=defcustom= is defined; configuration is passed as arguments.
+For the =markdown-table-wrap= engine, all public functions are pure
+(except =inside-code-fence-p=). No =defcustom= is defined;
+configuration is passed as arguments. (The optional
+=markdown-table-wrap-pretty= display layer is a separate package with
+its own =defcustom=s; see above.)
* License
diff --git a/markdown-table-wrap-pretty.el b/markdown-table-wrap-pretty.el
new file mode 100644
index 0000000..aa5b603
--- /dev/null
+++ b/markdown-table-wrap-pretty.el
@@ -0,0 +1,886 @@
+;;; markdown-table-wrap-pretty.el --- Display-only pretty rendering of pipe tables -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 SayreBlades
+
+;; Author: SayreBlades
+;; URL: https://github.com/dnouri/markdown-table-wrap
+;; Keywords: convenience, wp, markdown, org, tables
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;; This file is not part of GNU Emacs.
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+;;
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+;;
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see .
+
+;;; Commentary:
+
+;; This is the OPTIONAL display companion to `markdown-table-wrap'.
+;; It renders markdown and org pipe tables as a wrapped, box-drawing
+;; "pretty" view ON TOP OF the canonical raw table text, and toggles
+;; between that pretty view and the raw source in place. It is the
+;; table analogue of inline images or `org-latex-preview':
+;;
+;; - The buffer text is always the CANONICAL raw pipe table. Nothing
+;; is ever rewritten, so export / copy / share / push-to-GitHub /
+;; send-to-LLM always see a valid, single-row-per-record table.
+;; - A display overlay shows a pretty rendering (wrapped to the
+;; window, Unicode box-drawing borders by default).
+;; - One command flips the overlay off to reveal/edit the raw source,
+;; and back on to re-render.
+;;
+;; Why display-only (not buffer mutation): `markdown-table-wrap' wraps a
+;; long cell into multiple `| ... |' rows, but pipe-table syntax has no
+;; row-spanning, so any real renderer (cmark-gfm, pandoc, GitHub, org
+;; HTML export) sees N independent data rows, not one wrapped cell.
+;; Mutating the buffer looks right in Emacs but is semantically broken
+;; the moment the text leaves Emacs. This package keeps the buffer
+;; canonical and makes the pretty rendering a disposable display layer.
+;;
+;; Model (mirrors `org-latex-preview', `C-c C-x C-l'):
+;; - One display overlay per raw line (so `C-n' walks raw lines and big
+;; tables scroll naturally; the toggle is still whole-table).
+;; - Pretty = a view; editing reveals the raw source automatically via
+;; `modification-hooks' that remove the overlays on edit (the same
+;; pattern `org-latex-preview' uses, NOT `read-only', which would
+;; break undo). Toggle back to re-render from canonical.
+;; - State = overlay presence (no bookkeeping): "is this table
+;; pretty?" = "does it have `markdown-table-wrap-pretty-display'
+;; overlays?".
+;; - Resize re-renders pretty tables losslessly (regenerate from
+;; canonical; never unwrap). Tables toggled to raw stay raw.
+;;
+;; Toggle command (`markdown-table-wrap-pretty-toggle') is point-aware,
+;; like `org-latex-preview':
+;; - Point on a table -> toggle that table.
+;; - Point off-table -> toggle all tables in the buffer.
+;; - Active region -> toggle tables overlapping the region.
+;; - `C-u' -> force pretty on all tables in buffer.
+;; - `C-u C-u' -> force raw on all tables in buffer.
+;;
+;; Detection is universal line-based (`^[ \t]*|' runs, code-fence
+;; guarded for markdown flavors, `org-in-src-block-p' for org) — no
+;; tree-sitter dependency, so it works in `markdown-mode', `gfm-mode',
+;; `md-ts-mode', and `org-mode'.
+;;
+;; Org: `|---+---|' hlines and width cookies `<[lrc]?[0-9]*>' are
+;; stripped for RENDERING only; the raw buffer keeps them. A trailing
+;; `#+TBLFM:' line is outside the table region and stays visible and
+;; unchanged while pretty. This package does NOT refuse `#+TBLFM'
+;; tables.
+;;
+;; Keybindings: this package ships NO default key (it owns no
+;; major-mode map). A consistent cross-mode key is the user's to
+;; choose; one free, non-surprising slot in both `markdown-mode' and
+;; `org-mode' is `C-c C-x C-k'. Example:
+;;
+;; (require 'markdown-table-wrap-pretty)
+;; (define-key markdown-mode-map (kbd "C-c C-x C-k")
+;; #'markdown-table-wrap-pretty-toggle)
+;; (define-key org-mode-map (kbd "C-c C-x C-k")
+;; #'markdown-table-wrap-pretty-toggle)
+;;
+;; Depends only on `markdown-table-wrap' (public API: `markdown-table-wrap',
+;; `markdown-table-wrap-cell', `markdown-table-wrap-compute-widths',
+;; `markdown-table-wrap-parse', `markdown-table-wrap-visible-width', and
+;; `markdown-table-wrap-inside-code-fence-p'). There is no `markdown-mode'
+;; or `org' dependency at load time; those features are called lazily, so
+;; the file loads even when they are absent.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'subr-x)
+(require 'markdown-table-wrap)
+
+(declare-function org-in-src-block-p "org" (&optional inside))
+
+;;;; Customization
+
+(defgroup markdown-table-wrap-pretty nil
+ "Display-only pretty rendering of pipe tables."
+ :group 'text
+ :group 'convenience)
+
+(defcustom markdown-table-wrap-pretty-prettify t
+ "Non-nil means render pretty tables with Unicode box-drawing characters.
+Nil means use plain markdown pipes (`|')."
+ :type 'boolean
+ :group 'markdown-table-wrap-pretty)
+
+(defcustom markdown-table-wrap-pretty-auto-rewrap-on-resize t
+ "Non-nil means re-render pretty tables when the window is resized.
+Re-rendering is lossless (regenerate overlays from canonical source).
+Tables toggled to raw stay raw. Nil disables the resize hook."
+ :type 'boolean
+ :group 'markdown-table-wrap-pretty)
+
+(defcustom markdown-table-wrap-pretty-default-on-major-modes nil
+ "Major modes where tables default to pretty when the minor mode is on.
+A list of major-mode symbols. Default is nil, so tables start raw
+everywhere and you toggle on demand. Add symbols such as
+`markdown-mode' or `org-mode' to start tables pretty in those modes."
+ :type '(repeat symbol)
+ :group 'markdown-table-wrap-pretty)
+
+(defcustom markdown-table-wrap-pretty-rewrap-idle-delay 0.3
+ "Seconds to wait after a resize before re-rendering pretty tables.
+0 means immediate. Debounces window drag."
+ :type 'number
+ :group 'markdown-table-wrap-pretty)
+
+(defface markdown-table-wrap-pretty-code-face
+ '((t :inherit fixed-pitch))
+ "Face used for inline code spans in pretty tables.
+Defaults to `fixed-pitch'. Customize this face for a different look;
+to match a surrounding `markdown-mode' buffer, inherit
+`markdown-inline-code-face'. Applied by
+`markdown-table-wrap-pretty-render-inline-spans'."
+ :group 'markdown-table-wrap-pretty)
+
+;;;; Buffer-Local State
+
+;; Forward declaration: `markdown-table-wrap-pretty-mode' is defined by
+;; `define-minor-mode' below; it is referenced earlier by
+;; `markdown-table-wrap-pretty--schedule-refresh' and
+;; `markdown-table-wrap-pretty--maybe-default-pretty'.
+(defvar markdown-table-wrap-pretty-mode nil)
+
+(defvar-local markdown-table-wrap-pretty--last-width nil
+ "Last window width used for table rendering in this buffer.")
+
+(defvar-local markdown-table-wrap-pretty--refresh-timer nil
+ "Idle timer for debounced resize re-render in this buffer.")
+
+;;;; Width
+
+(defun markdown-table-wrap-pretty--display-window ()
+ "Return a visible window showing the current buffer, or nil."
+ (or (get-buffer-window (current-buffer) nil)
+ (get-buffer-window (current-buffer) 'visible)))
+
+(defun markdown-table-wrap-pretty--window-width ()
+ "Return usable character columns for the current buffer's window.
+Fall back to 80 when the buffer has no visible window. NOTE: this is
+the raw window capacity and does NOT account for per-line visual
+prefixes such as `line-prefix'/`wrap-prefix' (set by `org-indent-mode').
+Use `markdown-table-wrap-pretty--effective-width-at' for the width
+actually available at a given buffer position."
+ (if-let* ((win (markdown-table-wrap-pretty--display-window)))
+ (or (window-max-chars-per-line win) 80)
+ 80))
+
+(defun markdown-table-wrap-pretty--prefix-display-width (spec)
+ "Return the display width of a `line-prefix'/`wrap-prefix' value SPEC.
+SPEC may be nil, a string, or a display spec; only the string case is
+measured (what `org-indent-mode' sets). Return 0 otherwise."
+ (cond
+ ((stringp spec)
+ (if (= (length spec) (string-bytes spec))
+ (length spec) ; ASCII fast path (spaces)
+ (let ((w 0) (i 0) (n (length spec)))
+ (while (< i n) (setq w (+ w (char-width (aref spec i))) i (1+ i)))
+ w)))
+ (t 0)))
+
+(defun markdown-table-wrap-pretty--indent-width-at (pos)
+ "Return the visual indent width consumed by text properties at POS.
+Account for `line-prefix' and `wrap-prefix' (set by `org-indent-mode'
+and other indentation minor modes), which eat columns that
+`window-max-chars-per-line' does not see."
+ (let ((line-pfx (get-text-property pos 'line-prefix))
+ (wrap-pfx (get-text-property pos 'wrap-prefix)))
+ (+ (markdown-table-wrap-pretty--prefix-display-width line-pfx)
+ (markdown-table-wrap-pretty--prefix-display-width wrap-pfx))))
+
+(defun markdown-table-wrap-pretty--effective-width-at (&optional pos)
+ "Return the usable table width at POS (default: point).
+Window capacity minus any visual indent prefix at POS (e.g. org-indent).
+Never return less than 1."
+ (max 1 (- (markdown-table-wrap-pretty--window-width)
+ (markdown-table-wrap-pretty--indent-width-at (or pos (point))))))
+
+;;;; Detection
+
+(defconst markdown-table-wrap-pretty--table-line-re "\\`[[:blank:]]*|"
+ "Regexp matching the start of a pipe-table line.
+The leading backtick anchors to the start of a line STRING (used via
+`string-match-p'), not to `point-min'. See
+`markdown-table-wrap-pretty--table-line-p'.")
+
+(defun markdown-table-wrap-pretty--table-line-p (&optional line)
+ "Return non-nil when LINE (or the current line) is a pipe-table line."
+ (if line
+ (string-match-p markdown-table-wrap-pretty--table-line-re line)
+ ;; Operate on the line STRING so the regex backtick anchors to the
+ ;; line start, not `point-min' (which `looking-at-p' would do).
+ (save-excursion
+ (beginning-of-line)
+ (string-match-p
+ markdown-table-wrap-pretty--table-line-re
+ (buffer-substring-no-properties
+ (point) (line-end-position))))))
+
+(defun markdown-table-wrap-pretty--separator-line-p (line)
+ "Return non-nil when LINE is a pipe-table separator row.
+Accept both GFM (`|---|---|') and org (`|---+---|') separators."
+ (let ((trimmed (string-trim line)))
+ (and (string-prefix-p "|" trimmed)
+ (or (string-suffix-p "|" trimmed)
+ (string-suffix-p "|" (substring trimmed 0 -1)))
+ (string-match-p "\\`|[-: +]*[-: +|]*|?\\'"
+ (replace-regexp-in-string
+ "[[:blank:]]" "" trimmed)))))
+
+(defun markdown-table-wrap-pretty--inside-code-fence-p (pos)
+ "Return non-nil when POS is inside a code block (fence or org src)."
+ (cond
+ ((derived-mode-p 'org-mode)
+ (and (fboundp 'org-in-src-block-p)
+ (save-excursion (goto-char pos) (org-in-src-block-p))))
+ ((derived-mode-p 'markdown-mode 'md-ts-mode)
+ (markdown-table-wrap-inside-code-fence-p pos))
+ (t nil)))
+
+(defun markdown-table-wrap-pretty--block-has-separator-p (beg end)
+ "Return non-nil when the line block BEG..END has a separator row."
+ (save-excursion
+ (goto-char beg)
+ (while (and (< (point) end)
+ (not (markdown-table-wrap-pretty--separator-line-p
+ (buffer-substring-no-properties
+ (line-beginning-position)
+ (min end (line-end-position))))))
+ (forward-line 1))
+ (< (point) end)))
+
+(defun markdown-table-wrap-pretty--table-bounds (&optional pos)
+ "Return (BEG . END) for the pipe table at POS, or nil.
+END is the start of the first line after the table. Return nil when
+POS is not on a table line, the block has no separator row, or the
+block is inside a code fence."
+ (save-excursion
+ (goto-char (or pos (point)))
+ (beginning-of-line)
+ (when (markdown-table-wrap-pretty--table-line-p)
+ (while (and (not (bobp))
+ (save-excursion
+ (forward-line -1)
+ (markdown-table-wrap-pretty--table-line-p)))
+ (forward-line -1))
+ (let ((beg (line-beginning-position)))
+ (while (and (not (eobp))
+ (markdown-table-wrap-pretty--table-line-p))
+ (forward-line 1))
+ (let ((end (point)))
+ (and (markdown-table-wrap-pretty--block-has-separator-p beg end)
+ (not (markdown-table-wrap-pretty--inside-code-fence-p beg))
+ (cons beg end)))))))
+
+(defun markdown-table-wrap-pretty--table-regions (beg end)
+ "Return pipe-table regions overlapping BEG..END, in buffer order.
+Each element is (TABLE-BEG . TABLE-END) as from
+`markdown-table-wrap-pretty--table-bounds'."
+ (let ((start (min beg end))
+ (limit (max beg end))
+ (regions nil))
+ (save-excursion
+ (goto-char start)
+ (beginning-of-line)
+ (while (< (point) limit)
+ (let ((bounds (markdown-table-wrap-pretty--table-bounds (point))))
+ (if bounds
+ (progn
+ (when (> (cdr bounds) start)
+ (push bounds regions))
+ (goto-char (cdr bounds)))
+ (forward-line 1)))))
+ (nreverse regions)))
+
+;;;; Parsing
+
+(defun markdown-table-wrap-pretty--normalize-org-line (line)
+ "Normalize org table LINE for GFM-style parsing.
+Strip org width cookies `<[lrc]?[0-9]*>'. Convert org hline
+separators `|---+---|' to GFM form `|---|---|'."
+ (let ((stripped (replace-regexp-in-string "<[lrc]?[0-9]*>" "" line)))
+ (if (and (derived-mode-p 'org-mode)
+ (markdown-table-wrap-pretty--separator-line-p stripped))
+ (replace-regexp-in-string "+" "|" stripped)
+ stripped)))
+
+(defun markdown-table-wrap-pretty--parse-table (raw-lines)
+ "Parse RAW-LINES into (HEADERS ALIGNS ROWS), or nil.
+RAW-LINES is a list of pipe-table line strings (no trailing newlines).
+Org separators and width cookies are normalized for parsing only.
+Return nil when there is no separator row or no header before it.
+Cell splitting and alignment are delegated to the public
+`markdown-table-wrap-parse'."
+ (let* ((norm (mapcar #'markdown-table-wrap-pretty--normalize-org-line raw-lines))
+ (sep-idx (cl-position-if
+ #'markdown-table-wrap-pretty--separator-line-p norm)))
+ (when (and sep-idx (> sep-idx 0))
+ (markdown-table-wrap-parse (mapconcat #'identity norm "\n")))))
+
+;;;; Rendering
+
+(defun markdown-table-wrap-pretty-render-inline-spans (cell)
+ "Render markdown inline spans in CELL for display: drop delimiters, add faces.
+A display-only transform (the canonical CELL is not mutated).
+
+Spans (longer delimiters folded first):
+- Escaped pipe `\\|' -> `|' (GFM table-level unescape)
+- Image `' -> `alt' (`link' face + url help-echo)
+- Link `[text](url)' -> `text' (`link' face + url help-echo)
+- Bold-italic `***x***' -> `x' (`bold-italic' face)
+- Bold `**x**' -> `x' (`bold' face)
+- Strikethrough `~~x~~' -> `x' (strike-through attribute)
+- Inline code (`x` or `` x ``) -> `x' (code face, see below)
+- Italic `*x*' -> `x' (`italic' face)
+
+Escaped pipes unescape FIRST (GFM table syntax; a `\\|' inside a code
+span resolves to `|' like cmark-gfm). Code spans are then extracted to
+placeholders so their content is literal — `` `**not bold**` `` renders
+as `**not bold**` in code face, not folded. Text properties survive
+`markdown-table-wrap-cell', so faces stay attached across wrapped
+continuation lines; width measurement ignores text properties, so
+columns size to the rendered text, not the raw markup."
+ (let ((result (replace-regexp-in-string "\\\\|" "|" cell)) ; \| -> | (GFM, first)
+ (code-spans nil)
+ (placeholder-idx 0))
+ ;; Phase 1: extract code spans (double-backtick then single) so their
+ ;; content is literal. Placeholders \x00CODE\x00 cannot appear in
+ ;; markdown. Double-backtick: CommonMark trims one leading/trailing space.
+ (setq result
+ (replace-regexp-in-string
+ "``\\([^`]\\|`[^`]\\)+``"
+ (lambda (match)
+ (let* ((inner (substring match 2 -2))
+ (trimmed (if (and (string-prefix-p " " inner)
+ (string-suffix-p " " inner)
+ (> (length inner) 1))
+ (substring inner 1 -1)
+ inner))
+ (ph (format "\x00CODE%d\x00" placeholder-idx)))
+ (push (cons ph trimmed) code-spans)
+ (setq placeholder-idx (1+ placeholder-idx))
+ ph))
+ result t t))
+ (setq result
+ (replace-regexp-in-string
+ "`\\([^`]+\\)`"
+ (lambda (match)
+ (let* ((inner (substring match 1 -1))
+ (ph (format "\x00CODE%d\x00" placeholder-idx)))
+ (push (cons ph inner) code-spans)
+ (setq placeholder-idx (1+ placeholder-idx))
+ ph))
+ result t t))
+ ;; Phase 2: fold non-code spans with faces. Image before link (both
+ ;; use brackets); bold-italic before bold before italic (longer first).
+ (setq result
+ (replace-regexp-in-string ; image 
+ "!\\[\\([^]]*\\)\\](\\([^)]*\\))"
+ (lambda (m)
+ (propertize (or (match-string 1 m) "")
+ 'face 'link 'mouse-face 'highlight
+ 'help-echo (or (match-string 2 m) "")))
+ result t t))
+ (setq result
+ (replace-regexp-in-string ; link [text](url)
+ "\\[\\([^]]*\\)\\](\\([^)]*\\))"
+ (lambda (m)
+ (propertize (or (match-string 1 m) "")
+ 'face 'link 'mouse-face 'highlight
+ 'help-echo (or (match-string 2 m) "")))
+ result t t))
+ (setq result
+ (replace-regexp-in-string ; bold-italic ***x***
+ "\\*\\*\\*\\([^*]+\\)\\*\\*\\*"
+ (lambda (m)
+ (propertize (match-string 1 m) 'face 'bold-italic))
+ result t t))
+ (setq result
+ (replace-regexp-in-string ; bold **x**
+ "\\*\\*\\([^*]+\\)\\*\\*"
+ (lambda (m)
+ (propertize (match-string 1 m) 'face 'bold))
+ result t t))
+ (setq result
+ (replace-regexp-in-string ; strikethrough ~~x~~
+ "~~\\([^~]+\\)~~"
+ (lambda (m)
+ (propertize (match-string 1 m) 'face '(:strike-through t)))
+ result t t))
+ (setq result
+ (replace-regexp-in-string ; italic *x*
+ "\\*\\([^*]+\\)\\*"
+ (lambda (m)
+ (propertize (match-string 1 m) 'face 'italic))
+ result t t))
+ ;; Phase 3: restore code spans with the code face.
+ (dolist (pair code-spans)
+ (setq result
+ (replace-regexp-in-string
+ (regexp-quote (car pair))
+ (propertize (cdr pair) 'face 'markdown-table-wrap-pretty-code-face)
+ result t t)))
+ result))
+
+(defun markdown-table-wrap-pretty--render-inline-spans-in-cell (cell)
+ "Pretty-view wrapper around `markdown-table-wrap-pretty-render-inline-spans'.
+Return CELL unchanged when `markdown-table-wrap-pretty-prettify' is nil,
+so the raw pipe view shows the canonical markdown verbatim (valid source)."
+ (if markdown-table-wrap-pretty-prettify
+ (markdown-table-wrap-pretty-render-inline-spans cell)
+ cell))
+
+(defun markdown-table-wrap-pretty--render-row-lines (cells col-widths aligns)
+ "Render table CELLS into display lines using COL-WIDTHS and ALIGNS.
+When `markdown-table-wrap-pretty-prettify' is non-nil, emit Unicode
+box-drawing verticals instead of markdown pipes. Return a list of
+display-line strings (one per wrapped visual row)."
+ (let* ((num-cols (length col-widths))
+ (padded (append cells
+ (make-list (max 0 (- num-cols (length cells))) "")))
+ (wrapped-cells
+ (cl-mapcar (lambda (cell column-width)
+ (markdown-table-wrap-cell (or cell "") column-width))
+ padded col-widths))
+ (max-height (apply #'max (mapcar #'length wrapped-cells)))
+ (pretty markdown-table-wrap-pretty-prettify)
+ (delim-open (if pretty "│ " "| "))
+ (delim-mid (if pretty " │ " " | "))
+ (delim-close (if pretty " │" " |")))
+ (cl-loop for line-index below max-height
+ collect
+ (let ((acc (list delim-open)))
+ (cl-loop for cell-lines in wrapped-cells
+ for column-width in col-widths
+ for align in aligns
+ for first = t then nil
+ do
+ (unless first (push delim-mid acc))
+ (let* ((cell (or (nth line-index cell-lines) ""))
+ (empty (string-empty-p cell))
+ (pad (if empty
+ column-width
+ (max 0 (- column-width
+ (markdown-table-wrap-visible-width cell))))))
+ (cond
+ (empty
+ (push (make-string column-width ?\s) acc))
+ ((eq align 'right)
+ (push (make-string pad ?\s) acc)
+ (push cell acc))
+ ((eq align 'center)
+ (let ((left-pad (/ pad 2)))
+ (push (make-string left-pad ?\s) acc)
+ (push cell acc)
+ (push (make-string (- pad left-pad) ?\s) acc)))
+ (t
+ (push cell acc)
+ (push (make-string pad ?\s) acc)))))
+ (push delim-close acc)
+ (apply #'concat (nreverse acc))))))
+
+(defun markdown-table-wrap-pretty--render-separator-line (col-widths aligns)
+ "Render the separator line for COL-WIDTHS and ALIGNS.
+When `markdown-table-wrap-pretty-prettify' is non-nil, emit a
+box-drawing rule; otherwise emit standard markdown separator syntax."
+ (if markdown-table-wrap-pretty-prettify
+ (concat "├─" (mapconcat (lambda (w) (make-string (max 1 w) ?─))
+ col-widths "─┼─")
+ "─┤")
+ (let ((parts
+ (cl-mapcar
+ (lambda (column-width align)
+ (let ((dashes (make-string (max 1 column-width) ?-)))
+ (pcase align
+ ('left
+ (if (>= column-width 2)
+ (concat ":" (substring dashes 1))
+ ":"))
+ ('right
+ (if (>= column-width 2)
+ (concat (substring dashes 1) ":")
+ ":"))
+ ('center
+ (if (>= column-width 3)
+ (concat ":" (substring dashes 2) ":")
+ (if (>= column-width 2) "::" ":")))
+ (_ dashes))))
+ col-widths aligns)))
+ (concat "| " (mapconcat #'identity parts " | ") " |"))))
+
+;;;; Display Groups (per raw line)
+
+(defun markdown-table-wrap-pretty--split-line-prefix (line)
+ "Split table LINE into (PREFIX . BARE).
+PREFIX is everything before the first `|'; BARE starts at the first `|'."
+ (if-let* ((pipe-index (string-match-p "|" line)))
+ (cons (substring line 0 pipe-index)
+ (substring line pipe-index))
+ (cons "" line)))
+
+(defun markdown-table-wrap-pretty--table-display-groups (raw-lines width)
+ "Return prefix-aware display groups for RAW-LINES at WIDTH.
+Each result element corresponds to one raw source line and is a list
+of the display lines for that logical table row. Container prefixes
+such as blockquotes and indentation are preserved on every visual
+continuation line. Plain tables take a fast path."
+ (let* ((no-prefix (cl-every (lambda (l) (and (> (length l) 0)
+ (= (aref l 0) ?|)))
+ raw-lines))
+ (parts (unless no-prefix
+ (mapcar #'markdown-table-wrap-pretty--split-line-prefix raw-lines)))
+ (prefixes (unless no-prefix (mapcar #'car parts)))
+ (bare-lines (if no-prefix raw-lines (mapcar #'cdr parts)))
+ (prefix-width (if no-prefix 0
+ (apply #'max 0 (mapcar #'string-width prefixes)))))
+ (when (and (>= (length bare-lines) 2)
+ (markdown-table-wrap-pretty--parse-table bare-lines))
+ (pcase-let* ((`(,headers ,aligns ,rows)
+ (markdown-table-wrap-pretty--parse-table bare-lines))
+ ;; Fold inline spans (escaped pipes, links/images,
+ ;; bold/italic/strike/code) via
+ ;; `markdown-table-wrap-pretty--render-inline-spans-in-cell'
+ ;; so columns size and render to the visible text rather
+ ;; than the raw markup. No-op when
+ ;; `markdown-table-wrap-pretty-prettify' is nil.
+ (disp-headers (mapcar #'markdown-table-wrap-pretty--render-inline-spans-in-cell
+ headers))
+ (disp-rows (mapcar (lambda (r)
+ (mapcar #'markdown-table-wrap-pretty--render-inline-spans-in-cell r))
+ rows))
+ (num-cols (max (length disp-headers)
+ (length aligns)
+ (if disp-rows
+ (apply #'max (mapcar #'length disp-rows))
+ 0)))
+ (content-width (max 1 (- width prefix-width)))
+ (col-widths
+ (markdown-table-wrap-compute-widths
+ disp-headers disp-rows content-width num-cols))
+ (header-lines
+ (markdown-table-wrap-pretty--render-row-lines disp-headers col-widths aligns))
+ (separator-line
+ (markdown-table-wrap-pretty--render-separator-line col-widths aligns))
+ (row-groups
+ (mapcar (lambda (row)
+ (markdown-table-wrap-pretty--render-row-lines
+ row col-widths aligns))
+ disp-rows)))
+ (if no-prefix
+ (append (list header-lines)
+ (list (list separator-line))
+ row-groups)
+ (append
+ (list (mapcar (lambda (l) (concat (car prefixes) l)) header-lines))
+ (list (list (concat (nth 1 prefixes) separator-line)))
+ (cl-mapcar (lambda (prefix row-lines)
+ (mapcar (lambda (l) (concat prefix l)) row-lines))
+ (nthcdr 2 prefixes)
+ row-groups)))))))
+
+;;;; Overlay Management
+
+(defun markdown-table-wrap-pretty--overlays-in (beg end)
+ "Return the `markdown-table-wrap-pretty-display' overlays in BEG..END."
+ (cl-remove-if-not
+ (lambda (ov) (overlay-get ov 'markdown-table-wrap-pretty-display))
+ (overlays-in beg end)))
+
+(defun markdown-table-wrap-pretty--undecorate-table (beg end)
+ "Remove all markdown-table-wrap-pretty display overlays in BEG..END."
+ (dolist (ov (markdown-table-wrap-pretty--overlays-in beg end))
+ (delete-overlay ov)))
+
+(defun markdown-table-wrap-pretty--table-pretty-p (beg end)
+ "Return non-nil when the table at BEG..END is currently pretty."
+ (cl-some (lambda (ov) (overlay-get ov 'markdown-table-wrap-pretty-display))
+ (overlays-in beg end)))
+
+(defun markdown-table-wrap-pretty--on-modify (ov _flag _beg _end &optional _length)
+ "Modification hook: reveal the whole table when OV's text is edited.
+This auto-removes the pretty display on any edit (org-latex-preview
+style), so the user sees and edits the raw source directly. There is no
+`read-only' (which would break undo) and no blind editing. The other
+arguments are the standard modification-hook arguments and are ignored."
+ (let ((inhibit-modification-hooks t))
+ (when-let* ((markers (overlay-get ov 'markdown-table-wrap-pretty-markers))
+ (mb (marker-position (car markers)))
+ (me (marker-position (cdr markers))))
+ ;; Drop the markers first so a re-entrant call is a no-op.
+ (overlay-put ov 'markdown-table-wrap-pretty-markers nil)
+ (markdown-table-wrap-pretty--undecorate-table mb me))))
+
+(defun markdown-table-wrap-pretty--decorate-table (beg end &optional width)
+ "Create per-line display overlays for the raw table BEG..END.
+WIDTH defaults to the effective width at BEG (window capacity minus any
+visual indent prefix from `org-indent-mode' and friends). The raw
+buffer text is preserved; each raw line gets its own overlay whose
+`display' shows the wrapped/pretty output. Return t when overlays were
+created, nil when the table needs no change (fits, no prettify)."
+ (let* ((table-beg (save-excursion (goto-char beg) (line-beginning-position)))
+ (width (or width (markdown-table-wrap-pretty--effective-width-at table-beg)))
+ (raw (buffer-substring-no-properties table-beg end))
+ (trimmed (string-trim-right raw "\n+"))
+ (trailing (substring raw (length trimmed))))
+ (when-let* ((raw-lines (split-string trimmed "\n"))
+ (groups (markdown-table-wrap-pretty--table-display-groups raw-lines width)))
+ ;; Skip when the display is identical to the raw (fits, no prettify).
+ (unless (and (= (length groups) (length raw-lines))
+ (cl-every (lambda (raw-line group)
+ (and (= (length group) 1)
+ (equal raw-line (car group))))
+ raw-lines groups))
+ (let ((n (length raw-lines))
+ (beg-marker (make-marker))
+ (end-marker (make-marker)))
+ (set-marker beg-marker table-beg)
+ (set-marker end-marker end)
+ (save-excursion
+ (goto-char table-beg)
+ (dotimes (i n)
+ (let* ((line-beg (line-beginning-position))
+ (line-end (if (= i (1- n))
+ end
+ (min (1+ (line-end-position)) end)))
+ (group (nth i groups))
+ (display-str (concat (mapconcat #'identity group "\n")
+ (if (= i (1- n)) trailing "\n")))
+ (ov (make-overlay line-beg line-end nil nil nil)))
+ (overlay-put ov 'display display-str)
+ (overlay-put ov 'face 'default)
+ (overlay-put ov 'markdown-table-wrap-pretty-display t)
+ (overlay-put ov 'markdown-table-wrap-pretty-markers
+ (cons beg-marker end-marker))
+ (overlay-put ov 'evaporate t)
+ (overlay-put ov 'modification-hooks
+ (list #'markdown-table-wrap-pretty--on-modify))
+ (overlay-put ov 'insert-in-front-hooks
+ (list #'markdown-table-wrap-pretty--on-modify))
+ (overlay-put ov 'insert-behind-hooks
+ (list #'markdown-table-wrap-pretty--on-modify)))
+ (forward-line 1)))
+ t)))))
+
+(defun markdown-table-wrap-pretty--decorate-tables-in-region (beg end &optional width)
+ "Decorate all tables in BEG..END.
+WIDTH, when non-nil, overrides the per-table effective width; otherwise
+each table uses the effective width at its own position (window capacity
+minus any visual indent prefix). Idempotent: existing table overlays
+are removed first."
+ (let ((gc-cons-threshold (max gc-cons-threshold (* 8 1024 1024))))
+ (setq markdown-table-wrap-pretty--last-width
+ (markdown-table-wrap-pretty--window-width))
+ (dolist (region (markdown-table-wrap-pretty--table-regions beg end))
+ (markdown-table-wrap-pretty--undecorate-table (car region) (cdr region))
+ (let* ((rb (car region))
+ (re (cdr region))
+ (raw (buffer-substring-no-properties rb re)))
+ (when (markdown-table-wrap-pretty--parse-table
+ (split-string (string-trim-right raw "\n+") "\n"))
+ (pcase-let* ((`(,_headers ,_aligns ,rows)
+ (markdown-table-wrap-pretty--parse-table
+ (split-string (string-trim-right raw "\n+") "\n"))))
+ (when rows ; skip header-only tables
+ (markdown-table-wrap-pretty--decorate-table rb re width))))))))
+
+;;;; Resize
+
+(defun markdown-table-wrap-pretty--refresh-visible ()
+ "Re-render all currently-pretty tables at the current window width.
+Tables toggled to raw (no overlays) are left alone. Lossless:
+regenerate from canonical source."
+ (let ((width (markdown-table-wrap-pretty--window-width)))
+ (unless (equal width markdown-table-wrap-pretty--last-width)
+ (setq markdown-table-wrap-pretty--last-width width)
+ (let ((gc-cons-threshold (max gc-cons-threshold (* 8 1024 1024))))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (let ((bounds (markdown-table-wrap-pretty--table-bounds (point))))
+ (if bounds
+ (progn
+ (when (markdown-table-wrap-pretty--table-pretty-p
+ (car bounds) (cdr bounds))
+ (markdown-table-wrap-pretty--undecorate-table
+ (car bounds) (cdr bounds))
+ ;; Let decorate compute the effective width at this
+ ;; table's position (accounts for org-indent prefix).
+ (markdown-table-wrap-pretty--decorate-table
+ (car bounds) (cdr bounds)))
+ (goto-char (cdr bounds)))
+ (forward-line 1)))))))))
+
+(defun markdown-table-wrap-pretty--schedule-refresh ()
+ "Schedule a debounced `markdown-table-wrap-pretty--refresh-visible'."
+ (when (and markdown-table-wrap-pretty-mode
+ markdown-table-wrap-pretty-auto-rewrap-on-resize)
+ (when markdown-table-wrap-pretty--refresh-timer
+ (cancel-timer markdown-table-wrap-pretty--refresh-timer))
+ (setq markdown-table-wrap-pretty--refresh-timer
+ (run-with-idle-timer markdown-table-wrap-pretty-rewrap-idle-delay nil
+ (lambda ()
+ (when (buffer-live-p (current-buffer))
+ (with-current-buffer (current-buffer)
+ (markdown-table-wrap-pretty--refresh-visible))))))))
+
+;;;; Minor Mode
+
+(defun markdown-table-wrap-pretty--maybe-default-pretty ()
+ "Decorate all tables if the current major mode defaults to pretty."
+ (when (and markdown-table-wrap-pretty-mode
+ (memq major-mode markdown-table-wrap-pretty-default-on-major-modes))
+ (markdown-table-wrap-pretty--decorate-tables-in-region (point-min) (point-max))))
+
+;;;###autoload
+(define-minor-mode markdown-table-wrap-pretty-mode
+ "Toggle display-only pretty rendering of pipe tables in this buffer.
+When on, tables can be toggled between a pretty view and the canonical
+raw pipe text with `markdown-table-wrap-pretty-toggle'. Resize
+re-renders pretty tables losslessly when
+`markdown-table-wrap-pretty-auto-rewrap-on-resize' is on. Tables
+start raw unless the major mode is in
+`markdown-table-wrap-pretty-default-on-major-modes'. Editing a pretty
+table auto-reveals the raw source via `modification-hooks' (the
+`org-latex-preview' pattern; not `read-only', which would break undo)."
+ :lighter " TblPretty"
+ :group 'markdown-table-wrap-pretty
+ (if markdown-table-wrap-pretty-mode
+ (progn
+ ;; `window-configuration-change-hook' is a normal (no-arg) hook
+ ;; and is buffer-local-correct; it fires on window size changes
+ ;; as well as layout changes, which is what we need for re-wrap.
+ ;; NOTE: do NOT use `window-size-change-functions' here: it is an
+ ;; abnormal hook called with one arg (the frame), so a no-arg
+ ;; function would error (silently, via `safe-run-hooks'); and it
+ ;; is global, so a buffer-local registration is unreliable.
+ (add-hook 'window-configuration-change-hook
+ #'markdown-table-wrap-pretty--schedule-refresh nil t)
+ (markdown-table-wrap-pretty--maybe-default-pretty))
+ (remove-hook 'window-configuration-change-hook
+ #'markdown-table-wrap-pretty--schedule-refresh t)
+ (when markdown-table-wrap-pretty--refresh-timer
+ (cancel-timer markdown-table-wrap-pretty--refresh-timer)
+ (setq markdown-table-wrap-pretty--refresh-timer nil))
+ (markdown-table-wrap-pretty--undecorate-table (point-min) (point-max))))
+
+;;;; Public Commands
+
+(defun markdown-table-wrap-pretty--force (state regions)
+ "Apply STATE (`pretty' or `raw') to REGIONS.
+Return the number of tables actually rendered (for `pretty') or
+processed (for `raw'). A `pretty' table that already fits without
+prettification (`markdown-table-wrap-pretty-prettify' is nil and the
+table is narrower than the window) is not rendered and is not counted,
+so the returned count reflects what the user actually sees change."
+ (let ((count 0))
+ (dolist (r regions)
+ (let ((beg (car r)) (end (cdr r)))
+ (if (eq state 'pretty)
+ (progn
+ (markdown-table-wrap-pretty--undecorate-table beg end)
+ ;; Let decorate compute the effective width at beg.
+ (when (markdown-table-wrap-pretty--decorate-table beg end)
+ (setq count (1+ count))))
+ (markdown-table-wrap-pretty--undecorate-table beg end)
+ (setq count (1+ count)))))
+ count))
+
+;;;###autoload
+(defun markdown-table-wrap-pretty-toggle (&optional arg)
+ "Toggle the pretty rendering of the pipe table at point, or all tables.
+Mirrors `org-latex-preview'. ARG selects the force behavior: a single
+prefix arg forces pretty on every table, a double prefix arg forces
+raw; otherwise the location rules below decide:
+ - Point on a table -> toggle that table.
+ - Point off-table -> toggle all tables in the buffer (if any are
+ pretty, make all raw; otherwise make all pretty).
+ - Active region -> toggle tables overlapping the region."
+ (interactive "P")
+ (cond
+ ;; C-u C-u -> force raw on all.
+ ((equal arg '(16))
+ (let ((all (markdown-table-wrap-pretty--table-regions
+ (point-min) (point-max))))
+ (markdown-table-wrap-pretty--force 'raw all)
+ (message "Tables raw (%d)" (length all))))
+ ;; C-u -> force pretty on all.
+ ((equal arg '(4))
+ (let* ((all (markdown-table-wrap-pretty--table-regions
+ (point-min) (point-max)))
+ (n (markdown-table-wrap-pretty--force 'pretty all)))
+ (message "Tables pretty (%d of %d)" n (length all))))
+ ;; Active region -> toggle tables overlapping the region.
+ ((use-region-p)
+ (let* ((rb (region-beginning))
+ (re (region-end))
+ (regs (markdown-table-wrap-pretty--table-regions rb re))
+ (any-pretty (cl-some
+ (lambda (r)
+ (markdown-table-wrap-pretty--table-pretty-p
+ (car r) (cdr r)))
+ regs))
+ (state (if any-pretty 'raw 'pretty))
+ (n (markdown-table-wrap-pretty--force state regs)))
+ (message "Region tables %s (%d%s)"
+ (if (eq state 'raw) "raw" "pretty")
+ n
+ (if (eq state 'raw) "" (format " of %d" (length regs))))))
+ ;; No region: point on a table -> toggle it; else toggle all.
+ (t
+ (let ((bounds (markdown-table-wrap-pretty--table-bounds (point))))
+ (cond
+ ((null bounds)
+ (let* ((all (markdown-table-wrap-pretty--table-regions
+ (point-min) (point-max)))
+ (any-pretty (cl-some
+ (lambda (r)
+ (markdown-table-wrap-pretty--table-pretty-p
+ (car r) (cdr r)))
+ all))
+ (state (if any-pretty 'raw 'pretty))
+ (n (markdown-table-wrap-pretty--force state all)))
+ (message "Tables %s (%d%s)"
+ (if (eq state 'raw) "raw" "pretty")
+ n
+ (if (eq state 'raw) "" (format " of %d" (length all))))))
+ ((markdown-table-wrap-pretty--table-pretty-p (car bounds) (cdr bounds))
+ (markdown-table-wrap-pretty--undecorate-table (car bounds) (cdr bounds))
+ (message "Table raw"))
+ (t
+ (markdown-table-wrap-pretty--decorate-table (car bounds) (cdr bounds))
+ (message "Table pretty")))))))
+
+;;;###autoload
+(defun markdown-table-wrap-pretty-buffer ()
+ "Force pretty rendering on all tables in the current buffer."
+ (interactive)
+ (let* ((all (markdown-table-wrap-pretty--table-regions
+ (point-min) (point-max)))
+ (n (markdown-table-wrap-pretty--force 'pretty all)))
+ (message "Tables pretty (%d of %d)" n (length all))))
+
+;;;###autoload
+(defun markdown-table-wrap-pretty-region (beg end)
+ "Force pretty rendering on all tables in BEG..END."
+ (interactive "r")
+ (let* ((regs (markdown-table-wrap-pretty--table-regions beg end))
+ (n (markdown-table-wrap-pretty--force 'pretty regs)))
+ (message "Region tables pretty (%d of %d)" n (length regs))))
+
+(provide 'markdown-table-wrap-pretty)
+;;; markdown-table-wrap-pretty.el ends here
diff --git a/test/markdown-table-wrap-pretty-test.el b/test/markdown-table-wrap-pretty-test.el
new file mode 100644
index 0000000..f7a240a
--- /dev/null
+++ b/test/markdown-table-wrap-pretty-test.el
@@ -0,0 +1,256 @@
+;;; markdown-table-wrap-pretty-test.el --- Tests for markdown-table-wrap-pretty -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 SayreBlades
+
+;; Author: SayreBlades
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+
+;; ERT tests for the display companion package `markdown-table-wrap-pretty'.
+;;
+;; These tests cover the PURE helpers that the display layer is built from:
+;; separator detection, org normalization, table parsing, row/separator
+;; rendering (plain and Unicode-pretty), inline-span styling, and
+;; prefix-aware display-group construction. The overlay/toggle behavior
+;; itself is not exercised here because it needs a live window/display;
+;; the pure helpers are the high-value, deterministic surface.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'ert)
+(require 'org)
+(require 'markdown-table-wrap)
+(require 'markdown-table-wrap-pretty)
+
+(defmacro markdown-table-wrap-pretty-test--with-prettify (prettify &rest body)
+ "Evaluate BODY with `markdown-table-wrap-pretty-prettify' bound to PRETTIFY.
+Bodies that call the renderers depend on this defcustom; bind it
+locally so tests are deterministic and order-independent."
+ (declare (indent 1))
+ `(let ((markdown-table-wrap-pretty-prettify ,prettify))
+ ,@body))
+
+;;;; Separator detection
+
+(ert-deftest markdown-table-wrap-pretty-test-separator-gfm ()
+ "A plain GFM separator row is recognized."
+ (should (markdown-table-wrap-pretty--separator-line-p "|---|---|")))
+
+(ert-deftest markdown-table-wrap-pretty-test-separator-gfm-aligned ()
+ "A GFM separator with alignment colons is recognized."
+ (should (markdown-table-wrap-pretty--separator-line-p "|:--|:-:|--:|")))
+
+(ert-deftest markdown-table-wrap-pretty-test-separator-org ()
+ "An org separator with `+' joins is recognized."
+ (should (markdown-table-wrap-pretty--separator-line-p "|---+---|")))
+
+(ert-deftest markdown-table-wrap-pretty-test-separator-not-a-row ()
+ "A data row is not a separator."
+ (should-not (markdown-table-wrap-pretty--separator-line-p "| a | b |")))
+
+;;;; Org normalization
+
+(ert-deftest markdown-table-wrap-pretty-test-normalize-strips-cookies ()
+ "Org width cookies are stripped regardless of major mode."
+ (should (equal "| | |"
+ (markdown-table-wrap-pretty--normalize-org-line
+ "| | <5> |"))))
+
+(ert-deftest markdown-table-wrap-pretty-test-normalize-org-hline-in-org ()
+ "In `org-mode' the `+' join in a separator becomes GFM `|'."
+ (with-temp-buffer
+ (org-mode)
+ (should (equal "|---|---|"
+ (markdown-table-wrap-pretty--normalize-org-line
+ "|---+---|")))))
+
+(ert-deftest markdown-table-wrap-pretty-test-normalize-gfm-sep-unchanged ()
+ "An already-GFM separator is left untouched in `org-mode'."
+ (with-temp-buffer
+ (org-mode)
+ (should (equal "|---|---|"
+ (markdown-table-wrap-pretty--normalize-org-line
+ "|---|---|")))))
+
+;;;; Parsing
+
+(ert-deftest markdown-table-wrap-pretty-test-parse-gfm ()
+ "Parse a GFM table into headers, aligns, and rows."
+ (should (equal '(("A" "B") (nil nil) (("1" "2")))
+ (markdown-table-wrap-pretty--parse-table
+ (split-string "| A | B |\n|---|---|\n| 1 | 2 |" "\n")))))
+
+(ert-deftest markdown-table-wrap-pretty-test-parse-org-hline-cookie ()
+ "Parse an org table, normalizing its hline and stripping cookies."
+ (with-temp-buffer
+ (org-mode)
+ (should (equal '(("A" "B") (nil nil) (("1" "2")))
+ (markdown-table-wrap-pretty--parse-table
+ (split-string "| A | B |\n|---+---|\n| 1 | 2 |" "\n"))))))
+
+(ert-deftest markdown-table-wrap-pretty-test-parse-no-separator ()
+ "A block without a separator row parses to nil."
+ (should-not (markdown-table-wrap-pretty--parse-table
+ (split-string "| a | b |\n| c | d |" "\n"))))
+
+(ert-deftest markdown-table-wrap-pretty-test-parse-separator-first ()
+ "A block whose first line is the separator (no header) parses to nil."
+ (should-not (markdown-table-wrap-pretty--parse-table
+ (split-string "|---|---|\n| 1 | 2 |" "\n"))))
+
+;;;; Separator-line rendering
+
+(ert-deftest markdown-table-wrap-pretty-test-render-separator-plain ()
+ "Plain separator renders standard markdown dashes."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (should (equal "| - | - |"
+ (markdown-table-wrap-pretty--render-separator-line
+ '(1 1) '(nil nil))))))
+
+(ert-deftest markdown-table-wrap-pretty-test-render-separator-aligned ()
+ "Plain separator preserves GFM alignment colons."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (should (equal "| :-- | --: | :-: |"
+ (markdown-table-wrap-pretty--render-separator-line
+ '(3 3 3) '(left right center))))))
+
+(ert-deftest markdown-table-wrap-pretty-test-render-separator-pretty ()
+ "Pretty separator renders a Unicode box-drawing rule."
+ (markdown-table-wrap-pretty-test--with-prettify t
+ (should (equal "├───┼───┤"
+ (markdown-table-wrap-pretty--render-separator-line
+ '(1 1) '(nil nil))))))
+
+;;;; Row rendering
+
+(ert-deftest markdown-table-wrap-pretty-test-render-row-plain ()
+ "A single short row renders as one plain markdown line."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (should (equal '("| A | B |")
+ (markdown-table-wrap-pretty--render-row-lines
+ '("A" "B") '(1 1) '(nil nil))))))
+
+(ert-deftest markdown-table-wrap-pretty-test-render-row-pretty ()
+ "A single short row renders with Unicode verticals when prettify is on."
+ (markdown-table-wrap-pretty-test--with-prettify t
+ (should (equal '("│ A │ B │")
+ (markdown-table-wrap-pretty--render-row-lines
+ '("A" "B") '(1 1) '(nil nil))))))
+
+(ert-deftest markdown-table-wrap-pretty-test-render-row-right-align ()
+ "Right alignment pads on the left."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (should (equal '("| A |")
+ (markdown-table-wrap-pretty--render-row-lines
+ '("A") '(3) '(right))))))
+
+(ert-deftest markdown-table-wrap-pretty-test-render-row-center-align ()
+ "Center alignment splits padding, favoring the left on odd widths."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (should (equal '("| A |")
+ (markdown-table-wrap-pretty--render-row-lines
+ '("A") '(3) '(center))))))
+
+(ert-deftest markdown-table-wrap-pretty-test-render-row-wraps ()
+ "A cell wider than its column wraps across multiple visual rows."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (should (equal '("| hello |" "| world |")
+ (markdown-table-wrap-pretty--render-row-lines
+ '("hello world") '(5) '(nil nil))))))
+
+;;;; Inline-span styling
+
+(ert-deftest markdown-table-wrap-pretty-test-span-bold ()
+ "`**x**' folds to the text in the `bold' face."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "**bold**")))
+ (should (equal "bold" (substring-no-properties r)))
+ (should (eq 'bold (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-italic ()
+ "`*x*' folds to the text in the `italic' face."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "*it*")))
+ (should (equal "it" (substring-no-properties r)))
+ (should (eq 'italic (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-bold-italic ()
+ "`***x***' folds before `**x**' and uses `bold-italic'."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "***bi***")))
+ (should (equal "bi" (substring-no-properties r)))
+ (should (eq 'bold-italic (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-strikethrough ()
+ "`~~x~~' folds to the text with a strike-through face."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "~~st~~")))
+ (should (equal "st" (substring-no-properties r)))
+ (should (equal '(:strike-through t) (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-code-single ()
+ "A single-backtick code span uses the code face."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "`code`")))
+ (should (equal "code" (substring-no-properties r)))
+ (should (eq 'markdown-table-wrap-pretty-code-face
+ (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-code-double ()
+ "A double-backtick code span trims one surrounding space (CommonMark)."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "`` c d ``")))
+ (should (equal "c d" (substring-no-properties r)))
+ (should (eq 'markdown-table-wrap-pretty-code-face
+ (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-link ()
+ "`[text](url)' folds to the text in the `link' face."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "[t](u)")))
+ (should (equal "t" (substring-no-properties r)))
+ (should (eq 'link (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-image ()
+ "`' folds to the alt text in the `link' face."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "")))
+ (should (equal "a" (substring-no-properties r)))
+ (should (eq 'link (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-escaped-pipe ()
+ "An escaped pipe `\\|' unescapes to a literal pipe (GFM table level)."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "a \\| b")))
+ (should (equal "a | b" (substring-no-properties r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-code-literal-markup ()
+ "Markup inside a code span is literal, not folded."
+ (let ((r (markdown-table-wrap-pretty-render-inline-spans "`**x**`")))
+ (should (equal "**x**" (substring-no-properties r)))
+ (should (eq 'markdown-table-wrap-pretty-code-face
+ (get-text-property 0 'face r)))))
+
+(ert-deftest markdown-table-wrap-pretty-test-span-in-cell-passthrough ()
+ "With `prettify' nil, cells pass through unchanged (valid source)."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (should (equal "**bold**"
+ (markdown-table-wrap-pretty--render-inline-spans-in-cell
+ "**bold**")))))
+
+;;;; Display groups (prefix-aware)
+
+(ert-deftest markdown-table-wrap-pretty-test-display-groups-plain-fits ()
+ "A plain table that fits yields one group per source line."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (should (= 3 (length
+ (markdown-table-wrap-pretty--table-display-groups
+ (split-string "| A | B |\n|---|---|\n| 1 | 2 |" "\n")
+ 80))))))
+
+(ert-deftest markdown-table-wrap-pretty-test-display-groups-blockquote-prefix ()
+ "A blockquote prefix is repeated on every wrapped continuation line."
+ (markdown-table-wrap-pretty-test--with-prettify nil
+ (let* ((lines (split-string
+ "> | Name | Age |\n> |------|-----|\n> | Alexander | 30 |"
+ "\n"))
+ (groups (markdown-table-wrap-pretty--table-display-groups lines 14))
+ (all-lines (apply #'append groups)))
+ (should all-lines)
+ (should (cl-every (lambda (l) (string-prefix-p "> " l)) all-lines)))))
+
+(provide 'markdown-table-wrap-pretty-test)
+;;; markdown-table-wrap-pretty-test.el ends here