fix: justified lines fit the real display, not an idealized one

Two root causes made justified text overrun the window's right edge
in real user sessions (every line ending in the "$" truncation
marker), while emacs -Q looked fine:

1. Windows WITHOUT fringes draw the truncation/continuation
   indicator in the text area's LAST COLUMN, so the usable width is
   one character less than window-body-width.  ekp reserved only a
   2px margin: with fringes disabled (a common minimal setup, and
   all ttys) every line that hit the target width exactly had its
   final glyph displaced by the "$".  ekp-region--window-pixel now
   reserves one frame-char-width when the window has no right
   fringe.  (The same lesson ebox-playground encodes in its
   viewport-width reserve.)

2. Measurement was blind to the buffer's display context:
   string-pixel-width works in a bare hidden buffer, ignoring
   face-remapping-alist — which is where text-scale-mode, themes
   and per-buffer font tweaks live.  Under a remap, the DP laid
   lines out with one font's metrics and the display rendered them
   with another's: scale +3 made 5 of 7 sample lines overflow a
   1330px window by up to 900px (GUI-measured).  Measurement now
   runs with the destination buffer's face-remapping-alist (the
   29/30-compatible equivalent of Emacs 31's string-pixel-width
   BUFFER argument), and the width/paragraph caches key on that
   context so buffers at different scales never alias.  text-scale
   changes also trigger a re-flow in ekp-auto-justify-mode.

Ground truth, measured with window-text-pixel-size in GUI Emacs
across 7 display contexts (plain / text-scale ±| face remap /
no-fringes / no-fringes+scale / narrow+scale): the widest justified
line equals the target width exactly in every case, zero lines
overflow.  Verbatim code blocks are exempt by design (they never
reflow, like any code line in a narrow window).

New tooling so this never regresses invisibly:
- M-x ekp-diagnose: renders a probe line in YOUR buffer and reports
  target vs rendered width — run it in any session where justified
  text looks wrong.
- tests/ekp-gui-verify.el: M-x ekp-gui-verify (single check in a
  customized session) and ekp-gui-verify-matrix (the 7-case table,
  for emacs -Q).
