refactor: 移除標點懸掛(機制級死路)——負 margin 程式碼/CSS/型別一併刪

標點懸掛經實測為**機制級死路**(純 CSS 之寬度守恆矛盾):行端標點盒保持非零寬則墨色
出去多少版心內就空多少(行末實義字無法齊邊);收為零寬則釋放一整字寬、斷行器把下一字
拉上本行與標點重疊(且 justifyAtoms 預設之 inline-block 下負 margin 被 justify 吸收)。
CSS hack 接觸不到斷行器,此恒等式無解。唯一正解為原生 hanging-punctuation(僅 Safari
支援、且 jz-char 為 inline-block 原子非文本,原生懸掛不作用),屬遠期「原生管線」新功能、
與此處無共享,故不保留休眠碼。

移除:
  lineedge.ts — jz-hang-* class、HANG_* 常量、applyEdge hang 分支、modeForChar、
    HANG_ENABLED 總開關;行端僅餘 squeeze(半形)。
  _jiya.css — jz-hang-* 三條規則(margin 半形 fallback 完整保留)。
  types.ts — LineEndMode 型別、ResolvedOptions.lineEnd、features.hanging。
  index.ts — lineEnd 鎖。
  hanging 公開選項保留為 @deprecated no-op(API 相容)。

文檔:README「標點懸掛」節、ARCHITECTURE §6.5(開頭/§6.5.2/§6.5.4/§6.5.5/§6.5.6)、
demo(showcase §三/resize-check)全部由「停用/休眠」改為「已移除」+寬度守恆論證。

