feat: no-break spans, NBSP/WORD JOINER support, verbatim atoms

- text property ekp-no-break turns any span into a rigid typesetting
  atom: no line break inside, no hyphenation, literal spacing (space
  runs inside the span become preserved space boxes instead of
  stretchable glue) — inline code, product names, numbers with units
- character-driven joins: NBSP, NARROW NBSP, FIGURE SPACE keep their
  neighbors together (they are boxes whose adjacent gaps are
  unbreakable and glue-free — the character supplies its spacing);
  WORD JOINER / ZWNBSP attach invisibly like combining marks
- new commands: ekp-no-break-region / ekp-allow-break-region
- overlong atoms fall back to single emergency lines (atomic-run
  handling from the break-permission layer); zero C changes needed

Tests: 52 ERT green incl. atomicity across widths, hyphenation
suppression, NBSP/WJ adjacency sweeps; fuzz 300/300 with C parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kinneyzhang 2026-07-26 21:12:07 +08:00
parent 64eb2f38d5
commit ea96a6dc96
4 changed files with 99 additions and 17 deletions

View File

@ -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)

View File

@ -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)

48
ekp.el
View File

@ -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)

View File

@ -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 "他说:「今天天气很好。」然后就离开了这里,再也没有回来过。"))