From 3fc05c1405f411fadc26df9bb1f690f73103e0fd Mon Sep 17 00:00:00 2001 From: Kinneyzhang Date: Mon, 27 Jul 2026 01:36:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20typography=20wave=20=E2=80=94=20diction?= =?UTF-8?q?ary=20HYPHENMIN,=20JIS=20kinsoku,=20fast=20indent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ekp-hyphen.el | 29 +++++++--- ekp-utils.el | 2 +- ekp.el | 141 ++++++++++++++++++++++++++++++--------------- ekp_c/ekp.c | 48 +++++++++------ ekp_c/ekp_kp.c | 24 ++++++-- ekp_c/ekp_module.h | 6 +- tests/ekp-tests.el | 70 +++++++++++++++++++++- 7 files changed, 237 insertions(+), 83 deletions(-) diff --git a/ekp-hyphen.el b/ekp-hyphen.el index 4a7de7e..d9f89ab 100644 --- a/ekp-hyphen.el +++ b/ekp-hyphen.el @@ -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) diff --git a/ekp-utils.el b/ekp-utils.el index 6b1355f..b256ad9 100644 --- a/ekp-utils.el +++ b/ekp-utils.el @@ -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 diff --git a/ekp.el b/ekp.el index e733ccc..4eac1a1 100644 --- a/ekp.el +++ b/ekp.el @@ -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) diff --git a/ekp_c/ekp.c b/ekp_c/ekp.c index 8bd2394..32a2c7e 100644 --- a/ekp_c/ekp.c +++ b/ekp_c/ekp.c @@ -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)"); diff --git a/ekp_c/ekp_kp.c b/ekp_c/ekp_kp.c index 9418402..4f8dbd5 100644 --- a/ekp_c/ekp_kp.c +++ b/ekp_c/ekp_kp.c @@ -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; } diff --git a/ekp_c/ekp_module.h b/ekp_c/ekp_module.h index c537285..0117c08 100644 --- a/ekp_c/ekp_module.h +++ b/ekp_c/ekp_module.h @@ -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; /* diff --git a/tests/ekp-tests.el b/tests/ekp-tests.el index f5d611d..8294b56 100644 --- a/tests/ekp-tests.el +++ b/tests/ekp-tests.el @@ -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