;;; ftable.el --- Fill a table to fit in n columns -*- lexical-binding: t; -*- ;; Copyright (C) 2019-2021 Free Software Foundation, Inc. ;; Author: Yuan Fu ;; Maintainer: Yuan Fu ;; URL: https://github.com/casouri/ftable ;; Version: 1.1 ;; Keywords: convenience, text, table ;; Package-Requires: ((emacs "26.0")) ;; This file is part of GNU Emacs. ;; GNU Emacs 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. ;; GNU Emacs 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 GNU Emacs. If not, see . ;;; Commentary: ;; ;; This package provides some convenient commands for filling a table, ;; i.e., adjusting the layout of the table so it can fit in n columns. ;; ;; Commands provided: ;; ;; - ftable-fill Fill the table at point ;; - ftable-reformat Change the style of the table. For example, from ;; ASCII +--+--+ to Unicode ┌──┬──┐ ;; | | | │ │ │ ;; +--+--+ └──┴──┘ ;; - ftable-edit-cell Edit the cell at point ;; ;; There are some limitations. Currently ftable doesn’t support tables ;; with compound cells (cells that span multiple rows/columns) because ;; they are more complicated to handle. If the need arises in the ;; future (unlikely), I might improve ftable to handle more complex ;; tables. Also, after filling, any manual line-break in a cell is ;; discarded. ;; ;; Customization: ;; ;; - ftable-fill-column ;;; Code: (require 'cl-lib) (require 'cl-generic) (require 'pcase) ;; (require 'fill) fill.el doesn’t have a provide form. ;;; Customization (defgroup ftable nil "Fill (auto-layout) tables." :group 'text) (defcustom ftable-fill-column fill-column "Basically `fill-column' for ftable." :local t :type 'number) ;; KLUDGE: There seems to be a bug preventing ftable-fill-column to be ;; set. (#44911) (setq ftable-fill-column fill-column) ;;; Table structure (cl-defstruct ftable "A table. COLUMN-WIDTH-LIST A list that records the width (in characters) of each column. MIN-WIDTH-LIST A list that records the minimum width (in characters) of each column. CELL-MATRIX A list of list of strings. Each string is a cell. Cells don’t contain newlines. Each cell is a string, the cell doesn’t contain newlines. Column width can be smaller than the string length of a cell, in which case means the line cell is filled to that width." column-width-list min-width-list matrix) (cl-deftype ftable-cell () '(satisfies stringp)) (cl-defmethod ftable--row-count ((table ftable)) "Return the number of rows in TABLE." (length (ftable-matrix table))) (cl-defmethod ftable--column-count ((table ftable)) "Return the number of columns in TABLE." (length (car (ftable-matrix table)))) ;;; Parse ;; ;; Transforming between text and table structure (defvar ftable-box-charset-alist '((ascii . " +-++ | || +-++ +-++") (unicode . " ┌─┬┐ │ ││ ├─┼┤ └─┴┘")) "An alist of (NAME . CHARSET). A charset tells ftable how to parse the table. I.e., what are the box drawing characters to use. Don’t forget the first newline. NAME is the mnemonic for that charset.") (defun ftable-box-char (code charset) "Return a specific box drawing character in CHARSET. Return a string. CHARSET should be like `ftable-box-char-set'. Mapping between CODE and position: ┌┬┐ 123 ├┼┤ <-> 456 └┴┘ 789 ┌─┐ 1 H 3 H: horizontal │ │ <-> V V V: vertical └─┘ 7 H 9 Examples: (ftable-box-char 'h charset) => \"─\" (ftable-box-char 2 charset) => \"┬\"" (let ((index (pcase code ('h 10) ('v 11) ('n 12) ('s 13) (_ code)))) (char-to-string (aref charset ; 1 2 3 4 5 6 7 8 9 H V N S (nth index '(nil 1 3 4 11 13 14 16 18 19 2 6 0 7)))))) ;; Check `ftable-box-char' with the following form, you should see: ;; ┌─┬┐ ;; │ ││ ;; ├─┼┤ ;; └─┴┘ ;; (dolist (code '( ;; 1 h 2 3 n ;; v s v v n ;; 4 h 5 6 n ;; 7 h 8 9 n)) ;; (insert (ftable-box-char code (cdadr ftable-box-charset-alist)))) (define-error 'ftable-parse-error "Error parsing table") (cl-defmethod ftable--parse-to ((table-type (eql ftable)) text &optional box-charset) "Parse TEXT into a table of TABLE-TYPE. For BOX-CHARSET, see documentation of `ftable-box-charset-alist'. It defaults to the first charset." (ignore table-type) ;; TODO Handle parse error. (let ((charset (or box-charset (cdar ftable-box-charset-alist))) line-list matrix buffer) ;; TEXT: ;; ┌──┬─┐ ;; │ab│c│ ;; ├──┼─┤ ;; │de│f│ ;; │gh│i│ ;; └──┴─┘ ;; 1. Split into lines. (setq line-list (split-string text "\n")) ;; LINE-LIST: ;; ("┌──┬─┐" "│ab│c│" "├──┼─┤" "│de│f│" "│gh│i│" "└──┴─┘") ;; ;; 2. Group each line into columns. (dolist (line line-list) (setq line (string-trim line)) (if (or (string-prefix-p (ftable-box-char 1 charset) line) (string-prefix-p (ftable-box-char 4 charset) line) (string-prefix-p (ftable-box-char 7 charset) line)) ;; Delimiter line, i.e. ┌──┬─┐, ├──┼─┤, etc. (progn (when buffer (push (reverse buffer) matrix)) (setq buffer nil)) (push (ftable--tokenize-line line charset) buffer))) (setq matrix (reverse matrix)) ;; Sanity check. (when (not (ftable--check-dimension matrix)) (signal 'ftable-parse-error '("Dimension mismatch"))) ;; MATRIX: ;; ((("ab" "c")) (("de" "f") ("gh" "i"))) ;; ;; 3. Merge lines that belongs to the same row. (setq matrix (mapcar #'ftable--merge-lines matrix)) ;; MATRIX: ;; (("ab" "c") ("de gh" "f i")) (make-ftable :column-width-list (mapcar (lambda (column) (apply #'max (mapcar #'string-width column))) (cl-loop for n from 0 to (1- (length (car matrix))) collect (ftable--nth-column n matrix))) :min-width-list (ftable--min-column-width matrix) :matrix matrix))) (defun ftable--check-dimension (matrix) "Check that the dimension of MATRIX is correct. Correct dimension means each row has the same number of columns. Return t if the dimension is correct, nil if not." (let* ((matrix (apply #'append matrix)) (first-row-column-count (length (car matrix)))) (cl-loop for row in (cdr matrix) if (not (eq first-row-column-count (length row))) return nil finally return t))) (defun ftable--tokenize-line (text-line box-charset) "Tokenize TEXT-LINE into a list of tokens. Each token belongs to a cell. I.e., (ftable--tokenize-line \"│a│b│c│\") => (\"a\" \"b\" \"c\") BOX-CHARSET is the same as in `ftable--parse-to'. Assumes each line begines with box drawing characters, i.e., no white space characters." (mapcar #'string-trim (split-string (string-trim text-line (ftable-box-char 'v box-charset) (ftable-box-char 'v box-charset)) (ftable-box-char 'v box-charset)))) (defun ftable--merge-lines (line-list) "Merge lines in LINE-LIST together. (ftable--merge-lines '((\"1\" \"2\" \"3\") (\"a\" \"b\" \"c\"))) => (\"1 a\" \"2 b\" \"3 c\") Assumes each line in LINE-LIST has the same length." (let (row) ;; Initialize ROW. (dotimes (_ (length (nth 0 line-list))) (push "" row)) ;; Append cell contents. (dolist (line line-list) (dotimes (col-idx (length line)) (setf (nth col-idx row) (concat (nth col-idx row) " " (nth col-idx line))))) (mapcar #'string-trim row))) (defun ftable--nth-column (n matrix) "Return the Nth column of MATRIX." (mapcar (lambda (row) (nth n row)) matrix)) (defun ftable--min-column-width (matrix) "Return the minimum width of each column in MATRIX." (with-temp-buffer (mapcar (lambda (column) (cl-loop for cell in column maximize (progn (erase-buffer) (insert cell) (let ((fill-column 1)) (fill-region-as-paragraph (point-min) (point-max))) (ftable--max-line-width)))) (cl-loop for col from 0 to (1- (length (car matrix))) collect (ftable--nth-column col matrix))))) ;;; Fill (cl-defmethod ftable--fill ((table ftable) table-max-width) "Return a new TABLE that fits in TABLE-MAX-WIDTH. Try to fit in TABLE-MAX-WIDTH, if not possible, return the mininum width table." (let ((desired-width table-max-width) table-height) (when (< table-max-width (ftable--min-width table)) (setq desired-width (ftable--min-width table))) ;; While we haven’t satisfied the requirement and there is still ;; room for improvement: (while (< desired-width (ftable--width table)) (setq table-height (ftable--height table)) (let ((candidate-list ;; A list of (delta of height . new table). (cl-loop for col = 0 then (1+ col) for col-width in (ftable-column-width-list table) for min-width in (ftable-min-width-list table) if (> col-width min-width) collect (let ((new-table (ftable--shrink-column table col 1))) (cons (- (ftable--height new-table) table-height) new-table))))) (if (= 0 (length candidate-list)) (debug)) (setq table (if (< (length candidate-list) 2) (cdar candidate-list) (cdr (cl-reduce ;; Find argmin(delta of height). (lambda (a b) (if (< (car a) (car b)) a b)) candidate-list)))))) table)) (cl-defmethod ftable--width ((table ftable)) "Return the width of TABLE in characters. This width includes all the box drawing characters." (let ((lst (ftable-column-width-list table))) (+ (apply #'+ lst) ;; Plus the horizontal bars. (1+ (length lst)) ;; Plus one space padding for each column. (1+ (length lst))))) (cl-defmethod ftable--min-width ((table ftable)) "Return the smallest possible width of TABLE." (let ((lst (ftable-min-width-list table))) (+ (apply #'+ lst) ;; Plus the horizontal bars. (1+ (length lst)) ;; Plus one space padding for each column. (1+ (length lst))))) (cl-defmethod ftable--height ((table ftable)) "Return the height of TABLE in chracters. This height includes all the box drawing characters." (let ((width-list (ftable-column-width-list table)) (matrix (ftable-matrix table))) (+ (cl-loop for row in matrix sum (ftable--row-height row width-list)) (1+ (length (ftable-matrix table)))))) (defun ftable--row-height (row column-width-list) "Return the height of ROW. Each cell in ROW is first filled according to COLUMN-WIDTH-LIST, then the height is calculated." (with-temp-buffer (cl-loop for col from 0 to (1- (length row)) ;; For each cell, fill the cell and count lines. maximize (let ((cell (nth col row)) (width (nth col column-width-list))) (erase-buffer) (insert cell) (let ((fill-column width)) (fill-region-as-paragraph (point-min) (point-max)) (count-lines (point-min) (point-max))))))) (cl-defmethod ftable--shrink-column ((table ftable) n step) "Shrink column N of TABLE by STEP character. Return a new table with shrinked column." (let ((width-list (ftable-column-width-list table)) (min-list (ftable-min-width-list table)) (matrix (ftable-matrix table))) (setf (nth n width-list) (- (nth n width-list) step)) (make-ftable :column-width-list width-list :min-width-list min-list :matrix matrix))) (defun ftable--max-line-width () "Return the maximum line width in buffer." (apply #'max (mapcar #'string-width (split-string (buffer-string) "\n")))) ;;; Unparse (cl-defmethod ftable--unparse ((table ftable) &optional box-charset) "Export TABLE to text form. BOX-CHARSET is the same as in `ftable--parse-to'." (let ((charset (or box-charset (cdar ftable-box-charset-alist))) (matrix (ftable-matrix table)) (column-width-list (ftable-column-width-list table))) ;; MATRIX: ;; (("abc def" "123") ;; ("ghi" "m")) ;; ;; WIDTH-LIST: ;; (3 3) ;; ;; 1. Split each row into lines. (setq matrix (mapcar (lambda (row) (ftable--split-row row column-width-list)) matrix)) ;; MATRIX: ;; ((("abc" "123") ;; ("def" "")) ;; (("ghi" "m"))) ;; ;; We add a one-space padding to each column (only) when drawing ;; the table. (setq column-width-list (mapcar #'1+ column-width-list)) (with-temp-buffer (cl-loop ;; Draw the top border. initially do (ftable--insert-grid-line column-width-list '(1 2 3) charset) for row in matrix ;; Draw lines of each row. do (cl-loop for line in row do (cl-loop initially do (insert (ftable-box-char 'v charset)) for column in line for width in column-width-list do (insert (ftable--pad-to column width)) do (insert (ftable-box-char 'v charset)) finally do (insert "\n"))) ;; Draw the separator line. (ftable--insert-grid-line column-width-list '(4 5 6) charset) ;; Draw the bottom border. finally do (progn (forward-line -1) (delete-region (line-beginning-position) (line-end-position)) (ftable--insert-grid-line column-width-list '(7 8 9) charset))) (string-trim (buffer-string))))) ;; (defun ftable--transpose (matrix) ;; "Transpose MATRIX." ;; (cl-loop for col-idx from 0 to (1- (length (car matrix))) ;; collect ;; (cl-loop for row in matrix ;; collect (nth col-idx row)))) (defun ftable--insert-grid-line (column-width-list codeset charset) "Insert a grid line that separates cells vertically. For example, ├──┼─┤. COLUMN-WIDTH-LIST is the one in `ftable' struct. CODESET is a list of codes that corresponds to the left, middle and right box drawing character codes to pass to `ftable-box-char'. It can be (1 2 3), (4 5 6), or (7 8 9). CHARSET is the same as BOX-CHARSET in `ftable--parse'." (let ((left (ftable-box-char (nth 0 codeset) charset)) (middle (ftable-box-char (nth 1 codeset) charset)) (right (ftable-box-char (nth 2 codeset) charset))) (cl-loop initially do (insert left) for width in column-width-list do (dotimes (_ width) (insert (ftable-box-char 'h charset))) do (insert middle) finally do (progn (backward-delete-char 1) (insert right "\n"))))) (defun ftable--split-row (row column-width-list) "Split ROW into several lines according to COLUMN-WIDTH-LIST. This is the opposite of `ftable--merge-lines'. Return value has the form of: ((abc 123 ...) (def 456 ...) ...) which corresponds to |abc|123|...| |def|456|...| ..." (let (line-count line-list line) (with-temp-buffer (setq row (cl-loop for cell in row for width in column-width-list collect (progn (erase-buffer) (insert cell) (let ((fill-column width)) (fill-region-as-paragraph (point-min) (point-max))) (split-string (string-trim (buffer-string)) "\n")))) (setq line-count (apply #'max (mapcar #'length row))) (dotimes (idx line-count) (setq line nil) (dolist (cell row) (push (or (nth idx cell) "") line)) (push (reverse line) line-list)) (reverse line-list)))) (defun ftable--pad-to (text width) "Append padding to TEXT until it is WIDTH characters long. Return a new string." (if (< (string-width text) width) (concat text (make-vector (- width (string-width text)) ?\s)) text)) ;;; Convenience ;;;###autoload (defun ftable-fill () "Fill the table (or paragraph) at point." (interactive) (pcase-let ((`(,text ,beg ,end ,cell-p ,tablep , charset) (ftable--table-info))) (if tablep (ftable--replace-text beg end text (string-trim (ftable--unparse (ftable--fill (ftable--parse-to 'ftable text charset) ftable-fill-column) charset)) (when cell-p #'table-recognize-region)) (fill-paragraph)))) ;;;###autoload (defun ftable-edit-cell () "Edit the cell at point." (interactive) (pcase-let* ((pt (point)) (p-column (- (point) (line-beginning-position))) (`(,text ,beg ,end ,cell-p,tablep ,charset) (ftable--table-info)) (x -1) ;; If these two characters are the same, we will count ;; one extra. (y (if (equal (ftable-box-char 3 charset) (ftable-box-char 6 charset)) -1 0))) (if (not tablep) (user-error "There is no table at point") (save-excursion (goto-char beg) ;; Parse out the coordinate of the cell at point. (while (search-forward (format "%s\n" (ftable-box-char 6 charset)) pt t) (cl-incf y)) (while (search-forward (ftable-box-char 'v charset) (+ (line-beginning-position) p-column) t) (cl-incf x))) ;; Prompt user to edit. (let* ((table (ftable--parse-to 'ftable text charset)) (cell (nth x (nth y (ftable-matrix table)))) (new-cell (read-string "Edit: " cell))) (setf (nth x (nth y (ftable-matrix table))) new-cell) ;; Apply change. (ftable--replace-text beg end text (ftable--unparse (ftable--fill table ftable-fill-column) charset) (when cell-p #'table-recognize-region)))))) ;;;###autoload (defun ftable-reformat (style) "Change box drawing STYLE for table at point. STYLE can be ’ascii or ’unicode." (interactive (list (intern (downcase (completing-read "Style: " '("ASCII" "Unicode")))))) (pcase-let ((`(,text ,beg ,end ,cell-p ,tablep ,charset) (ftable--table-info))) (if (not tablep) (user-error "There is no table at point") (ftable--replace-text beg end text (ftable--unparse ;; We still need to fill the table, otherwise it will be ;; the widest table layout. (ftable--fill (ftable--parse-to 'ftable text charset) ftable-fill-column) (alist-get style ftable-box-charset-alist)) (when cell-p #'table-recognize-region))))) (defun ftable--at-table-p () "Return non-nil if point is in a table." (save-excursion (beginning-of-line) (skip-chars-forward " \t") (member (char-to-string (char-after)) (append (cl-loop for elt in ftable-box-charset-alist for charset = (cdr elt) collect (ftable-box-char 1 charset) collect (ftable-box-char 4 charset) collect (ftable-box-char 7 charset) collect (ftable-box-char 'v charset)))))) (defun ftable--beginning-of-table () "Go backward to the beginning of the table at point. Assumes point is on a table." ;; This implementation allows non-table lines before a table, e.g., ;; #+latex: xxx ;; |------+----| (when (ftable--at-table-p) (beginning-of-line)) (while (and (< (point-min) (point)) (ftable--at-table-p)) (forward-line -1)) (unless (ftable--at-table-p) (forward-line 1))) (defun ftable--end-of-table () "Go forward to the end of the table at point. Assumes point is on a table." (let ((start (point))) (when (ftable--at-table-p) (beginning-of-line)) (while (and (< (point) (point-max)) (ftable--at-table-p)) (forward-line 1)) (unless (<= (point) start) (skip-chars-backward "\n")) (when (< (point) start) (error "End of table goes backwards")))) (defun ftable--table-info () "Return (TEXT BEG END TABLE-CELL-P TABLEP CHARSET). TEXT is the table’s text. BEG and END are the beginning and end position of the table, not including any newlines. TABLE-CELL-P is t if the table is managed by table.el. TABLEP is t if point is on a table, nil if not. CHARSET is the box drawing charset used by the table (if there is a table). \(See `ftable-box-charset-alist'.)" (let* ((beg (save-excursion (ftable--beginning-of-table) (point))) (end (save-excursion (ftable--end-of-table) (point))) (text (buffer-substring-no-properties beg end)) (table-cell-p (text-property-any beg end 'table-cell t))) (append (list text beg end table-cell-p) (cl-loop for charset in (mapcar #'cdr ftable-box-charset-alist) if (equal (substring text 0 1) (ftable-box-char 1 charset)) return (list t charset) finally return (list nil nil))))) (defun ftable--replace-text (beg end text new-text &optional fn) "Replace TEXT between BEG and END with NEW-TEXT. If FN non-nil, run it with the new BEG and END after replacing the text. I.e., (FN BEG END)." (unless (equal text new-text) (let ((p (point))) (delete-region beg end) (insert new-text) (setq end (point)) ;; Go back to roughly where we were. (goto-char p) (when fn (funcall fn beg end))))) ;;; Test (with-eval-after-load 'ert (ert-deftest ftable--misc-test () (let ((text (string-trim " ┌──┬─┐ │ab│c│ ├──┼─┤ │de│f│ │gh│i│ └──┴─┘"))) (should (equal (ftable--parse-to 'ftable text (cdadr ftable-box-charset-alist)) (make-ftable :column-width-list '(5 3) :min-width-list '(2 1) :matrix '(("ab" "c") ("de gh" "f i")))))) ;; ftable--tokenize-line (should (equal (mapcar (lambda (x) (ftable--tokenize-line x (cdadr ftable-box-charset-alist))) '( "│ab│c│" "│de│f│" "│gh│i│")) '(("ab" "c") ("de" "f") ("gh" "i") ))) (should (equal (ftable--tokenize-line "|fgh| | z|" (cdar ftable-box-charset-alist)) '("fgh" "" "z"))) ;; ftable--merge-lines (should (equal (mapcar #'ftable--merge-lines '((("ab" "c")) (("de" "f") ("gh" "i")))) '(("ab" "c") ("de gh" "f i")))) ;; ftable--nth-column (should (equal (ftable--nth-column 1 '((1 2 3) (4 5 6) (7 8 9))) '(2 5 8))) ;; ftable--row-height (should (equal (ftable--row-height '("ab c" "def" "ghi") '(2 3 3)) 2)) ;; ftable--split-row (should (equal (ftable--split-row '("abc de" "12" "xy z") '(3 2 2)) '(("abc" "12" "xy") ("de" "" "z")))) ;; ftable--pad-to (should (equal (ftable--pad-to "123" 5) "123 ")))) (provide 'ftable) ;;; ftable.el ends here