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:
parent
ea96a6dc96
commit
57a3abe853
@ -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
95
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))
|
||||
|
||||
@ -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\
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user