feat: buffer-level justification — region commands and auto-justify mode

The renderer (shared by both engines) is now lossless: synthesized
glue carries the original text it replaced (ekp-glue), soft line
breaks carry the whitespace swallowed around the break
(ekp-soft-break), break hyphens are marked (ekp-soft-hyphen), and
paragraph-edge whitespace survives as zero-display ekp-hidden text.
Orphaned ekp--combine-glues-and-boxes / ekp--interleave removed.

New ekp-region.el:
- ekp-justify-region / ekp-unjustify-region: in-place justification
  with exact structural restore (character- and property-exact),
  robust to edits made while justified
- ekp-auto-justify-mode: keeps the buffer justified at the window
  width; debounced re-flow on window resize, incremental
  per-paragraph re-justification after edits (served by the
  paragraph cache)
- fix found in live GUI testing: buffer-local members of
  window-size-change-functions receive the WINDOW as argument and
  may run with an unrelated buffer current; the handler now resolves
  window and buffer explicitly (regression test included)

Tests: 47 ERT (36 core + 11 region) passing; 300-case fuzz 0
failures (C/elisp parity unchanged); byte-compile clean with
error-on-warn. Verified interactively in GUI Emacs: justified at
1403px maximized, auto-reflowed to 614px on frame resize.

docs: interactive-use section in readme.md / readme_zh.md
ci: byte-compile list includes ekp-region.el

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kinneyzhang 2026-07-26 20:23:03 +08:00
parent d3972fdc26
commit 95ed2b32d1
7 changed files with 679 additions and 44 deletions

View File

@ -26,7 +26,7 @@ jobs:
run: | run: |
emacs -Q --batch -L . \ emacs -Q --batch -L . \
--eval '(setq byte-compile-error-on-warn t)' \ --eval '(setq byte-compile-error-on-warn t)' \
-f batch-byte-compile ekp.el ekp-utils.el ekp-hyphen.el -f batch-byte-compile ekp.el ekp-utils.el ekp-hyphen.el ekp-region.el
- name: Run ERT suite (C-module tests auto-skip) - name: Run ERT suite (C-module tests auto-skip)
run: tests/run-tests.sh emacs run: tests/run-tests.sh emacs

312
ekp-region.el Normal file
View File

