diff --git a/ekp.el b/ekp.el index 784c56e..8f45e82 100644 --- a/ekp.el +++ b/ekp.el @@ -120,6 +120,19 @@ compression. `latin-close': chars from `ekp--no-line-start-chars' ending a word (period, comma, quotes). `hyphen': the soft hyphen inserted at a break.") +(defvar ekp-parshape nil + "Per-line layout, as a sequence of (INDENT . WIDTH) cons cells. +Line i (0-based) uses element i; lines beyond the last element reuse +it (like TeX \\parshape). INDENT is the left offset in pixels, +WIDTH the text width — the rendered line occupies INDENT + WIDTH. +Line-number-dependent widths require the (position × line-count) DP, +so this is Elisp-only: the C module is bypassed while set.") + +(defvar ekp-first-line-indent nil + "First-line indentation: pixels, or t for 2 em of the paragraph font. +Sugar for the common CJK paragraph convention; ignored when +`ekp-parshape' is set. Elisp-only, like `ekp-parshape'.") + (defvar ekp-looseness 0 "Target line count offset: 0=optimal, +1=looser (more lines), -1=tighter. When non-zero, a full (position × line-count) dynamic program is run @@ -483,6 +496,8 @@ are derived per string)." ekp-alignment ekp-ragged-stretch-pixel (and ekp-protrusion ekp-protrusion-ratios) + ekp-parshape + ekp-first-line-indent (if (and ekp--params-explicit (ekp--params-set-p)) (list ekp-lws-ideal-pixel ekp-lws-stretch-pixel ekp-lws-shrink-pixel ekp-mws-ideal-pixel @@ -552,6 +567,39 @@ box's. 0 when `ekp-protrusion' was off at paragraph build time." (or ekp-ragged-stretch-pixel (max 1 (* 8 (or ekp-lws-ideal-pixel 1))))) +(defun ekp--parshape-active-p () + "Non-nil when per-line widths are in effect (parshape or indent)." + (or ekp-parshape ekp-first-line-indent)) + +(defun ekp--first-indent-pixel (para) + "Resolve `ekp-first-line-indent' to pixels for PARA." + (cond + ((numberp ekp-first-line-indent) ekp-first-line-indent) + (ekp-first-line-indent + (* 2 (string-pixel-width + (propertize "字" 'face + (list :family (ekp-para-cjk-font para)))))) + (t 0))) + +(defun ekp--line-spec (para line-index measure) + "Layout of LINE-INDEX (0-based) as (INDENT . WIDTH). +MEASURE is the paragraph measure passed to the justify call. +`ekp-parshape' takes precedence; its last entry repeats. Otherwise +`ekp-first-line-indent' shifts line 0. WIDTH never drops below 1." + (cond + (ekp-parshape + (let* ((shape (if (vectorp ekp-parshape) + (append ekp-parshape nil) + ekp-parshape)) + (spec (or (nth line-index shape) (car (last shape))))) + (cons (max 0 (car spec)) (max 1 (cdr spec))))) + (ekp-first-line-indent + (if (= line-index 0) + (let ((indent (ekp--first-indent-pixel para))) + (cons indent (max 1 (- measure indent)))) + (cons 0 measure))) + (t (cons 0 measure)))) + (defun ekp--make-para (string) "Create and fully initialize `ekp-para' struct for STRING. Computes ALL data in one pass: text, params, and prefix arrays." @@ -880,8 +928,10 @@ stripped space-box runs, plus hyphen width when the line hyphenates." ;; least as bad as the worst regular line. (defun ekp--dp-cache-elisp (para line-pixel) - "Pure Elisp DP implementation. Returns and caches the dp-result plist." - (if (/= ekp-looseness 0) + "Pure Elisp DP implementation. Returns and caches the dp-result plist. +Looseness and per-line widths (parshape/first-line indent) need the +\(position × line-count) DP." + (if (or (/= ekp-looseness 0) (ekp--parshape-active-p)) (ekp--dp-cache-elisp-loose para line-pixel) (let ((dp-result (or (ekp--dp-run-1d para line-pixel nil) (ekp--dp-run-1d para line-pixel t)))) @@ -1161,6 +1211,8 @@ breaks when no valid layout exists." (dotimes (i n) (dolist (lc (aref counts-at i)) (let* ((st (gethash (cons i lc) states)) + ;; per-line layout: line LC (0-based) may have its own width + (this-width (cdr (ekp--line-spec para lc line-pixel))) (prev-dem (aref st 0)) (prev-fitness (aref st 2)) (prev-hyphen-count (aref st 3)) @@ -1185,7 +1237,7 @@ breaks when no valid layout exists." (ekp--hyphenate-p hyphen-positions (1- k))) (hyph-w (if end-with-hyphenp hyphen-pixel 0)) ;; right-edge protrusion releases width at this k - (lw (+ line-pixel + (lw (+ this-width (if end-with-hyphenp hyphen-protrude (aref tail-protrudes k)))) @@ -1341,8 +1393,10 @@ CANDIDATE is (DEM-DELTA REST GAPS FITNESS HYPHEN-COUNT)." (and ekp-use-c-module (boundp 'ekp-c-module-loaded) ekp-c-module-loaded (fboundp 'ekp-c-break-with-arrays) - ;; looseness needs the (position × line-count) DP, Elisp only - (= ekp-looseness 0))) + ;; looseness and per-line widths need the (position × line-count) + ;; DP, Elisp only + (= ekp-looseness 0) + (not (ekp--parshape-active-p)))) (defun ekp--c-sync-params () "Push current K-P penalty settings to the C module." @@ -1627,8 +1681,11 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]." nil)) (is-last (>= end boxes-num)) (hyphen-p (ekp--hyphenate-p hyphen-positions (1- end))) + ;; per-line layout (parshape / first-line indent) + (line-spec (ekp--line-spec para i line-pixel)) + (line-indent (car line-spec)) ;; right-edge protrusion widens this line's effective target - (eff-pixel (+ line-pixel + (eff-pixel (+ (cdr line-spec) (if hyphen-p (ekp-para-hyphen-protrude para) (aref (ekp-para-tail-protrudes para) end)))) @@ -1691,6 +1748,10 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]." (append (list filler) (cdr (butlast glue-list)) (list 0)))))) + ;; left indent renders as a leading spacer + (when (> line-indent 0) + (setq glue-list (cons (+ (car glue-list) line-indent) + (cdr glue-list)))) (aset line-glues i (vconcat glue-list)) (setq start end))) line-glues)) diff --git a/tests/ekp-region-tests.el b/tests/ekp-region-tests.el index e80726d..487efac 100644 --- a/tests/ekp-region-tests.el +++ b/tests/ekp-region-tests.el @@ -166,6 +166,15 @@ ;; re-enable so with-mode's cleanup disable is a no-op state-wise (ekp-auto-justify-mode 1)))) +(ert-deftest ekp-region-test-indent-roundtrip () + "First-line indent spacers vanish exactly on unjustify." + (let ((ekp-first-line-indent 6) + (text "首行缩进往返检查内容足够长断行几次")) + (ekp-region-test--with-text text + (ekp-justify-region (point-min) (point-max) 30) + (ekp-unjustify-region (point-min) (point-max)) + (should (equal-including-properties (buffer-string) text))))) + (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 diff --git a/tests/ekp-tests.el b/tests/ekp-tests.el index 6cd41b0..d49a2fc 100644 --- a/tests/ekp-tests.el +++ b/tests/ekp-tests.el @@ -198,6 +198,33 @@ Used to verify no content is lost by justification." (ekp-pixel-justify text 60)))) (should (string= a b)))))) +(ert-deftest ekp-test-first-line-indent () + "First line carries an indent spacer; all lines fill the measure." + (let ((ekp-first-line-indent 6) + (text "缩进段落的内容足够长可以断成好几行来验证首行缩进的正确表现")) + (let ((lines (split-string (ekp-pixel-justify text 30) "\n"))) + (should (> (length lines) 2)) + (should (get-text-property 0 'ekp-glue (car lines))) + (should-not (get-text-property 0 'ekp-glue (cadr lines))) + (dolist (l (butlast lines)) + (should (= (string-pixel-width l) 30)))))) + +(ert-deftest ekp-test-parshape () + "Per-line (INDENT . WIDTH) specs shape the paragraph." + (let ((ekp-parshape '((0 . 20) (6 . 24) (0 . 30))) + (text "参差形状段落内容也要足够长以便验证每一行宽度设置都生效呢")) + (let ((lines (split-string (ekp-pixel-justify text 30) "\n"))) + (should (>= (length lines) 3)) + (should (= (string-pixel-width (nth 0 lines)) 20)) + (should (= (string-pixel-width (nth 1 lines)) 30)) + (dolist (l (butlast (cddr lines))) + (should (= (string-pixel-width l) 30)))))) + +(ert-deftest ekp-test-parshape-bypasses-c () + "Per-line widths force the Elisp 2D path." + (let ((ekp-first-line-indent 6)) + (should-not (ekp--c-available-p)))) + (ert-deftest ekp-test-protrusion-hangs-line-end-punct () "Protrusion lets line-final fullwidth punctuation hang past the edge." (let ((ekp-protrusion t)