feat: typography wave — dictionary HYPHENMIN, JIS kinsoku, fast indent

Three quality gaps closed, one of them unlocking the C engine for
the most common Chinese configuration:

- first-line indent now runs on the 1D DP and the C engine (module
  1.5: ekp-c-break-with-arrays grows a 15th arg FIRST-LINE-WIDTH).
  A line starts at box 0 exactly when i = 0, so no (position x
  line-count) state is needed; only true parshape and looseness
  still take the 2D Elisp path.  Indented Chinese sample, w=400:
  C engine 82 ms (this configuration previously bypassed C entirely
  and ran the heaviest Elisp DP).  The C-result reconstruction
  mirrors the per-line width, and an equivalence test pins
  indent == the equivalent parshape across engines.
- LEFTHYPHENMIN / RIGHTHYPHENMIN are parsed from dictionaries and
  applied (en_US declares 2/3; the hardcoded 2/2 allowed breaks
  like "gen-cy" that the dictionary forbids).  Explicit overrides
  still win; partial overrides keep the dictionary's other side.
- JIS X 4051 line-start prohibition for small kana, the prolonged
  sound mark and iteration marks (new defcustom
  ekp-cjk-no-line-start-extra, char-table backed): っょー々 can no
  longer start a line.  Pure break-permission change; spacing and
  the DP are untouched.

Also fixed in passing:
- ekp--split-with-hyphen extracted regexp groups AFTER resolving
  the hyphenator; dictionary compilation clobbers the match data
  (latent since the resolver was hoisted; exposed by the HYPHENMIN
  parser) — groups are captured first now.
- ekp--first-indent-pixel goes through the width cache; it runs
  once per rendered line and was re-measuring the reference glyph
  every time (C path was 397 ms before, 82 ms after).

4 new ERT tests (92 total); fuzz 300/300 against C 1.5.
C module must be rebuilt: make -C ekp_c clean all

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kinneyzhang 2026-07-27 01:36:52 +08:00
parent bed359d439
commit 3fc05c1405
7 changed files with 237 additions and 83 deletions

View File

