From 4ff32cface97bd79ad0fe0274f9edee8aca63a48 Mon Sep 17 00:00:00 2001 From: Sayre Blades Date: Wed, 5 Aug 2026 21:15:59 -0400 Subject: [PATCH 1/2] Add markdown-table-wrap-pretty: display-only pretty rendering of pipe tables Add a new, opt-in package (separate from the pure wrapping engine) that renders markdown/org pipe tables as a wrapped, Unicode box-drawing view ON TOP OF the canonical raw text, and toggles between that view and the editable source in place -- the table analogue of `org-latex-preview'. The buffer is never mutated, so export/copy/share/push always see a valid single-row-per-record table; the pretty rendering is a disposable display layer (per-line overlays, auto-revealed on edit via modification-hooks, losslessly re-rendered on window resize). markdown-table-wrap.el is unchanged; the new file depends only on its public API. GFM and org are both supported (org separators and width cookies are normalized for rendering only). Adds an ERT suite for the pure helpers and wires the Makefile/README for the second package. --- Makefile | 49 +- README.org | 90 ++- markdown-table-wrap-pretty.el | 888 ++++++++++++++++++++++++ test/markdown-table-wrap-pretty-test.el | 256 +++++++ 4 files changed, 1263 insertions(+), 20 deletions(-) create mode 100644 markdown-table-wrap-pretty.el create mode 100644 test/markdown-table-wrap-pretty-test.el diff --git a/Makefile b/Makefile index 6bf5f71..174cd49 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,27 @@ 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..8a2faae --- /dev/null +++ b/markdown-table-wrap-pretty.el @@ -0,0 +1,888 @@ +;;; 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 +;; Version: 0.1.0 +;; Package-Requires: ((emacs "28.1") (markdown-table-wrap "0.2.0")) +;; 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)))) + ((or (derived-mode-p 'markdown-mode) (eq major-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](url)' -> `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 ![alt](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 ; 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 () + "`![alt](url)' folds to the alt text in the `link' face." + (let ((r (markdown-table-wrap-pretty-render-inline-spans "![a](u)"))) + (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 From 01c0538a81685e75efc84d99cafaf2febed0438c Mon Sep 17 00:00:00 2001 From: Sayre Blades Date: Wed, 5 Aug 2026 22:43:22 -0400 Subject: [PATCH 2/2] Co-package as one MELPA package (fix melpazoid) The MELPA recipe builds this repo as a single package (`markdown-table-wrap', main file markdown-table-wrap.el) containing both .el files. Treat the display companion accordingly so melpazoid is green: - Drop the per-file Version and Package-Requires from markdown-table-wrap-pretty.el: in a single package only the main file declares those, and the companion's `(require 'markdown-table-wrap)' is satisfied intra-package. (If split into a separate MELPA package later, re-add its Package-Requires.) - Makefile: set package-lint-main-file so local `make lint-package' matches melpazoid's single-package model (resolves "Package-Requires outside the main file have no effect"). - Use (derived-mode-p 'markdown-mode 'md-ts-mode) instead of (or (derived-mode-p 'markdown-mode) (eq major-mode 'md-ts-mode)) (melpazoid style note). make check (compile + checkdoc + package-lint + tests) green. --- Makefile | 1 + markdown-table-wrap-pretty.el | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 174cd49..a0d2c02 100644 --- a/Makefile +++ b/Makefile @@ -82,6 +82,7 @@ lint-package: --eval "(unless (package-installed-p 'package-lint) \ (package-install 'package-lint))" \ --eval "(require 'package-lint)" \ + --eval "(setq package-lint-main-file \"markdown-table-wrap.el\")" \ -f package-lint-batch-and-exit $(SOURCES) check-parens: diff --git a/markdown-table-wrap-pretty.el b/markdown-table-wrap-pretty.el index 8a2faae..aa5b603 100644 --- a/markdown-table-wrap-pretty.el +++ b/markdown-table-wrap-pretty.el @@ -4,8 +4,6 @@ ;; Author: SayreBlades ;; URL: https://github.com/dnouri/markdown-table-wrap -;; Version: 0.1.0 -;; Package-Requires: ((emacs "28.1") (markdown-table-wrap "0.2.0")) ;; Keywords: convenience, wp, markdown, org, tables ;; SPDX-License-Identifier: GPL-3.0-or-later @@ -250,7 +248,7 @@ Accept both GFM (`|---|---|') and org (`|---+---|') separators." ((derived-mode-p 'org-mode) (and (fboundp 'org-in-src-block-p) (save-excursion (goto-char pos) (org-in-src-block-p)))) - ((or (derived-mode-p 'markdown-mode) (eq major-mode 'md-ts-mode)) + ((derived-mode-p 'markdown-mode 'md-ts-mode) (markdown-table-wrap-inside-code-fence-p pos)) (t nil)))