測試:防逃逸測試改寫為「hanging 為 no-op、輸出與不傳一致、各路徑不產生 jz-hang-*」。
全綠 101。dist 已無 jz-hang/lineEnd/HANG 殘留。
This commit is contained in:
2026-06-11 16:16:01 +08:00
parent 2890549f44
commit a3c94ddee5
12 changed files with 156 additions and 336 deletions
+55 -78
View File
@@ -511,25 +511,21 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
不應作用於標題/按鈕/單行文本。
> **實作狀態(2026-06**6.5.0a 功能分級、6.5.1 垂懸字(方案 C,預設開)已落地且工作良好。
> 6.5.2 行端模式重構為 squeezehangnonesqueeze 為現有行端半形、保留並改善);**標點懸掛
> hang 經實測確認不可行、已全面停用**(仍保留休眠程式碼)。
> 6.5.2 行端僅做半形擠壓(squeeze,行首開類收左/行尾閉類收右);**標點懸掛 hang 經實測為機制級
> 死路、已移除**(負 margin 之 lineedge 程式碼、CSS、`lineEnd``LineEndMode``HANG_ENABLED`
> 等一併刪除;`hanging` 選項保留為 no-op)。
>
> **行歸屬判定 bug 修正**連帶改善 squeeze,保留):原以「行盒 top 差 > EPS=4px」判同/異行,
> **行歸屬判定 bug 修正**squeeze 之改善,保留):原以「行盒 top 差 > EPS=4px」判同/異行,
> 但 jz-char 為 inline-block,其行盒 top 與相鄰純文本字形 top 因基線差約 5px、遠小於行高卻超
> 舊容差,致同行誤判為下一行。改判「垂直區間」:下一行=`b.top ≥ a.bottom`、上一行=
> `b.bottom ≤ a.top`(容 2px 亞像素)。
>
> **標點懸掛停用之原因**(純負 margin 機制不可行,headless + 真實瀏覽器實測):
> 1. 句號等標點墨色偏字框左下角;安全凸出量(< 1 個 CJK 字寬 1em)只把空白半框推出版心、
> **墨色仍在版心內**(視覺不懸掛)。
> 2. 凸出滿 1em 又**釋放一整字寬**、把下一字拉上本行、與懸掛標點**重疊**。
> 3. 懸掛與 inline-block 對齊原子**互斥**justifyAtoms:true 下負 margin 被 justify 吸收)。
> 4. `ResizeObserver` 重排時,「自然佈局標記→施加 margin→佈局再變」致標記**錯亂**
>
> 真正的解是原生 CSS `hanging-punctuation`,惟 Chrome 不支援、且與本庫 inline-block 原子衝突。
> 故 hang 之 lineedge 程式碼與 CSS 保留休眠,並設**三重防逃逸**(`lineEnd` 鎖死 squeeze、
> `modeForChar` 降級、`applyEdge` 之 `HANG_ENABLED=false` 總開關)——縱使下游以任何方式要求亦
> 不啟用。詳見 6.5.6。
> **標點懸掛移除之根因**(純 CSS 之寬度守恆矛盾,headless + 真實瀏覽器實測):行端標點盒若
> 保持非零寬,墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,釋放
> 一整字寬、斷行器把下一字拉上本行與標點重疊(且 justifyAtoms 預設之 inline-block 下負 margin
> 被 justify 吸收)。**CSS hack 接觸不到斷行器,此恒等式無解**。唯一正解為原生
> `hanging-punctuation`(僅 Safari 支援、且 jz-char 為 inline-block 原子非文本,原生懸掛不作用),
> 屬遠期「原生管線」新功能、與已移除程式碼無共享。詳見 6.5.6
#### 6.5.0a 功能分級:文本級 vs 段落級 + 元素標記(前置基礎設施)
@@ -573,19 +569,17 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
|---|---|---|---|---|
| 禁則(§6.4 jinze | **斷行層** | 把標點趕到正確的邊(點號/閉類→行末、開類→行首;連續標點不拆) | nowrap `jz-jinze` | 已實作 |
| 行端半形(lineedge 現有) | **渲染層·紧排** | 行首開類/行末閉類**收半形** | `jz-half-le`(halt) | 已實作(將併入行端模式) |
| 標點懸掛 | **渲染層·凸出** | 行末點號/閉類**右凸**、行首開類**左凸**出版心 | 負 `margin` | ⛔ 實作後停用(見 6.5.6 |
| 垂懸字避免(本計劃) | **斷行層** | 段末行 **CJK 實義字數 ≥ N** | nowrap `jz-orphan` | 實作 |
| 標點懸掛 | **渲染層·凸出** | 行末點號/閉類**右凸**、行首開類**左凸**出版心 | ~~負 `margin`~~ | ❌ 已移除(機制級死路,見 6.5.6 |
| 垂懸字避免 | **斷行層** | 段末行 **CJK 實義字數 ≥ N** | nowrap `jz-orphan` | 實作 |
- **正交性**:斷行層(禁則/垂懸字,管「哪些字符在哪行、不可斷」)與渲染層(半形/懸掛,管
「邊緣標點凸出/收縮」)作用於不同 CSS 維度(`white-space` vs `margin`),可疊加共存。
- **協同**:禁則決定標點落行首或行末 → 半形/懸掛對落在邊緣者施收縮/凸出。
- **行端半形 vs 懸掛」互斥**(同屬渲染層):將 lineedge 行端處理升級為可配模式
`lineEnd: "squeeze"(現有半形)|"hang"(懸掛)|"none"`,二者共用行端標點偵測碼
- **垂懸字 vs 懸掛邊界**已研究):垂懸字限「末行實義字 ≥ N」、懸掛限「邊緣標點凸出」。唯一
重疊=**純標點末行**(實義字=0):懸掛可「吸回」(不動正文)、垂懸字綁定可「拉字下來」。
約定:方案 C(無測量)**附帶**消除純標點末行(副作用,現有益無害);未來若改由懸掛吸回
(更優),把 C 綁定**條件化**(僅末行實義字 ∈ [1,N−1] 才綁),借懸掛 layout pass 之測量協同
判定,C 不獨立測。末行實義字 ∈ [1,N−1] 僅垂懸字(換行)可解(懸掛不增實義字)。
- **行端半形擠壓**lineedge 行端處理只做 squeeze(行首開類收左、行尾閉類收右半形)。
原計劃之「懸掛」模式已移除(機制級死路,§6.5.6),故無「半形 vs 懸掛」之模式切換
- **垂懸字 vs 懸掛邊界**歷史記錄):曾研究兩者於「純標點末行」之重疊(懸掛吸回 vs 垂懸字
拉字)。懸掛移除後此協同不再適用;方案 C(無測量)**附帶**消除純標點末行即可(副作用,
現有益無害)。
#### 6.5.1 垂懸字避免(方案 C,斷行層,無測量)— 已實作
@@ -605,44 +599,16 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
- **測試**:jsdom 結構(末 N 實義字+尾標點入 jz-orphan;符號不計;單行段/實義字<N 不綁);
headless 窄容器拖動驗末行恒 ≥N。
#### 6.5.2 標點懸掛(Hanging,渲染層,layout)— 實作後停用
#### 6.5.2 標點懸掛(Hanging)— 移除(機制級死路)
> **停用2026-06**本節原設計之懸掛經實測**不可行、已全面停用**(原因見 6.5 開頭與 6.5.6
> 三重防逃逸見 6.5.6)。lineedge 之 squeeze 模式(行端半形)保留並改善。下文為原設計記錄,
> 供日後改用原生 `hanging-punctuation` 重啟時參考
> **已移除2026-06**原計劃之懸掛(行端標點負 margin 凸出版心)經 headless + 真實瀏覽器
> 實測確認為**機制級死路**(寬度守恆矛盾,純 CSS 接觸不到斷行器),程式碼與 CSS 已刪。完整
> 論證、已移除清單、未來原生路徑見 **§6.5.6**。`hanging` 選項保留為 no-op(API 相容)
- **目標**:行末 bd-stoppauseclose **右凸**出右緣;行首 bd-open **左凸**出左緣。文字邊緣齊。
- **不用 CSS `hanging-punctuation`**:瀏覽器支援差(僅 Safari 較全)+聚珍「原子透明」已對 jz-*
`hanging-punctuation: none`。故**自管負 margin**
- **機制**layout pass(複用 lineedge `installLayout`:立即跑+`ResizeObserver(root)`
`fonts.ready` 重算)。量測各標點 jz-char 是否在行首/行末(同 lineedge `edgeRect`/`firstRect`
跨 jz-* 包裹攀爬)。行末點號/閉類加 `jz-hang-r`→`margin-right: -<凸出量>`;行首開類加
`jz-hang-l`→`margin-left: -<凸出量>`。
- **與現有行端半形整合**(已實作):抽出「行端標點偵測」共用,按 `lineEnd` 模式(由
`options.hanging` 解析:開→`hang`、關→`squeeze`)於 `relayout` 加 `jz-half-le`squeeze
或 `jz-hang-*`hang)。squeeze 行為不變(回歸保綠)。
- **凸出量按類**headless 實測校準,Noto Sans CJK SC):句末/句內點號(。,等墨色小、留白
多)近全形 `--jz-hang-stop`(預設 1em,整體落版心外、正文齊邊);括號引號半形
`--jz-hang-bracket`(預設 0.5em,墨色偏側、留白半框)。實測句號右凸 +1.005em、開括號左凸
+0.5em,正確。CSS 變數可逐字型調校。
- **已 baseline 半形者不懸掛**jz-char 帶 `jz-half`(開明/半角式之括號等)已收為半形、無留白
可凸,JS 端 relayout 跳過(否則負 margin 把墨色整體拉出)。故 SC 開明式之括號維持半形不掛、
句末點號(不收半形)才掛。
- ⚠ **懸掛與 inline-block 對齊原子互斥(實測,重要約束)**:預設 `justifyAtoms:true`jz-char
為 inline-block)下,懸掛之負 margin 被 justify 重分配吸收、標點**不凸出**headless 量測
凸出量 0);改 `justifyAtoms:false`jz-char 為 inline)則正確凸出(+1em)。故欲懸掛須設
`justifyAtoms:false`。二者皆渲染層、不可兼得(如同 squeeze/hang 互斥)。容器宜留右
padding ≥1em 容納凸出標點。
- **lang 閘**(已實作):繁體橫排預設不懸掛(`relayout` 之 `modeForChar` 對 `zh-Hant` 降回
squeezeclreq);簡體可掛。`options.hanging` 預設**關**(顯式開)。
- **自我應驗**(已解,§6.5 開頭發現 4):採**兩階段** relayout——先 `clearEdge` 清全部行端
margin/半形,於自然(無行端效果)佈局量測**全部**標點之行端狀態(不變更 DOM),再一次性
套用。每次量測皆見無污染佈局(仿 gapTrim「起始全清」教訓),終態穩定。
- **行歸屬判定**(已修,§6.5 開頭發現 1):以「垂直區間」`lowerLine`/`higherLine` 取代舊
「top 差 > EPS」,吸收 inline-block 行盒與純文本字形之基線差(~5px),免同行誤判。
- **order**`90 lineEdge`squeeze/hang 共此 pass)。`revert` 清 `jz-half-le`/`jz-hang-*`。
- **跨瀏覽器**:負 margin 一致;須 ChromeSafariFF 目視(R1/R2)。headlessChrome)已驗
行末標點凸出右緣、行首凸出左緣、繁體不掛、無中段誤觸。
lineedge 行端處理只餘 **squeeze**(行端半形):量測各 jz-char 是否在行首(開類)/行末(閉類),
加 `jz-half-le`(CSS 同 halt)。兩階段免自我應驗(先 `clearEdge`、自然佈局量測全部、再一次套用);
行歸屬以「垂直區間」`lowerLine``higherLine` 判定(吸收 inline-block 行盒與純文本基線差 ~5px)
order `90 lineEdge``revert` 清 `jz-half-le`。純版面、jsdom 跳過、須瀏覽器目視。
#### 6.5.3 行端禁則(§6.4review
@@ -653,34 +619,45 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
#### 6.5.4 統一 pass order
`20 charify → 25 jiyaAdj → 40 jinze → 50 spacing → 60 longWord → 70 orphan(垂懸字)→
90 lineEdge(行端 squeezehang 已停用)→ 92 gapTrim`。
90 lineEdge(行端 squeeze 半形)→ 92 gapTrim`。
#### 6.5.5 實作順序與結果
0. ✅ **功能分級(前置,6.5.0a**pass 標 `level`、`finder.levelFor`/`levelAllows`、
`data-jz-level`/選項 `level.text`;段落級 pass gate 跳過 text-level 元素。
1. ✅ **垂懸字(方案 C**standalone pass、order 70、純結構無 layout、預設開 chars=2。工作良好。
2. ✅ **lineedge 重構為行端模式**:抽出偵測,squeeze 行為不變、回歸保綠;連帶修「行歸屬判定」
bug(垂直區間取代 top 差)。保留。
3. **懸掛 hang 模式**:實作後經 headless + 真實瀏覽器實測**確認不可行、全面停用**(原因見
6.5 開頭 + 6.5.6)。程式碼休眠 + 三重防逃逸
2. ✅ **lineedge 行端半形(squeeze)**:量測行首開類/行末閉類加 `jz-half-le`;連帶修「行歸屬
判定」bug(垂直區間取代 top 差)。保留。
3. **懸掛 hang**:實作後經 headless + 真實瀏覽器實測確認為**機制級死路、已移除**(負 margin
程式碼/CSS`LineEndMode``lineEnd``features.hanging` 一併刪;`hanging` 選項保留 no-op
論證見 6.5.6。
4. ✅ **邊界落地**:純標點末行由 orphan 之尾段綁定(含尾隨標點)全包消除。
5. ✅ **文檔/docsdemo**ARCHITECTUREREADMEdocs 分支標記懸掛停用;demoshowcase
resize-check)移除懸掛、保留功能分級與垂懸字
5. ✅ **行內透明性修復(下游 Bug)**:pill 前標點誤隙(finder 透明)、行內元素整體掉行
core/split 克隆切分)、行端攀爬集派生(T4)等,見 §4.1/§6.4
6. ✅ **文檔/docsdemo**ARCHITECTUREREADMEdocs 分支記懸掛移除;demo 同步。
#### 6.5.6 標點懸掛停用記錄與未來路徑
#### 6.5.6 標點懸掛移除記錄與未來路徑
- **停用結論**:純負 `margin` 機制無法視覺懸掛——(1) 句號墨色偏框左下,安全凸出量(< 1em)
只推出空白半框、墨色仍在版心內;(2) 凸出滿 1em 釋放一整字寬、拉升後字致重疊;(3) 與
inline-block 對齊原子互斥(justifyAtoms:true 下 margin 被 justify 吸收);(4) ResizeObserver
重排致兩階段標記錯亂。四者皆實測確認
- **三重防逃逸**(確保下游無法以任何方式啟用):① `index.ts` 鎖 `lineEnd: "squeeze"`(忽略
`hanging` 選項); `lineedge.ts` `modeForChar` 無條件降 squeeze;③ `applyEdge` 之
`HANG_ENABLED = false` 總開關。三層任一即足,疊加為冗餘保險。`hanging` 選項仍被接受(API
相容)但無效果。單元測試覆蓋 optiondata-juzhenblocks 各路徑皆不產生 jz-hang-*。
- **未來路徑**:原生 CSS `hanging-punctuation``allow-end`/`first`/`last`)為正解,待 Chrome
支援(現僅 Safari),且須解決與 inline-block 原子之衝突(屆時或令行端標點局部退原子化)
hang 之 lineedge 偵測碼、CSS`jz-hang-*`)、`LineEndMode` 型別皆保留休眠,重啟成本低。
- **移除結論(寬度守恆論證)**:純 CSS 手段(負 margin/相對位移/零寬盒)存在守恆矛盾,
機制級不可行。設行末標點墨色須落版心外、且行末實義字須齊版心右緣:
- 行末盒**保持非零寬**(負 margin 收一部分、或 `position:relative` 純視覺外移)→ 墨色可移出,
但盒仍佔行末寬 → 版心內留等寬空缺,「行末齊邊」不成立(0.5em 實測之「看不出懸掛」即此相)
- 行末盒**收為零寬**margin-right:-1em 等)→ 釋放一整字寬 → 斷行器把下一字拉上本行與標點
重疊(headless 實測每標點疊一字); `justifyAtoms:true` 之 inline-block 下負 margin 被
justify 吸收。
**墨色出去多少、版心內就空出多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到
斷行器。** 補償墊片、兩階段試錯都改變不了此恒等式,只會重蹈 ResizeObserver 標記錯亂之覆轍
- **已移除之物**`lineedge.ts` 之 `jz-hang-*` class`HANG_*` 常量/`applyEdge` hang 分支/
`modeForChar``HANG_ENABLED``_jiya.css` 之 `jz-hang-*` 規則;`types.ts` 之 `LineEndMode`
`ResolvedOptions.lineEnd``features.hanging``index.ts` 之 `lineEnd` 鎖。行端僅餘半形擠壓
`jz-half-le`)。`hanging` 公開選項保留為 **@deprecated no-op**API 相容;測試覆蓋
optiondata-juzhenblocks 各路徑皆不產生 jz-hang-* 且輸出與不傳一致)。
- **未來路徑(與已移除碼無共享)**:原生 CSS `hanging-punctuation` 為唯一正解,惟 Chrome
Firefox 至今不支援(僅 Safari);且本庫 `jz-char` 為 inline-block 原子、非文本,原生懸掛
不作用之。未來若做,須是**繞開 jz-char 的「原生管線」新功能**——`text-spacing-trim` 接管
擠壓 `hanging-punctuation` 接管懸掛,按 `CSS.supports` 能力門控,整體退出 jz-char 機制。
與已移除之負 margin 程式碼**沒有一行可複用**,故不保留休眠碼。
### 6.6 標點修正(Biaodian normalize)— 後續(opt-in