@ -112,9 +112,13 @@ E.g., \"a1bc2\" -> letters=\"abc\", values=(0 1 0 2)."
(list letters start (cl-subseq values start end))))))
(defun ekp-hyphen--compile (path)
"Compile dictionary at PATH into ekp-hyphen struct."
"Compile dictionary at PATH into ekp-hyphen struct.
Honors the dictionary's LEFTHYPHENMIN / RIGHTHYPHENMIN declarations
\(minimum characters kept before/after any break e.g. en_US
declares 2/3, so \"quick-ly\" is not a valid break); absent
declarations default to 2/2."
(let ((patterns (make-hash-table :test 'equal))
(maxlen 0))
(maxlen 0) (left 2) (right 2))
(with-temp-buffer
(insert-file-contents path)
(forward-line 1) ; skip encoding line
@ -124,7 +128,15 @@ E.g., \"a1bc2\" -> letters=\"abc\", values=(0 1 0 2)."
(skip (or (string-empty-p line)
(string-match-p "^[%#]\\|HYPHENMIN" line)
(string-match-p "/" line)))) ; skip alt patterns
(unless skip
(cond
((string-match "^\\(LEFT\\|RIGHT\\)HYPHENMIN[ \t]*\\([0-9]+\\)"
line)
(let ((n (string-to-number (match-string 2 line))))
(if (equal (match-string 1 line) "LEFT")
(setq left n)
(setq right n))))
(skip nil)
(t
;; Handle ^^XX hex escapes
(setq line (replace-regexp-in-string
"\\^\\^\\([0-9a-fA-F]\\{2\\}\\)"
@ -133,12 +145,12 @@ E.g., \"a1bc2\" -> letters=\"abc\", values=(0 1 0 2)."
line))
(when-let ((parsed (ekp-hyphen--parse-pattern line)))
(puthash (car parsed) (cdr parsed) patterns)
(setq maxlen (max maxlen (length (car parsed)))))))
(setq maxlen (max maxlen (length (car parsed))))))))
(forward-line 1)))
(ekp-hyphen--create :patterns patterns
:cache (make-hash-table :test 'equal)
:maxlen maxlen
:left 2 :right 2)))
:left left :right right)))
;;; Hyphenation Algorithm
@ -178,7 +190,9 @@ E.g., \"a1bc2\" -> letters=\"abc\", values=(0 1 0 2)."
(defun ekp-hyphen-create (&optional lang file left right)
"Create hyphenator for LANG or dictionary FILE.
LEFT/RIGHT: min chars before/after breaks (default 2)."
LEFT/RIGHT override the minimum characters kept before/after breaks;
by default the dictionary's own LEFTHYPHENMIN/RIGHTHYPHENMIN apply
\(2/2 when it declares none)."
(let ((path (or (and lang (ekp-hyphen--resolve-lang lang)) file)))
(unless path (error "No dictionary for: %s" lang))
(let ((h (or (gethash path ekp-hyphen--cache)
@ -188,7 +202,8 @@ LEFT/RIGHT: min chars before/after breaks (default 2)."
(ekp-hyphen--create :patterns (ekp-hyphen-patterns h)
:cache (ekp-hyphen-cache h)
:maxlen (ekp-hyphen-maxlen h)
:left (or left 2) :right (or right 2))
:left (or left (ekp-hyphen-left h))
:right (or right (ekp-hyphen-right h)))
h))))
(defun ekp-hyphen-positions (h word)

View File

@ -363,7 +363,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.4"
(defconst ekp-c-module-required-version "1.5"
"Minimum C module version compatible with this Elisp code.")
;;;###autoload

141
ekp.el
View File

@ -370,34 +370,39 @@ Returns (boxes-vector . hyphen-positions-vector)."
(hyphenator 'unset)
(idx 0) new-boxes hyphen-idxs)
(dolist (box (append boxes nil))
(if (and (string-match word-re box)
;; Never hyphenate inside a no-break span (verbatim atoms)
(null (text-property-not-all 0 (length box)
'ekp-no-break nil box))
(or (and (eq hyphenator 'unset)
(setq hyphenator
(condition-case nil
(ekp-hyphen-create ekp-latin-lang)
(error nil))))
hyphenator))
;; Latin word: apply hyphenation
(let* ((left (match-string 1 box))
(word (match-string 2 box))
(right (match-string 3 box))
(parts (ekp-hyphen-boxes hyphenator word))
(n (length parts)))
(when (> (length left) 0)
(setcar parts (concat left (car parts))))
(when (> (length right) 0)
(setcar (last parts)
(concat (car (last parts)) right)))
(push parts new-boxes)
(dotimes (i n)
(when (< i (1- n)) (push idx hyphen-idxs))
(cl-incf idx)))
;; Non-Latin: single box
(push (list box) new-boxes)
(cl-incf idx)))
(let ((parts nil))
(when (and (string-match word-re box)
;; Never hyphenate inside a no-break span
(null (text-property-not-all 0 (length box)
'ekp-no-break nil box)))
;; Extract the groups BEFORE resolving the hyphenator:
;; dictionary compilation runs regexps of its own and
;; clobbers the match data.
(let ((left (match-string 1 box))
(word (match-string 2 box))
(right (match-string 3 box)))
(when (eq hyphenator 'unset)
(setq hyphenator
(condition-case nil
(ekp-hyphen-create ekp-latin-lang)
(error nil))))
(when hyphenator
(setq parts (ekp-hyphen-boxes hyphenator word))
(when (> (length left) 0)
(setcar parts (concat left (car parts))))
(when (> (length right) 0)
(setcar (last parts)
(concat (car (last parts)) right))))))
(if parts
;; Latin word: hyphenated into syllable boxes
(let ((n (length parts)))
(push parts new-boxes)
(dotimes (i n)
(when (< i (1- n)) (push idx hyphen-idxs))
(cl-incf idx)))
;; Non-Latin box, or hyphenation unavailable
(push (list box) new-boxes)
(cl-incf idx))))
(cons (vconcat (apply #'append (nreverse new-boxes)))
(vconcat (nreverse hyphen-idxs)))))
@ -498,6 +503,36 @@ are covered by the `cjk-open' class.")
(defconst ekp--no-line-start-char-list (append ekp--no-line-start-chars nil))
(defconst ekp--no-line-end-char-list (append ekp--no-line-end-chars nil))
(defcustom ekp-cjk-no-line-start-extra
(concat "ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶ"
"ㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ"
"ーゝゞヽヾ々〻")
"CJK letters that must not start a line (JIS X 4051 kinsoku).
Small kana, the prolonged sound mark and iteration marks are
letters for spacing purposes but are line-start-prohibited in
Japanese typesetting. Stored as a string of characters."
:type 'string
:group 'ekp)
(defvar ekp--extra-nls-table nil
"Char-table view of `ekp-cjk-no-line-start-extra' (fast lookup).")
(defun ekp--extra-nls-rebuild (chars)
"Rebuild `ekp--extra-nls-table' from the string CHARS."
(let ((table (make-char-table 'ekp-extra-nls)))
(dolist (c (append (if (stringp chars) chars "") nil))
(aset table c t))
(setq ekp--extra-nls-table table)))
(ekp--extra-nls-rebuild ekp-cjk-no-line-start-extra)
(add-variable-watcher
'ekp-cjk-no-line-start-extra
(lambda (_sym new op _where)
(when (memq op '(set let unlet makunbound))
(ekp--extra-nls-rebuild new)
(setq ekp--last-para nil))))
(defun ekp--box-pure-set-p (box chars)
"Non-nil when BOX is non-empty and every char is a member of CHARS."
(let ((len (length box)) (i 0) (all t))
@ -511,6 +546,8 @@ are covered by the `cjk-open' class.")
(defun ekp--box-no-line-start-p (box box-type)
"Non-nil if BOX must not appear at the start of a line."
(or (eq (car box-type) 'cjk-close)
(and (> (length box) 0)
(aref ekp--extra-nls-table (aref box 0)))
(ekp--box-pure-set-p box ekp--no-line-start-char-list)))
(defun ekp--box-no-line-end-p (box box-type)
@ -651,6 +688,7 @@ are derived per string)."
(and ekp-protrusion ekp-protrusion-ratios)
ekp-parshape
ekp-first-line-indent
ekp-cjk-no-line-start-extra
(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
@ -720,16 +758,13 @@ box's. 0 when `ekp-protrusion' was off at paragraph build time."
(or ekp-ragged-stretch-pixel
(max 1 (* 8 (or ekp-lws-ideal-pixel 1)))))
(defun ekp--parshape-active-p ()
"Non-nil when per-line widths are in effect (parshape or indent)."
(or ekp-parshape ekp-first-line-indent))
(defun ekp--first-indent-pixel (para)
"Resolve `ekp-first-line-indent' to pixels for PARA."
"Resolve `ekp-first-line-indent' to pixels for PARA.
Goes through the width cache: this runs for every rendered line."
(cond
((numberp ekp-first-line-indent) ekp-first-line-indent)
(ekp-first-line-indent
(* 2 (string-pixel-width
(* 2 (ekp--measured-width
(propertize "" 'face
(list :family (ekp-para-cjk-font para))))))
(t 0)))
@ -1093,9 +1128,9 @@ width, so results at different looseness values must not alias
(defun ekp--dp-cache-elisp (para line-pixel)
"Pure Elisp DP implementation. Returns and caches the dp-result plist.
Looseness and per-line widths (parshape/first-line indent) need the
\(position × line-count) DP."
(if (or (/= ekp-looseness 0) (ekp--parshape-active-p))
Looseness and parshape need the (position × line-count) DP; a plain
first-line indent is handled by the 1D pass (line 0 = start at box 0)."
(if (or (/= ekp-looseness 0) ekp-parshape)
(ekp--dp-cache-elisp-loose para line-pixel)
(let ((dp-result (or (ekp--dp-run-1d para line-pixel nil)
(ekp--dp-run-1d para line-pixel t))))
@ -1132,6 +1167,10 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)."
(breaks-ok (ekp-para-breaks-allowed para))
(tail-protrudes (ekp-para-tail-protrudes para))
(hyphen-protrude (ekp-para-hyphen-protrude para))
;; First-line indent shrinks line 0 only; a line starts at
;; box 0 exactly when i = 0, so the 1D DP handles it without
;; the (position × line-count) state (parshape still needs it).
(first-line-pixel (cdr (ekp--line-spec para 0 line-pixel)))
(params (ekp-para-glue-params para))
(lws-stretch (plist-get params :lws-stretch))
(mws-stretch (plist-get params :mws-stretch))
@ -1176,7 +1215,7 @@ unreachable (only possible when ALLOW-EMERGENCY is nil)."
(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
(lw (+ (if (= i 0) first-line-pixel line-pixel)
(if end-with-hyphenp
hyphen-protrude
(aref tail-protrudes k))))
@ -1557,10 +1596,10 @@ CANDIDATE is (DEM-DELTA REST GAPS FITNESS HYPHEN-COUNT)."
(and ekp-use-c-module
(boundp 'ekp-c-module-loaded) ekp-c-module-loaded
(fboundp 'ekp-c-break-with-arrays)
;; looseness and per-line widths need the (position × line-count)
;; DP, Elisp only
;; looseness and parshape need the (position × line-count) DP,
;; Elisp only; first-line indent is a scalar the C engine takes
(= ekp-looseness 0)
(not (ekp--parshape-active-p))))
(not ekp-parshape)))
(defun ekp--c-sync-params ()
"Push current K-P penalty settings to the C module."
@ -1589,10 +1628,13 @@ If `ekp-use-c-module' is non-nil and the C module is available (and
(t (ekp--dp-cache-elisp para line-pixel)))))
(defun ekp--lines-data-from-breaks (para line-pixel breaks)
"Compute (RESTS . GAPS) lists for BREAKS, matching the DP's metrics."
(let ((start 0) rests gapss)
"Compute (RESTS . GAPS) lists for BREAKS, matching the DP's metrics.
Per-line widths (first-line indent) must mirror the DP exactly, or
the reconstructed rests overfill the indented line."
(let ((start 0) (idx 0) rests gapss)
(dolist (end breaks)
(push (- (+ line-pixel (ekp--line-edge-release para start end))
(push (- (+ (cdr (ekp--line-spec para idx line-pixel))
(ekp--line-edge-release para start end))
(ekp--line-ideal-pixel para start end))
rests)
(push (if (or (= end (1+ start))
@ -1600,7 +1642,8 @@ If `ekp-use-c-module' is non-nil and the C module is available (and
nil
(ekp--gaps-between para start end))
gapss)
(setq start end))
(setq start end
idx (1+ idx)))
(cons (nreverse rests) (nreverse gapss))))
(defun ekp--store-c-result (para line-pixel breaks cost)
@ -1615,7 +1658,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 14-element vector for the C batch API."
"Prepare PARA data as a 15-element vector for the C batch API."
(vector (ekp-para-ideal-prefixs para)
(ekp-para-min-prefixs para)
(ekp-para-max-prefixs para)
@ -1629,7 +1672,8 @@ If `ekp-use-c-module' is non-nil and the C module is available (and
(ekp-para-trail-spaces para)
(ekp-para-forbidden-positions para)
(ekp-para-tail-protrudes para)
(ekp-para-hyphen-protrude para)))
(ekp-para-hyphen-protrude para)
(cdr (ekp--line-spec para 0 line-pixel))))
(defun ekp--dp-cache-via-c (para line-pixel)
"Compute breaks using the C module with PARA's precomputed arrays.
@ -1650,7 +1694,8 @@ runs the pure DP. Falls back to Elisp when the C call fails."
(ekp-para-trail-spaces para)
(ekp-para-forbidden-positions para)
(ekp-para-tail-protrudes para)
(ekp-para-hyphen-protrude para)))
(ekp-para-hyphen-protrude para)
(cdr (ekp--line-spec para 0 line-pixel))))
(c-breaks (car result))
(c-cost (cdr result)))
(if (null c-breaks)

View File

@ -320,11 +320,13 @@ static emacs_value Fekp_c_thread_count(emacs_env *env, ptrdiff_t nargs,
/*
* ekp-c-break-with-arrays: Pure DP with Elisp-provided prefix arrays
*
* Args: (ideal-prefix min-prefix max-prefix glue-ideals glue-shrinks glue-stretches
* hyphen-positions hyphen-width line-width)
* Args: (ideal-prefix min-prefix max-prefix glue-ideals glue-shrinks
* glue-stretches hyphen-positions hyphen-width line-width
* lead-spaces trail-spaces forbidden-positions tail-protrudes
* hyphen-protrude first-line-width)
*
* All 6 arrays must have consistent sizes:
* - ideal/min/max-prefix: (n+1) elements
* Array sizes must be consistent:
* - ideal/min/max-prefix, lead/trail-spaces, tail-protrudes: n+1
* - glue-ideals/shrinks/stretches: n elements
*
* Returns: (breaks . total-cost) where breaks is a list of box indices.
@ -337,7 +339,7 @@ static emacs_value Fekp_c_break_with_arrays(emacs_env *env, ptrdiff_t nargs,
{
(void)data;
if (!ekp_global || nargs < 14)
if (!ekp_global || nargs < 15)
return env->intern(env, "nil");
/* Get prefix array sizes (n+1 elements) */
@ -405,6 +407,7 @@ static emacs_value Fekp_c_break_with_arrays(emacs_env *env, ptrdiff_t nargs,
}
}
int32_t hyphen_protrude = env->extract_integer(env, args[13]);
int32_t first_line_width = env->extract_integer(env, args[14]);
/* Forbidden break positions (sorted gap indices, may be empty) */
ptrdiff_t forb_count = env->vec_size(env, args[11]);
@ -427,7 +430,7 @@ static emacs_value Fekp_c_break_with_arrays(emacs_env *env, ptrdiff_t nargs,
hyph_width, line_width,
lead_spaces, trail_spaces,
forb_pos, (forb_pos && forb_count > 0) ? (size_t)forb_count : 0,
tail_pro, hyphen_protrude);
tail_pro, hyphen_protrude, first_line_width);
free(ideal_prefix); free(min_prefix); free(max_prefix);
free(glue_ideals); free(glue_shrinks); free(glue_stretches);
@ -469,7 +472,8 @@ static bool extract_paragraph_data(
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 **tail_pro, int32_t *hyphen_protrude)
int32_t **tail_pro, int32_t *hyphen_protrude,
int32_t *first_line_width)
{
ptrdiff_t prefix_len = env->vec_size(env, args[0]);
if (prefix_len <= 1)
@ -541,6 +545,7 @@ static bool extract_paragraph_data(
}
}
*hyphen_protrude = env->extract_integer(env, args[13]);
*first_line_width = env->extract_integer(env, args[14]);
return true;
}
@ -595,15 +600,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 14 arguments from this paragraph's vector */
emacs_value para_args[14];
for (int i = 0; i < 14; i++) {
/* Extract 15 arguments from this paragraph's vector */
emacs_value para_args[15];
for (int i = 0; i < 15; 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, hyphen_protrude;
int32_t hyph_width, line_width, hyphen_protrude, first_line_width;
if (!extract_paragraph_data(env, para_args,
&all_ideal[p], &all_min[p], &all_max[p],
@ -612,7 +617,8 @@ static emacs_value Fekp_c_break_batch(emacs_env *env, ptrdiff_t nargs,
&hyph_width, &line_width,
&all_lead[p], &all_trail[p],
&all_forb[p], &forb_count,
&all_pro[p], &hyphen_protrude)) {
&all_pro[p], &hyphen_protrude,
&first_line_width)) {
/* Cleanup on failure */
for (ptrdiff_t j = 0; j < p; j++) {
free(all_ideal[j]); free(all_min[j]); free(all_max[j]);
@ -644,6 +650,7 @@ static emacs_value Fekp_c_break_batch(emacs_env *env, ptrdiff_t nargs,
(all_forb[p] && forb_count > 0) ? (size_t)forb_count : 0;
inputs[p].tail_protrudes = all_pro[p];
inputs[p].hyphen_protrude = hyphen_protrude;
inputs[p].first_line_width = first_line_width;
}
/* Process all paragraphs in parallel */
@ -768,7 +775,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", 14, 14, Fekp_c_break_with_arrays,
defun(env, "ekp-c-break-with-arrays", 15, 15, 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\
@ -780,11 +787,16 @@ HYPHEN-POS: vector of hyphenable box indices (sorted)\n\
HYPHEN-WIDTH: pixel width of hyphen character\n\
LINE-WIDTH: target line width in pixels\n\
LEAD-SPACES: vector (n+1) of space-box run widths starting at box i\n\
TRAIL-SPACES: vector (n+1) of space-box run widths ending at box k-1\n\n\
TRAIL-SPACES: vector (n+1) of space-box run widths ending at box k-1\n\
FORBIDDEN-POS: vector of gap indices where breaking is forbidden (sorted)\n\
TAIL-PROTRUDES: vector (n+1) of right-edge protrusion pixels per gap\n\
HYPHEN-PROTRUDE: protrusion pixels for the soft hyphen\n\
FIRST-LINE-WIDTH: width of line 0 (first-line indent); <=0 = LINE-WIDTH\n\n\
Returns (BREAKS . TOTAL-COST) where BREAKS is list of box indices.\n\
This API ensures C uses Elisp's font-dependent measurements.\n\n\
(fn IDEAL-PREFIX MIN-PREFIX MAX-PREFIX GLUE-IDEALS GLUE-SHRINKS GLUE-STRETCHES \
HYPHEN-POS HYPHEN-WIDTH LINE-WIDTH LEAD-SPACES TRAIL-SPACES)");
HYPHEN-POS HYPHEN-WIDTH LINE-WIDTH LEAD-SPACES TRAIL-SPACES FORBIDDEN-POS \
TAIL-PROTRUDES HYPHEN-PROTRUDE FIRST-LINE-WIDTH)");
defun(env, "ekp-c-version", 0, 0, Fekp_c_version,
"Return EKP C module version string.");
@ -794,10 +806,8 @@ HYPHEN-POS HYPHEN-WIDTH LINE-WIDTH LEAD-SPACES TRAIL-SPACES)");
defun(env, "ekp-c-break-batch", 1, 1, Fekp_c_break_batch,
"Break multiple paragraphs in parallel.\n\n\
PARAGRAPHS: vector of paragraph data, each element is a vector of 11 items:\n\
[ideal-prefix min-prefix max-prefix glue-ideals glue-shrinks\n\
glue-stretches hyphen-positions hyphen-width line-width\n\
lead-spaces trail-spaces]\n\n\
PARAGRAPHS: vector of paragraph data, each element a vector of the\n\
same 15 items `ekp-c-break-with-arrays' takes, in the same order.\n\n\
Returns vector of (BREAKS . COST) for each paragraph.\n\
This is the high-performance API for multi-paragraph processing.\n\n\
(fn PARAGRAPHS)");

View File

@ -170,6 +170,10 @@ typedef struct {
/* Dimensions */
size_t n; /* box count */
int32_t line_width;
/* Width of line 0 (first-line indent support); equals line_width
* when no indent is active. In the forward DP a line starts at
* box 0 exactly when i == 0, so this needs no extra state. */
int32_t first_line_width;
/* K-P parameters */
int line_penalty;
@ -281,7 +285,10 @@ static void dp_process_position(
int32_t *line_counts)
{
size_t n = in->n;
int32_t line_width = in->line_width;
/* Line 0 (i == 0) may have a different width: first-line indent */
int32_t line_width = (i == 0 && in->first_line_width > 0)
? in->first_line_width
: in->line_width;
/* Get leading glue for line starting at i */
int32_t lead_ideal = (in->glue_ideals && i < n) ? in->glue_ideals[i] : 0;
@ -670,10 +677,13 @@ ekp_result_t *ekp_break_with_prefixes(
const int32_t *forbidden_positions,
size_t forbidden_count,
const int32_t *tail_protrudes,
int32_t hyphen_protrude)
int32_t hyphen_protrude,
int32_t first_line_width)
{
if (!ideal_prefix || !min_prefix || !max_prefix || n == 0 || line_width <= 0)
return NULL;
if (first_line_width <= 0)
first_line_width = line_width;
/* Allocate DP arrays */
double *demerits = malloc((n + 1) * sizeof(double));
@ -728,6 +738,7 @@ ekp_result_t *ekp_break_with_prefixes(
.trail_spaces = trail_spaces,
.n = n,
.line_width = line_width,
.first_line_width = first_line_width,
.line_penalty = lp,
.hyphen_penalty = hp,
.fitness_penalty = fp,
@ -847,7 +858,8 @@ static void batch_worker(void *arg)
in->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count,
in->tail_protrudes, in->hyphen_protrude);
in->tail_protrudes, in->hyphen_protrude,
in->first_line_width);
}
/*
@ -877,7 +889,8 @@ ekp_result_t **ekp_break_batch(ekp_batch_input_t *inputs, size_t count)
in->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count,
in->tail_protrudes, in->hyphen_protrude);
in->tail_protrudes, in->hyphen_protrude,
in->first_line_width);
}
return results;
}
@ -896,7 +909,8 @@ ekp_result_t **ekp_break_batch(ekp_batch_input_t *inputs, size_t count)
in->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count,
in->tail_protrudes, in->hyphen_protrude);
in->tail_protrudes, in->hyphen_protrude,
in->first_line_width);
}
return results;
}

View File

@ -26,7 +26,7 @@
/* Version */
#define EKP_VERSION_MAJOR 1
#define EKP_VERSION_MINOR 4
#define EKP_VERSION_MINOR 5
/* Limits */
#define EKP_MAX_PATTERN_LEN 64
@ -270,7 +270,8 @@ ekp_result_t *ekp_break_with_prefixes(
const int32_t *forbidden_positions,
size_t forbidden_count,
const int32_t *tail_protrudes,
int32_t hyphen_protrude);
int32_t hyphen_protrude,
int32_t first_line_width);
/*
* Batch input for parallel processing
@ -293,6 +294,7 @@ typedef struct {
size_t forbidden_count;
const int32_t *tail_protrudes; /* nullable, n+1 elements */
int32_t hyphen_protrude;
int32_t first_line_width; /* width of line 0; <=0 = line_width */
} ekp_batch_input_t;
/*

View File

@ -79,8 +79,10 @@ Used to verify no content is lost by justification."
(let ((h (ekp-hyphen-create "en_US")))
(should (equal (ekp-hyphen-boxes h "hyphenation")
'("hy" "phen" "ation")))
;; RIGHTHYPHENMIN 3 (declared by en_US): no "gen-cy" break,
;; "cy" would leave only 2 characters after the hyphen.
(should (equal (ekp-hyphen-boxes h "emergency")
'("emer" "gen" "cy")))
'("emer" "gency")))
;; Words with no break points come back whole
(should (equal (ekp-hyphen-boxes h "cat") '("cat")))))
@ -696,6 +698,72 @@ kept returning the paragraph resolved under the previous style."
(ekp-clear-caches)
(should (equal-including-properties out-j (ekp-pixel-justify s 30)))))
;;;; Typography quality (M3 wave)
(ert-deftest ekp-test-hyphenmin-honored ()
"Dictionary LEFTHYPHENMIN/RIGHTHYPHENMIN are parsed and applied.
en_US declares 2/3; the old hardcoded 2/2 allowed \"quick-ly\"."
(let ((h (ekp-hyphen-create "en_US")))
(should (= (ekp-hyphen-left h) 2))
(should (= (ekp-hyphen-right h) 3))
(dolist (w '("quickly" "mainly" "activity" "hyphenation" "reader"))
(dolist (p (ekp-hyphen-positions h w))
(should (>= p 2))
(should (<= p (- (length w) 3)))))
;; explicit overrides still work, partial override keeps the
;; dictionary's value for the other side
(let ((h2 (ekp-hyphen-create "en_US" nil 1 1))
(h3 (ekp-hyphen-create "en_US" nil 4 nil)))
(should (= (ekp-hyphen-left h2) 1))
(should (= (ekp-hyphen-right h2) 1))
(should (= (ekp-hyphen-left h3) 4))
(should (= (ekp-hyphen-right h3) 3)))))
(ert-deftest ekp-test-jis-kinsoku-line-start ()
"Small kana and the prolonged sound mark never start a line.
JIS X 4051 line-start prohibition for っゃー々 etc."
(ekp-tests--with-clean-state
(let ((text "がっこうへいくよラーメンをたべたいなあそうかなぁいいなぁと")
(forbidden (append ekp-cjk-no-line-start-extra nil)))
(dolist (w '(20 28 40 60))
(let ((out (ekp-pixel-justify text w)))
(dolist (line (split-string out "\n"))
(when (> (length line) 0)
(should-not (memq (aref line 0) forbidden)))))))))
(ert-deftest ekp-test-first-line-indent-1d-matches-parshape ()
"A plain first-line indent equals the equivalent parshape.
The indent runs on the 1D DP (and the C engine); parshape runs on
the (position × line-count) Elisp DP they must agree."
(ekp-tests--with-clean-state
(let* ((s "首行缩进等价性检查内容足够长会断行几次的样子哦")
(w 30)
(via-indent (let ((ekp-first-line-indent 8))
(ekp-pixel-justify s w)))
(via-parshape (progn
(ekp-clear-caches)
(let ((ekp-parshape (list (cons 8 (- w 8))
(cons 0 w))))
(ekp-pixel-justify s w)))))
(should (equal-including-properties via-indent via-parshape)))))
(ert-deftest ekp-test-first-line-indent-c-parity ()
"First-line indent: C and Elisp engines agree byte-for-byte."
(skip-unless (ekp-tests--c-available))
(ekp-tests--with-clean-state
(dolist (indent '(t 8))
(let ((ekp-first-line-indent indent))
(dolist (s '("中文首行缩进检查内容足够长会断行几次的样子哦"
"Mixed 混排 first line indent parity with words"))
(dolist (w '(30 60 90))
(let* ((via-c (let ((ekp-use-c-module t))
(ekp-pixel-justify s w)))
(_ (ekp-clear-caches))
(via-el (let ((ekp-use-c-module nil))
(ekp-pixel-justify s w))))
(ekp-clear-caches)
(should (equal-including-properties via-c via-el)))))))))
(provide 'ekp-tests)
;;; ekp-tests.el ends here