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:
parent
64eb2f38d5
commit
ea96a6dc96
@ -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)
|
||||
|
||||
13
ekp-utils.el
13
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)
|
||||
|
||||
48
ekp.el
48
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)
|
||||
|
||||
@ -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 "他说:「今天天气很好。」然后就离开了这里,再也没有回来过。"))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user