diff --git a/ekp-region.el b/ekp-region.el index 2b560d3..712f1b0 100644 --- a/ekp-region.el +++ b/ekp-region.el @@ -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 diff --git a/ekp-utils.el b/ekp-utils.el index ecbf418..24c93d7 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.3" +(defconst ekp-c-module-required-version "1.4" "Minimum C module version compatible with this Elisp code.") (defun ekp-c-module-load () diff --git a/ekp.el b/ekp.el index c4ccb5d..784c56e 100644 --- a/ekp.el +++ b/ekp.el @@ -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 diff --git a/ekp_c/ekp.c b/ekp_c/ekp.c index 3966a08..61b2e09 100644 --- a/ekp_c/ekp.c +++ b/ekp_c/ekp.c @@ -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\ diff --git a/ekp_c/ekp_kp.c b/ekp_c/ekp_kp.c index 433b23f..97b82c6 100644 --- a/ekp_c/ekp_kp.c +++ b/ekp_c/ekp_kp.c @@ -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; } diff --git a/ekp_c/ekp_module.h b/ekp_c/ekp_module.h index 93954da..ec142e1 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 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; /* diff --git a/tests/ekp-tests.el b/tests/ekp-tests.el index b0b1fb4..6cd41b0 100644 --- a/tests/ekp-tests.el +++ b/tests/ekp-tests.el @@ -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))