diff --git a/ekp-utils.el b/ekp-utils.el index 2b4de32..ecbf418 100644 --- a/ekp-utils.el +++ b/ekp-utils.el @@ -347,7 +347,7 @@ after CALLBACK returns." (defalias 'ekp-c-module-reload #'ekp--module-reload "Load MODULE from a temp copy to allow rebuilding.") -(defconst ekp-c-module-required-version "1.2" +(defconst ekp-c-module-required-version "1.3" "Minimum C module version compatible with this Elisp code.") (defun ekp-c-module-load () diff --git a/ekp.el b/ekp.el index ca9b7dc..c4ccb5d 100644 --- a/ekp.el +++ b/ekp.el @@ -86,6 +86,22 @@ Applied as: this × (1 - fill-ratio) when fill < `ekp-last-line-min-ratio'.") (defvar ekp-last-line-min-ratio 0.5 "Minimum fill ratio for last line (0.0-1.0).") +(defvar ekp-alignment 'justify + "Paragraph alignment mode. +`justify' — flush both edges (default) +`ragged-right' — natural spacing, lines end ragged on the right +`ragged-left' — natural spacing, lines start ragged on the left +`center' — natural spacing, both edges share the leftover +Non-justify modes keep inter-word glue rigid; the K-P optimizer still +picks breaks that minimize raggedness within +`ekp-ragged-stretch-pixel' per line.") + +(defvar ekp-ragged-stretch-pixel nil + "Per-line end-of-line flexibility (pixels) for non-justify alignment. +This is what a ragged line may fall short of the target width without +badness reaching infinity (like \\raggedright with a finite \\rightskip +stretch). nil derives 8× the Latin word-space ideal (≈2 em).") + (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 @@ -441,6 +457,8 @@ are derived per string)." (prin1-to-string (object-intervals string)) latin-font cjk-font ekp-latin-lang + ekp-alignment + ekp-ragged-stretch-pixel (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 @@ -479,6 +497,11 @@ reduces the number of `string-pixel-width' calls." "Return non-nil if BOX-TYPE describes a whitespace box." (and box-type (eq (car box-type) 'space))) +(defun ekp--ragged-extra-stretch () + "Resolve the per-line flexibility for non-justify alignment." + (or ekp-ragged-stretch-pixel + (max 1 (* 8 (or ekp-lws-ideal-pixel 1))))) + (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." @@ -547,8 +570,14 @@ Computes ALL data in one pass: text, params, and prefix arrays." (let* ((box-w (aref boxes-widths i)) (glue-type (aref glues-types i)) (g-ideal (ekp-glue-ideal-pixel glue-type)) - (g-min (ekp-glue-min-pixel glue-type)) - (g-max (ekp-glue-max-pixel glue-type))) + ;; Non-justify alignment: inter-word glue is rigid; the + ;; per-line flexibility comes from :extra-stretch instead. + (g-min (if (eq ekp-alignment 'justify) + (ekp-glue-min-pixel glue-type) + g-ideal)) + (g-max (if (eq ekp-alignment 'justify) + (ekp-glue-max-pixel glue-type) + g-ideal))) (aset glue-ideals i g-ideal) (aset glue-shrinks i (- g-ideal g-min)) (aset glue-stretches i (- g-max g-ideal)) @@ -599,15 +628,19 @@ Computes ALL data in one pass: text, params, and prefix arrays." :trail-spaces trail-spaces :breaks-allowed breaks-allowed :forbidden-positions (vconcat (nreverse forbidden)) - :glue-params (list :lws-ideal ekp-lws-ideal-pixel - :lws-stretch ekp-lws-stretch-pixel - :lws-shrink ekp-lws-shrink-pixel - :mws-ideal ekp-mws-ideal-pixel - :mws-stretch ekp-mws-stretch-pixel - :mws-shrink ekp-mws-shrink-pixel - :cws-ideal ekp-cws-ideal-pixel - :cws-stretch ekp-cws-stretch-pixel - :cws-shrink ekp-cws-shrink-pixel) + :glue-params (let ((justify (eq ekp-alignment 'justify))) + (list :lws-ideal ekp-lws-ideal-pixel + :lws-stretch (if justify ekp-lws-stretch-pixel 0) + :lws-shrink (if justify ekp-lws-shrink-pixel 0) + :mws-ideal ekp-mws-ideal-pixel + :mws-stretch (if justify ekp-mws-stretch-pixel 0) + :mws-shrink (if justify ekp-mws-shrink-pixel 0) + :cws-ideal ekp-cws-ideal-pixel + :cws-stretch (if justify ekp-cws-stretch-pixel 0) + :cws-shrink (if justify ekp-cws-shrink-pixel 0) + :alignment ekp-alignment + :extra-stretch (if justify 0 + (ekp--ragged-extra-stretch)))) :dp-cache (make-hash-table :test 'eql :size 20)))) (defun ekp--get-para (string) @@ -817,6 +850,7 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)." (lws-shrink (plist-get params :lws-shrink)) (mws-shrink (plist-get params :mws-shrink)) (cws-shrink (plist-get params :cws-shrink)) + (extra-stretch (or (plist-get params :extra-stretch) 0)) (backptrs (make-vector (1+ n) nil)) (demerits (make-vector (1+ n) nil)) (rests (make-vector (1+ n) nil)) @@ -861,7 +895,7 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)." hyph-w)) (maxw (+ (- (aref max-prefixs k) mx-i lead-glue-max space-w) - hyph-w))) + hyph-w extra-stretch))) (cond ;; Line already too long: emergency-record atomic run, ;; then stop extending. @@ -916,7 +950,8 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)." (if (> adjustment 0) (+ (* lcnt lws-stretch) (* mcnt mws-stretch) - (* ccnt cws-stretch)) + (* ccnt cws-stretch) + extra-stretch) (+ (* lcnt lws-shrink) (* mcnt mws-shrink) (* ccnt cws-shrink)))) @@ -1035,6 +1070,7 @@ breaks when no valid layout exists." (lws-shrink (plist-get params :lws-shrink)) (mws-shrink (plist-get params :mws-shrink)) (cws-shrink (plist-get params :cws-shrink)) + (extra-stretch (or (plist-get params :extra-stretch) 0)) ;; state: (pos . lines) -> [dem backptr fitness hyph rest gaps] (states (make-hash-table :test 'equal :size (* 4 (1+ n)))) (counts-at (make-vector (1+ n) nil))) @@ -1075,7 +1111,7 @@ breaks when no valid layout exists." hyph-w)) (maxw (+ (- (aref max-prefixs k) mx-i lead-glue-max space-w) - hyph-w)) + hyph-w extra-stretch)) (adjustment (- line-pixel ideal)) candidate) (cond @@ -1127,7 +1163,8 @@ breaks when no valid layout exists." (if (> adjustment 0) (+ (* lcnt lws-stretch) (* mcnt mws-stretch) - (* ccnt cws-stretch)) + (* ccnt cws-stretch) + extra-stretch) (+ (* lcnt lws-shrink) (* mcnt mws-shrink) (* ccnt cws-shrink)))) @@ -1228,7 +1265,10 @@ CANDIDATE is (DEM-DELTA REST GAPS FITNESS HYPHEN-COUNT)." ekp-adjacent-fitness-penalty (float ekp-last-line-min-ratio) ekp-consecutive-hyphen-penalty - (float ekp-last-line-short-penalty)))) + (float ekp-last-line-short-penalty) + (if (eq ekp-alignment 'justify) + 0 + (ekp--ragged-extra-stretch))))) (defun ekp-dp-cache (string line-pixel) "Compute optimal line breaks for STRING at LINE-PIXEL width. @@ -1478,6 +1518,9 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]." (let* ((para (ekp--get-para string)) (boxes-num (length (ekp-para-boxes para))) (glues-types (ekp-para-glues-types para)) + (alignment (or (plist-get (ekp-para-glue-params para) :alignment) + 'justify)) + (ragged (not (eq alignment 'justify))) (hyphen-positions (ekp-para-hyphen-positions para)) (breaks (ekp-line-breaks string line-pixel)) (lines-rests (ekp-dp-data string line-pixel :rests)) @@ -1517,8 +1560,9 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]." (- ideal-pixel (if hyphen-p hyphen-pixel 0)) hyphen-p hyphen-pixel)) - ;; Last line: ragged right - (is-last + ;; Last line, or any line under non-justify alignment: + ;; natural glue widths plus a trailing filler. + ((or is-last ragged) (ekp--line-glue-last-line para line-glues-types ideal-pixel line-pixel)) ;; Emergency underfull line (can't stretch to width): @@ -1534,6 +1578,21 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]." (ekp--line-glue-normal para line-glues-types (nth i lines-rests) (nth i lines-gaps))))) + ;; Non-justify alignment: place the leftover per mode + ;; (ragged-right keeps it trailing; center splits it; ragged-left + ;; moves it to the head). + (when (and ragged (>= (length glue-list) 2) + (memq alignment '(center ragged-left))) + (let ((filler (car (last glue-list)))) + (setq glue-list + (if (eq alignment 'center) + (let ((lead (/ filler 2))) + (append (list lead) + (cdr (butlast glue-list)) + (list (- filler lead)))) + (append (list filler) + (cdr (butlast glue-list)) + (list 0)))))) (aset line-glues i (vconcat glue-list)) (setq start end))) line-glues)) diff --git a/ekp_c/ekp.c b/ekp_c/ekp.c index e927266..3966a08 100644 --- a/ekp_c/ekp.c +++ b/ekp_c/ekp.c @@ -152,6 +152,10 @@ static emacs_value Fekp_c_set_penalties(emacs_env *env, ptrdiff_t nargs, ekp_global->consec_hyphen_penalty = env->extract_integer(env, args[4]); if (nargs > 5) ekp_global->last_line_short_penalty = env->extract_float(env, args[5]); + /* Per-line extra stretch for non-justify alignment; reset to 0 + * when the caller omits it so stale values never leak. */ + ekp_global->extra_stretch = + (nargs > 6) ? (int32_t)env->extract_integer(env, args[6]) : 0; return env->intern(env, "t"); } @@ -707,7 +711,7 @@ Arguments are: LWS-IDEAL LWS-STRETCH LWS-SHRINK\n\ LWS = Latin Word Space, MWS = Mixed, CWS = CJK.\n\n\ (fn LWS-I LWS-+ LWS-- MWS-I MWS-+ MWS-- CWS-I CWS-+ CWS--)"); - defun(env, "ekp-c-set-penalties", 4, 6, Fekp_c_set_penalties, + defun(env, "ekp-c-set-penalties", 4, 7, Fekp_c_set_penalties, "Set Knuth-Plass algorithm penalties.\n\n\ LINE-PENALTY: base penalty per line break (default 10)\n\ HYPHEN-PENALTY: penalty for hyphenated breaks (default 50)\n\ diff --git a/ekp_c/ekp_kp.c b/ekp_c/ekp_kp.c index d010619..433b23f 100644 --- a/ekp_c/ekp_kp.c +++ b/ekp_c/ekp_kp.c @@ -162,6 +162,9 @@ typedef struct { double last_line_ratio; int consec_hyphen_penalty; double last_line_short_penalty; + /* Per-line flexibility for non-justify alignment (0 = justify): + * widens max_w, so flexibility = max_w - ideal includes it. */ + int32_t extra_stretch; /* Two-pass strategy: strict K-P first; emergency single-box * breaks only in the second pass (when no valid layout exists). */ @@ -301,7 +304,8 @@ static void dp_process_position( int32_t min_w = in->min_prefix[k] - in->min_prefix[i] - (lead_ideal - lead_shrink) - space_w + hyph_w; int32_t max_w = in->max_prefix[k] - in->max_prefix[i] - - (lead_ideal + lead_stretch) - space_w + hyph_w; + (lead_ideal + lead_stretch) - space_w + hyph_w + + in->extra_stretch; /* Too long? (last line is never shrunk below its ideal) */ if (min_w > line_width || (is_last && ideal > line_width)) { @@ -680,6 +684,7 @@ ekp_result_t *ekp_break_with_prefixes( double last_ratio = ekp_global ? ekp_global->last_line_ratio : 0.5; int chp = ekp_global ? ekp_global->consec_hyphen_penalty : 100; double llsp = ekp_global ? ekp_global->last_line_short_penalty : 50.0; + int32_t xstretch = ekp_global ? ekp_global->extra_stretch : 0; /* Create unified input structure */ dp_input_t in = { @@ -704,6 +709,7 @@ ekp_result_t *ekp_break_with_prefixes( .last_line_ratio = last_ratio, .consec_hyphen_penalty = chp, .last_line_short_penalty = llsp, + .extra_stretch = xstretch, .allow_emergency = false }; diff --git a/ekp_c/ekp_module.h b/ekp_c/ekp_module.h index 26f3a7f..93954da 100644 --- a/ekp_c/ekp_module.h +++ b/ekp_c/ekp_module.h @@ -16,7 +16,7 @@ /* Version */ #define EKP_VERSION_MAJOR 1 -#define EKP_VERSION_MINOR 2 +#define EKP_VERSION_MINOR 3 /* Limits */ #define EKP_MAX_PATTERN_LEN 64 @@ -193,6 +193,8 @@ typedef struct { double last_line_ratio; int consec_hyphen_penalty; /* multiplier for consecutive hyphens */ double last_line_short_penalty; /* multiplier for short last lines */ + int32_t extra_stretch; /* per-line flexibility for non-justify + * alignment (0 = justify) */ } ekp_state_t; /* Global state instance */ diff --git a/tests/ekp-tests.el b/tests/ekp-tests.el index 8108d47..b0b1fb4 100644 --- a/tests/ekp-tests.el +++ b/tests/ekp-tests.el @@ -137,6 +137,67 @@ Used to verify no content is lost by justification." (dolist (k '(1 5 7 10)) (should (aref ok k))))) +(defun ekp-test--line-glue-widths (line) + "Pixel widths of synthesized glue spaces in LINE, in order." + (let (ws) + (dotimes (i (length line)) + (let ((d (get-text-property i 'display line))) + (when (and (consp d) (eq (car d) 'space) + (get-text-property i 'ekp-glue line)) + (push (car (plist-get (cdr d) :width)) ws)))) + (nreverse ws))) + +(ert-deftest ekp-test-alignment-ragged-right () + "Ragged-right: lines fit, interior spacing stays at ideal." + (let ((ekp-alignment 'ragged-right) + (text "aaa bbb ccc ddd eee fff ggg hhh iii jjj kkk lll")) + (let ((lines (split-string (ekp-pixel-justify text 12) "\n"))) + (should (> (length lines) 1)) + (dolist (line lines) + (should (<= (string-pixel-width line) 12)) + ;; all interior glues at ideal (1px word space in batch); + ;; only the trailing filler may be wider + (let ((interior (butlast (ekp-test--line-glue-widths line)))) + (dolist (w interior) (should (<= w 1)))))))) + +(ert-deftest ekp-test-alignment-ragged-left () + "Ragged-left: every line is pushed flush to the right edge." + (let ((ekp-alignment 'ragged-left) + (text "aaa bbb ccc ddd eee fff ggg hhh iii jjj kkk lll")) + (dolist (line (split-string (ekp-pixel-justify text 12) "\n")) + (should (= (string-pixel-width line) 12))))) + +(ert-deftest ekp-test-alignment-center () + "Center: leftover splits evenly between the two edges." + (let ((ekp-alignment 'center) + (text "aaa bbb ccc ddd eee fff ggg hhh iii jjj kkk lll")) + (dolist (line (split-string (ekp-pixel-justify text 12) "\n")) + (should (= (string-pixel-width line) 12)) + (let* ((len (length line)) + (lead (if (and (> len 0) + (get-text-property 0 'ekp-glue line)) + (or (car (ekp-test--line-glue-widths line)) 0) + 0)) + (trail (if (and (> len 0) + (get-text-property (1- len) 'ekp-glue line)) + (or (car (last (ekp-test--line-glue-widths line))) 0) + 0))) + (should (<= (abs (- lead trail)) 1)))))) + +(ert-deftest ekp-test-alignment-c-parity () + "C and elisp engines agree under every alignment mode." + (skip-unless (ekp-tests--c-available)) + (dolist (align '(justify ragged-right ragged-left center)) + (let ((ekp-alignment align) + (text "对齐 parity 检查内容 mixed 中英文字 several words here too")) + (let ((a (let ((ekp-use-c-module nil)) + (ekp-clear-caches) + (ekp-pixel-justify text 60))) + (b (let ((ekp-use-c-module t)) + (ekp-clear-caches) + (ekp-pixel-justify text 60)))) + (should (string= a b)))))) + (ert-deftest ekp-test-no-break-span-atomic () "An ekp-no-break span never splits, stretches, or hyphenates." (let* ((code (propertize "foo bar baz" 'ekp-no-break t))