- 2 new batch ERT tests pin the context-keyed caches (94 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kinneyzhang 2026-07-27 03:19:27 +08:00
parent 448198bdb9
commit 29cef97ef8
7 changed files with 359 additions and 15 deletions

View File

@ -166,13 +166,27 @@ Non-zero only while `ekp-protrusion' is enabled: protruding glyphs
extend past the flush edge, so the layout width must leave room."
(if ekp-protrusion
(max 2 (ceiling (* (alist-get 'cjk-close ekp-protrusion-ratios 0.5)
(string-pixel-width ""))))
(ekp--measured-width ""))))
0))
(defun ekp-region--indicator-reserve (&optional window)
"Pixels the truncation/continuation indicator eats in WINDOW.
With no right fringe (and on text terminals), Emacs draws the `$'
or `\\' indicator in the text area's LAST COLUMN a line that
fills the body width exactly gets its final glyph displaced and
every justified line appears truncated. Reserve that column; with
a right fringe the indicator lives in the fringe and costs nothing."
(let ((win (or window (selected-window))))
(if (and (display-graphic-p (window-frame win))
(> (or (cadr (window-fringes win)) 0) 0))
0
(frame-char-width (window-frame win)))))
(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
(ekp-region--indicator-reserve window)
(ekp-region-protrusion-reserve))))
(defun ekp-region--effective-width (&optional buffer)
@ -185,6 +199,43 @@ Falls back to the selected window when the buffer is not displayed."
(apply #'min (mapcar #'ekp-region--window-pixel wins))
(ekp-region--window-pixel))))
;;;###autoload
(defun ekp-diagnose ()
"Check that ekp's measurement matches this window's real rendering.
Justifies a probe line to the window width, renders it invisibly in
this buffer, and compares the rendered pixel width against the
target. A mismatch means glyph metrics differ between measurement
and display (e.g. a face-remapping ekp does not see) and justified
lines would come out over- or under-full."
(interactive)
(let* ((win (or (get-buffer-window (current-buffer)) (selected-window)))
(target (ekp-region--window-pixel win))
(probe (ekp-pixel-justify
(concat "汉字排版像素精确性探针,中英混排 probe line with "
"Latin words, 标点。悬挂?以及 hyphenation-ready "
"vocabulary examples 结尾。")
target))
(line (car (split-string probe "\n")))
(rendered
(with-silent-modifications
(let ((beg (point-max)))
(unwind-protect
(progn
(goto-char beg)
(insert "\n" line)
(car (window-text-pixel-size win (1+ beg) (point-max))))
(delete-region beg (point-max))))))
(delta (- rendered target)))
(message (concat "ekp-diagnose: target %dpx, rendered %dpx (Δ%+d) — %s"
(if (buffer-local-value 'face-remapping-alist
(current-buffer))
" [buffer has face remappings]" ""))
target rendered delta
(if (<= (abs delta) ekp-region-margin-pixel)
"OK, measurement matches rendering"
"MISMATCH: justified lines will not fit this window"))
delta))
;;;; Pure string transforms
(defun ekp-region--split-hard (string)
@ -703,6 +754,19 @@ Catches the buffer becoming displayed (possibly for the first time),
window splits, and deletions of the narrowest window."
(ekp-region--schedule-reflow))
(defun ekp-region--on-text-scale ()
"Re-flow after a text-scale change.
Scaling remaps the default face, so every glyph metric changed: the
current layout is wrong at the same pixel width and must be re-done
under the new measurement context."
(when (and ekp-auto-justify-mode 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) ekp-region--auto-width))))
(defun ekp-region--schedule-reflow ()
"Debounce a re-flow of the current buffer to its effective width."
(when ekp-auto-justify-mode
@ -855,6 +919,7 @@ the buffer text is restored exactly when the mode is turned off."
(add-hook 'window-configuration-change-hook
#'ekp-region--on-window-change nil t)
(add-hook 'window-scroll-functions #'ekp-region--on-scroll nil t)
(add-hook 'text-scale-mode-hook #'ekp-region--on-text-scale nil t)
(add-hook 'after-change-functions #'ekp-region--after-change nil t)
(ekp-region--install-integrations)
;; Turning the major mode off/over kills local hooks silently;
@ -864,6 +929,7 @@ the buffer text is restored exactly when the mode is turned off."
(remove-hook 'window-configuration-change-hook
#'ekp-region--on-window-change t)
(remove-hook 'window-scroll-functions #'ekp-region--on-scroll t)
(remove-hook 'text-scale-mode-hook #'ekp-region--on-text-scale t)
(remove-hook 'after-change-functions #'ekp-region--after-change t)
(remove-hook 'change-major-mode-hook #'ekp-region--teardown t)
(ekp-region--teardown)

View File

@ -114,6 +114,8 @@ family when STRING contains no Latin letter."
;; no latin letter in string, use default
(face-attribute 'default :family)))
(declare-function ekp--measured-width "ekp")
(defun ekp-word-spacing-pixel (string)
"Return the pixel width of an inter-word space for STRING.
Use the blank glyph of STRING's Latin font; for a monospace font
@ -121,11 +123,11 @@ that width is the space's own advance."
;; font is monospace, use the pixel of blank
;; as word spacing pixel
(if-let ((font-family (ekp-monospace-p string)))
(string-pixel-width
(ekp--measured-width
(propertize " " 'face `(:family ,font-family)))
(let* ((letter (ekp-get-latin-letter string))
(font-family (ekp-font-family letter)))
(string-pixel-width
(ekp--measured-width
(propertize
" " 'face `(:family ,font-family))))))

52
ekp.el
View File

@ -249,7 +249,7 @@ when non-zero the C module is bypassed automatically."
"Cache: equal-keyed table, content key → ekp-para struct.")
(defvar ekp--last-para nil
"Fast path: (string-object lang para) of the most recent lookup.
"Fast path: (string-object lang width-context para), most recent lookup.
One justification call resolves the same string object many times;
this avoids recomputing the full cache key each time. Invalidated
by parameter changes, language changes, style-variable changes (see
@ -671,16 +671,48 @@ before.")
(defvar ekp--box-width-cache-limit 65536
"Entry cap for `ekp--box-width-cache'; the cache is flushed beyond it.")
(defun ekp--string-pixel-width (string)
"Pixel width of STRING as it will render in the current buffer.
Like `string-pixel-width', but honors the current buffer's
`face-remapping-alist' which is where `text-scale-mode', themes
and mode-specific font tweaks live. Plain `string-pixel-width'
measures in a bare hidden buffer, so in any buffer with remapped
faces it reports the wrong font's metrics and every \"pixel-exact\"
line comes out wrong on screen (Emacs 31 grew a BUFFER argument for
exactly this; this is the 29/30-compatible equivalent)."
(if (null face-remapping-alist)
(string-pixel-width string)
(let ((remap face-remapping-alist))
(with-current-buffer (get-buffer-create " *ekp-pixel-width*" t)
(setq-local face-remapping-alist remap)
(delete-region (point-min) (point-max))
;; Keep line-affecting context out, like string-pixel-width.
(setq-local line-prefix nil wrap-prefix nil)
(insert string)
(prog1 (car (buffer-text-pixel-size nil nil t))
(delete-region (point-min) (point-max)))))))
(defun ekp--width-context ()
"The display context that box measurement depends on.
nil in an unremapped buffer (the common case); otherwise the
buffer's `face-remapping-alist', which changes glyph metrics and
therefore must key every measurement and paragraph cache entry."
face-remapping-alist)
(defun ekp--measured-width (str)
"`string-pixel-width' of STR, through the global width cache."
"Pixel width of STR in the current display context, cached."
(let* ((ivs (ekp--key-intervals str))
(key (if ivs (cons str ivs) str)))
(ctx (ekp--width-context))
(key (cond ((and (null ivs) (null ctx)) str)
((null ctx) (cons str ivs))
(t (list str ivs ctx)))))
(or (gethash key ekp--box-width-cache)
(progn
(when (>= (hash-table-count ekp--box-width-cache)
ekp--box-width-cache-limit)
(clrhash ekp--box-width-cache))
(puthash key (string-pixel-width str) ekp--box-width-cache)))))
(puthash key (ekp--string-pixel-width str)
ekp--box-width-cache)))))
(defun ekp--para-key (string)
"Compute cache key for STRING.
@ -694,6 +726,10 @@ are derived per string)."
(list string
(prin1-to-string (ekp--key-intervals string))
latin-font cjk-font
;; Buffers with remapped faces (text-scale, themes) render
;; — and therefore measure — differently: never alias their
;; paragraphs with an unremapped buffer's.
(ekp--width-context)
ekp-latin-lang
ekp-alignment
ekp-ragged-stretch-pixel
@ -970,8 +1006,9 @@ Computes ALL data in one pass: text, params, and prefix arrays."
This is the main entry point for cached paragraph data."
(if (and ekp--last-para
(eq (car ekp--last-para) string)
(equal (nth 1 ekp--last-para) ekp-latin-lang))
(nth 2 ekp--last-para)
(equal (nth 1 ekp--last-para) ekp-latin-lang)
(equal (nth 2 ekp--last-para) (ekp--width-context)))
(nth 3 ekp--last-para)
(unless ekp--para-cache
(setq ekp--para-cache (make-hash-table :test 'equal :size 100)))
(let* ((key (ekp--para-key string))
@ -986,7 +1023,8 @@ This is the main entry point for cached paragraph data."
(let ((p (ekp--make-para string)))
(puthash key p ekp--para-cache)
p)))))
(setq ekp--last-para (list string ekp-latin-lang para))
(setq ekp--last-para
(list string ekp-latin-lang (ekp--width-context) para))
para)))
;;;###autoload

