feat: alignment modes — ragged-right, ragged-left, center (C 1.3)

ekp-alignment selects the paragraph alignment. Non-justify modes keep
inter-word glue rigid (arrays and class params zeroed at para build)
while the DP gains a per-line flexibility ekp-ragged-stretch-pixel
(default ≈2 em): badness = 100·(shortfall/R)³, so Knuth-Plass still
globally minimizes raggedness — this is \raggedright with a finite
\rightskip stretch, not first-fit.

Rendering distributes each line's leftover by mode: trailing
(ragged-right), split evenly (center), leading (ragged-left); the
spacers are ekp-glue-marked, so buffer roundtrips stay exact.

C module 1.3: set-penalties gains an extra-stretch scalar (reset when
omitted); max_w widens by it, so the prefix-based flexibility picks it
up automatically. Cache keys include alignment and R.

Tests: 56 ERT green incl. per-mode invariants (fit, rigid interior
glue, flush edges, centered leftover ±1px) and four-mode C parity;
fuzz 300/300.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kinneyzhang 2026-07-26 21:22:19 +08:00
parent ea96a6dc96
commit 57a3abe853
6 changed files with 154 additions and 22 deletions

View File

@ -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 ()

95
ekp.el
View File

@ -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))

View File

@ -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\

View File

@ -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
};

View File

@ -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 */

View File

@ -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))