diff --git a/ekp-region.el b/ekp-region.el index 20109cf..2b560d3 100644 --- a/ekp-region.el +++ b/ekp-region.el @@ -190,6 +190,21 @@ tails. Text the user typed into the justified region is preserved." (remove-text-properties (min beg end) end-m '(ekp-justified nil))) (set-marker end-m nil)))) +;;;###autoload +(defun ekp-no-break-region (beg end) + "Mark the region as an unbreakable typesetting atom. +Justification treats it as one rigid unit: no line break inside, no +hyphenation, spacing stays literal (inline code, product names, +numbers with units)." + (interactive "r") + (add-text-properties beg end '(ekp-no-break t))) + +;;;###autoload +(defun ekp-allow-break-region (beg end) + "Remove `ekp-no-break' marking from the region." + (interactive "r") + (remove-text-properties beg end '(ekp-no-break nil))) + ;;;; Auto-justify minor mode (defun ekp-region--para-bounds (marker-pair) diff --git a/ekp-utils.el b/ekp-utils.el index 59cc9e0..2b4de32 100644 --- a/ekp-utils.el +++ b/ekp-utils.el @@ -170,6 +170,10 @@ Rules: ;; CJK involved: preserve all spaces (cjk-involved (setq boxes (cons spaces boxes))) + ;; Inside a no-break span: spacing is literal, glue would + ;; stretch — preserve the run as a rigid space box. + ((text-property-not-all 0 (length spaces) 'ekp-no-break nil spaces) + (setq boxes (cons spaces boxes))) ;; Latin-Latin with multiple spaces: preserve all but last ((> (length spaces) 1) (setq boxes (cons (substring spaces 0 -1) boxes))) @@ -185,11 +189,12 @@ Rules: (defun ekp--zero-width-attaching-p (char) "Return non-nil if zero-width CHAR must attach to the preceding text. -Combining marks (Mn/Mc/Me), ZWJ/ZWNJ, CGJ and variation selectors -attach to the previous character; other zero-width characters (such -as zero-width space U+200B) are treated as invisible break points." +Combining marks (Mn/Mc/Me), ZWJ/ZWNJ, CGJ, variation selectors and +the word joiner attach to the previous character; other zero-width +characters (such as zero-width space U+200B) are treated as +invisible break points." (or (memq (get-char-code-property char 'general-category) '(Mn Mc Me)) - (memq char '(#x200C #x200D #x034F)) + (memq char '(#x200C #x200D #x034F #x2060 #xFEFF)) (and (>= char #xFE00) (<= char #xFE0F)))) (defun ekp--handle-latin-char (str state latin-word cjk-char boxes) diff --git a/ekp.el b/ekp.el index e39df70..ca9b7dc 100644 --- a/ekp.el +++ b/ekp.el @@ -227,7 +227,10 @@ Returns (boxes-vector . hyphen-positions-vector)." ekp--word-right-punct)) (idx 0) new-boxes hyphen-idxs) (dolist (box (append boxes nil)) - (if (string-match word-re box) + (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))) ;; Latin word: apply hyphenation (let* ((left (match-string 1 box)) (word (match-string 2 box)) @@ -316,6 +319,13 @@ mws between cjk and latin; nws means no whitespace. Space boxes ((or (eq before 'cjk-close) (eq after 'cjk-open)) 'cws)) 'nws))) +(defconst ekp--no-break-joiner-chars '(#x00A0 #x202F #x2007 #x2060 #xFEFF) + "Characters that forbid a break between their neighbors. +NO-BREAK SPACE, NARROW NO-BREAK SPACE, FIGURE SPACE, WORD JOINER and +the deprecated ZWNBSP. The zero-width ones attach to the preceding +box; the visible ones are boxes of their own whose adjacent gaps are +unbreakable and glue-free (the character supplies its own spacing).") + (defconst ekp--no-line-start-chars ".,;:!?)]}%’”»›…·" "Halfwidth/neutral punctuation that must not start a line. Applies to boxes consisting solely of these characters (a lone comma @@ -505,20 +515,32 @@ Computes ALL data in one pass: text, params, and prefix arrays." (trail-spaces (make-vector (1+ n) 0)) (breaks-allowed (make-bool-vector (1+ n) t)) (forbidden nil)) - ;; Kinsoku via break permissions: a line may not end with an - ;; opening-punct box, nor start with a closing-punct box — full- - ;; and halfwidth alike. Punctuation also hugs its content: those - ;; unbreakable gaps carry no glue. + ;; Break permissions. A gap is unbreakable when: + ;; - kinsoku: the line would end with an opener or start with a + ;; closer (full- and halfwidth alike), + ;; - it lies strictly inside an `ekp-no-break' span, or + ;; - a no-break joiner character (NBSP & friends) touches it. + ;; Unbreakable gaps carry no glue: punctuation hugs its content, + ;; atoms stay rigid, NBSP supplies its own spacing. (let ((k 1)) (while (< k n) - (when (or (ekp--box-no-line-end-p (aref boxes (1- k)) - (aref boxes-types (1- k))) - (ekp--box-no-line-start-p (aref boxes k) - (aref boxes-types k))) - (aset breaks-allowed k nil) - (push k forbidden) - (unless (eq (aref glues-types k) 'nws) - (aset glues-types k 'nws))) + (let* ((prev-box (aref boxes (1- k))) + (curr-box (aref boxes k)) + (prev-last (aref prev-box (1- (length prev-box)))) + (curr-first (aref curr-box 0))) + (when (or (ekp--box-no-line-end-p prev-box + (aref boxes-types (1- k))) + (ekp--box-no-line-start-p curr-box + (aref boxes-types k)) + (and (get-text-property (1- (length prev-box)) + 'ekp-no-break prev-box) + (get-text-property 0 'ekp-no-break curr-box)) + (memq prev-last ekp--no-break-joiner-chars) + (memq curr-first ekp--no-break-joiner-chars)) + (aset breaks-allowed k nil) + (push k forbidden) + (unless (eq (aref glues-types k) 'nws) + (aset glues-types k 'nws)))) (setq k (1+ k)))) ;; Single loop for all prefix computations (dotimes (i n) diff --git a/tests/ekp-tests.el b/tests/ekp-tests.el index 7d6f27b..8108d47 100644 --- a/tests/ekp-tests.el +++ b/tests/ekp-tests.el @@ -137,6 +137,46 @@ Used to verify no content is lost by justification." (dolist (k '(1 5 7 10)) (should (aref ok k))))) +(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)) + (text (concat "prefix words before " code " and after more words"))) + (dolist (w '(40 80 120 200)) + (let ((out (ekp-pixel-justify text w))) + ;; contiguous, with literal single spaces — on one line + (should (string-match-p "foo bar baz" out)))))) + +(ert-deftest ekp-test-no-break-overlong-atom () + "An atom wider than the line becomes a single emergency line." + (let ((atom (propertize "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbb" + 'ekp-no-break t))) + (let ((out (ekp-pixel-justify (concat "x " atom " y") 30))) + (should (string-match-p "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbb" out))))) + +(ert-deftest ekp-test-no-break-suppresses-hyphenation () + "No soft hyphen appears inside a no-break span." + (let ((word "internationalization")) + ;; sanity: unmarked long word does hyphenate at narrow width + (should (string-match-p "-\n" (ekp-pixel-justify + (concat "pad " word " pad") 15))) + (should-not (string-match-p "-\n" + (ekp-pixel-justify + (concat "pad " + (propertize word 'ekp-no-break t) + " pad") + 15))))) + +(ert-deftest ekp-test-nbsp-and-word-joiner () + "NBSP and WORD JOINER keep their neighbors on the same line." + (let ((nbsp (string #x00A0)) (wj (string #x2060))) + (dolist (w '(20 30 44 60 90)) + (let ((out (ekp-pixel-justify + (concat "甲乙丙丁" nbsp "戊己庚辛写更多字") w))) + (should (string-match-p (concat "丁" nbsp "戊") out))) + (let ((out (ekp-pixel-justify + (concat "甲乙丙" wj "丁戊己庚辛写更多字") w))) + (should (string-match-p (concat "丙" wj "丁") out)))))) + (ert-deftest ekp-test-kinsoku-rendered-output () "No rendered line starts with a closer or ends with an opener." (let ((text "他说:「今天天气很好。」然后就离开了这里,再也没有回来过。"))