View File

@ -262,9 +262,13 @@ the point.)
## Known Limitations
- Widths are computed from the string's own text properties. If the
destination buffer remaps faces (different `:height`, themes), widths
may differ; justify with the same properties you will display.
- Measurement follows the current buffer's face remappings
(`text-scale-mode`, themes, `ekp-org-setup`-style tweaks) and
reserves the truncation-indicator column in windows without
fringes, so justified lines fit the real display. If lines ever
look truncated or short in an exotic setup, run `M-x ekp-diagnose`
in that buffer — it reports whether measurement matches rendering
(and `M-x ekp-gui-verify` runs a full fit check).
- One font is assumed per Latin/CJK script per paragraph when computing
spacing defaults; mixed-font paragraphs work but spacing defaults come
from the first font found.

View File

@ -224,8 +224,11 @@ Silicon 测得;方法见 DEVELOPER_ZH.md:
## 已知限制
- 宽度按字符串自身的文本属性测量。若目标 buffer 重映射了 face(不同
`:height`、主题),宽度可能有偏差;请用与显示时相同的属性做排版。
- 测量会跟随当前 buffer 的 face 重映射(`text-scale-mode`、主题等),
并在无 fringe 的窗口里为截断指示符预留一列,排版行贴合真实显示。
若在特殊配置下仍出现截断或偏短,在该 buffer 里执行
`M-x ekp-diagnose`——它会报告测量与渲染是否一致
(`M-x ekp-gui-verify` 可跑完整贴合检查)。
- 计算默认间距时假定每段落的拉丁/CJK 各使用一种字体;混合字体段落可以
工作,但默认间距取自找到的第一个字体。
- `ekp-pixel-range-justify` 用三分搜索加局部扫描最小化平均 demerits;

