From 29cef97ef86748f955f7aa3c749d599952bcc6ce Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Mon, 27 Jul 2026 03:19:27 +0800 Subject: [PATCH] fix: justified lines fit the real display, not an idealized one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ekp-region.el | 68 +++++++++++++- ekp-utils.el | 6 +- ekp.el | 52 +++++++++-- readme.md | 10 ++- readme_zh.md | 7 +- tests/ekp-gui-verify.el | 195 ++++++++++++++++++++++++++++++++++++++++ tests/ekp-tests.el | 36 ++++++++ 7 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 tests/ekp-gui-verify.el diff --git a/ekp-region.el b/ekp-region.el index 3b466bf..62cbb30 100644 --- a/ekp-region.el +++ b/ekp-region.el @@ -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) diff --git a/ekp-utils.el b/ekp-utils.el index 165413f..5634a75 100644 --- a/ekp-utils.el +++ b/ekp-utils.el @@ -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)))))) diff --git a/ekp.el b/ekp.el index 3798c78..2da5f5d 100644 --- a/ekp.el +++ b/ekp.el @@ -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 diff --git a/readme.md b/readme.md index bd40567..44beab8 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/readme_zh.md b/readme_zh.md index b6d2c2a..bf8c2ea 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -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; diff --git a/tests/ekp-gui-verify.el b/tests/ekp-gui-verify.el new file mode 100644 index 0000000..cf76536 --- /dev/null +++ b/tests/ekp-gui-verify.el @@ -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 + +;; This file is NOT part of GNU Emacs. + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; 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 diff --git a/tests/ekp-tests.el b/tests/ekp-tests.el index 8294b56..e20360c 100644 --- a/tests/ekp-tests.el +++ b/tests/ekp-tests.el @@ -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