@ -0,0 +1,312 @@
;;; ekp-region.el --- Buffer-level justification for ekp -*- lexical-binding: t; -*-
;; Author: Kinney Zhang
;; Keywords: wp, convenience
;;; Commentary:
;; Interactive layer over the ekp string API.
;;
;; - `ekp-justify-region' / `ekp-unjustify-region': justify buffer text
;; in place. Unjustification is a pure structural transform driven by
;; the text properties the renderer leaves behind (`ekp-glue',
;; `ekp-soft-break', `ekp-soft-hyphen', `ekp-soft-trail'), so the
;; original text — including whitespace runs stripped at line breaks —
;; is recovered exactly, even after the justified text was edited.
;;
;; - `ekp-auto-justify-mode': keeps the whole buffer justified to the
;; window width. Re-flows (debounced) when the window width changes,
;; and incrementally re-justifies only the edited paragraphs after
;; edits, so large buffers stay responsive (unchanged paragraphs hit
;; the ekp paragraph cache).
;;; Code:
(require 'ekp)
(require 'cl-lib)
(defvar ekp-auto-justify-mode)
(defgroup ekp-region nil
"Buffer-level justification built on ekp."
:group 'text
:prefix "ekp-")
(defcustom ekp-region-margin-pixel 2
"Pixels subtracted from the window body width when justifying.
A small safety margin that keeps justified lines from being wrapped
by the display engine due to rounding."
:type 'natnum)
(defcustom ekp-auto-justify-resize-delay 0.15
"Seconds to debounce window-resize re-flows in `ekp-auto-justify-mode'."
:type 'number)
(defcustom ekp-auto-justify-edit-delay 0.3
"Idle seconds before edited paragraphs are re-justified."
:type 'number)
(defvar ekp-region--inhibit nil
"Non-nil while ekp-region is modifying the buffer itself.")
(defvar-local ekp-region--auto-width nil
"Pixel width the buffer is currently auto-justified to.")
(defvar-local ekp-region--resize-timer nil)
(defvar-local ekp-region--edit-timer nil)
(defvar-local ekp-region--dirty nil
"Pending edited regions, as a list of (BEG-MARKER . END-MARKER).")
;;;; Width
(defun ekp-region--window-pixel (&optional window)
"Usable text width in pixels of WINDOW (default: selected window)."
(max 1 (- (window-body-width window t) ekp-region-margin-pixel)))
;;;; Pure string transforms
(defun ekp-region--split-hard (string)
"Split justified STRING on hard newlines (those without `ekp-soft-break')."
(let ((parts nil) (start 0) (i 0) (len (length string)))
(while (< i len)
(when (and (eq (aref string i) ?\n)
(not (get-text-property i 'ekp-soft-break string)))
(push (substring string start i) parts)
(setq start (1+ i)))
(setq i (1+ i)))
(push (substring string start) parts)
(nreverse parts)))
(defun ekp-region--justify-string (text pixel)
"Return TEXT justified to PIXEL with exact-recovery markers.
Hard newlines are preserved one-to-one. Whitespace-only paragraphs
(which the string API would empty out) survive as hidden text."
(let* ((paras (split-string text "\n"))
(cores (cl-remove-if #'string-blank-p paras))
(out (and cores
(ekp-region--split-hard
(ekp-pixel-justify (string-join cores "\n") pixel)))))
(unless (= (length out) (length cores))
(error "ekp-region: paragraph count mismatch (%d vs %d)"
(length out) (length cores)))
(string-join
(mapcar (lambda (p)
(if (string-blank-p p)
(ekp--hide-string p)
(pop out)))
paras)
"\n")))
(defun ekp-region--pos-for-offset (string offset)
"Physical position in justified STRING for logical OFFSET.
Glue characters count for the length of the original text they
replaced (their `ekp-glue' value), soft breaks for their payload,
soft hyphens for nothing; everything else (including hidden text)
is one logical character."
(let ((i 0) (len (length string)))
(while (and (< i len) (> offset 0))
(let ((glue (get-text-property i 'ekp-glue string)))
(cond
(glue
(setq offset (- offset (length glue))))
((get-text-property i 'ekp-soft-hyphen string))
((and (eq (aref string i) ?\n)
(get-text-property i 'ekp-soft-break string))
(setq offset (- offset (length (get-text-property
i 'ekp-soft-break string)))))
(t (setq offset (1- offset)))))
(setq i (1+ i)))
i))
;;;; Commands
;;;###autoload
(defun ekp-justify-region (beg end &optional pixel)
"Justify the text between BEG and END to PIXEL width.
PIXEL defaults to the window text width (see `ekp-region-margin-pixel');
interactively, a numeric prefix argument supplies it explicitly.
Already-justified text is unjustified first, so the command is
idempotent and can re-flow to a new width."
(interactive
(list (region-beginning) (region-end)
(and current-prefix-arg (prefix-numeric-value current-prefix-arg))))
(setq pixel (or pixel (ekp-region--window-pixel)))
(let ((beg (copy-marker (min beg end)))
(end (copy-marker (max beg end) t))
(ekp-region--inhibit t)
(inhibit-read-only t))
(unwind-protect
(atomic-change-group
;; Re-flow support: strip previous justification first.
(when (text-property-not-all beg end 'ekp-justified nil)
(ekp-unjustify-region beg end))
(let* ((text (buffer-substring beg end))
(justified (ekp-region--justify-string text pixel))
(point-offset (and (>= (point) beg) (< (point) end)
(- (point) beg))))
(unless (equal-including-properties text justified)
(goto-char beg)
(delete-region beg end)
(insert justified)
(when point-offset
(goto-char (+ beg (ekp-region--pos-for-offset
justified point-offset)))))
(add-text-properties beg end (list 'ekp-justified pixel))))
(set-marker beg nil)
(set-marker end nil))))
;;;###autoload
(defun ekp-unjustify-region (beg end)
"Restore the logical text between BEG and END.
Removes synthesized glue and soft hyphens, replaces soft line breaks
with the whitespace they swallowed, and re-exposes hidden paragraph
tails. Text the user typed into the justified region is preserved."
(interactive "r")
(let ((end-m (copy-marker (max beg end) t))
(ekp-region--inhibit t)
(inhibit-read-only t))
(unwind-protect
(save-excursion
(goto-char (min beg end))
(while (< (point) end-m)
(let* ((pos (point))
(glue (get-text-property pos 'ekp-glue)))
(cond
(glue
(delete-region pos (1+ pos))
(when (stringp glue) (insert glue)))
((get-text-property pos 'ekp-soft-hyphen)
(delete-region pos (1+ pos)))
((and (eq (char-after pos) ?\n)
(get-text-property pos 'ekp-soft-break))
(let ((payload (get-text-property pos 'ekp-soft-break)))
(delete-region pos (1+ pos))
(insert payload)))
((get-text-property pos 'ekp-hidden)
(remove-text-properties pos (1+ pos)
'(ekp-hidden nil display nil))
(forward-char 1))
(t (forward-char 1)))))
(remove-text-properties (min beg end) end-m '(ekp-justified nil)))
(set-marker end-m nil))))
;;;; Auto-justify minor mode
(defun ekp-region--para-bounds (marker-pair)
"Hard-paragraph bounds containing MARKER-PAIR, as (BEG . END)."
(let ((b (marker-position (car marker-pair)))
(e (marker-position (cdr marker-pair))))
(save-excursion
(goto-char (max (point-min) (min b (point-max))))
(while (and (> (point) (point-min))
(let ((prev (1- (point))))
(not (and (eq (char-after prev) ?\n)
(not (get-text-property prev 'ekp-soft-break))))))
(forward-char -1))
(setq b (point))
(goto-char (max (point-min) (min e (point-max))))
(while (and (< (point) (point-max))
(not (and (eq (char-after) ?\n)
(not (get-text-property (point) 'ekp-soft-break)))))
(forward-char 1))
(cons b (point)))))
(defun ekp-region--merge-regions (regions)
"Merge overlapping or adjacent (BEG . END) REGIONS."
(let ((sorted (sort regions (lambda (a b) (< (car a) (car b)))))
merged)
(dolist (r sorted)
(if (and merged (<= (car r) (cdr (car merged))))
(setcdr (car merged) (max (cdr (car merged)) (cdr r)))
(push (cons (car r) (cdr r)) merged)))
(nreverse merged)))
(defun ekp-region--after-change (beg end _len)
"Record the edit between BEG and END for incremental re-justification."
(when (and ekp-auto-justify-mode (not ekp-region--inhibit))
(push (cons (copy-marker beg) (copy-marker end)) ekp-region--dirty)
(when (timerp ekp-region--edit-timer)
(cancel-timer ekp-region--edit-timer))
(setq ekp-region--edit-timer
(run-with-idle-timer ekp-auto-justify-edit-delay nil
#'ekp-region--flush-dirty (current-buffer)))))
(defun ekp-region--flush-dirty (buffer)
"Re-justify the paragraphs of BUFFER touched by recent edits."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when (and ekp-auto-justify-mode ekp-region--dirty ekp-region--auto-width)
(let* ((pairs (prog1 ekp-region--dirty (setq ekp-region--dirty nil)))
;; Convert all bounds to markers before the first
;; re-justification shifts later positions.
(regions (mapcar (lambda (r)
(cons (copy-marker (car r))
(copy-marker (cdr r) t)))
(ekp-region--merge-regions
(mapcar #'ekp-region--para-bounds pairs)))))
(dolist (r regions)
(ekp-justify-region (car r) (cdr r) ekp-region--auto-width)
(set-marker (car r) nil)
(set-marker (cdr r) nil))
(dolist (p pairs)
(set-marker (car p) nil)
(set-marker (cdr p) nil)))))))
(defun ekp-region--on-resize (window-or-frame)
"Debounced re-flow after WINDOW-OR-FRAME changed size.
Buffer-local members of `window-size-change-functions' receive the
window showing the buffer and are not guaranteed to run with that
buffer current so resolve both explicitly."
(let ((win (cond ((windowp window-or-frame) window-or-frame)
((framep window-or-frame)
(get-buffer-window (current-buffer) window-or-frame))
(t (get-buffer-window (current-buffer))))))
(when (window-live-p win)
(with-current-buffer (window-buffer win)
(when ekp-auto-justify-mode
(let ((w (ekp-region--window-pixel win)))
(when (and ekp-region--auto-width (/= w ekp-region--auto-width))
(when (timerp ekp-region--resize-timer)
(cancel-timer ekp-region--resize-timer))
(setq ekp-region--resize-timer
(run-with-timer ekp-auto-justify-resize-delay nil
#'ekp-region--reflow
(current-buffer) w)))))))))
(defun ekp-region--reflow (buffer width)
"Re-justify all of BUFFER to WIDTH."
(when (buffer-live-p buffer)
(with-current-buffer buffer
(when ekp-auto-justify-mode
(setq ekp-region--auto-width width)
(ekp-justify-region (point-min) (point-max) width)))))
;;;###autoload
(define-minor-mode ekp-auto-justify-mode
"Keep the buffer pixel-justified to the window width.
Re-flows when the window width changes and re-justifies edited
paragraphs incrementally. Designed for reading and previewing;
the buffer text is restored exactly when the mode is turned off."
:lighter " EKP"
(if ekp-auto-justify-mode
(progn
(setq ekp-region--auto-width
(ekp-region--window-pixel (get-buffer-window)))
(ekp-justify-region (point-min) (point-max) ekp-region--auto-width)
(add-hook 'window-size-change-functions #'ekp-region--on-resize nil t)
(add-hook 'after-change-functions #'ekp-region--after-change nil t))
(remove-hook 'window-size-change-functions #'ekp-region--on-resize t)
(remove-hook 'after-change-functions #'ekp-region--after-change t)
(when (timerp ekp-region--resize-timer)
(cancel-timer ekp-region--resize-timer))
(when (timerp ekp-region--edit-timer)
(cancel-timer ekp-region--edit-timer))
(setq ekp-region--resize-timer nil
ekp-region--edit-timer nil
ekp-region--dirty nil
ekp-region--auto-width nil)
(ekp-unjustify-region (point-min) (point-max))))
(provide 'ekp-region)
;;; ekp-region.el ends here

155
ekp.el
View File

@ -1425,41 +1425,25 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]."
(and box (not (string-empty-p box)) (and box (not (string-empty-p box))
(or (string-blank-p box) (= (string-width box) 0)))) (or (string-blank-p box) (= (string-width box) 0))))
(defun ekp--interleave (list1 list2)
"Interleave elements of LIST1 and LIST2."
(let (result)
(while (or list1 list2)
(when list1 (push (pop list1) result))
(when list2 (push (pop list2) result)))
(nreverse result)))
(defun ekp--combine-glues-and-boxes (glues boxes)
"Combine GLUES (n+1 elements) and BOXES (n elements) into string."
(let* ((glues (append glues nil))
(last-glue (car (last glues)))
(glues (butlast glues))
(boxes (append boxes nil)))
(if (= (length glues) (length boxes))
(string-join (append (ekp--interleave glues boxes)
(list last-glue)))
(error "Glues count (%d) must equal boxes count (%d) + 1"
(1+ (length glues)) (length boxes)))))
(defun ekp--strip-line-spaces (line-boxes line-glues (defun ekp--strip-line-spaces (line-boxes line-glues
&optional strip-leading strip-trailing) &optional strip-leading strip-trailing)
"Strip leading/trailing space boxes from LINE-BOXES based on flags. "Strip leading/trailing space boxes from LINE-BOXES based on flags.
STRIP-LEADING / STRIP-TRAILING: strip space boxes at that edge. STRIP-LEADING / STRIP-TRAILING: strip space boxes at that edge.
Returns (stripped-boxes . adjusted-glues). LINE-GLUES is treated as an opaque list of n+1 glue values kept in
sync with the boxes. Returns (kept-boxes kept-glues nlead ntrail)
where NLEAD / NTRAIL count the boxes stripped at each edge.
The stripped widths are NOT redistributed: the DP already excluded The stripped widths are NOT redistributed: the DP already excluded
these space-box runs from its line metrics, so the remaining boxes these space-box runs from its line metrics, so the remaining boxes
plus distributed glues already fill the target width exactly." plus distributed glues already fill the target width exactly."
(let* ((boxes (append line-boxes nil)) (let ((boxes (append line-boxes nil))
(glues (append line-glues nil))) (glues (append line-glues nil))
(nlead 0) (ntrail 0))
(when (> (length boxes) 0) (when (> (length boxes) 0)
;; Strip trailing space boxes (if requested) ;; Strip trailing space boxes (if requested)
(when strip-trailing (when strip-trailing
(while (and boxes (ekp--box-space-p (car (last boxes)))) (while (and boxes (ekp--box-space-p (car (last boxes))))
(setq ntrail (1+ ntrail))
(setq boxes (butlast boxes)) (setq boxes (butlast boxes))
;; Remove second-to-last glue (the one before the trailing ;; Remove second-to-last glue (the one before the trailing
;; space box); keep the last glue (line's trailing filler). ;; space box); keep the last glue (line's trailing filler).
@ -1468,55 +1452,140 @@ plus distributed glues already fill the target width exactly."
;; Strip leading space boxes (if requested) ;; Strip leading space boxes (if requested)
(when strip-leading (when strip-leading
(while (and boxes (ekp--box-space-p (car boxes))) (while (and boxes (ekp--box-space-p (car boxes)))
(setq nlead (1+ nlead))
(setq boxes (cdr boxes)) (setq boxes (cdr boxes))
;; Remove the second glue (the one after the leading glue) ;; Remove the second glue (the one after the leading glue)
(when (> (length glues) 1) (when (> (length glues) 1)
(setq glues (cons (car glues) (cddr glues))))))) (setq glues (cons (car glues) (cddr glues)))))))
(cons (vconcat boxes) glues))) (list boxes glues nlead ntrail)))
(defun ekp--box-offsets (string boxes)
"Locate each of BOXES in STRING; return a vector of (START . END).
Boxes are in order and separated only by characters the tokenizer
dropped (whitespace runs, zero-width breakers), so a sequential
leftmost scan aligns them unambiguously."
(let ((offsets (make-vector (length boxes) nil))
(p 0) (i 0))
(dolist (box boxes)
(let ((blen (length box)))
(while (not (eq t (compare-strings string p (+ p blen) box 0 blen)))
(setq p (1+ p)))
(aset offsets i (cons p (+ p blen)))
(setq p (+ p blen))
(setq i (1+ i))))
offsets))
(defun ekp--hide-string (string)
"Return STRING marked `ekp-hidden' and displayed as nothing."
(if (string-empty-p string)
string
(propertize string 'ekp-hidden t 'display "")))
(defun ekp--render-glue (pixel payload)
"Render a glue of PIXEL width that replaced original text PAYLOAD.
Zero-width glue renders as the hidden PAYLOAD itself, so no original
character is ever dropped."
(cond
((> pixel 0)
(propertize " " 'display `(space :width (,pixel)) 'ekp-glue payload))
(t (ekp--hide-string payload))))
(defun ekp--hyphen-for-box (box) (defun ekp--hyphen-for-box (box)
"Return a hyphen string styled like the end of BOX." "Return a hyphen string styled like the end of BOX.
The `ekp-soft-hyphen' property marks it as synthesized, so
`ekp-unjustify-region' can strip it structurally."
(let ((props (and (> (length box) 0) (let ((props (and (> (length box) 0)
(text-properties-at (1- (length box)) box)))) (text-properties-at (1- (length box)) box))))
(if props (apply #'propertize "-" props) "-"))) (apply #'propertize "-" 'ekp-soft-hyphen t props)))
(defun ekp--pixel-justify (string line-pixel) (defun ekp--pixel-justify (string line-pixel)
"Justify single-paragraph STRING to LINE-PIXEL." "Justify single-paragraph STRING to LINE-PIXEL.
(let* ((boxes (ekp--boxes string))
The output is lossless with respect to STRING:
- synthesized spacing carries an `ekp-glue' property whose value is
the original text it replaced (usually a whitespace run),
- soft line breaks are newlines whose `ekp-soft-break' property holds
the original text swallowed around the break,
- original text outside any visible line (paragraph-edge whitespace)
survives as zero-display `ekp-hidden' text,
- break hyphens carry `ekp-soft-hyphen'.
`ekp-unjustify-region' inverts all four structurally."
(let* ((boxes (append (ekp--boxes string) nil))
(offsets (ekp--box-offsets string boxes))
(breaks (ekp-line-breaks string line-pixel)) (breaks (ekp-line-breaks string line-pixel))
(num (length breaks)) (num (length breaks))
(lines-glues (ekp-line-glues string line-pixel)) (lines-glues (ekp-line-glues string line-pixel))
(hyphen-positions (ekp--hyphen-positions string)) (hyphen-positions (ekp--hyphen-positions string))
(start 0) strings) (start 0)
;; (rendered-text first-box-idx last-box-idx) per visible line
(lines nil))
(dotimes (i num) (dotimes (i num)
(let* ((end (nth i breaks)) (let* ((end (nth i breaks))
(line-boxes (cl-subseq boxes start end)) (line-boxes (cl-subseq boxes start end))
(line-glues-raw (mapcar #'ekp-pixel-spacing (glue-pixels (append (aref lines-glues i) nil))
(aref lines-glues i)))
;; Strip space boxes: ;; Strip space boxes:
;; - First line (i=0): keep leading spaces (indentation) ;; - First line (i=0): keep leading spaces (indentation)
;; - Other lines: strip leading spaces (break artifacts) ;; - Other lines: strip leading spaces (break artifacts)
;; - All lines: strip trailing spaces ;; - All lines: strip trailing spaces
(is-first-line (= i 0)) (is-first-line (= i 0))
(stripped (ekp--strip-line-spaces line-boxes line-glues-raw (stripped (ekp--strip-line-spaces line-boxes glue-pixels
(not is-first-line) (not is-first-line)
t)) t))
(line-boxes (car stripped)) (kept (nth 0 stripped))
(line-glues (cdr stripped)) (kept-glues (nth 1 stripped))
(first-idx (+ start (nth 2 stripped)))
;; Check if last box of this line needs hyphen ;; Check if last box of this line needs hyphen
(need-hyphen (need-hyphen
(and (< i (1- num)) ; not last line (and (< i (1- num)) ; not last line
(ekp--hyphenate-p hyphen-positions (1- end))))) (ekp--hyphenate-p hyphen-positions (1- end)))))
(when (and need-hyphen (> (length line-boxes) 0)) (when kept
(let ((last-idx (1- (length line-boxes)))) (let ((parts nil) (idx first-idx) (glues kept-glues) (n 0))
(aset line-boxes last-idx (dolist (box kept)
(concat (aref line-boxes last-idx) (push (ekp--render-glue
(ekp--hyphen-for-box (aref line-boxes last-idx)))))) (pop glues)
(when (> (length line-boxes) 0) (if (> idx first-idx)
(push (ekp--combine-glues-and-boxes line-glues line-boxes) (substring string
strings)) (cdr (aref offsets (1- idx)))
(car (aref offsets idx)))
;; leading glue of a line is always 0px and
;; replaces nothing; edge text is handled by
;; soft breaks / hidden runs below
""))
parts)
(push box parts)
(setq idx (1+ idx) n (1+ n)))
(when need-hyphen
(push (ekp--hyphen-for-box (car (last kept))) parts))
;; trailing filler glue (synthesized, replaces nothing)
(push (ekp--render-glue (car glues) "") parts)
(push (list (apply #'concat (nreverse parts))
first-idx (+ first-idx n -1))
lines)))
(setq start end))) (setq start end)))
(mapconcat 'identity (nreverse strings) "\n"))) (setq lines (nreverse lines))
(if (null lines)
;; Defensive: no visible box at all (blank paragraphs are
;; filtered before this function).
(ekp--hide-string string)
(let* ((first-line (car lines))
(last-line (car (last lines)))
(parts (list (ekp--hide-string
(substring string 0
(car (aref offsets (nth 1 first-line)))))))
(prev nil))
(dolist (line lines)
(when prev
(push (propertize "\n" 'ekp-soft-break
(substring string
(cdr (aref offsets (nth 2 prev)))
(car (aref offsets (nth 1 line)))))
parts))
(push (nth 0 line) parts)
(setq prev line))
(push (ekp--hide-string
(substring string (cdr (aref offsets (nth 2 last-line)))))
parts)
(apply #'concat (nreverse parts))))))
(defun ekp--validate-width (line-pixel) (defun ekp--validate-width (line-pixel)
"Signal a user error unless LINE-PIXEL is a positive integer." "Signal a user error unless LINE-PIXEL is a positive integer."

View File

@ -64,6 +64,31 @@ engines produce **identical output**; Elisp is the always-available
fallback. If the module on disk is older than the Elisp code expects, fallback. If the module on disk is older than the Elisp code expects,
loading refuses with a message asking you to rebuild. loading refuses with a message asking you to rebuild.
## Interactive Use (buffer & region)
`ekp-region.el` turns the string API into buffer-level commands:
```elisp
(require 'ekp-region)
```
- `M-x ekp-justify-region` — justify the region to the window text
width (with a numeric prefix argument, to that many pixels).
- `M-x ekp-unjustify-region` — restore the original text **exactly**,
including collapsed whitespace runs. Justification is lossless: every
synthesized space, soft line break, and soft hyphen carries the
original text it replaced, so restoring is a structural transform that
also works after you edited the justified text.
- `M-x ekp-auto-justify-mode` — keep the whole buffer justified to the
window width. Re-flows (debounced by
`ekp-auto-justify-resize-delay`) when the window width changes, and
after edits re-justifies only the touched paragraphs
(`ekp-auto-justify-edit-delay`), so unchanged paragraphs hit the
paragraph cache. Turning the mode off restores the buffer exactly.
`ekp-region-margin-pixel` (default 2) is subtracted from the window
width as a rounding safety margin.
## Configuration ## Configuration
### Hyphenation language ### Hyphenation language

View File

@ -57,6 +57,26 @@ cd ekp_c && make # 需要 C11 编译器,产出 ekp.dylib/.so/.dll
Elisp 与 C 两个引擎的输出**完全一致**;Elisp 是永远可用的后备。若磁盘 Elisp 与 C 两个引擎的输出**完全一致**;Elisp 是永远可用的后备。若磁盘
上的模块版本旧于 Elisp 代码的要求,加载会拒绝并提示重新编译。 上的模块版本旧于 Elisp 代码的要求,加载会拒绝并提示重新编译。
## 交互使用(buffer 与 region)
`ekp-region.el` 把字符串 API 变成 buffer 级命令:
```elisp
(require 'ekp-region)
```
- `M-x ekp-justify-region` — 把选区排版到窗口文本宽度(数字前缀参数
可指定像素宽)。
- `M-x ekp-unjustify-region` — **精确**还原原文,包括被折叠的连续空
格。排版是无损的:每个合成空隙、软换行、软连字符都携带它所替换的
原文,还原是纯结构变换,即使排版后又编辑过也能正确还原。
- `M-x ekp-auto-justify-mode` — 让整个 buffer 保持按窗口宽度排版。
窗口宽度变化时自动重排(防抖延迟 `ekp-auto-justify-resize-delay`);
编辑后只重排被改动的段落(空闲延迟 `ekp-auto-justify-edit-delay`),
未变段落直接命中段落缓存。关闭 mode 时 buffer 精确恢复原状。
`ekp-region-margin-pixel`(默认 2)是从窗口宽度中扣除的取整安全边距。
## 配置 ## 配置
### 断词语言 ### 断词语言

208
tests/ekp-region-tests.el Normal file
View File

@ -0,0 +1,208 @@
;;; ekp-region-tests.el --- Tests for ekp-region.el -*- lexical-binding: t; -*-
;;; Commentary:
;; Batch-safe ERT tests for the buffer-level justification layer.
;; Widths are always passed explicitly, so no window is required.
;;; Code:
(require 'ert)
(require 'ekp-region)
(defconst ekp-region-test--samples
(list "简单的中文段落测试内容,排版效果应当良好稳定。"
"The quick brown fox jumps over the lazy dog several times today."
"Mixed 中英文 paragraph with double spaces inside and a tail "
"para one\n\npara two 混排 content here\nthird para"
" leading indent 段落内容 preserved intact"
"\n\n\n多个空段落之间的内容")
"Logical texts covering CJK, Latin, mixed, blanks, indent, tails.")
(defconst ekp-region-test--widths '(30 80 200 400)
"Pixel widths from emergency-narrow to comfortable.")
(defmacro ekp-region-test--with-text (text &rest body)
"Run BODY in a temp buffer containing TEXT."
(declare (indent 1))
`(with-temp-buffer
(insert ,text)
,@body))
;;;; Roundtrip exactness
(ert-deftest ekp-region-test-roundtrip-exact ()
"justify + unjustify restores text and properties exactly."
(dolist (text ekp-region-test--samples)
(dolist (w ekp-region-test--widths)
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) w)
(ekp-unjustify-region (point-min) (point-max))
(should (equal-including-properties (buffer-string) text))))))
(ert-deftest ekp-region-test-roundtrip-propertized ()
"Roundtrip preserves user text properties."
(let ((text (concat (propertize "加粗的中文开头内容" 'face 'bold)
" plain middle part "
(propertize "italic tail words" 'face 'italic))))
(dolist (w '(60 250))
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) w)
(ekp-unjustify-region (point-min) (point-max))
(should (equal-including-properties (buffer-string) text))))))
(ert-deftest ekp-region-test-hard-newlines-preserved ()
"Hard newline count survives justification."
(ekp-region-test--with-text "a 段落 one\n\nb 段落 two\nc 段落 three"
(ekp-justify-region (point-min) (point-max) 100)
(let ((hard 0))
(goto-char (point-min))
(while (search-forward "\n" nil t)
(unless (get-text-property (match-beginning 0) 'ekp-soft-break)
(setq hard (1+ hard))))
(should (= hard 3)))))
;;;; Justified-state invariants
(ert-deftest ekp-region-test-justified-marked ()
"Justified region carries the ekp-justified width property."
(ekp-region-test--with-text "中文内容需要标记属性验证正确性"
(ekp-justify-region (point-min) (point-max) 120)
(should (eq (get-text-property (point-min) 'ekp-justified) 120))
(should-not (text-property-not-all (point-min) (point-max)
'ekp-justified 120))))
(ert-deftest ekp-region-test-rejustify-idempotent ()
"Justifying at a new width equals a fresh justification at that width."
(let ((text "The idempotence check 中英混排 must hold across widths."))
(let (fresh)
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 150)
(setq fresh (buffer-string)))
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 300)
(ekp-justify-region (point-min) (point-max) 150)
(should (equal-including-properties (buffer-string) fresh))))))
;;;; Edit robustness
(ert-deftest ekp-region-test-edit-then-unjustify ()
"Text typed into a justified buffer survives unjustification."
(ekp-region-test--with-text "abcdef ghijkl 中文内容 mnopqr stuvwx"
(ekp-justify-region (point-min) (point-max) 80)
;; Insert inside the first word: physical == logical there.
(goto-char (+ (point-min) 2))
(insert "XY")
(ekp-unjustify-region (point-min) (point-max))
(should (equal (buffer-string)
"abXYcdef ghijkl 中文内容 mnopqr stuvwx"))))
(ert-deftest ekp-region-test-point-stable ()
"Point returns to its logical position after a roundtrip."
(ekp-region-test--with-text "abcdef ghijkl mnopqr stuvwx yzabcd"
(goto-char (+ (point-min) 9)) ; inside "ghijkl"
(ekp-justify-region (point-min) (point-max) 60)
(ekp-unjustify-region (point-min) (point-max))
(should (= (point) (+ (point-min) 9)))))
;;;; Auto-justify mode
(defmacro ekp-region-test--with-mode (text width &rest body)
"Enable `ekp-auto-justify-mode' on TEXT at WIDTH, run BODY, disable."
(declare (indent 2))
`(ekp-region-test--with-text ,text
(cl-letf (((symbol-function 'ekp-region--window-pixel)
(lambda (&optional _) ,width)))
(ekp-auto-justify-mode 1)
(unwind-protect
(progn ,@body)
(ekp-auto-justify-mode -1)))))
(ert-deftest ekp-region-test-mode-roundtrip ()
"Enabling then disabling the mode restores the buffer exactly."
(let ((text "first paragraph 内容 aaa bbb ccc\nsecond paragraph 内容 ddd"))
(ekp-region-test--with-mode text 150
(should ekp-region--auto-width)
(should (get-text-property (point-min) 'ekp-justified)))
;; body ran; with-mode disabled the mode on exit — verify restore
(ekp-region-test--with-text text
(cl-letf (((symbol-function 'ekp-region--window-pixel)
(lambda (&optional _) 150)))
(ekp-auto-justify-mode 1)
(ekp-auto-justify-mode -1)
(should (equal-including-properties (buffer-string) text))))))
(ert-deftest ekp-region-test-mode-incremental-edit ()
"Edits re-justify only the touched paragraph, content stays correct."
(let ((text "aaa bbb ccc ddd eee fff\nggg hhh iii jjj kkk lll")
(calls nil))
(ekp-region-test--with-mode text 100
(let ((orig (symbol-function 'ekp-justify-region)))
(cl-letf (((symbol-function 'ekp-justify-region)
(lambda (b e &optional px)
(push (cons (marker-position (copy-marker b))
(marker-position (copy-marker e)))
calls)
(funcall orig b e px))))
;; Edit inside paragraph 1.
(goto-char (+ (point-min) 4))
(insert "zz")
(should ekp-region--dirty)
(ekp-region--flush-dirty (current-buffer))
;; Exactly one incremental call, confined before the hard \n.
(should (= (length calls) 1))
(let ((hard-nl (save-excursion
(goto-char (point-min))
(catch 'nl
(while (search-forward "\n" nil t)
(unless (get-text-property (match-beginning 0)
'ekp-soft-break)
(throw 'nl (match-beginning 0))))))))
(should (<= (cdar calls) hard-nl)))))
;; Logical text after disable = original with the edit applied.
(ekp-auto-justify-mode -1)
(should (equal (buffer-string)
"aaa zzbbb ccc ddd eee fff\nggg hhh iii jjj kkk lll"))
;; re-enable so with-mode's cleanup disable is a no-op state-wise
(ekp-auto-justify-mode 1))))
(ert-deftest ekp-region-test-resize-hook-window-arg ()
"The resize hook handles its WINDOW argument and foreign current buffer.
Regression: buffer-local `window-size-change-functions' members get
the displaying WINDOW, with an arbitrary buffer current."
(let ((text "resize hook 检查 aaa bbb ccc ddd eee fff"))
(ekp-region-test--with-mode text 200
(let ((buf (current-buffer))
(win (selected-window)))
(set-window-buffer win buf)
(cl-letf (((symbol-function 'ekp-region--window-pixel)
(lambda (&optional _) 120)))
;; simulate redisplay: window argument, unrelated buffer current
(with-temp-buffer
(ekp-region--on-resize win)))
(with-current-buffer buf
(should (timerp ekp-region--resize-timer))
(cancel-timer ekp-region--resize-timer)
;; run what the timer would have run
(ekp-region--reflow buf 120)
(should (= ekp-region--auto-width 120)))))))
(ert-deftest ekp-region-test-mode-reflow-width ()
"Reflow to a new width matches a fresh justification at that width."
(let ((text "reflow 检查 aaa bbb ccc ddd eee fff ggg hhh")
fresh)
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 90)
(setq fresh (buffer-substring (point-min) (point-max))))
(ekp-region-test--with-mode text 200
(ekp-region--reflow (current-buffer) 90)
(should (= ekp-region--auto-width 90))
(let ((got (buffer-substring (point-min) (point-max))))
;; ekp-justified was written at two widths; ignore that prop
(remove-text-properties 0 (length got) '(ekp-justified nil) got)
(remove-text-properties 0 (length fresh) '(ekp-justified nil) fresh)
(should (equal-including-properties got fresh))))))
(provide 'ekp-region-tests)
;;; ekp-region-tests.el ends here

View File

@ -7,4 +7,5 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
exec "$EMACS" -Q --batch -L "$ROOT" \ exec "$EMACS" -Q --batch -L "$ROOT" \
-l "$ROOT/tests/ekp-tests.el" \ -l "$ROOT/tests/ekp-tests.el" \
-l "$ROOT/tests/ekp-region-tests.el" \
-f ert-run-tests-batch-and-exit -f ert-run-tests-batch-and-exit