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
+13 -14
View File
@@ -146,23 +146,22 @@ createJuzhen({
createJuzhen({ level: { text: "h1,h2,h3,button,.ui" } }); // 選擇器一鍵標記
```
### 標點懸掛(`hanging`)— 已評估停用
### 標點懸掛(`hanging`)— 已移除
原計劃之標點懸掛(行端標點凸出版心)經 headless 真實瀏覽器實測**確認不可行、已全面停用**。
`hanging` 選項仍被接受(API 相容)但**無任何效果**——縱使下游以 `hanging:true``data-juzhen`
`blocks` 等任何方式要求,亦不會啟用(行端恒 squeeze;三重防逃逸:`lineEnd` 鎖死、`modeForChar`
降級、`applyEdge` 總開關)。
原計劃之標點懸掛(行端標點凸出版心)經實測**機制級死路、已移除**(負 margin 程式碼與
CSS 皆刪)。`hanging` 選項仍被接受(API 相容)但**無任何效果**——`hanging:true`
`data-juzhen``blocks` 任一要求皆被靜默忽略,行端僅做半形擠壓。
**為何不可行**(純`margin` 機制):
**為何機制級不可行**(純 CSS 之寬度守恆矛盾):行端標點盒若保持非零寬,則「墨色出去多少、
版心內就空出多少」(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字
拉上本行與標點重疊(且 `justifyAtoms` 預設之 inline-block 下負 margin 被 justify 吸收)。
**墨色出去多少版心內就空多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到斷行器**
補償墊片、兩階段試錯都改變不了此恒等式。
1. 句號等標點墨色偏字框左下角;安全凸出量(&lt; 1 個 CJK 字寬 1em)只把空白半框推出版心、
**墨色仍在版心內**(視覺不懸掛)。
2. 凸出滿 1em 又**釋放一整字寬**、把下一字拉上本行、與懸掛標點**重疊**。
3. `ResizeObserver` 重排時,「自然佈局標記→施加 margin→佈局再變」致標記**錯亂**
真正的解是原生 CSS `hanging-punctuation`,惟 **Chrome 至今不支援**(僅 Safari 較全),且與本庫
`inline-block` 對齊原子結構衝突。故 hang 之程式碼與 CSS **保留休眠**,俟原生支援成熟再議。
詳見 `ARCHITECTURE.md §6.5.6`
唯一正解為原生 CSS `hanging-punctuation`,惟 **ChromeFirefox 至今不支援**(僅 Safari),且
本庫之 `jz-char` 為 inline-block 原子、非文本,原生懸掛不作用——未來若做,須是繞開 `jz-char`
的「原生管線」新功能(`text-spacing-trim` `hanging-punctuation`,按能力門控),與已移除之
程式碼無共享。詳見 `ARCHITECTURE.md §6.5.6`
---
+1 -1
View File
@@ -77,7 +77,7 @@
<script src="../dist/juzhen.iife.js"></script>
<script>
// 全頁一次渲染(quanjiao 全角式,與下游回饋之政策一致);各框由其 lang 屬性
// 驅動簡/繁字形。(標點懸掛已停用,見 ARCHITECTURE §6.5.6。)
// 驅動簡/繁字形。(標點懸掛已移除,見 ARCHITECTURE §6.5.6。)
Juzhen.createJuzhen({ scope: { include: ".juzhen" }, lang: { style: "quanjiao" } })
.render(document.body);
+4 -4
View File
@@ -44,7 +44,7 @@
<body>
<h1>聚珍 — 新功能效果演示(定寬對照)</h1>
<p class="lede">本頁以<strong>定寬文本框</strong>(不可拖動)並排對照,逐場景展示兩項新功能:<strong>功能分級</strong>(文本級/段落級)、<strong>垂懸字避免</strong><code>orphan</code>)。各框寬度經 headless 校準,配 Noto Sans CJK 可穩定重現所述效果。勾選下方開關可描出聚珍注入之結構(紫=垂懸字綁定、紅=中西間隙、藍=半形標點)。第三項曾規劃之「標點懸掛」經實測停用,說明見文末。</p>
<p class="lede">本頁以<strong>定寬文本框</strong>(不可拖動)並排對照,逐場景展示兩項新功能:<strong>功能分級</strong>(文本級/段落級)、<strong>垂懸字避免</strong><code>orphan</code>)。各框寬度經 headless 校準,配 Noto Sans CJK 可穩定重現所述效果。勾選下方開關可描出聚珍注入之結構(紫=垂懸字綁定、紅=中西間隙、藍=半形標點)。第三項曾規劃之「標點懸掛」經實測為機制級死路、已移除,說明見文末。</p>
<div class="toolbar">
<label><input type="checkbox" id="t-orphan"> 顯示垂懸字綁定(紫 <code>jz-orphan</code></label>
@@ -110,9 +110,9 @@
<p class="hint">邊界:段落實義字 &lt; N(或單行段)→ 不綁、無副作用。極窄容器(&lt;N 字寬)→ 該尾段可能溢出(罕見)。</p>
</div>
<!-- ===================== 三、標點懸掛(已停用 ===================== -->
<h2>三、標點懸掛(<code>hanging</code>)— 已評估停用</h2>
<p class="note">原計劃之標點懸掛(行端標點凸出版心)經 headless + 真實瀏覽器實測<strong>確認不可行</strong>,已<strong>全面停用</strong><code>hanging</code> 選項仍被接受但無任何效果)。原因:純負 <code>margin</code> 機制下,句號墨色偏字框左下角,安全凸出量(&lt; 1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又釋放一整字寬、把下一字拉上本行重疊<code>ResizeObserver</code> 重排時標記錯亂。真正的解是原生 CSS <code>hanging-punctuation</code>,惟 Chrome 至今不支援、且與本庫 inline-block 對齊原子結構衝突。程式碼與 CSS 保留休眠,俟原生支援成熟再議。詳見 <code>ARCHITECTURE.md §6.5.6</code></p>
<!-- ===================== 三、標點懸掛(已移除 ===================== -->
<h2>三、標點懸掛(<code>hanging</code>)— 已移除</h2>
<p class="note">原計劃之標點懸掛(行端標點凸出版心)經 headless + 真實瀏覽器實測確認為<strong>機制級死路</strong>,已<strong>移除</strong><code>margin</code> 程式碼與 CSS 皆刪;<code>hanging</code> 選項保留為 no-op無任何效果)。<strong>寬度守恆矛盾</strong>:行端標點盒若保持非零寬,墨色出去多少、版心內就空出多少(行末實義字無法齊版心右緣);若收為零寬,釋放一整字寬、斷行器把下一字拉上本行與標點重疊<code>justifyAtoms</code> 預設之 inline-block 下負 margin 被 justify 吸收)。<strong>CSS hack 接觸不到斷行器,此恒等式無解。</strong>唯一正解為原生 CSS <code>hanging-punctuation</code>,惟 ChromeFirefox 至今不支援(僅 Safari),且本庫 <code>jz-char</code> 為 inline-block 原子非文本、原生懸掛不作用之——未來若做須是繞開 jz-char 的「原生管線」新功能。詳見 <code>ARCHITECTURE.md §6.5.6</code></p>
<footer style="margin-top:2.4rem;color:#888;font-size:.78rem;border-top:1px solid #eee;padding-top:.6rem">
聚珍(Juzhen)· 新功能效果演示。另見 <a href="./resize-check.html">resize-check.html</a>(可拖動容器之穩定性檢查)。寬度配 Noto Sans CJK 校準;換字型時斷行點略異,效果方向不變。
-10
View File
@@ -85,16 +85,6 @@ jz-char.jz-half-le jz-inner {
[data-jz-halfwidth=halt] jz-char.jz-half-le {
width: auto;
}
jz-char.bd-stop.jz-hang-r,
jz-char.bd-pause.jz-hang-r {
margin-right: calc(-1 * var(--jz-hang-stop, 0.5em));
}
jz-char.bd-close.jz-hang-r {
margin-right: calc(-1 * var(--jz-hang-bracket, 0.5em));
}
jz-char.bd-open.jz-hang-l {
margin-left: calc(-1 * var(--jz-hang-bracket, 0.5em));
}
/* src/css/_jinze.css */
jz-jinze {
+9 -43
View File
@@ -1160,13 +1160,10 @@ var Juzhen = (() => {
// src/typeset/lineedge.ts
var LE = "jz-half-le";
var HANG_L = "jz-hang-l";
var HANG_R = "jz-hang-r";
var ALL_EDGE = [LE, HANG_L, HANG_R];
var ALL_EDGE = [LE];
var EPS = 2;
var HEAD_CLASSES = /* @__PURE__ */ new Set(["bd-open"]);
var TAIL_SQUEEZE = /* @__PURE__ */ new Set(["bd-close"]);
var TAIL_HANG = /* @__PURE__ */ new Set(["bd-close", "bd-stop", "bd-pause"]);
var TAIL_CLASSES = /* @__PURE__ */ new Set(["bd-close"]);
var observers = /* @__PURE__ */ new WeakMap();
function hasLayout() {
return typeof ResizeObserver !== "undefined";
@@ -1267,28 +1264,11 @@ var Juzhen = (() => {
const rects = el.getClientRects();
return rects.length ? rects[rects.length - 1] : null;
}
var HANG_ENABLED = false;
function applyEdge(el, edge, mode) {
if (mode === "squeeze") {
el.classList.add(LE);
return;
}
if (mode === "hang" && HANG_ENABLED) {
el.classList.add(edge === "head" ? HANG_L : HANG_R);
}
}
function clearEdge(root) {
for (const cls of ALL_EDGE) {
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
}
}
function modeForChar(el, options) {
const base = options.lineEnd;
if (base !== "hang") {
return base;
}
return "squeeze";
}
function relayout(root, finder, options) {
clearEdge(root);
const wrapSet = buildWrapSet(options);
@@ -1303,20 +1283,12 @@ var Juzhen = (() => {
if (!finder.featureEnabledFor(el, "jiya")) {
continue;
}
const mode = modeForChar(el, options);
if (mode === "none") {
continue;
}
if (mode === "hang" && el.classList.contains("jz-half")) {
continue;
}
const bd = bdClassOf(el);
if (!bd) {
continue;
}
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
const isHead = HEAD_CLASSES.has(bd);
const isTail = tailSet.has(bd);
const isTail = TAIL_CLASSES.has(bd);
if (!isHead && !isTail) {
continue;
}
@@ -1327,17 +1299,17 @@ var Juzhen = (() => {
if (isHead) {
const prev = edgeRect(el.previousSibling, "prev", el, wrapSet);
if (!prev || higherLine(r, prev)) {
decisions.push({ el, edge: "head", mode });
decisions.push({ el });
}
} else {
const next = edgeRect(el.nextSibling, "next", el, wrapSet);
if (!next || lowerLine(r, next)) {
decisions.push({ el, edge: "tail", mode });
decisions.push({ el });
}
}
}
for (const d of decisions) {
applyEdge(d.el, d.edge, d.mode);
d.el.classList.add(LE);
}
}
var lineEdgePass = {
@@ -2002,21 +1974,15 @@ var Juzhen = (() => {
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
hanging: feature(opts.hanging, false),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false)
},
longWord: resolveLongWord(opts.longWord),
orphan: resolveOrphan(opts.orphan),
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
// 經 headless 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
// hang 之 CSSlineedge 程式碼保留(休眠),俟原生支援成熟再議。
lineEnd: "squeeze",
// 行端僅做半形擠壓(lineedge:行首開類收左、行尾閉類收右)。**標點懸掛已移除**
// (§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);
// opts.hanging 仍被接受以保 API 相容,但無任何效果。
jiyaConfig: resolveJiya(opts.jiya),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
+9 -43
View File
@@ -1132,13 +1132,10 @@ var jinzePass = {
// src/typeset/lineedge.ts
var LE = "jz-half-le";
var HANG_L = "jz-hang-l";
var HANG_R = "jz-hang-r";
var ALL_EDGE = [LE, HANG_L, HANG_R];
var ALL_EDGE = [LE];
var EPS = 2;
var HEAD_CLASSES = /* @__PURE__ */ new Set(["bd-open"]);
var TAIL_SQUEEZE = /* @__PURE__ */ new Set(["bd-close"]);
var TAIL_HANG = /* @__PURE__ */ new Set(["bd-close", "bd-stop", "bd-pause"]);
var TAIL_CLASSES = /* @__PURE__ */ new Set(["bd-close"]);
var observers = /* @__PURE__ */ new WeakMap();
function hasLayout() {
return typeof ResizeObserver !== "undefined";
@@ -1239,28 +1236,11 @@ function lastRect(el) {
const rects = el.getClientRects();
return rects.length ? rects[rects.length - 1] : null;
}
var HANG_ENABLED = false;
function applyEdge(el, edge, mode) {
if (mode === "squeeze") {
el.classList.add(LE);
return;
}
if (mode === "hang" && HANG_ENABLED) {
el.classList.add(edge === "head" ? HANG_L : HANG_R);
}
}
function clearEdge(root) {
for (const cls of ALL_EDGE) {
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
}
}
function modeForChar(el, options) {
const base = options.lineEnd;
if (base !== "hang") {
return base;
}
return "squeeze";
}
function relayout(root, finder, options) {
clearEdge(root);
const wrapSet = buildWrapSet(options);
@@ -1275,20 +1255,12 @@ function relayout(root, finder, options) {
if (!finder.featureEnabledFor(el, "jiya")) {
continue;
}
const mode = modeForChar(el, options);
if (mode === "none") {
continue;
}
if (mode === "hang" && el.classList.contains("jz-half")) {
continue;
}
const bd = bdClassOf(el);
if (!bd) {
continue;
}
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
const isHead = HEAD_CLASSES.has(bd);
const isTail = tailSet.has(bd);
const isTail = TAIL_CLASSES.has(bd);
if (!isHead && !isTail) {
continue;
}
@@ -1299,17 +1271,17 @@ function relayout(root, finder, options) {
if (isHead) {
const prev = edgeRect(el.previousSibling, "prev", el, wrapSet);
if (!prev || higherLine(r, prev)) {
decisions.push({ el, edge: "head", mode });
decisions.push({ el });
}
} else {
const next = edgeRect(el.nextSibling, "next", el, wrapSet);
if (!next || lowerLine(r, next)) {
decisions.push({ el, edge: "tail", mode });
decisions.push({ el });
}
}
}
for (const d of decisions) {
applyEdge(d.el, d.edge, d.mode);
d.el.classList.add(LE);
}
}
var lineEdgePass = {
@@ -1974,21 +1946,15 @@ function normalizeOptions(opts = {}) {
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
hanging: feature(opts.hanging, false),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false)
},
longWord: resolveLongWord(opts.longWord),
orphan: resolveOrphan(opts.orphan),
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
// 經 headless 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
// hang 之 CSSlineedge 程式碼保留(休眠),俟原生支援成熟再議。
lineEnd: "squeeze",
// 行端僅做半形擠壓(lineedge:行首開類收左、行尾閉類收右)。**標點懸掛已移除**
// (§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);
// opts.hanging 仍被接受以保 API 相容,但無任何效果。
jiyaConfig: resolveJiya(opts.jiya),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
+2 -31
View File
@@ -121,34 +121,5 @@ jz-char.jz-half-le jz-inner
width: auto;
}
/* 標點懸掛§6.5.2hang 模式 lineEdge layout pass jz-hang-*
行尾點號閉類右凸出右緣jz-hang-r首開類左凸出左緣jz-hang-l令文字格線
齊版心標點溢出**自管負 margin**不用 CSS hanging-punctuation瀏覽器支援差
已被原子透明 reset
凸出量 **0.5em**句末句內點號 --jz-hang-stop括號引號 --jz-hang-bracketCSS 變數
可逐字型調校**關鍵約束headless 實測凸出量必須 < 一個 CJK 字寬1em**
margin 會減少該行斷行寬度若釋放出 1em 空間下一字會被拉上本行與懸掛標點
重疊實測 1em 時每個行末標點皆與後字重疊一整字0.5em 釋放半字不足容下後字
故後字不上移標點穩定凸出半形代價正文格線距版心約剩 0.5em非完全齊邊
margin 機制無法在不拉升後字下做到全凸全凸須改用原生 hanging-punctuation §6.5.6
baseline 半形者jz-half不懸掛JS 端跳過無留白可凸連續標點經鄰接挤压為半形
者亦在此列繁體預設不懸掛 lang
** justifyAtoms:falsejz-char inline**預設 inline-block justify 對齊
原子模型下 margin 會被 justify 重分配吸收標點不凸出headless 實測懸掛與
inline-block 對齊原子互斥欲懸掛須設 justifyAtoms:false README容器宜留左右
padding 0.5em 以容納凸出之標點 */
jz-char.bd-stop.jz-hang-r,
jz-char.bd-pause.jz-hang-r
{
margin-right: calc(-1 * var(--jz-hang-stop, 0.5em));
}
jz-char.bd-close.jz-hang-r
{
margin-right: calc(-1 * var(--jz-hang-bracket, 0.5em));
}
jz-char.bd-open.jz-hang-l
{
margin-left: calc(-1 * var(--jz-hang-bracket, 0.5em));
}
/* 標點懸掛jz-hang-* margin 凸出版心已移除 CSS 機制為機制級死路
寬度守恆矛盾 ARCHITECTURE §6.5.6端僅做半形擠壓jz-half-le上方規則 */
+3 -9
View File
@@ -109,21 +109,15 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
orphan: opts.orphan === false ? false : feature(opts.orphan, true),
hanging: feature(opts.hanging, false),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false),
},
longWord: resolveLongWord(opts.longWord),
orphan: resolveOrphan(opts.orphan),
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
// 經 headless 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
// hang 之 CSSlineedge 程式碼保留(休眠),俟原生支援成熟再議。
lineEnd: "squeeze",
// 行端僅做半形擠壓(lineedge:行首開類收左、行尾閉類收右)。**標點懸掛已移除**
// (§6.5.6):純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);
// opts.hanging 仍被接受以保 API 相容,但無任何效果。
jiyaConfig: resolveJiya(opts.jiya),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
+3 -10
View File
@@ -20,10 +20,6 @@ export interface OrphanOptions
chars?: number;
}
/** §6.5.2squeezehang
* nonesqueeze hang */
export type LineEndMode = "squeeze" | "hang" | "none";
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
export interface JiyaOptions
{
@@ -92,9 +88,9 @@ export interface JuzhenOptions
jiya?: boolean | JiyaOptions;
jinze?: boolean;
orphan?: boolean | OrphanOptions;
/** @deprecated ****§6.5.6 margin
* 滿resize hanging-punctuation Chrome
* API **** squeeze */
/** @deprecated ****§6.5.6 margin
* CSS hanging-punctuation Safari
* inline-block API **** */
hanging?: boolean;
biaodian?: boolean;
emphasis?: boolean;
@@ -133,7 +129,6 @@ export interface ResolvedOptions
jiya: boolean;
jinze: boolean;
orphan: boolean;
hanging: boolean;
biaodian: boolean;
emphasis: boolean;
ruby: boolean;
@@ -141,8 +136,6 @@ export interface ResolvedOptions
longWord: { minLength: number; lang: string };
/** 垂懸字避免設定(解析後)。 */
orphan: { chars: number };
/** 行端標點模式(解析後;§6.5.2)。 */
lineEnd: LineEndMode;
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
jiyaConfig: { halfWidth: "halt" | "margin"; bias: BiasTable };
blocks: BlockRule[];
+25 -65
View File
@@ -1,32 +1,29 @@
// 行端標點處理(架構 §6.4/§6.5;clreq 行首行尾擠壓 標點懸掛)。機制 B。
// standalone layout pass:量測各 jz-char 之行盒位置,偵測「行首開類」「行尾閉類
// 點號」,按**行端模式**套用——
// · squeeze(現有半形,預設):行首開類收左、行尾閉類收右半形(jz-half-le,同 halt);
// · hang(標點懸掛,§6.5.2):行首開類左凸、行尾閉類/點號右凸出版心(jz-hang-*,負 margin);
// · none:不處理。
// 行端偵測碼共用、僅「合格 bd 子類集」與「套用之 class」隨模式而異。掛 ResizeObserver
// 行端標點處理(架構 §6.4/§6.5;clreq 行首行尾擠壓)。機制 B。
// standalone layout pass:量測各 jz-char 之行盒位置,偵測「行首開類」「行尾閉類」,
// 收為行端半形(jz-half-le,CSS 同 halt):行首開類收左、行尾閉類收右。掛 ResizeObserver
// 於 root,容器寬度變動時重算;字型載入後亦重算。
//
// 註:原計劃之**標點懸掛**(行端標點負 margin 凸出版心)經實測為機制級死路(寬度守恆
// 矛盾:行端盒非零寬則墨色出去多少版心內就空多少、零寬則釋放一字寬致下一字拉上重疊;
// 純 CSS 接觸不到斷行器),已於 §6.5.6 移除。唯一正解為原生 hanging-punctuation(僅
// Safari 支援、且與本庫 inline-block 原子互斥),屬遠期「原生管線」新功能、與此處無共享。
//
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
// ChromeSafariFirefox 目視核對。
import { bdClassOf } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import type { LineEndMode, RenderContext, ResolvedOptions, StandalonePass } from "../types.js";
import type { RenderContext, ResolvedOptions, StandalonePass } from "../types.js";
const LE = "jz-half-le"; // 行端半形(squeeze 模式;CSS 同 halt
const HANG_L = "jz-hang-l"; // 行首開類左凸(hang 模式;負 margin-left
const HANG_R = "jz-hang-r"; // 行尾閉類/點號右凸(hang 模式;負 margin-right
const ALL_EDGE = [ LE, HANG_L, HANG_R ];
const LE = "jz-half-le"; // 行端半形(CSS 同 halt
const ALL_EDGE = [ LE ];
const EPS = 2; // px:行盒比較之亞像素容差。
// 行端合格之 bd 子類(隨模式):head=行首左緣偵測,tail=行尾右緣偵測。
// squeeze 僅括號引號(沿用現有:點號於行端保持全形避免孤立句末誤收);
// hang 之 tail 增點號(行末句號/逗號右凸為懸掛之經典用例)。
// 行端合格之 bd 子類:head=行首左緣偵測(開類)tail=行尾右緣偵測(閉類)
// 僅括號引號;點號於行端保持全形避免孤立句末誤收,實測困惑來源)。
const HEAD_CLASSES = new Set([ "bd-open" ]);
const TAIL_SQUEEZE = new Set([ "bd-close" ]);
const TAIL_HANG = new Set([ "bd-close", "bd-stop", "bd-pause" ]);
const TAIL_CLASSES = new Set([ "bd-close" ]);
// 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。
const observers = new WeakMap<Element, Map<string, ResizeObserver>>();
@@ -156,20 +153,6 @@ function lastRect(el: Element): DOMRect | null
return rects.length ? rects[rects.length - 1]! : null;
}
// ⚠ **標點懸掛總開關(防逃逸第三層)**。hang 已全面停用(§6.5.2/§6.5.6):經實測,純
// 負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 重排錯亂)。此常數為 false
// 時,applyEdge 永不施加 jz-hang-*,與「index 鎖 lineEnd=squeeze」「modeForChar 降 squeeze」
// 三重保證:縱使下游以任何方式(hanging 選項/data-juzhenblocks/直接改 lineEnd)要求,
// 亦絕無可能產生懸掛標記。hang 之程式碼與 CSS 保留休眠,俟原生 hanging-punctuation 成熟再議。
const HANG_ENABLED = false;
/** 套用行端結果之 class(按模式):head=行首左緣、tail=行尾右緣。 */
function applyEdge(el: Element, edge: "head" | "tail", mode: LineEndMode): void
{
if (mode === "squeeze") { el.classList.add(LE); return; }
if (mode === "hang" && HANG_ENABLED) { el.classList.add(edge === "head" ? HANG_L : HANG_R); }
}
function clearEdge(root: Element): void
{
for (const cls of ALL_EDGE)
@@ -178,30 +161,16 @@ function clearEdge(root: Element): void
}
}
/** jz-char
* **** hang index.ts§6.5.6使 base "hang"
* index squeeze squeeze jz-hang-*
* hang lang squeeze hang */
function modeForChar(el: Element, options: ResolvedOptions): LineEndMode
{
const base = options.lineEnd;
if (base !== "hang") { return base; }
// hang 已停用:無條件降 squeeze(防逃逸)。下行為日後重啟之逐 lang 閘(現不可達):
// if (classify(effectiveLang(el, options.locale.default)) === "zh-Hant") return "squeeze";
return "squeeze";
}
interface EdgeDecision { el: Element; edge: "head" | "tail"; mode: LineEndMode; }
interface EdgeDecision { el: Element; }
/**
* bd class
*
*
* ****§6.5.2 clearEdge margin
* **** 1 DOM
* 2
* hang marginhalt add class
* gapTrim ResizeObserver root
* */
* **** clearEdge
* **** 1 DOM jz-half-le 2
* halt add
* class gapTrim ResizeObserver root
* */
function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
{
clearEdge(root);
@@ -217,36 +186,27 @@ function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
// 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。
if (!finder.levelAllows(el, "paragraph")) { continue; }
if (!finder.featureEnabledFor(el, "jiya")) { continue; }
const mode = modeForChar(el, options);
if (mode === "none") { continue; }
// hang 模式跳過已 baseline 半形(jz-half)之標點:其字身已收為 0.5em、無留白可
// 凸出,再加負 margin 會把墨色整體拉出版心。故僅全形標點(有留白者)懸掛;
// 已半形者保持原狀(行端本就緊湊)。
if (mode === "hang" && el.classList.contains("jz-half")) { continue; }
const bd = bdClassOf(el);
if (!bd) { continue; }
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
const isHead = HEAD_CLASSES.has(bd);
const isTail = tailSet.has(bd);
const isTail = TAIL_CLASSES.has(bd);
if (!isHead && !isTail) { continue; }
// squeeze 不處理點號(行尾/行首點號保持全形,避免孤立句末誤收,實測困惑來源):
// 已由 TAIL_SQUEEZE 僅含 bd-close 保證;hang 之 tail 始含點號(懸掛經典用例)。
const r = firstRect(el);
if (!r) { continue; }
if (isHead)
{
const prev = edgeRect(el.previousSibling, "prev", el, wrapSet);
if (!prev || higherLine(r, prev)) { decisions.push({ el, edge: "head", mode }); }
if (!prev || higherLine(r, prev)) { decisions.push({ el }); }
}
else
{
const next = edgeRect(el.nextSibling, "next", el, wrapSet);
if (!next || lowerLine(r, next)) { decisions.push({ el, edge: "tail", mode }); }
if (!next || lowerLine(r, next)) { decisions.push({ el }); }
}
}
for (const d of decisions) { applyEdge(d.el, d.edge, d.mode); }
for (const d of decisions) { d.el.classList.add(LE); }
}
export const lineEdgePass: StandalonePass = {
+32 -28
View File
@@ -925,46 +925,50 @@ test("垂懸字 元素最小綁定 revert:兩個 <strong> 併回單一、文
assert.equal(p.querySelector("strong").textContent, "一段很長的加粗文字", "strong 還原");
});
// ----- 標點懸掛(§6.5.2,行端模式接線;版面凸出量於 headless Chrome 驗證)-----
// ----- 標點懸掛已移除(§6.5.6):hanging 選項保留為 no-op,永不產生 jz-hang-* -----
// 純負 margin 機制為機制級死路(寬度守恆矛盾,CSS 接觸不到斷行器);原生
// hanging-punctuation 為唯一正解但僅 Safari 支援、且與 inline-block 原子互斥。
// 下列測試確保 API 相容(hanging:truedata-juzhenblocks 任一要求皆被接受但無效果)。
test("懸掛:已全面停用——hanging 預設關,行端模式 squeeze", () =>
{
const jz = createJuzhen();
assert.equal(jz.options.lineEnd, "squeeze", "行端模式恒 squeeze");
});
test("懸掛:防逃逸——即使 hanging:true,行端模式仍鎖死 squeeze(不啟用 hang", () =>
{
const jz = createJuzhen({ hanging: true });
// option 仍被接受(API 不破),但 lineEnd 鎖死 squeeze——hang 不可能激活(§6.5.6 停用)。
assert.equal(jz.options.lineEnd, "squeeze", "hanging:true 亦不轉 hang(無逃逸)");
});
test("懸掛:防逃逸——hanging:true render 後不產生任何 jz-hang-* 標記", () =>
test("懸掛已移除:hanging 選項被接受但無任何效果(API 相容,render 不拋)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">春。夏。秋。冬。</p>');
createJuzhen({ hanging: true, justifyAtoms: false }).render(document.body);
const jz = createJuzhen({ hanging: true });
jz.render(document.body); // 不應拋錯;hanging 為 no-op
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "無 jz-hang-l");
});
test("懸掛:防逃逸——下游 data-juzhen=\"hanging\" 亦不啟用(lineEnd 不受 per-node 影響", () =>
test("懸掛已移除:hanging:true 之輸出與不傳 hanging 完全一致(無行為差異", () =>
{
const { document } = setupDom('<p lang="zh-Hans" data-juzhen="spacing jiya jinze hanging">春。夏。秋。冬。</p>');
const jz = createJuzhen({ hanging: true, justifyAtoms: false });
jz.render(document.body);
assert.equal(jz.options.lineEnd, "squeeze", "lineEnd 恒 squeeze");
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "data-juzhen 要求亦無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "data-juzhen 要求亦無 jz-hang-l");
const html = '<p lang="zh-Hans">春。夏。秋。冬。</p>';
const a = setupDom(html);
createJuzhen({ justifyAtoms: false }).render(a.document.body);
const withoutHang = a.document.body.innerHTML;
const b = setupDom(html);
createJuzhen({ hanging: true, justifyAtoms: false }).render(b.document.body);
assert.equal(b.document.body.innerHTML, withoutHang, "hanging:true 不改變任何輸出");
});
test("懸掛:防逃逸——blocks features:[\"hanging\"] 亦不啟用", () =>
test("懸掛已移除:data-juzhen=\"hanging\" 與 blocks features:[hanging] 皆無效果", () =>
{
const { document } = setupDom('<p class="q" lang="zh-Hans">春。夏。秋。冬。</p>');
const { document } = setupDom(
'<p lang="zh-Hans" data-juzhen="spacing jiya jinze hanging">春。夏。秋。</p>'
+ '<p class="q" lang="zh-Hans">春。夏。秋。</p>',
);
createJuzhen({
hanging: true, justifyAtoms: false,
justifyAtoms: false,
blocks: [ { selector: ".q", features: [ "spacing", "jiya", "hanging" ] } ],
}).render(document.body);
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "blocks 要求亦無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "blocks 要求亦無 jz-hang-l");
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "無 jz-hang-r");
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "無 jz-hang-l");
});
test("行端半形:lineedge 仍正常擠壓(squeeze 保留)——jsdom 無版面故僅驗 pass 不拋", () =>
{
const { document } = setupDom('<p lang="zh-Hant">「測試」。</p>');
const jz = createJuzhen();
jz.render(document.body); // lineedge 於 jsdom(無 ResizeObserver)跳過,不應拋
jz.revert(document.body);
assert.equal(countTag(document.body, "jz-char"), 0, "revert 清除");
});