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)))))) (list letters start (cl-subseq values start end))))))
(defun ekp-hyphen--compile (path) (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)) (let ((patterns (make-hash-table :test 'equal))
(maxlen 0)) (maxlen 0) (left 2) (right 2))
(with-temp-buffer (with-temp-buffer
(insert-file-contents path) (insert-file-contents path)
(forward-line 1) ; skip encoding line (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) (skip (or (string-empty-p line)
(string-match-p "^[%#]\\|HYPHENMIN" line) (string-match-p "^[%#]\\|HYPHENMIN" line)
(string-match-p "/" line)))) ; skip alt patterns (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 ;; Handle ^^XX hex escapes
(setq line (replace-regexp-in-string (setq line (replace-regexp-in-string
"\\^\\^\\([0-9a-fA-F]\\{2\\}\\)" "\\^\\^\\([0-9a-fA-F]\\{2\\}\\)"
@ -133,12 +145,12 @@ E.g., \"a1bc2\" -> letters=\"abc\", values=(0 1 0 2)."
line)) line))
(when-let ((parsed (ekp-hyphen--parse-pattern line))) (when-let ((parsed (ekp-hyphen--parse-pattern line)))
(puthash (car parsed) (cdr parsed) patterns) (puthash (car parsed) (cdr parsed) patterns)
(setq maxlen (max maxlen (length (car parsed))))))) (setq maxlen (max maxlen (length (car parsed))))))))
(forward-line 1))) (forward-line 1)))
(ekp-hyphen--create :patterns patterns (ekp-hyphen--create :patterns patterns
:cache (make-hash-table :test 'equal) :cache (make-hash-table :test 'equal)
:maxlen maxlen :maxlen maxlen
:left 2 :right 2))) :left left :right right)))
;;; Hyphenation Algorithm ;;; 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) (defun ekp-hyphen-create (&optional lang file left right)
"Create hyphenator for LANG or dictionary FILE. "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))) (let ((path (or (and lang (ekp-hyphen--resolve-lang lang)) file)))
(unless path (error "No dictionary for: %s" lang)) (unless path (error "No dictionary for: %s" lang))
(let ((h (or (gethash path ekp-hyphen--cache) (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) (ekp-hyphen--create :patterns (ekp-hyphen-patterns h)
:cache (ekp-hyphen-cache h) :cache (ekp-hyphen-cache h)
:maxlen (ekp-hyphen-maxlen 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)))) h))))
(defun ekp-hyphen-positions (h word) (defun ekp-hyphen-positions (h word)

View File

@ -363,7 +363,7 @@ after CALLBACK returns."
(defalias 'ekp-c-module-reload #'ekp--module-reload (defalias 'ekp-c-module-reload #'ekp--module-reload
"Load MODULE from a temp copy to allow rebuilding.") "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.") "Minimum C module version compatible with this Elisp code.")
;;;###autoload ;;;###autoload

141
ekp.el
View File

@ -370,34 +370,39 @@ Returns (boxes-vector . hyphen-positions-vector)."
(hyphenator 'unset) (hyphenator 'unset)
(idx 0) new-boxes hyphen-idxs) (idx 0) new-boxes hyphen-idxs)
(dolist (box (append boxes nil)) (dolist (box (append boxes nil))
(if (and (string-match word-re box) (let ((parts nil))
;; Never hyphenate inside a no-break span (verbatim atoms) (when (and (string-match word-re box)
(null (text-property-not-all 0 (length box) ;; Never hyphenate inside a no-break span
'ekp-no-break nil box)) (null (text-property-not-all 0 (length box)
(or (and (eq hyphenator 'unset) 'ekp-no-break nil box)))
(setq hyphenator ;; Extract the groups BEFORE resolving the hyphenator:
(condition-case nil ;; dictionary compilation runs regexps of its own and
(ekp-hyphen-create ekp-latin-lang) ;; clobbers the match data.
(error nil)))) (let ((left (match-string 1 box))
hyphenator)) (word (match-string 2 box))
;; Latin word: apply hyphenation (right (match-string 3 box)))
(let* ((left (match-string 1 box)) (when (eq hyphenator 'unset)
(word (match-string 2 box)) (setq hyphenator
(right (match-string 3 box)) (condition-case nil
(parts (ekp-hyphen-boxes hyphenator word)) (ekp-hyphen-create ekp-latin-lang)
(n (length parts))) (error nil))))
(when (> (length left) 0) (when hyphenator
(setcar parts (concat left (car parts)))) (setq parts (ekp-hyphen-boxes hyphenator word))
(when (> (length right) 0) (when (> (length left) 0)
(setcar (last parts) (setcar parts (concat left (car parts))))
(concat (car (last parts)) right))) (when (> (length right) 0)
(push parts new-boxes) (setcar (last parts)
(dotimes (i n) (concat (car (last parts)) right))))))
(when (< i (1- n)) (push idx hyphen-idxs)) (if parts
(cl-incf idx))) ;; Latin word: hyphenated into syllable boxes
;; Non-Latin: single box (let ((n (length parts)))
(push (list box) new-boxes) (push parts new-boxes)
(cl-incf idx))) (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))) (cons (vconcat (apply #'append (nreverse new-boxes)))
(vconcat (nreverse hyphen-idxs))))) (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-start-char-list (append ekp--no-line-start-chars nil))
(defconst ekp--no-line-end-char-list (append ekp--no-line-end-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) (defun ekp--box-pure-set-p (box chars)
"Non-nil when BOX is non-empty and every char is a member of CHARS." "Non-nil when BOX is non-empty and every char is a member of CHARS."
(let ((len (length box)) (i 0) (all t)) (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) (defun ekp--box-no-line-start-p (box box-type)
"Non-nil if BOX must not appear at the start of a line." "Non-nil if BOX must not appear at the start of a line."
(or (eq (car box-type) 'cjk-close) (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))) (ekp--box-pure-set-p box ekp--no-line-start-char-list)))
(defun ekp--box-no-line-end-p (box box-type) (defun ekp--box-no-line-end-p (box box-type)
@ -651,6 +688,7 @@ are derived per string)."
(and ekp-protrusion ekp-protrusion-ratios) (and ekp-protrusion ekp-protrusion-ratios)
ekp-parshape ekp-parshape
ekp-first-line-indent ekp-first-line-indent
ekp-cjk-no-line-start-extra
(if (and ekp--params-explicit (ekp--params-set-p)) (if (and ekp--params-explicit (ekp--params-set-p))
(list ekp-lws-ideal-pixel ekp-lws-stretch-pixel (list ekp-lws-ideal-pixel ekp-lws-stretch-pixel
ekp-lws-shrink-pixel ekp-mws-ideal-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 (or ekp-ragged-stretch-pixel
(max 1 (* 8 (or ekp-lws-ideal-pixel 1))))) (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) (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 (cond
((numberp ekp-first-line-indent) ekp-first-line-indent) ((numberp ekp-first-line-indent) ekp-first-line-indent)
(ekp-first-line-indent (ekp-first-line-indent
(* 2 (string-pixel-width (* 2 (ekp--measured-width
(propertize "" 'face (propertize "" 'face
(list :family (ekp-para-cjk-font para)))))) (list :family (ekp-para-cjk-font para))))))
(t 0))) (t 0)))
@ -1093,9 +1128,9 @@ width, so results at different looseness values must not alias
(defun ekp--dp-cache-elisp (para line-pixel) (defun ekp--dp-cache-elisp (para line-pixel)
"Pure Elisp DP implementation. Returns and caches the dp-result plist. "Pure Elisp DP implementation. Returns and caches the dp-result plist.
Looseness and per-line widths (parshape/first-line indent) need the Looseness and parshape need the (position × line-count) DP; a plain
\(position × line-count) DP." first-line indent is handled by the 1D pass (line 0 = start at box 0)."
(if (or (/= ekp-looseness 0) (ekp--parshape-active-p)) (if (or (/= ekp-looseness 0) ekp-parshape)
(ekp--dp-cache-elisp-loose para line-pixel) (ekp--dp-cache-elisp-loose para line-pixel)
(let ((dp-result (or (ekp--dp-run-1d para line-pixel nil) (let ((dp-result (or (ekp--dp-run-1d para line-pixel nil)
(ekp--dp-run-1d para line-pixel t)))) (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)) (breaks-ok (ekp-para-breaks-allowed para))
(tail-protrudes (ekp-para-tail-protrudes para)) (tail-protrudes (ekp-para-tail-protrudes para))
(hyphen-protrude (ekp-para-hyphen-protrude 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)) (params (ekp-para-glue-params para))
(lws-stretch (plist-get params :lws-stretch)) (lws-stretch (plist-get params :lws-stretch))
(mws-stretch (plist-get params :mws-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))) (end-with-hyphenp (aref hyph-flags (1- k)))
(hyph-w (if end-with-hyphenp hyphen-pixel 0)) (hyph-w (if end-with-hyphenp hyphen-pixel 0))
;; right-edge protrusion releases width at this k ;; right-edge protrusion releases width at this k
(lw (+ line-pixel (lw (+ (if (= i 0) first-line-pixel line-pixel)
(if end-with-hyphenp (if end-with-hyphenp
hyphen-protrude hyphen-protrude
(aref tail-protrudes k)))) (aref tail-protrudes k))))
@ -1557,10 +1596,10 @@ CANDIDATE is (DEM-DELTA REST GAPS FITNESS HYPHEN-COUNT)."
(and ekp-use-c-module (and ekp-use-c-module
(boundp 'ekp-c-module-loaded) ekp-c-module-loaded (boundp 'ekp-c-module-loaded) ekp-c-module-loaded
(fboundp 'ekp-c-break-with-arrays) (fboundp 'ekp-c-break-with-arrays)
;; looseness and per-line widths need the (position × line-count) ;; looseness and parshape need the (position × line-count) DP,
;; DP, Elisp only ;; Elisp only; first-line indent is a scalar the C engine takes
(= ekp-looseness 0) (= ekp-looseness 0)
(not (ekp--parshape-active-p)))) (not ekp-parshape)))
(defun ekp--c-sync-params () (defun ekp--c-sync-params ()
"Push current K-P penalty settings to the C module." "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))))) (t (ekp--dp-cache-elisp para line-pixel)))))
(defun ekp--lines-data-from-breaks (para line-pixel breaks) (defun ekp--lines-data-from-breaks (para line-pixel breaks)
"Compute (RESTS . GAPS) lists for BREAKS, matching the DP's metrics." "Compute (RESTS . GAPS) lists for BREAKS, matching the DP's metrics.
(let ((start 0) rests gapss) 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) (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)) (ekp--line-ideal-pixel para start end))
rests) rests)
(push (if (or (= end (1+ start)) (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 nil
(ekp--gaps-between para start end)) (ekp--gaps-between para start end))
gapss) gapss)
(setq start end)) (setq start end
idx (1+ idx)))
(cons (nreverse rests) (nreverse gapss)))) (cons (nreverse rests) (nreverse gapss))))
(defun ekp--store-c-result (para line-pixel breaks cost) (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)) dp-result))
(defun ekp--prepare-para-for-c (para line-pixel) (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) (vector (ekp-para-ideal-prefixs para)
(ekp-para-min-prefixs para) (ekp-para-min-prefixs para)
(ekp-para-max-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-trail-spaces para)
(ekp-para-forbidden-positions para) (ekp-para-forbidden-positions para)
(ekp-para-tail-protrudes 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) (defun ekp--dp-cache-via-c (para line-pixel)
"Compute breaks using the C module with PARA's precomputed arrays. "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-trail-spaces para)
(ekp-para-forbidden-positions para) (ekp-para-forbidden-positions para)
(ekp-para-tail-protrudes 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-breaks (car result))
(c-cost (cdr result))) (c-cost (cdr result)))
(if (null c-breaks) (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 * 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 * Args: (ideal-prefix min-prefix max-prefix glue-ideals glue-shrinks
* hyphen-positions hyphen-width line-width) * 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: * Array sizes must be consistent:
* - ideal/min/max-prefix: (n+1) elements * - ideal/min/max-prefix, lead/trail-spaces, tail-protrudes: n+1
* - glue-ideals/shrinks/stretches: n elements * - glue-ideals/shrinks/stretches: n elements
* *
* Returns: (breaks . total-cost) where breaks is a list of box indices. * 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; (void)data;
if (!ekp_global || nargs < 14) if (!ekp_global || nargs < 15)
return env->intern(env, "nil"); return env->intern(env, "nil");
/* Get prefix array sizes (n+1 elements) */ /* 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 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) */ /* Forbidden break positions (sorted gap indices, may be empty) */
ptrdiff_t forb_count = env->vec_size(env, args[11]); 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, hyph_width, line_width,
lead_spaces, trail_spaces, 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); tail_pro, hyphen_protrude, first_line_width);
free(ideal_prefix); free(min_prefix); free(max_prefix); free(ideal_prefix); free(min_prefix); free(max_prefix);
free(glue_ideals); free(glue_shrinks); free(glue_stretches); 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 *hyph_width, int32_t *line_width,
int32_t **lead_spaces, int32_t **trail_spaces, 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) int32_t **tail_pro, int32_t *hyphen_protrude,
int32_t *first_line_width)
{ {
ptrdiff_t prefix_len = env->vec_size(env, args[0]); ptrdiff_t prefix_len = env->vec_size(env, args[0]);
if (prefix_len <= 1) if (prefix_len <= 1)
@ -541,6 +545,7 @@ static bool extract_paragraph_data(
} }
} }
*hyphen_protrude = env->extract_integer(env, args[13]); *hyphen_protrude = env->extract_integer(env, args[13]);
*first_line_width = env->extract_integer(env, args[14]);
return true; 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++) { for (ptrdiff_t p = 0; p < para_count; p++) {
emacs_value para_vec = env->vec_get(env, args[0], p); emacs_value para_vec = env->vec_get(env, args[0], p);
/* Extract 14 arguments from this paragraph's vector */ /* Extract 15 arguments from this paragraph's vector */
emacs_value para_args[14]; emacs_value para_args[15];
for (int i = 0; i < 14; i++) { for (int i = 0; i < 15; i++) {
para_args[i] = env->vec_get(env, para_vec, i); para_args[i] = env->vec_get(env, para_vec, i);
} }
size_t n; size_t n;
ptrdiff_t hyph_count, forb_count; 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, if (!extract_paragraph_data(env, para_args,
&all_ideal[p], &all_min[p], &all_max[p], &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, &hyph_width, &line_width,
&all_lead[p], &all_trail[p], &all_lead[p], &all_trail[p],
&all_forb[p], &forb_count, &all_forb[p], &forb_count,
&all_pro[p], &hyphen_protrude)) { &all_pro[p], &hyphen_protrude,
&first_line_width)) {
/* Cleanup on failure */ /* Cleanup on failure */
for (ptrdiff_t j = 0; j < p; j++) { for (ptrdiff_t j = 0; j < p; j++) {
free(all_ideal[j]); free(all_min[j]); free(all_max[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; (all_forb[p] && forb_count > 0) ? (size_t)forb_count : 0;
inputs[p].tail_protrudes = all_pro[p]; inputs[p].tail_protrudes = all_pro[p];
inputs[p].hyphen_protrude = hyphen_protrude; inputs[p].hyphen_protrude = hyphen_protrude;
inputs[p].first_line_width = first_line_width;
} }
/* Process all paragraphs in parallel */ /* 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\ Returns (BREAKS . TOTAL-COST) where BREAKS is list of break positions.\n\n\
(fn STRING HYPHENATOR-INDEX LINE-WIDTH MEASURE-FUNC)"); (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\ "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\ IDEAL-PREFIX: vector of ideal width prefix sums (n+1 elements)\n\
MIN-PREFIX: vector of min 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\ HYPHEN-WIDTH: pixel width of hyphen character\n\
LINE-WIDTH: target line width in pixels\n\ LINE-WIDTH: target line width in pixels\n\
LEAD-SPACES: vector (n+1) of space-box run widths starting at box i\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\ Returns (BREAKS . TOTAL-COST) where BREAKS is list of box indices.\n\
This API ensures C uses Elisp's font-dependent measurements.\n\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 \ (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, defun(env, "ekp-c-version", 0, 0, Fekp_c_version,
"Return EKP C module version string."); "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, defun(env, "ekp-c-break-batch", 1, 1, Fekp_c_break_batch,
"Break multiple paragraphs in parallel.\n\n\ "Break multiple paragraphs in parallel.\n\n\
PARAGRAPHS: vector of paragraph data, each element is a vector of 11 items:\n\ PARAGRAPHS: vector of paragraph data, each element a vector of the\n\
[ideal-prefix min-prefix max-prefix glue-ideals glue-shrinks\n\ same 15 items `ekp-c-break-with-arrays' takes, in the same order.\n\n\
glue-stretches hyphen-positions hyphen-width line-width\n\
lead-spaces trail-spaces]\n\n\
Returns vector of (BREAKS . COST) for each paragraph.\n\ Returns vector of (BREAKS . COST) for each paragraph.\n\
This is the high-performance API for multi-paragraph processing.\n\n\ This is the high-performance API for multi-paragraph processing.\n\n\
(fn PARAGRAPHS)"); (fn PARAGRAPHS)");

View File

@ -170,6 +170,10 @@ typedef struct {
/* Dimensions */ /* Dimensions */
size_t n; /* box count */ size_t n; /* box count */
int32_t line_width; 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 */ /* K-P parameters */
int line_penalty; int line_penalty;
@ -281,7 +285,10 @@ static void dp_process_position(
int32_t *line_counts) int32_t *line_counts)
{ {
size_t n = in->n; 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 */ /* Get leading glue for line starting at i */
int32_t lead_ideal = (in->glue_ideals && i < n) ? in->glue_ideals[i] : 0; 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, const int32_t *forbidden_positions,
size_t forbidden_count, size_t forbidden_count,
const int32_t *tail_protrudes, 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) if (!ideal_prefix || !min_prefix || !max_prefix || n == 0 || line_width <= 0)
return NULL; return NULL;
if (first_line_width <= 0)
first_line_width = line_width;
/* Allocate DP arrays */ /* Allocate DP arrays */
double *demerits = malloc((n + 1) * sizeof(double)); double *demerits = malloc((n + 1) * sizeof(double));
@ -728,6 +738,7 @@ ekp_result_t *ekp_break_with_prefixes(
.trail_spaces = trail_spaces, .trail_spaces = trail_spaces,
.n = n, .n = n,
.line_width = line_width, .line_width = line_width,
.first_line_width = first_line_width,
.line_penalty = lp, .line_penalty = lp,
.hyphen_penalty = hp, .hyphen_penalty = hp,
.fitness_penalty = fp, .fitness_penalty = fp,
@ -847,7 +858,8 @@ static void batch_worker(void *arg)
in->hyphen_width, in->line_width, in->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces, in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count, 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->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces, in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count, in->forbidden_positions, in->forbidden_count,
in->tail_protrudes, in->hyphen_protrude); in->tail_protrudes, in->hyphen_protrude,
in->first_line_width);
} }
return results; 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->hyphen_width, in->line_width,
in->lead_spaces, in->trail_spaces, in->lead_spaces, in->trail_spaces,
in->forbidden_positions, in->forbidden_count, in->forbidden_positions, in->forbidden_count,
in->tail_protrudes, in->hyphen_protrude); in->tail_protrudes, in->hyphen_protrude,
in->first_line_width);
} }
return results; return results;
} }

