feat: right-edge protrusion — hanging punctuation (C 1.4)

ekp-protrusion enables microtype-style character protrusion at the
right edge: a line ending in fullwidth punctuation (。、」…) hangs
the whitespace half of the glyph past the flush edge — visually
identical to CLREQ line-end punctuation compression — and latin
periods/commas/quotes and soft hyphens protrude by their configured
ratios (ekp-protrusion-ratios).

Mechanics: per-box tail protrusion, folded through trailing space
boxes into a per-gap array; each DP candidate widens its effective
target (lw = width + release), mirrored exactly in the renderer's
per-line effective width and in the C-result reconstruction, so DP,
rendering and both engines stay coherent.  Left-edge protrusion is
documented as out of scope (Emacs cannot render before the line
origin).  ekp-region reserves the protrusion width in auto layouts
(ekp-region-protrusion-reserve).

Off by default; all-zero arrays make it a no-op on the shared path.
C module 1.4: break-with-arrays 12→14 args (tail-protrudes array,
hyphen-protrude scalar), batch vectors 14 elements.

Tests: 59 ERT green (deterministic hang check, flush-when-off,
protrusion C parity); fuzz 300/300.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kinneyzhang 2026-07-26 21:32:54 +08:00
parent 57a3abe853
commit f6aa64b3b1
7 changed files with 243 additions and 53 deletions

View File

