diff --git a/markdown-table-wrap.el b/markdown-table-wrap.el index 3578b75..f3b0155 100644 --- a/markdown-table-wrap.el +++ b/markdown-table-wrap.el @@ -63,7 +63,10 @@ ;; - Graceful degradation: when a column is too narrow for markup ;; overhead, markers are dropped and inner text is wrapped as ;; plain text, preserving legibility over formatting. -;; - Proportional column-width allocation based on content. +;; - Waterfill column-width allocation: wider columns get more space +;; (proportional to sqrt of content width) but the effect is +;; dampened so narrow columns aren't starved. Monotonic — widening +;; the terminal never shrinks any column. ;; - Alignment preservation (left, right, center). ;; - Optional cell-height cap with ellipsis truncation. ;; - Automatic row separators for visual breathing room when wrapping @@ -98,8 +101,7 @@ "When non-nil, strip inline markdown syntax before measuring width. This variable controls all width measurement in the package: `markdown-table-wrap-visible-width', `markdown-table-wrap-cell', -`markdown-table-wrap--pad-cell', and `markdown-table-wrap--longest-word-width' -all read it. +and `markdown-table-wrap--pad-cell' all read it. The main entry point `markdown-table-wrap' binds this via `let' based on its STRIP-MARKUP argument. If you call lower-level public @@ -308,21 +310,6 @@ Width measurement respects `markdown-table-wrap--strip-markup'." (make-string (- padding-needed left-pad) ?\s)))) (_ (concat cell (make-string padding-needed ?\s))))))) -(defun markdown-table-wrap--longest-word-width (s &optional max-width) - "Return width of the longest whitespace-delimited word in S. -When `markdown-table-wrap--strip-markup' is non-nil, strip markdown -markup before splitting and measuring. Otherwise, use raw text. -Width measurement uses `markdown-table-wrap--display-width' for -VS16 emoji correction. Cap at MAX-WIDTH if given." - (let* ((measured (if markdown-table-wrap--strip-markup - (markdown-table-wrap-strip-markup s) - s)) - (words (split-string measured "[ \t]+" t)) - (longest 0)) - (dolist (w words) - (setq longest (max longest (markdown-table-wrap--display-width w)))) - (if max-width (min longest max-width) longest))) - (defun markdown-table-wrap--tokenize-cell-text (text) "Split TEXT into tokens, keeping markdown markup spans intact. Return a list of strings. Plain text is split on whitespace. @@ -788,8 +775,8 @@ Column widths are stored internally as vectors for O(1) random access (cf. `shr-table-widths' in shr.el). The return value is a plain list for callers that iterate with `dolist' or `cl-mapcar'. -Proportional shrinking distributes available space based on each -column's grow potential (natural width minus minimum word width). +Shrinking uses a waterfill algorithm with sqrt-dampened weights — +see `markdown-table-wrap-distribute-widths'. This is a convenience wrapper around `markdown-table-wrap-compute-table-metrics' and `markdown-table-wrap-distribute-widths'. When rendering the same table at @@ -802,114 +789,101 @@ the width-independent metrics." (defun markdown-table-wrap-compute-table-metrics (headers rows num-cols) "Compute width-independent column metrics for HEADERS and ROWS. NUM-COLS is the number of columns. -Return a plist (:natural-widths VEC :min-word-widths VEC) where each -vector has NUM-COLS elements. - -Natural widths are the maximum visible width of any cell in each column. -Minimum word widths are the longest unbreakable word in each column, -capped at 30 characters. +Return a plist (:natural-widths VEC) where the vector has NUM-COLS +elements. Each element is the maximum visible width of any cell +in that column. These metrics depend only on cell content, not on the target rendering width, so they can be computed once and reused across multiple calls to `markdown-table-wrap-distribute-widths'." - (let ((max-unbroken-word-width 30) - (natural-widths (make-vector num-cols 0)) - (min-word-widths (make-vector num-cols 1))) + (let ((natural-widths (make-vector num-cols 0))) ;; Measure headers (cl-loop for cell in headers for i from 0 below num-cols do (aset natural-widths i (max (aref natural-widths i) - (markdown-table-wrap-visible-width cell))) - (aset min-word-widths i - (max (aref min-word-widths i) - (markdown-table-wrap--longest-word-width - cell max-unbroken-word-width)))) + (markdown-table-wrap-visible-width cell)))) ;; Measure data rows (dolist (row rows) (cl-loop for cell in row for i from 0 below num-cols do (aset natural-widths i (max (aref natural-widths i) - (markdown-table-wrap-visible-width cell))) - (aset min-word-widths i - (max (aref min-word-widths i) - (markdown-table-wrap--longest-word-width - cell max-unbroken-word-width))))) - (list :natural-widths natural-widths - :min-word-widths min-word-widths))) + (markdown-table-wrap-visible-width cell))))) + (list :natural-widths natural-widths))) (defun markdown-table-wrap-distribute-widths (metrics available-width num-cols) "Distribute AVAILABLE-WIDTH across columns using pre-computed METRICS. METRICS is a plist as returned by `markdown-table-wrap-compute-table-metrics'. NUM-COLS is the number of columns. -Return a list of column widths (integers)." +Return a list of column widths (integers). + +When all columns fit at their natural width, return those widths +unchanged. Otherwise use a waterfill algorithm with sqrt-dampened +weights: find the unique level L such that + + sum_i min(nat_i, L * sqrt(nat_i)) = budget + +then round via Webster/Sainte-Laguë priorities. This is +monotonic — widening the terminal never shrinks any column." (let* ((natural-widths (plist-get metrics :natural-widths)) - (min-word-widths (plist-get metrics :min-word-widths)) (border-overhead (+ (* 3 num-cols) 1)) - (available-for-cells (- available-width border-overhead)) - (min-col-w (copy-sequence min-word-widths)) - (min-cells-width (markdown-table-wrap--vsum min-word-widths))) - (when (> min-cells-width available-for-cells) - (setq min-col-w (make-vector num-cols 1)) - (let ((remaining (- available-for-cells num-cols))) - (when (> remaining 0) - (let ((total-weight - (cl-loop for i below num-cols - sum (max 0 (1- (aref min-word-widths i)))))) - (dotimes (i num-cols) - (let* ((weight (max 0 (1- (aref min-word-widths i)))) - (growth (if (> total-weight 0) - (floor (* (/ (float weight) total-weight) - remaining)) - 0))) - (aset min-col-w i (+ (aref min-col-w i) growth)))) - ;; Distribute rounding leftovers - (let* ((allocated (- (markdown-table-wrap--vsum min-col-w) num-cols)) - (leftover (- remaining allocated))) + (available (max num-cols (- available-width border-overhead))) + (total-natural (+ (markdown-table-wrap--vsum natural-widths) + border-overhead))) + (if (<= total-natural available-width) + (append natural-widths nil) + (let* ((col-w (make-vector num-cols 0)) + ;; Precompute sqrt weights. + (weights (vconcat + (cl-loop for i below num-cols + collect (sqrt (max 1.0 + (float (aref natural-widths i))))))) + ;; Sort column indices by ascending weight (ascending + ;; nat/weight ratio) so the analytical scan can peel + ;; off capped columns from the small end first. + (order (sort (number-sequence 0 (1- num-cols)) + (lambda (a b) + (< (aref weights a) (aref weights b))))) + ;; Find the continuous waterfill level. + (rem-budget (float available)) + (rem-weight (cl-loop for i below num-cols + sum (aref weights i))) + (level 1.0e10)) + (dolist (i order) + (let ((candidate (/ rem-budget (max 1.0e-9 rem-weight)))) + (cond + ((<= candidate 0.0) + (setq level 0.0)) + ((>= (* candidate (aref weights i)) + (aref natural-widths i)) + ;; Column i is capped at natural — remove from pool. + (setq rem-budget (- rem-budget (float (aref natural-widths i)))) + (setq rem-weight (- rem-weight (aref weights i)))) + (t + ;; Found the level; all remaining columns are uncapped. + (setq level candidate))))) + ;; Continuous allocations → floor, respecting bounds [1, nat]. + (dotimes (i num-cols) + (aset col-w i + (min (aref natural-widths i) + (max 1 (floor (* level (aref weights i))))))) + ;; Distribute rounding remainder via Webster priorities: + ;; priority_i = weight_i / (2 * current_i + 1). + (let ((diff (- available (markdown-table-wrap--vsum col-w)))) + (while (> diff 0) + (let ((best-i -1) (best-pri -1.0)) (dotimes (i num-cols) - (when (> leftover 0) - (aset min-col-w i (1+ (aref min-col-w i))) - (setq leftover (1- leftover)))))))) - (setq min-cells-width (markdown-table-wrap--vsum min-col-w))) - ;; Compute final column widths - (let ((total-natural (+ (markdown-table-wrap--vsum natural-widths) - border-overhead))) - (if (<= total-natural available-width) - ;; Everything fits naturally - (cl-loop for i below num-cols - collect (max (aref natural-widths i) - (aref min-col-w i))) - ;; Need to shrink: distribute extra space proportional to grow potential - (let* ((total-grow-potential - (cl-loop for i below num-cols - sum (max 0 (- (aref natural-widths i) - (aref min-col-w i))))) - (extra-width (max 0 (- available-for-cells min-cells-width))) - (col-w (make-vector num-cols 0))) - (dotimes (i num-cols) - (let* ((min-w (aref min-col-w i)) - (nat (aref natural-widths i)) - (delta (max 0 (- nat min-w))) - (grow (if (> total-grow-potential 0) - (floor (* (/ (float delta) - total-grow-potential) - extra-width)) - 0))) - (aset col-w i (+ min-w grow)))) - (let ((remaining (- available-for-cells - (markdown-table-wrap--vsum col-w)))) - ;; Give remaining space to columns still under natural width - (while (> remaining 0) - (let ((grew nil)) - (dotimes (i num-cols) - (when (and (> remaining 0) - (< (aref col-w i) (aref natural-widths i))) - (aset col-w i (1+ (aref col-w i))) - (setq remaining (1- remaining)) - (setq grew t))) - (unless grew (setq remaining 0)))) - (append col-w nil))))))) + (when (< (aref col-w i) (aref natural-widths i)) + (let ((pri (/ (aref weights i) + (1+ (* 2.0 (float (aref col-w i))))))) + (when (> pri best-pri) + (setq best-i i best-pri pri))))) + (if (>= best-i 0) + (progn (aset col-w best-i (1+ (aref col-w best-i))) + (setq diff (1- diff))) + (setq diff 0))))) + (append col-w nil))))) ;;;; Code Fence Detection diff --git a/test/markdown-table-wrap-test.el b/test/markdown-table-wrap-test.el index b8cd4fc..fedea5b 100644 --- a/test/markdown-table-wrap-test.el +++ b/test/markdown-table-wrap-test.el @@ -214,7 +214,7 @@ Each ☺️ sequence is display-width 2 via our VS16 correction." ;;;; Table Metrics (ert-deftest markdown-table-wrap-test-compute-metrics-returns-vectors () - "Table metrics returns natural-widths and min-word-widths as vectors." + "Table metrics returns natural-widths as a vector." (let* ((text "| Foo | Bar |\n|---|---|\n| Hello world | Baz |") (parsed (markdown-table-wrap-parse text)) (headers (nth 0 parsed)) @@ -222,9 +222,7 @@ Each ☺️ sequence is display-width 2 via our VS16 correction." (metrics (markdown-table-wrap-compute-table-metrics headers rows (length headers)))) (should (vectorp (plist-get metrics :natural-widths))) - (should (vectorp (plist-get metrics :min-word-widths))) - (should (= (length (plist-get metrics :natural-widths)) 2)) - (should (= (length (plist-get metrics :min-word-widths)) 2)))) + (should (= (length (plist-get metrics :natural-widths)) 2)))) (ert-deftest markdown-table-wrap-test-metrics-produce-same-widths () "Using pre-computed metrics produces identical column widths." @@ -772,6 +770,82 @@ subtract marker overhead (which is invisible)." (should (> (nth 0 widths) 3)) (should (> (nth 1 widths) 3)))) +;; Shared test fixture: 4-column table with diverse natural widths. +;; Natural widths: [46 22 29 85]. +(defconst markdown-table-wrap-test--wide-headers + '("App" "Model" "Candidate" "Rec")) +(defconst markdown-table-wrap-test--wide-rows + '(("Basic validation agents (basic_epd, basic_lca)" + "gemini-3-flash-preview" + "gemini-3.1-flash-lite-preview" + "Test via eval - could reduce cost for simple QA, but verify accuracy does not regress"))) + +(ert-deftest markdown-table-wrap-test-widths-content-rich-column-gets-more () + "A column with more content gets proportionally more width. +Sqrt-proportional allocation ensures that a column with 85 chars +of wrappable prose gets more space than one with only 22 chars, +even when the narrower column contains a long unbreakable token." + (let ((widths (markdown-table-wrap-compute-widths + markdown-table-wrap-test--wide-headers + markdown-table-wrap-test--wide-rows + 100 4))) + ;; Rec (natural=85) must get more width than Model (natural=22). + (should (> (nth 3 widths) (nth 1 widths))))) + +(ert-deftest markdown-table-wrap-test-widths-small-columns-favored () + "Small columns receive proportionally more of their natural width. +Sqrt dampening naturally favors narrow columns: a column with +natural width 1 keeps its full width, and a date column (nat=10) +retains most of it even when wider columns are heavily compressed." + (let ((widths (markdown-table-wrap-compute-widths + '("#" "Task" "Due" "Notes") + '(("1" "Implement authentication flow with SSO" "2026-03-20" + "Blocked on identity provider configuration")) + 60 4))) + ;; "#" (nat=1) should keep its full width — it's already minimal. + (should (= (nth 0 widths) 1)) + ;; "Due" (nat=10) should keep most of its width (>= 80%). + (should (>= (nth 2 widths) 8)) + ;; "Task" and "Notes" are wider and should be compressed more. + (should (< (/ (float (nth 1 widths)) 39.0) + (/ (float (nth 2 widths)) 10.0))))) + +(ert-deftest markdown-table-wrap-test-widths-sum-uses-full-budget () + "When shrinking, column widths sum to exactly the cell budget. +Border overhead is 3×num-cols+1. No space should be wasted when +columns are compressed to fit." + (dolist (available '(60 80 100 120)) + (let* ((num-cols 4) + (widths (markdown-table-wrap-compute-widths + markdown-table-wrap-test--wide-headers + markdown-table-wrap-test--wide-rows + available num-cols)) + (border-overhead (+ (* 3 num-cols) 1)) + (cell-budget (- available border-overhead))) + (should (= (apply #'+ widths) cell-budget))))) + +(ert-deftest markdown-table-wrap-test-widths-monotonic () + "Every column width is monotonically non-decreasing as width grows. +Widening the terminal must never shrink any column." + (dolist (nats '((2 12 11 93) (46 22 29 85) + (4 22 7 6 5 10 10 35) (18 30 16 29))) + (let* ((headers (cl-loop for i below (length nats) + collect (format "C%d" i))) + (row (make-list (length nats) "x")) + (num-cols (length nats)) + (prev nil)) + ;; Fake natural widths by using cells of exactly those widths. + (setq row (cl-loop for n in nats + collect (make-string n ?x))) + (cl-loop for w from 20 to 150 do + (let ((widths (markdown-table-wrap-compute-widths + headers (list row) w num-cols))) + (when prev + (dotimes (i num-cols) + (should-not + (< (nth i widths) (nth i prev))))) + (setq prev widths)))))) + (ert-deftest markdown-table-wrap-test-widths-extremely-narrow () "Extreme narrowing gives at least 1 char per column." (let ((widths (markdown-table-wrap-compute-widths @@ -1082,30 +1156,6 @@ All non-separator lines should have equal display width." (dolist (w (cdr widths)) (should (= w expected-w))))))) -;;;; Longest Word Width - -(ert-deftest markdown-table-wrap-test-longest-word-width-default-no-strip () - "Longest word width uses raw width by default (no stripping)." - (should (= (markdown-table-wrap--longest-word-width "hello world") 5)) - (should (= (markdown-table-wrap--longest-word-width "**bold** normal") 8)) - (should (= (markdown-table-wrap--longest-word-width - "[link](http://example.com)") 26)) - (should (= (markdown-table-wrap--longest-word-width "") 0))) - -(ert-deftest markdown-table-wrap-test-longest-word-width-with-strip () - "Longest word width strips markup when strip-markup is t." - (let ((markdown-table-wrap--strip-markup t)) - (should (= (markdown-table-wrap--longest-word-width "hello world") 5)) - (should (= (markdown-table-wrap--longest-word-width "**bold** normal") 6)) - (should (= (markdown-table-wrap--longest-word-width - "[link](http://example.com)") 4)) - (should (= (markdown-table-wrap--longest-word-width "") 0)))) - -(ert-deftest markdown-table-wrap-test-longest-word-width-capped () - "Longest word width respects the max-width cap." - (should (= (markdown-table-wrap--longest-word-width - "superlongword" 5) 5))) - ;;;; Truncate Cell Lines (ert-deftest markdown-table-wrap-test-truncate-cell-lines-no-mutation () @@ -1668,12 +1718,11 @@ they are wider by design (preserving markup integrity)." (ert-info ((format "T%d w%d: %d separators" tbl-idx width sep-count)) (should (= sep-count 1))))))))) -(ert-deftest markdown-table-wrap-test-e2e-overflow-is-markup () +(ert-deftest markdown-table-wrap-test-e2e-overflow-is-unbreakable () "Lines that exceed the target width do so only because of unbreakable -markup tokens (links, images, bold-italic spans). Verify that every -overflow line contains at least one recognized markup span whose -visible width exceeds the column allocation. This ensures overflow -is intentional (preserving markup) rather than a padding/alignment bug." +content: markup spans (links, images, bold-italic) or wide characters +(CJK) whose display width exceeds the column allocation. This ensures +overflow is intentional rather than a padding/alignment bug." (let ((tables (markdown-table-wrap-test--fixture-tables)) (widths markdown-table-wrap-test--e2e-widths)) (cl-loop for tbl in tables for tbl-idx from 1 do @@ -1683,23 +1732,25 @@ is intentional (preserving markup) rather than a padding/alignment bug." (lines (split-string wrapped "\n"))) (cl-loop for line in lines for line-no from 1 do (when (> (string-width line) width) - ;; At least one cell must have a recognized markup token (let* ((cells (markdown-table-wrap-test--extract-cells line)) - (has-markup + (has-unbreakable (cl-some (lambda (cell) (let ((trimmed (string-trim cell))) (and (> (length trimmed) 0) - (cl-some - (lambda (tok) - (markdown-table-wrap--markup-span-parts tok)) - (markdown-table-wrap--tokenize-cell-text - trimmed))))) + (or (cl-some + (lambda (tok) + (markdown-table-wrap--markup-span-parts tok)) + (markdown-table-wrap--tokenize-cell-text + trimmed)) + (cl-some + (lambda (ch) (> (char-width ch) 1)) + trimmed))))) cells))) - (ert-info ((format "T%d w%d L%d: overflow w/o markup, width=%d" + (ert-info ((format "T%d w%d L%d: overflow w/o cause, width=%d" tbl-idx width line-no (string-width line))) - (should has-markup))))))))))) + (should has-unbreakable))))))))))) (ert-deftest markdown-table-wrap-test-e2e-height-cap () "When max-cell-height is set, no logical row exceeds that many lines."