`)不誤加間隙**(標點非 pill 鄰字;`jz-char`/`jz-inner` 透明穿越,與 `collectRuns` 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 |
| **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 ``,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 |
| **標點擠壓** `jiya` | 標點 charify 為 `jz-char`+`bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點**雙側獨立**收——各標點若其有空白之側緊鄰另一標點即收半,故 `,「` 全角式與開明式皆 1em、`。」` 全角式 1.5em/開明式 1em;**唯句末點號接開類為句界**——`。「`/`?「` 保留句末點號右空白、僅收開類,全角式與開明式皆 1.5em;透明穿越 `strong`/`em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 |
-| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時自動併回原元素;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。兩側須符合作用域、paragraph 級別與 `jinze` 功能閘;不可切時也不把 avoid/text/off 後代整綁帶入。 |
+| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於**透明**行內樣式元素(`strong`/`em` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時驗來源身份及屬性後併回原元素;若外部插入兄弟、替換來源或改樣式而無法安全合併,保留片段元素與文字位置並撤銷聚珍標記;不可切元素(`a`/`abbr`/`ruby`/帶 `id`,以及 **pill/隔離**——見「行內元素三分類」)退化為**整元素綁定**——pill(如行內公式 `.katex`)即作為原子鄰字單位整體綁入 `jz-jinze`,絕不下潛肢解其內部。兩側須符合作用域、paragraph 級別與 `jinze` 功能閘;不可切時也不把 avoid/text/off 後代整綁帶入。 |
| **垂懸字避免** `orphan` | 段末可處理的連續區域 CJK 實義字 ≥ N(預設 2;標點/符號不計,完整碼點及 base+VS 算一字)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。avoid/text/off/`isSkipped` 區域中斷計數,只綁其後的合法尾部;不足 N 字或不可切容器含排除內容時不綁。 |
| **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 |
| **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 |
@@ -184,7 +186,7 @@ createJuzhen({
lang: {
default: "zh-Hant", // 無 lang 祖先時之預設
style: "kaiming", // 全域政策一鍵覆寫(quanjiao|kaiming|banjiao)
- policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" }, // 逐 lang(最高優先)
+ policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" }, // 逐 lang(低於逐元素覆寫)
},
scope: {
root: document.body, // 處理根;render(root) 可逐次覆寫
@@ -217,7 +219,7 @@ createJuzhen({
| 選項 | 型別 | 預設 | 作用 |
|---|---|---|---|
-| `lang.default` | string | `"zh-Hant"` | 無 `lang` 祖先時之預設語言標籤 |
+| `lang.default` | string | `"zh-Hant"` | 自身及祖先皆無 `lang` 時之預設;空字串表示未知 |
| `lang.style` | `quanjiao|kaiming|banjiao` | 逐語言內建(繁→全角、簡→開明) | **全域政策一鍵覆寫** |
| `lang.policy` | `Partial>` | `{}` | 按語言分類覆寫:鍵僅為 `zh-Hans`、`zh-Hant`、`ja`、`other`;如 `zh-TW` 先歸入 `zh-Hant`,不直接作為鍵 |
| `lang.stylePolicy` | `{selector,style}[]` | `[]` | **逐元素樣式覆寫**(選擇器命中之子樹用指定樣式;如 `[{selector:"h1,button,.ui",style:"banjiao"}]`);配合 `data-jz-style` 屬性,優先於 lang |
@@ -481,7 +483,7 @@ CSS 皆刪)。`hanging` 選項仍被接受(API 相容)但**無任何效果
**墨色出去多少版心內就空多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到斷行器**。
補償墊片、兩階段試錯都改變不了此恒等式。
-唯一正解為原生 CSS `hanging-punctuation`,惟 **Chrome/Firefox 至今不支援**(僅 Safari),且
+原生 CSS `hanging-punctuation` 是未來可評估路徑,須另行核對各引擎能力及字型行為,且
本庫之 `jz-char` 為 inline-block 原子、非文本,原生懸掛不作用——未來若做,須是繞開 `jz-char`
的「原生管線」新功能(`text-spacing-trim` + `hanging-punctuation`,按能力門控),與已移除之
程式碼無共享。詳見 `ARCHITECTURE.md §6.5.6`。
@@ -566,11 +568,14 @@ createJuzhen({
換用新字型時,依下列步驟確認半形渲染:
1. **驗 `halt`**:`python3 tools/measure-bias.py `(需 `pip install fonttools`)。
- 首行報該字型是否含 OpenType `halt`。
- - **有 `halt`** → 直接用預設(`jiya: true`,halt 模式),無需配字,到此為止。
- - **無 `halt`**(多數系統字型、GBK 老字型)→ 用 `margin` 後備,續下。
+ 首行報該字型是否含 OpenType `halt`;特性存在不保證所有標點/語言字形均覆蓋。
+ - **有 `halt`** → 先在目標瀏覽器、字型及 `lang` 下逐標點驗證預設 halt 模式;
+ 覆蓋及定位符合要求者可免配字,未覆蓋者改用局部 `margin`。
+ - **無 `halt`** → 用 `margin` 後備,續下。
2. **取偏側建議**:同一工具同時量各標點 glyph 之墨色中心、印出建議偏側表
- (`left|center|right|full`)與可貼入 `jiya.bias` 之 JSON。
+ (`left|center|right|full`)與可貼入 `jiya.bias` 之 JSON。工具只量預設 cmap glyph,
+ 不做 shaping/`locl`;第二個語言參數僅選 JSON 分類鍵,不會改變所量字形。泛 CJK 地域字形須
+ 以瀏覽器實際輸出微調,不能把各語言同值建議當成逐語言字形驗收。
3. **套用**:`createJuzhen({ jiya: { halfWidth: "margin", bias: <貼入 JSON> } })`;
或全用內建參考表(多數宋體系 GBK 字型與參考表相符,未必需覆寫)。
4. **目視微調**:以 `demo/fallback.html`(或自有頁面)對照原文,逐字檢查——
@@ -582,7 +587,7 @@ createJuzhen({
> 偏側是「字身於 0.5em 盒內左移多少」的三檔量化(0/0.25/0.5em),量測值近門檻
> (如 0.6 介於 center/right)時,以目視為準。`halt` 字型走 OpenType 由字型精確
-> 定位,無此配字需求——`margin` 配字僅為缺 `halt` 字型之務實近似。
+> 定位;是否完整覆蓋仍須核對——`margin` 配字是缺 `halt` 或覆蓋不足字型之務實近似。
---
@@ -594,7 +599,9 @@ JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS
1. **引入樣式(必須)**:``(或 ESM
`import "cjk-autospace/css"`)。缺此則間隙/擠壓/justify 原子化全部失效。
2. **設定 `lang`(強烈建議)**:在內容容器標 `lang="zh-Hans"`/`"zh-Hant"`/`"ja"`,
- 驅動全角/開明政策與字形 `locl`。無 `lang` 時回退 `options.lang.default`。
+ 驅動全角/開明政策與字形 `locl`。自身及祖先均無 `lang` 時回退 `options.lang.default`;顯式 `lang=""` 為未知語言
+ (`other`),不繼承外層語言。簡繁分類只讀 BCP 47 的 script/region 位置,忽略
+ extension/private-use 內的同名字串(如 `zh-Hans-x-hant` 仍為簡體)。
3. **作用域類**:受管內容容器須加 `class="juzhen"`,以啟用內建原生間距禁用樣式。
`scope.include` 只控制 JS 處理範圍,不會改寫 CSS 選擇器;若使用 `.prose` 等自訂
選擇器,請同時加類別(如 `class="prose juzhen"`)。
@@ -645,6 +652,9 @@ JS 只注入 `jz-*` 結構,**所有視覺尺寸在 `juzhen.css`**——故 CSS
## 自定義字符分類(`charClass`)
+`cjk`/`western` 是 regex 字元類片段,特殊字元須轉義;`biaodian` 各子類則是
+逐碼點字串,不解析範圍。追加三個圈碼應寫 `"①②③"`;`"①-③"` 會追加 `①`、`-`、`③`。
+
每個字符的分類(中文/西文、全寬標點各子類)抽象為**規則集**:內置默認規則集即聚珍
的分類表,消費端可經 `options.charClass` **擴充**,以覆寫 `spacing`/`jiya`/`jinze`
等規則對特定字符之判定。語義為**追加**——把當前未分類之字符、或新符號歸入對應類。
diff --git a/UPDATES.md b/UPDATES.md
index 0c0a075..b20d666 100644
--- a/UPDATES.md
+++ b/UPDATES.md
@@ -2,6 +2,54 @@
目前用法與標記契約以 [README](./README.md#用法) 為準;本檔只記錄升級影響與所需動作。
+## 2026-10-03:全專案掃描後的行緣、回退與語言修復
+
+- **基線/範圍**:以 `024ad5d`(套件 2.0.0、ruleset 1.0.0)為掃描基線,修復
+ Web adapter、demo、量測工具及文件。套件版本仍為 2.0.0。
+- **交付狀態**:本次修復已提交並推送至 `main`,倉庫含配套 JS/CSS 與型別宣告。
+ 未進行 npm 發佈、下游更新或部署;既有安裝及網站不會自動更新。
+
+### 可見行為與升級影響
+
+1. **物理行緣**:`jz-orphan` 包裹不再把同行開括號誤當行首;自訂 isolate/opaque
+ 的實際 inline/inline-block/contents 盒可攀出,block 仍是邊界。可見 `br` 作為
+ 強制行緣,隱藏 `br` 可穿越。修正影響行端半形及同行/行末間隙判斷,邏輯隔離政策不變。
+2. **切分回退**:splitoff 不再只憑前兄弟標籤合併。私有切分/合併鏈證明同源、仍相鄰
+ 且作者屬性一致時才併回,支援多實例再次切分;外部插入、替換來源或改樣式時保留
+ 片段元素、樣式及字序並撤銷本庫標記/憑證。受外部干預後不承諾恢復歷史 DOM 快照。
+3. **語言**:自身及祖先最近的 `lang=""` 表示未知(`other`),僅屬性缺失才繼承/回退;
+ `lang.default: ""` 亦表示未知。script/region 只按 BCP 47 的位置判讀,私用或擴展
+ 內容不再冒充腳本,如 `zh-Hans-x-hant` 為簡體、`zh-TW-x-hans` 為繁體。四個分類鍵、
+ 裸 `zh` 預設繁體及逐元素樣式優先序不變。依賴舊錯誤行為者須改成明確語言或樣式。
+4. **demo 控制**:主 demo 對齊/寬度切換顯式重排,revert 後控制保持停用;拖曳頁
+ 八個框各自作為 render 根,寬度觀察及對齊刷新不再只依賴 body 尺寸變化。
+5. **字型後備**:`margin`+逐語言分類/逐字 `jiya.bias`(left/center/right/full)
+ 完整保留。工具補量 `.`(U+FF0E),並明示只量預設 cmap、未做 shaping/`locl`;
+ 語言參數僅選 JSON 分類鍵,`halt` 特性存在不保證逐字覆蓋。目標字型與語言下仍須目視微調。
+
+共享規則升為 **1.0.1 patch**:澄清既有 explicit-script/region 契約並新增七個反例向量,
+原有 55 個向量結果、字符集合、政策、schema 及 adapter 義務均不變。Web 修正違反既有
+契約的解析錯誤,不另立 Web 專用分類政策;其他 adapter 須核對全部 62 個向量。
+本次未驗收 XeLaTeX 或其他 adapter。版本依據見 [共享規則](./rules/README.md#101-語言契約澄清)。
+
+取用本次倉庫修改時,一起更新配套 JS/CSS/型別及共享規則;公開入口與選項名稱不變。
+文件另修正 Finder 快取生命週期、no-op 懸掛說明、功能級別預設、樣式優先序及
+`charClass` 輸入格式:cjk/western 是 regex 字元類片段,biaodian 是逐碼點字串,
+`"①-③"` 不代表三個圈碼,應寫 `"①②③"`。
+
+### 本地驗證
+
+- `npm run build`:JS/CSS 與 TypeScript 型別宣告完整構建通過。
+- `npm test`:**376/376 通過,0 失敗、0 跳過**;含 strict schema/常數對照、
+ 全部 62 個語義向量、外部插入/替換/改樣式、observer 回退及多實例切分 36 個組合。
+- `npm run test:wkwebview`:最終構建 **64/64 通過**,failures 為空;新增行緣、
+ 語言、外部同標籤兄弟及多實例切分用例均在真實 WKWebView 執行。
+- 最終產物另以原生宿主載入兩個 demo 副本:主頁 4 項、拖曳頁 5 項通過,核對
+ 對齊後與手動重排一致、revert 停用,以及框寬變化實際改變行端決策。
+
+以上是本地 DOM/原生幾何及控制驗證,不代表三引擎目視、所有字型地域字形或真實下游
+頁面驗收。本次未重新驗收 Chrome/Edge;**Firefox 依用戶要求暫緩**,不安裝或作相容性結論。
+
## 2026-10-02:Web 模組邊界、半形模式與局部生命週期修復
- **基線/範圍**:以 `1c2fb0e`(套件 2.0.0、ruleset 1.0.0)為修復前基線;本次為
diff --git a/demo/e2e.html b/demo/e2e.html
index 7d987f4..d2aec40 100644
--- a/demo/e2e.html
+++ b/demo/e2e.html
@@ -352,6 +352,97 @@
});
layoutJz.revert(layoutFixture);
layoutJz.disconnect();
+ var auditRoot = document.createElement("section");
+ auditRoot.lang = "zh-Hant";
+ auditRoot.style.cssText = "font-size:20px;width:600px;text-align:left";
+ document.body.appendChild(auditRoot);
+ [ false, true ].forEach(function (orphan)
+ {
+ auditRoot.innerHTML = '前甲「乙丙」
';
+ var edgeOwner = Juzhen.createJuzhen({ orphan: orphan, spacing: false, longWord: false });
+ edgeOwner.render(auditRoot);
+ record("同行 orphan 不製造行首,orphan=" + orphan,
+ !auditRoot.querySelector(".bd-open").classList.contains("jz-half-le"));
+ edgeOwner.revert(auditRoot);
+ edgeOwner.disconnect();
+ });
+ [ "inline", "inline-block", "contents", "block" ].forEach(function (display)
+ {
+ auditRoot.innerHTML = '前甲「乙」 丙
';
+ var edgeOwner = Juzhen.createJuzhen({ isolateSelector: "x-box", orphan: false, spacing: false, longWord: false });
+ edgeOwner.render(auditRoot);
+ record("自訂隔離元素按物理行內流判斷,display=" + display,
+ Array.from(auditRoot.querySelectorAll("jz-char")).every(function (char)
+ {
+ return char.classList.contains("jz-half-le") === (display === "block");
+ }));
+ edgeOwner.revert(auditRoot);
+ edgeOwner.disconnect();
+ });
+ [ false, true ].forEach(function (hidden)
+ {
+ auditRoot.innerHTML = '甲」「乙
';
+ var edgeOwner = Juzhen.createJuzhen({ orphan: false, spacing: false, longWord: false });
+ edgeOwner.render(auditRoot);
+ record("BR 是強制行緣,hidden=" + hidden,
+ Array.from(auditRoot.querySelectorAll("jz-char")).every(function (char)
+ {
+ return char.classList.contains("jz-half-le") === !hidden;
+ }));
+ edgeOwner.revert(auditRoot);
+ edgeOwner.disconnect();
+ });
+ auditRoot.innerHTML = '甲乙丙,丁
';
+ var splitOwner = Juzhen.createJuzhen({ orphan: false, spacing: false, longWord: false });
+ splitOwner.render(auditRoot);
+ var splitFragment = auditRoot.querySelector('[data-jz-kind="splitoff"]');
+ var inserted = document.createElement("strong");
+ inserted.className = "external";
+ inserted.textContent = "新";
+ splitFragment.before(inserted);
+ splitOwner.revert(auditRoot);
+ record("splitoff 不將邊界字移入外部同標籤兄弟",
+ inserted.textContent === "新" && splitFragment.textContent === "丙"
+ && splitFragment.className === "original" && !splitFragment.hasAttribute("data-jz")
+ && auditRoot.textContent === "甲乙新丙,丁");
+ splitOwner.disconnect();
+ auditRoot.innerHTML = '甲,乙
甲,乙
甲,乙
';
+ var localeOwner = Juzhen.createJuzhen({ orphan: false, jinze: false });
+ localeOwner.render(auditRoot);
+ var localeChars = auditRoot.querySelectorAll("jz-char");
+ record("語言私用部分不覆寫 script/region,空值為未知",
+ localeChars[0].classList.contains("jz-half")
+ && !localeChars[1].classList.contains("jz-half")
+ && !localeChars[2].classList.contains("jz-half"));
+ localeOwner.revert(auditRoot);
+ localeOwner.disconnect();
+ var splitMatrixPass = true;
+ var splitOrders = [ [0,1,2], [0,2,1], [1,0,2], [1,2,0], [2,0,1], [2,1,0] ];
+ [ '甲乙丙丁,戊己', '「甲乙丙丁」',
+ '甲乙丙丁,戊己',
+ '甲乙丙丁,戊己',
+ '甲乙𠀀\uDB40\uDD00丙丁,戊己',
+ '甲乙丙丁,戊己'
+ ].forEach(function (html)
+ {
+ splitOrders.forEach(function (order)
+ {
+ auditRoot.innerHTML = '' + html + '
';
+ var p = auditRoot.firstElementChild;
+ var original = p.innerHTML;
+ var owners = [
+ Juzhen.createJuzhen({ jiya: false, spacing: false, orphan: false, longWord: false }),
+ Juzhen.createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false }),
+ Juzhen.createJuzhen({ spacing: false, orphan: false, longWord: false })
+ ];
+ owners.forEach(function (owner) { owner.render(p); });
+ order.forEach(function (index) { owners[index].revert(p); });
+ if (p.innerHTML !== original) { splitMatrixPass = false; }
+ owners.forEach(function (owner) { owner.disconnect(); });
+ });
+ });
+ record("私有切分來源鏈支援多實例六種回退順序,36 組合", splitMatrixPass);
+ auditRoot.remove();
var moduleFixture = document.getElementById("module-fixture");
var splitSurrogate = document.getElementById("surrogate-spacing");
[ "\uD840", "\uDC00", "\uDB40", "\uDD00" ].forEach(function (part, index)
diff --git a/demo/fallback.html b/demo/fallback.html
index 6a027de..fe06669 100644
--- a/demo/fallback.html
+++ b/demo/fallback.html
@@ -16,8 +16,8 @@
font-family: system-ui, sans-serif;
}
/* 字型類獨立於 .juzhen:A 用裸 .cn(瀏覽器原生排版),B/C 才掛 .juzhen
- (含 normalize 之 text-spacing-trim:space-all)。否則 A 之原生標點被 space-all
- 撐開、顯得比 B 鬆,造成「B 似乎有效」之假象(實則 B 全形未擠、與真原文等寬)。 */
+ (禁用原生 text-autospace)。聚珍不設定 text-spacing-trim;A 保留瀏覽器原生
+ 排版,B/C 使用相同受管基線以比較 halt 與 margin。 */
/* 字型設於容器即可——聚珍不管字型(無 _lang.css)、亦不強制 jz-inner(`font:
inherit`),故消費端字型經繼承正確套到標點字面(標點亦以 FZShuSong 渲染,halt
確實缺席)。此即「字型由消費端負責」之示例。 */
@@ -47,8 +47,9 @@
方正書宋 GBK · margin 後備模式
@@ -78,7 +79,7 @@
.render(document.getElementById("margin"));
// A 面板留原文(不 render)。
// 註:margin 模式以 width:0.5em+字身溢出定位,標點 advance 真為 0.5em;整句
- // 行寬 halt 24.0em → margin 19.5em(10 標點各收 0.5em),可於瀏覽器 devtools 量。
+ // 整句行寬差取決於實際壓縮字符及目標字型,可於瀏覽器 devtools 量;不以固定 em 值驗收。