@ -59,9 +59,20 @@ by the display engine due to rounding."
;;;; Width
(defun ekp-region-protrusion-reserve ()
"Pixels reserved at the right margin for hanging punctuation.
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 ""))))
0))
(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)))
(max 1 (- (window-body-width window t)
ekp-region-margin-pixel
(ekp-region-protrusion-reserve))))
;;;; Pure string transforms

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.3"
(defconst ekp-c-module-required-version "1.4"
"Minimum C module version compatible with this Elisp code.")
(defun ekp-c-module-load ()

144
ekp.el
View File

@ -102,6 +102,24 @@ 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-protrusion nil
"Non-nil enables right-edge character protrusion (hanging punctuation).
A line ending in punctuation lets part of that glyph hang past the
flush edge, per `ekp-protrusion-ratios' CLREQ line-end punctuation
squeeze and microtype-style hanging periods/hyphens in one mechanism.
Left-edge protrusion is not implemented: Emacs cannot render text
before the line origin. When enabled, reserve the protrusion width
in the layout (see `ekp-region-protrusion-reserve')." )
(defvar ekp-protrusion-ratios
'((cjk-close . 0.5) (latin-close . 0.5) (hyphen . 1.0))
"Alist CLASS → RATIO of the glyph width allowed to protrude.
`cjk-close': fullwidth closers (); 0.5 hangs exactly the
whitespace half of the glyph visually equivalent to CLREQ line-end
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-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
@ -137,6 +155,11 @@ when non-zero the C module is bypassed automatically.")
;; index n (paragraph end) is always allowed. forbidden-positions is
;; the same information as a sparse int vector for the C bridge.
breaks-allowed forbidden-positions
;; Right-edge protrusion: tail-protrudes[k] = pixels the last
;; non-space box before gap k may hang past the flush edge (all
;; zeros when `ekp-protrusion' is off); hyphen-protrude = same for
;; the soft hyphen at a hyphenated break.
tail-protrudes hyphen-protrude
;; Glue params snapshot at para creation time (plist)
glue-params
(dp-cache nil :type hash-table))
@ -459,6 +482,7 @@ are derived per string)."
ekp-latin-lang
ekp-alignment
ekp-ragged-stretch-pixel
(and ekp-protrusion ekp-protrusion-ratios)
(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
@ -497,6 +521,32 @@ 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--tail-protrude-pixel (box box-type)
"Pixels the last visible char of BOX may protrude past the flush edge."
(if (not ekp-protrusion)
0
(let* ((tail-type (cdr box-type))
(last-str (substring box -1))
(ratio (cond
((eq tail-type 'cjk-close)
(alist-get 'cjk-close ekp-protrusion-ratios 0))
((memq (aref box (1- (length box)))
(append ekp--no-line-start-chars nil))
(alist-get 'latin-close ekp-protrusion-ratios 0))
(t 0))))
(if (> ratio 0)
(floor (* ratio (string-pixel-width last-str)))
0))))
(defun ekp--line-edge-release (para _start end)
"Pixels released at the right edge of the line [START, END).
The protrusion of the line's final glyph: the soft hyphen's when the
line breaks at a hyphenation point, otherwise the last non-space
box's. 0 when `ekp-protrusion' was off at paragraph build time."
(if (ekp--hyphenate-p (ekp-para-hyphen-positions para) (1- end))
(ekp-para-hyphen-protrude para)
(aref (ekp-para-tail-protrudes para) end)))
(defun ekp--ragged-extra-stretch ()
"Resolve the per-line flexibility for non-justify alignment."
(or ekp-ragged-stretch-pixel
@ -537,7 +587,13 @@ Computes ALL data in one pass: text, params, and prefix arrays."
(lead-spaces (make-vector (1+ n) 0))
(trail-spaces (make-vector (1+ n) 0))
(breaks-allowed (make-bool-vector (1+ n) t))
(forbidden nil))
(forbidden nil)
(tail-protrudes (make-vector (1+ n) 0))
(hyphen-protrude
(if ekp-protrusion
(floor (* (alist-get 'hyphen ekp-protrusion-ratios 0)
hyphen-pixel))
0)))
;; Break permissions. A gap is unbreakable when:
;; - kinsoku: the line would end with an opener or start with a
;; closer (full- and halfwidth alike),
@ -565,6 +621,21 @@ Computes ALL data in one pass: text, params, and prefix arrays."
(unless (eq (aref glues-types k) 'nws)
(aset glues-types k 'nws))))
(setq k (1+ k))))
;; Right-edge protrusion: tail-protrudes[k] = protrusion of the
;; last non-space box before gap k (renderer strips trailing
;; space boxes, so look through them).
(when ekp-protrusion
(let ((pro (make-vector (max n 1) 0)))
(dotimes (b n)
(aset pro b (ekp--tail-protrude-pixel (aref boxes b)
(aref boxes-types b))))
(let ((k 1))
(while (<= k n)
(aset tail-protrudes k
(if (ekp--space-box-type-p (aref boxes-types (1- k)))
(aref tail-protrudes (1- k))
(aref pro (1- k))))
(setq k (1+ k))))))
;; Single loop for all prefix computations
(dotimes (i n)
(let* ((box-w (aref boxes-widths i))
@ -628,6 +699,8 @@ Computes ALL data in one pass: text, params, and prefix arrays."
:trail-spaces trail-spaces
:breaks-allowed breaks-allowed
:forbidden-positions (vconcat (nreverse forbidden))
:tail-protrudes tail-protrudes
:hyphen-protrude hyphen-protrude
:glue-params (let ((justify (eq ekp-alignment 'justify)))
(list :lws-ideal ekp-lws-ideal-pixel
:lws-stretch (if justify ekp-lws-stretch-pixel 0)
@ -843,6 +916,8 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)."
(lead-spaces (ekp-para-lead-spaces para))
(trail-spaces (ekp-para-trail-spaces para))
(breaks-ok (ekp-para-breaks-allowed para))
(tail-protrudes (ekp-para-tail-protrudes para))
(hyphen-protrude (ekp-para-hyphen-protrude para))
(params (ekp-para-glue-params para))
(lws-stretch (plist-get params :lws-stretch))
(mws-stretch (plist-get params :mws-stretch))
@ -886,6 +961,11 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)."
(atomic-run (not saw-allowed))
(end-with-hyphenp (aref hyph-flags (1- k)))
(hyph-w (if end-with-hyphenp hyphen-pixel 0))
;; right-edge protrusion releases width at this k
(lw (+ line-pixel
(if end-with-hyphenp
hyphen-protrude
(aref tail-protrudes k))))
(raw-ideal (- (aref ideal-prefixs k) ip-i lead-glue-ideal))
(space-w (min raw-ideal
(+ lead-space (aref trail-spaces k))))
@ -899,20 +979,20 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)."
(cond
;; Line already too long: emergency-record atomic run,
;; then stop extending.
((or (> minw line-pixel)
(and is-last (> ideal line-pixel)))
((or (> minw lw)
(and is-last (> ideal lw)))
(when (and atomic-run allow-emergency)
(ekp--dp-relax-emergency
demerits backptrs rests gaps hyphen-counts
fitness-classes i k prev-dem
(- line-pixel ideal) end-with-hyphenp
(- lw ideal) end-with-hyphenp
prev-hyphen-count
(unless single-box (ekp--gaps-between para i k))))
(throw 'break nil))
;; Valid break point
((or (<= minw line-pixel maxw)
(and is-last (<= ideal line-pixel)))
(let* ((adjustment (- line-pixel ideal))
((or (<= minw lw maxw)
(and is-last (<= ideal lw)))
(let* ((adjustment (- lw ideal))
dem line-gaps fitness new-hyphen)
(cond
;; Single box line: fixed flexibility of 1
@ -929,7 +1009,7 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)."
end-with-hyphenp prev-hyphen-count))))
;; Last line: minimal demerits if reasonably filled
(is-last
(let* ((fill-ratio (/ (float ideal) line-pixel))
(let* ((fill-ratio (/ (float ideal) lw))
(badness (if (< fill-ratio
ekp-last-line-min-ratio)
(* ekp-last-line-short-penalty
@ -982,7 +1062,7 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)."
(ekp--dp-relax-emergency
demerits backptrs rests gaps hyphen-counts
fitness-classes i k prev-dem
(- line-pixel ideal) end-with-hyphenp
(- lw ideal) end-with-hyphenp
prev-hyphen-count
(unless single-box (ekp--gaps-between para i k)))))
(setq saw-allowed t)
@ -1063,6 +1143,8 @@ breaks when no valid layout exists."
(lead-spaces (ekp-para-lead-spaces para))
(trail-spaces (ekp-para-trail-spaces para))
(breaks-ok (ekp-para-breaks-allowed para))
(tail-protrudes (ekp-para-tail-protrudes para))
(hyphen-protrude (ekp-para-hyphen-protrude para))
(params (ekp-para-glue-params para))
(lws-stretch (plist-get params :lws-stretch))
(mws-stretch (plist-get params :mws-stretch))
@ -1102,6 +1184,11 @@ breaks when no valid layout exists."
(end-with-hyphenp
(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
(if end-with-hyphenp
hyphen-protrude
(aref tail-protrudes k))))
(raw-ideal (- (aref ideal-prefixs k) ip-i lead-glue-ideal))
(space-w (min raw-ideal
(+ lead-space (aref trail-spaces k))))
@ -1112,11 +1199,11 @@ breaks when no valid layout exists."
(maxw (+ (- (aref max-prefixs k) mx-i lead-glue-max
space-w)
hyph-w extra-stretch))
(adjustment (- line-pixel ideal))
(adjustment (- lw ideal))
candidate)
(cond
((or (> minw line-pixel)
(and is-last (> ideal line-pixel)))
((or (> minw lw)
(and is-last (> ideal lw)))
(when (and atomic-run allow-emergency)
(setq candidate
(list (+ (expt (+ ekp-line-penalty
@ -1131,8 +1218,8 @@ breaks when no valid layout exists."
(ekp--dp-loose-relax states counts-at k (1+ lc) i
prev-dem candidate))
(throw 'break nil))
((or (<= minw line-pixel maxw)
(and is-last (<= ideal line-pixel)))
((or (<= minw lw maxw)
(and is-last (<= ideal lw)))
(setq candidate
(cond
(single-box
@ -1146,7 +1233,7 @@ breaks when no valid layout exists."
end-with-hyphenp prev-hyphen-count)
adjustment nil 1 nh)))
(is-last
(let* ((fill-ratio (/ (float ideal) line-pixel))
(let* ((fill-ratio (/ (float ideal) lw))
(badness (if (< fill-ratio
ekp-last-line-min-ratio)
(* ekp-last-line-short-penalty
@ -1287,7 +1374,9 @@ If `ekp-use-c-module' is non-nil and the C module is available (and
"Compute (RESTS . GAPS) lists for BREAKS, matching the DP's metrics."
(let ((start 0) rests gapss)
(dolist (end breaks)
(push (- line-pixel (ekp--line-ideal-pixel para start end)) rests)
(push (- (+ line-pixel (ekp--line-edge-release para start end))
(ekp--line-ideal-pixel para start end))
rests)
(push (if (or (= end (1+ start))
(= end (length (ekp-para-boxes para))))
nil
@ -1308,7 +1397,7 @@ If `ekp-use-c-module' is non-nil and the C module is available (and
dp-result))
(defun ekp--prepare-para-for-c (para line-pixel)
"Prepare PARA data as a 12-element vector for the C batch API."
"Prepare PARA data as a 14-element vector for the C batch API."
(vector (ekp-para-ideal-prefixs para)
(ekp-para-min-prefixs para)
(ekp-para-max-prefixs para)
@ -1320,7 +1409,9 @@ If `ekp-use-c-module' is non-nil and the C module is available (and
line-pixel
(ekp-para-lead-spaces para)
(ekp-para-trail-spaces para)
(ekp-para-forbidden-positions para)))
(ekp-para-forbidden-positions para)
(ekp-para-tail-protrudes para)
(ekp-para-hyphen-protrude para)))
(defun ekp--dp-cache-via-c (para line-pixel)
"Compute breaks using the C module with PARA's precomputed arrays.
@ -1339,7 +1430,9 @@ runs the pure DP. Falls back to Elisp when the C call fails."
line-pixel
(ekp-para-lead-spaces para)
(ekp-para-trail-spaces para)
(ekp-para-forbidden-positions para)))
(ekp-para-forbidden-positions para)
(ekp-para-tail-protrudes para)
(ekp-para-hyphen-protrude para)))
(c-breaks (car result))
(c-cost (cdr result)))
(if (null c-breaks)
@ -1534,6 +1627,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)))
;; right-edge protrusion widens this line's effective target
(eff-pixel (+ line-pixel
(if hyphen-p
(ekp-para-hyphen-protrude para)
(aref (ekp-para-tail-protrudes para) end))))
;; DP-consistent metrics (space-box runs excluded, hyphen incl.)
(ideal-pixel (ekp--line-ideal-pixel para start end))
(max-pixel (let* ((mx (ekp-para-max-prefixs para))
@ -1556,7 +1654,7 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]."
(cond
;; Single box: just trailing space
((= 1 (- end start))
(ekp--line-glue-single-box line-pixel
(ekp--line-glue-single-box eff-pixel
(- ideal-pixel
(if hyphen-p hyphen-pixel 0))
hyphen-p hyphen-pixel))
@ -1564,15 +1662,15 @@ Each line's glues: [0 glue1 glue2 ... trailing-space]."
;; natural glue widths plus a trailing filler.
((or is-last ragged)
(ekp--line-glue-last-line
para line-glues-types ideal-pixel line-pixel))
para line-glues-types ideal-pixel eff-pixel))
;; Emergency underfull line (can't stretch to width):
;; set glues to max and pad with trailing filler.
((< max-pixel line-pixel)
((< max-pixel eff-pixel)
(append '(0)
(mapcar (lambda (type)
(ekp--para-glue-max para type))
line-glues-types)
(list (max 0 (- line-pixel max-pixel)))))
(list (max 0 (- eff-pixel max-pixel)))))
;; Normal justified line
(t
(ekp--line-glue-normal para line-glues-types

View File

@ -327,7 +327,7 @@ static emacs_value Fekp_c_break_with_arrays(emacs_env *env, ptrdiff_t nargs,
{
(void)data;
if (!ekp_global || nargs < 12)
if (!ekp_global || nargs < 14)
return env->intern(env, "nil");
/* Get prefix array sizes (n+1 elements) */
@ -387,6 +387,15 @@ static emacs_value Fekp_c_break_with_arrays(emacs_env *env, ptrdiff_t nargs,
int32_t hyph_width = env->extract_integer(env, args[7]);
int32_t line_width = env->extract_integer(env, args[8]);
/* Right-edge protrusion: per-gap array (n+1) and hyphen scalar */
int32_t *tail_pro = malloc(prefix_len * sizeof(int32_t));
if (tail_pro) {
for (ptrdiff_t i = 0; i < prefix_len; i++) {
tail_pro[i] = env->extract_integer(env, env->vec_get(env, args[12], i));
}
}
int32_t hyphen_protrude = env->extract_integer(env, args[13]);
/* Forbidden break positions (sorted gap indices, may be empty) */
ptrdiff_t forb_count = env->vec_size(env, args[11]);
int32_t *forb_pos = NULL;
@ -407,13 +416,15 @@ static emacs_value Fekp_c_break_with_arrays(emacs_env *env, ptrdiff_t nargs,
hyph_pos, hyph_count > 0 ? (size_t)hyph_count : 0,
hyph_width, line_width,
lead_spaces, trail_spaces,
forb_pos, (forb_pos && forb_count > 0) ? (size_t)forb_count : 0);
forb_pos, (forb_pos && forb_count > 0) ? (size_t)forb_count : 0,
tail_pro, hyphen_protrude);
free(ideal_prefix); free(min_prefix); free(max_prefix);
free(glue_ideals); free(glue_shrinks); free(glue_stretches);
free(lead_spaces); free(trail_spaces);
free(hyph_pos);
free(forb_pos);
free(tail_pro);
if (!result)
return env->intern(env, "nil");
@ -447,7 +458,8 @@ static bool extract_paragraph_data(
int32_t **hyph_pos, size_t *n, ptrdiff_t *hyph_count,
int32_t *hyph_width, int32_t *line_width,
int32_t **lead_spaces, int32_t **trail_spaces,
int32_t **forb_pos, ptrdiff_t *forb_count)
int32_t **forb_pos, ptrdiff_t *forb_count,
int32_t **tail_pro, int32_t *hyphen_protrude)
{
ptrdiff_t prefix_len = env->vec_size(env, args[0]);
if (prefix_len <= 1)
@ -512,6 +524,14 @@ static bool extract_paragraph_data(
}
}
*tail_pro = malloc(prefix_len * sizeof(int32_t));
if (*tail_pro) {
for (ptrdiff_t i = 0; i < prefix_len; i++) {
(*tail_pro)[i] = env->extract_integer(env, env->vec_get(env, args[12], i));
}
}
*hyphen_protrude = env->extract_integer(env, args[13]);
return true;
}
@ -550,13 +570,14 @@ static emacs_value Fekp_c_break_batch(emacs_env *env, ptrdiff_t nargs,
int32_t **all_lead = calloc(para_count, sizeof(int32_t *));
int32_t **all_trail = calloc(para_count, sizeof(int32_t *));
int32_t **all_forb = calloc(para_count, sizeof(int32_t *));
int32_t **all_pro = calloc(para_count, sizeof(int32_t *));
if (!inputs || !all_ideal || !all_min || !all_max ||
!all_glue_i || !all_glue_sh || !all_glue_st || !all_hyph ||
!all_lead || !all_trail || !all_forb) {
!all_lead || !all_trail || !all_forb || !all_pro) {
free(inputs); free(all_ideal); free(all_min); free(all_max);
free(all_glue_i); free(all_glue_sh); free(all_glue_st); free(all_hyph);
free(all_lead); free(all_trail); free(all_forb);
free(all_lead); free(all_trail); free(all_forb); free(all_pro);
return env->intern(env, "nil");
}
@ -564,15 +585,15 @@ static emacs_value Fekp_c_break_batch(emacs_env *env, ptrdiff_t nargs,
for (ptrdiff_t p = 0; p < para_count; p++) {
emacs_value para_vec = env->vec_get(env, args[0], p);
/* Extract 12 arguments from this paragraph's vector */
emacs_value para_args[12];
for (int i = 0; i < 12; i++) {
/* Extract 14 arguments from this paragraph's vector */
emacs_value para_args[14];
for (int i = 0; i < 14; i++) {
para_args[i] = env->vec_get(env, para_vec, i);
}
size_t n;
ptrdiff_t hyph_count, forb_count;
int32_t hyph_width, line_width;
int32_t hyph_width, line_width, hyphen_protrude;
if (!extract_paragraph_data(env, para_args,
&all_ideal[p], &all_min[p], &all_max[p],
@ -580,17 +601,18 @@ static emacs_value Fekp_c_break_batch(emacs_env *env, ptrdiff_t nargs,
&all_hyph[p], &n, &hyph_count,
&hyph_width, &line_width,
&all_lead[p], &all_trail[p],
&all_forb[p], &forb_count)) {
&all_forb[p], &forb_count,
&all_pro[p], &hyphen_protrude)) {
/* Cleanup on failure */
for (ptrdiff_t j = 0; j < p; j++) {
free(all_ideal[j]); free(all_min[j]); free(all_max[j]);
free(all_glue_i[j]); free(all_glue_sh[j]); free(all_glue_st[j]);
free(all_hyph[j]); free(all_lead[j]); free(all_trail[j]);
free(all_forb[j]);
free(all_forb[j]); free(all_pro[j]);
}
free(inputs); free(all_ideal); free(all_min); free(all_max);
free(all_glue_i); free(all_glue_sh); free(all_glue_st); free(all_hyph);
free(all_lead); free(all_trail); free(all_forb);
free(all_lead); free(all_trail); free(all_forb); free(all_pro);
return env->intern(env, "nil");
}
@ -610,6 +632,8 @@ static emacs_value Fekp_c_break_batch(emacs_env *env, ptrdiff_t nargs,
inputs[p].forbidden_positions = all_forb[p];
inputs[p].forbidden_count =
(all_forb[p] && forb_count > 0) ? (size_t)forb_count : 0;
inputs[p].tail_protrudes = all_pro[p];
inputs[p].hyphen_protrude = hyphen_protrude;
}
/* Process all paragraphs in parallel */
@ -620,11 +644,11 @@ static emacs_value Fekp_c_break_batch(emacs_env *env, ptrdiff_t nargs,
free(all_ideal[p]); free(all_min[p]); free(all_max[p]);
free(all_glue_i[p]); free(all_glue_sh[p]); free(all_glue_st[p]);
free(all_hyph[p]); free(all_lead[p]); free(all_trail[p]);
free(all_forb[p]);
free(all_forb[p]); free(all_pro[p]);
}
free(inputs); free(all_ideal); free(all_min); free(all_max);
free(all_glue_i); free(all_glue_sh); free(all_glue_st); free(all_hyph);
free(all_lead); free(all_trail); free(all_forb);
free(all_lead); free(all_trail); free(all_forb); free(all_pro);
if (!results)
return env->intern(env, "nil");
@ -734,7 +758,7 @@ MEASURE-FUNC: function that takes a string and returns pixel width\n\n\
Returns (BREAKS . TOTAL-COST) where BREAKS is list of break positions.\n\n\
(fn STRING HYPHENATOR-INDEX LINE-WIDTH MEASURE-FUNC)");
defun(env, "ekp-c-break-with-arrays", 12, 12, Fekp_c_break_with_arrays,
defun(env, "ekp-c-break-with-arrays", 14, 14, Fekp_c_break_with_arrays,
"Break lines using Elisp's pre-computed prefix arrays (preferred API).\n\n\
IDEAL-PREFIX: vector of ideal width prefix sums (n+1 elements)\n\
MIN-PREFIX: vector of min width prefix sums (n+1 elements)\n\

View File

@ -143,6 +143,12 @@ typedef struct {
const int32_t *forbidden_positions;
size_t forbidden_count;
/* Right-edge protrusion (nullable, n+1): pixels the line's final
* glyph may hang past the flush edge when breaking at gap k;
* hyphen_protrude is the same for soft hyphens. */
const int32_t *tail_protrudes;
int32_t hyphen_protrude;
/* Space-box run widths (nullable, n+1 elements each):
* lead_spaces[i] = width of space-box run starting at box i
* trail_spaces[k] = width of space-box run ending at box k-1
@ -292,6 +298,11 @@ static void dp_process_position(
bool end_hyphen = dp_is_hyphen(in, k - 1);
int32_t hyph_w = end_hyphen ? in->hyphen_width : 0;
/* Right-edge protrusion widens this candidate's target */
int32_t lw = line_width +
(end_hyphen ? in->hyphen_protrude
: (in->tail_protrudes ? in->tail_protrudes[k] : 0));
/* Line metrics from i to k, excluding leading glue and the
* space-box runs the renderer strips (leading + trailing). */
int32_t raw_ideal = in->ideal_prefix[k] - in->ideal_prefix[i] - lead_ideal;
@ -308,32 +319,32 @@ static void dp_process_position(
in->extra_stretch;
/* Too long? (last line is never shrunk below its ideal) */
if (min_w > line_width || (is_last && ideal > line_width)) {
if (min_w > lw || (is_last && ideal > lw)) {
if (atomic_run && in->allow_emergency)
dp_relax_emergency(in, i, k, prev_dem, prev_hyph, prev_lines,
line_width - ideal, end_hyphen,
lw - ideal, end_hyphen,
demerits, backptrs, rest_pixels,
fitness, hyphen_counts, line_counts);
break; /* No point trying longer lines */
}
/* Valid break? */
bool valid = (min_w <= line_width && max_w >= line_width) ||
(is_last && ideal <= line_width);
bool valid = (min_w <= lw && max_w >= lw) ||
(is_last && ideal <= lw);
if (!valid) {
/* Rigid underfull atomic run: emergency-record so the
* position after it stays reachable (2nd pass only). */
if (atomic_run && in->allow_emergency)
dp_relax_emergency(in, i, k, prev_dem, prev_hyph, prev_lines,
line_width - ideal, end_hyphen,
lw - ideal, end_hyphen,
demerits, backptrs, rest_pixels,
fitness, hyphen_counts, line_counts);
continue;
}
/* Compute demerits */
int32_t adjustment = line_width - ideal;
int32_t adjustment = lw - ideal;
int32_t flexibility = (adjustment > 0) ?
(max_w - ideal) : (ideal - min_w);
@ -356,7 +367,7 @@ static void dp_process_position(
in->consec_hyphen_penalty);
} else if (is_last) {
/* Last line: minimal penalty if reasonably filled */
double fill_ratio = (double)ideal / line_width;
double fill_ratio = (double)ideal / lw;
if (fill_ratio < in->last_line_ratio) {
badness = in->last_line_short_penalty * (1.0 - fill_ratio);
} else {
@ -647,7 +658,9 @@ ekp_result_t *ekp_break_with_prefixes(
const int32_t *lead_spaces,
const int32_t *trail_spaces,
const int32_t *forbidden_positions,
size_t forbidden_count)
size_t forbidden_count,
const int32_t *tail_protrudes,
int32_t hyphen_protrude)
{
if (!ideal_prefix || !min_prefix || !max_prefix || n == 0 || line_width <= 0)
return NULL;
@ -699,6 +712,8 @@ ekp_result_t *ekp_break_with_prefixes(
.hyphen_width = hyphen_width,
.forbidden_positions = forbidden_positions,
.forbidden_count = forbidden_count,
.tail_protrudes = tail_protrudes,
.hyphen_protrude = hyphen_protrude,
.lead_spaces = lead_spaces,
.trail_spaces = trail_spaces,
.n = n,
@ -821,7 +836,8 @@ static void batch_worker(void *arg)
in->hyphen_positions, in->hyphen_count,
in->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count);
in->forbidden_positions, in->forbidden_count,
in->tail_protrudes, in->hyphen_protrude);
}
/*
@ -850,7 +866,8 @@ ekp_result_t **ekp_break_batch(ekp_batch_input_t *inputs, size_t count)
in->hyphen_positions, in->hyphen_count,
in->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count);
in->forbidden_positions, in->forbidden_count,
in->tail_protrudes, in->hyphen_protrude);
}
return results;
}
@ -868,7 +885,8 @@ ekp_result_t **ekp_break_batch(ekp_batch_input_t *inputs, size_t count)
in->hyphen_positions, in->hyphen_count,
in->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count);
in->forbidden_positions, in->forbidden_count,
in->tail_protrudes, in->hyphen_protrude);
}
return results;
}

View File

@ -16,7 +16,7 @@
/* Version */
#define EKP_VERSION_MAJOR 1
#define EKP_VERSION_MINOR 3
#define EKP_VERSION_MINOR 4
/* Limits */
#define EKP_MAX_PATTERN_LEN 64
@ -258,7 +258,9 @@ ekp_result_t *ekp_break_with_prefixes(
const int32_t *lead_spaces,
const int32_t *trail_spaces,
const int32_t *forbidden_positions,
size_t forbidden_count);
size_t forbidden_count,
const int32_t *tail_protrudes,
int32_t hyphen_protrude);
/*
* Batch input for parallel processing
@ -279,6 +281,8 @@ typedef struct {
const int32_t *trail_spaces; /* nullable, n+1 elements */
const int32_t *forbidden_positions; /* nullable, sorted gap indices */
size_t forbidden_count;
const int32_t *tail_protrudes; /* nullable, n+1 elements */
int32_t hyphen_protrude;
} ekp_batch_input_t;
/*

View File

@ -198,6 +198,41 @@ Used to verify no content is lost by justification."
(ekp-pixel-justify text 60))))
(should (string= a b))))))
(ert-deftest ekp-test-protrusion-hangs-line-end-punct ()
"Protrusion lets line-final fullwidth punctuation hang past the edge."
(let ((ekp-protrusion t)
;; periodic 5-char sentences: at 20px exactly two per line, so
;; every interior break lands right after 。
(text (mapconcat #'identity (make-list 6 "四字一句。") "")))
(let* ((lines (split-string (ekp-pixel-justify text 20) "\n")))
(should (> (length lines) 1))
;; at least one non-last line hangs its punctuation
(should (seq-some (lambda (l) (> (string-pixel-width l) 20))
(butlast lines)))
;; and never beyond the configured ratio (0.5 × 2px in batch)
(dolist (l (butlast lines))
(should (<= (string-pixel-width l) 21))))))
(ert-deftest ekp-test-protrusion-off-is-flush ()
"With protrusion off (default), no line exceeds the target width."
(let ((text "第一句话结束。第二句话继续写下去,内容足够长才会断行成很多行。"))
(dolist (l (butlast (split-string (ekp-pixel-justify text 20) "\n")))
(should (<= (string-pixel-width l) 20)))))
(ert-deftest ekp-test-protrusion-c-parity ()
"C and elisp agree with protrusion enabled."
(skip-unless (ekp-tests--c-available))
(let ((ekp-protrusion t)
(text "悬挂 parity 检查。标点很多,逗号,句号。分号;更多内容写在这里。"))
(dolist (w '(24 40 60))
(let ((a (let ((ekp-use-c-module nil))
(ekp-clear-caches)
(ekp-pixel-justify text w)))
(b (let ((ekp-use-c-module t))
(ekp-clear-caches)
(ekp-pixel-justify text w))))
(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))