195
tests/ekp-gui-verify.el Normal file
View File

@ -0,0 +1,195 @@
;;; ekp-gui-verify.el --- GUI pixel-fit verification for ekp -*- lexical-binding: t; -*-
;; Copyright (C) 2024-2026 Kinney Zhang
;; Author: Kinney Zhang <kinneyzhang666@gmail.com>
;; 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 <https://www.gnu.org/licenses/>.
;;; Commentary:
;; Ground-truth verification that justified text really fits the
;; window, measured with `window-text-pixel-size' on the live display
;; — the one thing batch tests cannot check.
;;
;; Two ways to run:
;;
;; M-x ekp-gui-verify in ANY running GUI Emacs — including your
;; fully customized session. Use this when
;; justified text looks truncated: it tells
;; you whether measurement matches rendering
;; under your fonts/remappings.
;;
;; emacs -Q -L . -L tests -l tests/ekp-gui-verify.el \
;; -f ekp-gui-verify-matrix
;; runs the full matrix (plain, text-scale
;; up/down, face remap, narrow+scale) and
;; prints a PASS/FAIL table.
;;
;; Criterion: every justified line's rendered width equals the target
;; width (± `ekp-region-margin-pixel'). Verbatim paragraphs
;; (`ekp-verbatim') are exempt — code blocks pass through unwrapped by
;; design and may exceed a narrow window, like any code line.
;;; Code:
(require 'ekp)
(require 'ekp-region)
(require 'ekp-showcase)
(defun ekp-gui-verify--scan (buffer)
"Measure every line of BUFFER in its window; return a result plist."
(with-current-buffer buffer
(let* ((win (get-buffer-window buffer))
(body (window-body-width win t))
(target ekp-region--auto-width)
(worst 0) (over 0) (n 0) (exempt 0))
(save-excursion
(goto-char (point-min))
(while (not (eobp))
(let* ((bol (line-beginning-position))
(eol (line-end-position))
(px (if (= bol eol) 0
(car (window-text-pixel-size win bol eol t)))))
(when (> px 0)
(if (text-property-not-all bol eol 'ekp-verbatim nil)
(setq exempt (1+ exempt))
(setq n (1+ n))
(when (> px worst) (setq worst px))
(when (> px body) (setq over (1+ over))))))
(forward-line 1)))
(list :body body :target target :widest worst :over over
:lines n :exempt exempt
:pass (and (= over 0)
(<= (abs (- worst target))
(max 2 ekp-region-margin-pixel)))))))
;;;###autoload
(defun ekp-gui-verify ()
"Verify pixel-exact justification against this session's display.
Opens the ekp showcase, enables follow-window justification, and
checks with `window-text-pixel-size' that every justified line
renders at exactly the window's text width under YOUR fonts,
themes, remappings and text-scale. Reports PASS or FAIL."
(interactive)
(unless (display-graphic-p)
(user-error "GUI verification needs a graphical frame"))
(ekp-showcase)
(redisplay t)
(with-current-buffer "*ekp-showcase*"
(ekp-auto-justify-mode 1)
(when (timerp ekp-region--resize-timer)
(cancel-timer ekp-region--resize-timer))
(ekp-region--reflow (current-buffer) (ekp-region--effective-width))
(redisplay t)
(let* ((r (ekp-gui-verify--scan (current-buffer)))
(msg (format
"ekp-gui-verify: %s — %d lines, widest %dpx vs target %dpx (window %dpx)%s"
(if (plist-get r :pass) "PASS" "FAIL")
(plist-get r :lines) (plist-get r :widest)
(plist-get r :target) (plist-get r :body)
(if (> (plist-get r :exempt) 0)
(format ", %d verbatim lines exempt"
(plist-get r :exempt))
""))))
(message "%s" msg)
r)))
(defun ekp-gui-verify--case (name setup)
"Run one matrix case NAME with buffer SETUP; return a report line."
;; Leftover debounce timers from the previous case must not fire
;; into this case's fresh buffer.
(dolist (fn (list #'ekp-region--reflow
#'ekp-region--flush-dirty
#'ekp-region--process-chunk))
(cancel-function-timers fn))
(when (get-buffer "*ekp-showcase*")
(kill-buffer "*ekp-showcase*"))
(ekp-showcase)
(redisplay t)
(with-current-buffer "*ekp-showcase*"
(funcall setup)
(redisplay t)
(ekp-auto-justify-mode 1)
(when (timerp ekp-region--resize-timer)
(cancel-timer ekp-region--resize-timer))
(ekp-region--reflow (current-buffer) (ekp-region--effective-width))
(redisplay t)
(let ((r (ekp-gui-verify--scan (current-buffer))))
(prog1 (format "%-22s body=%4d target=%4d widest=%4d over=%d/%d %s"
name (plist-get r :body) (plist-get r :target)
(plist-get r :widest) (plist-get r :over)
(plist-get r :lines)
(if (plist-get r :pass) "PASS" "FAIL"))
(ekp-auto-justify-mode -1)))))
;;;###autoload
(defun ekp-gui-verify-matrix ()
"Run the display-context matrix and print a PASS/FAIL table.
Covers: plain, text-scale up/down, family+height face remap, and a
narrow frame with scaling. Intended for `emacs -Q'; in a customized
session prefer `ekp-gui-verify'."
(interactive)
(unless (display-graphic-p)
(user-error "GUI verification needs a graphical frame"))
(save-current-buffer
(ekp-gui-verify--matrix-1)))
(defun ekp-gui-verify--matrix-1 ()
"Run the matrix cases; caller guards the current buffer."
(let (results)
(set-frame-size (selected-frame) 190 40)
(push (ekp-gui-verify--case "base" #'ignore) results)
(push (ekp-gui-verify--case "text-scale +3"
(lambda () (text-scale-set 3)))
results)
(push (ekp-gui-verify--case "text-scale -2"
(lambda () (text-scale-set -2)))
results)
(push (ekp-gui-verify--case "remap family+height"
(lambda ()
(face-remap-add-relative
'default :height 1.15)))
results)
(push (ekp-gui-verify--case "no fringes"
(lambda ()
(set-window-fringes
(get-buffer-window (current-buffer))
0 0)))
results)
(push (ekp-gui-verify--case "no fringes + scale +2"
(lambda ()
(set-window-fringes
(get-buffer-window (current-buffer))
0 0)
(text-scale-set 2)))
results)
(set-frame-size (selected-frame) 70 40)
(push (ekp-gui-verify--case "narrow + scale +2"
(lambda () (text-scale-set 2)))
results)
(let ((table (string-join (nreverse results) "\n")))
(if noninteractive
(princ (concat table "\n"))
(with-current-buffer (get-buffer-create "*ekp-gui-verify*")
(erase-buffer)
(insert table "\n")
(display-buffer (current-buffer))))
table)))
(provide 'ekp-gui-verify)
;;; ekp-gui-verify.el ends here

View File

@ -764,6 +764,42 @@ the (position × line-count) Elisp DP — they must agree."
(ekp-clear-caches)
(should (equal-including-properties via-c via-el)))))))))
;;;; Display-context measurement (M5 wave)
(ert-deftest ekp-test-width-context-keys-caches ()
"Buffers with face remappings must never share cached paragraphs.
`text-scale-mode' and theme tweaks live in `face-remapping-alist';
glyphs render at different sizes there, so paragraph data measured
in one context is wrong in another."
(ekp-clear-caches)
(let ((s "上下文键控检查内容足够长断行"))
(should-not (equal (ekp--para-key s)
(let ((face-remapping-alist
'((default :height 1.5))))
(ekp--para-key s))))
;; the same-string fast path must not leak across contexts either
(ekp-clear-caches)
(let* ((p1 (ekp--get-para s))
(p2 (let ((face-remapping-alist '((default :height 1.5))))
(ekp--get-para s))))
(should-not (eq p1 p2)))
(ekp-clear-caches)))
(ert-deftest ekp-test-width-context-measurement-cached-separately ()
"The width cache keeps remapped and plain measurements apart."
(ekp-clear-caches)
(let* ((s "")
(plain (ekp--measured-width s))
(remapped (let ((face-remapping-alist '((default :height 2.0))))
(ekp--measured-width s))))
;; In batch both degrade to columns (equal values); the point is
;; that neither call poisons the other's cache entry.
(should (= plain (ekp--measured-width s)))
(should (= remapped
(let ((face-remapping-alist '((default :height 2.0))))
(ekp--measured-width s))))
(ekp-clear-caches)))
(provide 'ekp-tests)
;;; ekp-tests.el ends here