View File

@ -26,7 +26,7 @@
/* Version */ /* Version */
#define EKP_VERSION_MAJOR 1 #define EKP_VERSION_MAJOR 1
#define EKP_VERSION_MINOR 4 #define EKP_VERSION_MINOR 5
/* Limits */ /* Limits */
#define EKP_MAX_PATTERN_LEN 64 #define EKP_MAX_PATTERN_LEN 64
@ -270,7 +270,8 @@ ekp_result_t *ekp_break_with_prefixes(
const int32_t *forbidden_positions, const int32_t *forbidden_positions,
size_t forbidden_count, size_t forbidden_count,
const int32_t *tail_protrudes, const int32_t *tail_protrudes,
int32_t hyphen_protrude); int32_t hyphen_protrude,
int32_t first_line_width);
/* /*
* Batch input for parallel processing * Batch input for parallel processing
@ -293,6 +294,7 @@ typedef struct {
size_t forbidden_count; size_t forbidden_count;
const int32_t *tail_protrudes; /* nullable, n+1 elements */ const int32_t *tail_protrudes; /* nullable, n+1 elements */
int32_t hyphen_protrude; int32_t hyphen_protrude;
int32_t first_line_width; /* width of line 0; <=0 = line_width */
} ekp_batch_input_t; } 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"))) (let ((h (ekp-hyphen-create "en_US")))
(should (equal (ekp-hyphen-boxes h "hyphenation") (should (equal (ekp-hyphen-boxes h "hyphenation")
'("hy" "phen" "ation"))) '("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") (should (equal (ekp-hyphen-boxes h "emergency")
'("emer" "gen" "cy"))) '("emer" "gency")))
;; Words with no break points come back whole ;; Words with no break points come back whole
(should (equal (ekp-hyphen-boxes h "cat") '("cat"))))) (should (equal (ekp-hyphen-boxes h "cat") '("cat")))))
@ -696,6 +698,72 @@ kept returning the paragraph resolved under the previous style."
(ekp-clear-caches) (ekp-clear-caches)
(should (equal-including-properties out-j (ekp-pixel-justify s 30))))) (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) (provide 'ekp-tests)
;;; ekp-tests.el ends here ;;; ekp-tests.el ends here