diff --git a/.gitignore b/.gitignore index 9b8b130..e047fab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store node_modules/ *.log +.scratch/ # 文檔(HTML 文檔+changelog)只存於 docs 分支;此處為 main 上之未追蹤本地鏡像, # 唯讀,請勿在此編輯——所有文檔操作須於 docs 分支進行。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 56c9d2b..404db16 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -248,6 +248,16 @@ IIFE 構建額外掛 `globalThis.createCjkAutospace` 別名,使 設計要點:finder 不知道任何具體排版規則;它只提供「在何處、跨何種邊界、對哪些 字元」的遍歷與安全變更原語。規則由各 pass 以 unicode 表 + 回呼注入。 +**「行內透明」原則之一致性(下游 Bug 修復)**:「邏輯文本應透明穿越之行內元素」一語 +須在所有遍歷原語中**口徑一致**,否則同一段文本在不同原語下被切成不同邏輯串、生成相 +互矛盾之結果。`isTransparentInline` 為單一真相源,列入:樣式行內標籤、`jz-hws`/ +`jz-jinze`(wrap 群組)、**`jz-char`/`jz-inner`(標點原子)**。後者必須透明,否則 +`adjacentLogicalChar` 之 `descend` 遇標點原子既不進入亦不終止、而是**跳過**它續找更前 +之兄弟——`漢字。` 經禁則綁定後,pill 之左鄰字應為「。」(標點,非 pill 鄰字、不 +補間隙),舊邏輯卻越過 `jz-char` 取到「字」誤補 `jz-hws`。修復後與 `collectRuns` 之既 +有透明遞迴對齊。同理 `lineedge` 之 `edgeRect` 攀爬集由 `options.finder.styleInlines` +**派生**(非硬編碼子集),免遺漏 `ins`/`del`/`sub` 等致行端誤判(T4)。 + ### 4.2 `unicode.ts`——字元分類與規則表 集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致: @@ -301,6 +311,11 @@ CSS 端對應以 `:lang()` 選擇器落實同一政策(I2/I3)。 冪等標記(`data-jz=""`)、`revertPass(name, root)`(移除該 pass 之產物、 解包 `jz-*`、合併回文本節點並 `normalize()`)。 +`data-jz-kind` 區分產物還原方式:`marker`(純注入,移除)、`wrap`(包裝原內容,解包)、 +**`splitoff`**(行內元素克隆切分之邊界詞元,見 `core/split.ts`/§6.4)。`revertPass` 分 +**兩階段**處理:主迴圈逆序解包 marker/wrap,第二階段再把 splitoff 併回前兄弟(同標籤 +原元素)——順序必要,否則 splitoff 之前兄弟可能仍是 wrap 殼(如 `jz-jinze`)而非原元素。 + --- ## 5. 渲染元素詞彙(`jz-*`) @@ -315,6 +330,7 @@ CSS 端對應以 `:lang()` 選擇器落實同一政策(I2/I3)。 | `jz-inner` | `jz-char` 內層字面容器,供 margin/letter-spacing 擠壓而不動外盒 | | `jz-cs` | 字間補償空白(連續/行端擠壓用,於行邊界折疊) | | `jz-jinze` | 禁則群組:綁定不可斷單元(亦為 justify 綁定載體) | +| `jz-orphan` | 垂懸字避免群組:段末末 N 實義字 `nowrap`(§6.5.1) | | `jz-em` | 著重號載體 | | `jz-ruby`/`jz-rb`/`jz-rt` | ruby/注音 | @@ -474,6 +490,14 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j (閉類/點號/間隔/連接省略)綁到**前一字**、「不可置行末」之開類綁到**後一字**, 且**只隔離單一邊界字**(長引文中段仍正常斷行,不溢出)。實作為兩階段:依原始 相鄰算禁斷邊界→切邊界字;重掃併入 nowrap 群組。亦為 §8 justify 綁定載體。 +- **行內元素最小綁定(`core/split.ts`,下游 Bug 修復)**:邊界字落於**樣式行內元素** + (`strong`/`em`/`span` 等)內時,舊版整個元素入 `jz-jinze`——`很長一串,` + 會把整串粗體塞進 `nowrap` inline-block,行末放不下即**整體掉行、上行留巨大空隙**。 + 改以 `isolateBoundaryToken(el, side)` **克隆切分**:沿「文本節點→邊界元素」祖先鏈逐層 + 把邊界詞元剝入一個淺克隆(保留標籤/class/style/lang,去 id),只綁該克隆(最小綁定), + 原元素其餘部分留原位、渲染視覺無縫。克隆標 `data-jz-kind="splitoff"`,`revertPass` 之**延後 + 階段**(先解包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼)併回原元素、還原一致 + (I6)。僅純樣式標籤白名單可切;`a`/`abbr`/`ruby`/帶 id 者退化為整元素綁定(安全)。 - **切片**:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、 更細之禁則對象列後續,沿用同一 `jz-jinze` 結構。 - **已知交互(待瀏覽器精修)**:`lineedge` 之行端偵測需跨 `jz-jinze` 群組邊界向上 diff --git a/README.md b/README.md index 98a120f..26f20fa 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,11 @@ jz.render(document.querySelector("main")); | 功能 | 說明 | |---|---| -| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws`(`user-select:none`,剪貼板跳過)。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 `code`/`kbd` pill 邊界(pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。 | +| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws`(`user-select:none`,剪貼板跳過)。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 `ā ǎ ē`)、希臘、西里爾、數字、貨幣(`€ ₩ ₿`)、數學運算符等;CJK 標點(`。,「」()……` 等)不誤判為西文、不生隙。含跨樣式行內邊界與 `code`/`kbd` pill 邊界(pill 緊鄰標點時穿越 `jz-jinze` 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。**pill 之邏輯鄰字為標點時(如 `漢字。`)不誤加間隙**(標點非 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;透明穿越 `strong`/`em` 等行內邊界);③行端擠壓(`lineedge` layout pass)。半形預設以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。**字型缺 `halt` 時可切 `margin` 後備模式**(見下節)。 | -| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。 | -| **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 | +| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。邊界字落於行內樣式元素(`strong`/`em`/`span` 等)內時**克隆切分**只綁邊界詞元(避免整個元素入 `nowrap` 致行末整體掉行、上行留巨大空隙),切出之克隆標 `data-jz-kind="splitoff"`、`revert` 時自動併回原元素;不可切元素(`a`/`abbr`/`ruby`/帶 `id`)退化為整元素綁定。 | +| **垂懸字避免** `orphan` | 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 `jz-orphan`(`white-space:nowrap`),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣**克隆切分**(只綁末 N 字、避免整個長元素入 `nowrap` 溢出容器;機制同禁則)。**預設開**(`orphan:false` 關,`{chars:n}` 調)。段落級功能。 | | ~~**標點懸掛** `hanging`~~ | **已評估停用**(§6.5.6):純負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 重排錯亂),原生 `hanging-punctuation` 為正解但 Chrome 不支援。`hanging` 選項仍被接受但**無任何效果**;程式碼休眠待原生支援成熟。 | | **功能分級** `level` | 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、懸掛、長詞——需行/段布局)。塊級元素預設全功能;`data-jz-level="text"` 子樹或選項 `level:{text:"選擇器"}`(如標題/按鈕)僅跑文本級。 | | **justify 單份間距** | 每個 `jz-char` 為 `inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 | diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index 67f1e9b..72f7173 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -48,10 +48,18 @@ var Juzhen = (() => { return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); } /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 - * jz-*(jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 - * 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ + * jz-*(jz-hws 間隙、jz-jinze 禁則群組、jz-char/jz-inner 標點原子)。穿越這些 + * 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 + * 鄰字仍須可達;I7)。 + * ⚠ jz-char/jz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 + * 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。` 經 jinze + * 綁定為 `汉`,pill 之左鄰字應 + * 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 + * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend + * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 + * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ isTransparentInline(name) { - return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE"; + return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER"; } /** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / * data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ @@ -471,8 +479,11 @@ var Juzhen = (() => { if (!parent) { continue; } - touched.add(parent); const kind = el.getAttribute(KIND_ATTR); + if (kind === "splitoff") { + continue; + } + touched.add(parent); if (kind === "marker") { parent.removeChild(el); } else { @@ -482,6 +493,30 @@ var Juzhen = (() => { parent.removeChild(el); } } + const splitoffs = Array.from( + root.querySelectorAll( + "[" + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]' + ) + ); + for (const so of splitoffs) { + const parent = so.parentNode; + if (!parent) { + continue; + } + touched.add(parent); + const prev = so.previousSibling; + if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) { + while (so.firstChild) { + prev.appendChild(so.firstChild); + } + parent.removeChild(so); + } else { + while (so.firstChild) { + parent.insertBefore(so.firstChild, so); + } + parent.removeChild(so); + } + } for (const p of touched) { if (p.normalize) { p.normalize(); @@ -803,6 +838,8 @@ var Juzhen = (() => { squeeze(prev, el); } prev = el; + } else if (finder.pillMatches(el)) { + prev = null; } else if (finder.isAvoided(el)) { prev = null; } else if (blockSet.has(nm)) { @@ -820,27 +857,60 @@ var Juzhen = (() => { } }; - // src/typeset/jinze.ts - var PASS2 = "jinze"; - var AVOID_HEAD = /* @__PURE__ */ new Set(["bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]); - var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); - function isCharEl(n) { - return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; + // src/core/split.ts + var PASS_ATTR2 = "data-jz"; + var KIND_ATTR2 = "data-jz-kind"; + var SPLITTABLE = /* @__PURE__ */ new Set([ + "STRONG", + "EM", + "B", + "I", + "U", + "S", + "SPAN", + "MARK", + "SMALL", + "INS", + "DEL", + "SUB", + "SUP" + ]); + function canSplit(el) { + return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id"); } - function forbiddenBreak(a, b, finder) { - if (isCharEl(b)) { - const c = bdClassOf(b); - if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { - return true; + function firstText(el) { + for (let c = el.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 3 && c.data.length > 0) { + return c; + } + if (c.nodeType === 1) { + if (!canSplit(c)) { + return null; + } + const r = firstText(c); + if (r) { + return r; + } } } - if (isCharEl(a)) { - const c = bdClassOf(a); - if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { - return true; + return null; + } + function lastText(el) { + for (let c = el.lastChild; c; c = c.previousSibling) { + if (c.nodeType === 3 && c.data.length > 0) { + return c; + } + if (c.nodeType === 1) { + if (!canSplit(c)) { + return null; + } + const r = lastText(c); + if (r) { + return r; + } } } - return false; + return null; } function isTokenChar(ch, anyCjk) { return !anyCjk.test(ch) && !/\s/.test(ch); @@ -866,16 +936,135 @@ var Juzhen = (() => { } return s; } + function splitTreeAfter(top, textNode, offset, pass) { + if (offset <= 0 && firstText(top) === textNode) { + return null; + } + let rightAtLevel; + if (offset >= textNode.data.length) { + rightAtLevel = textNode.nextSibling; + } else if (offset <= 0) { + rightAtLevel = textNode; + } else { + rightAtLevel = textNode.splitText(offset); + } + let ancestor = textNode.parentNode; + let topClone = null; + while (ancestor && ancestor.nodeType === 1) { + const anc = ancestor; + let clone = null; + if (rightAtLevel) { + clone = anc.cloneNode(false); + clone.removeAttribute("id"); + clone.setAttribute(PASS_ATTR2, pass); + clone.setAttribute(KIND_ATTR2, "splitoff"); + let n = rightAtLevel; + while (n) { + const next = n.nextSibling; + clone.appendChild(n); + n = next; + } + } + if (anc === top) { + if (!clone) { + return null; + } + if (top.parentNode) { + top.parentNode.insertBefore(clone, top.nextSibling); + } + topClone = clone; + break; + } + if (clone) { + if (anc.parentNode) { + anc.parentNode.insertBefore(clone, anc.nextSibling); + } + rightAtLevel = clone; + } else { + rightAtLevel = anc.nextSibling; + } + ancestor = anc.parentNode; + } + return topClone; + } + function isolateBoundaryToken(el, side, anyCjk, pass) { + if (!canSplit(el)) { + return el; + } + const text = side === "tail" ? lastText(el) : firstText(el); + if (!text) { + return el; + } + const point = side === "tail" ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; + const clone = splitTreeAfter(el, point.textNode, point.offset, pass); + if (!clone) { + return el; + } + return side === "tail" ? clone : el; + } + function splitElementAt(el, textNode, offset, pass) { + if (!canSplit(el)) { + return null; + } + return splitTreeAfter(el, textNode, offset, pass); + } + + // src/typeset/jinze.ts + var PASS2 = "jinze"; + var AVOID_HEAD = /* @__PURE__ */ new Set(["bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]); + var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); + function isCharEl(n) { + return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; + } + function forbiddenBreak(a, b, finder) { + if (isCharEl(b)) { + const c = bdClassOf(b); + if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { + return true; + } + } + if (isCharEl(a)) { + const c = bdClassOf(a); + if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { + return true; + } + } + return false; + } + function isTokenChar2(ch, anyCjk) { + return !anyCjk.test(ch) && !/\s/.test(ch); + } + function leadingTokenEnd2(d, anyCjk) { + if (anyCjk.test(d.charAt(0))) { + return 1; + } + let e = 1; + while (e < d.length && isTokenChar2(d.charAt(e), anyCjk)) { + e += 1; + } + return e; + } + function trailingTokenStart2(d, anyCjk) { + const last = d.length - 1; + if (anyCjk.test(d.charAt(last))) { + return last; + } + let s = last; + while (s > 0 && isTokenChar2(d.charAt(s - 1), anyCjk)) { + s -= 1; + } + return s; + } function splitBoundaries(t, needFirst, needLast, anyCjk) { let node = t; if (needFirst) { - const e = leadingTokenEnd(node.data, anyCjk); + const e = leadingTokenEnd2(node.data, anyCjk); if (e < node.data.length) { node = node.splitText(e); } } if (needLast) { - const s = trailingTokenStart(node.data, anyCjk); + const s = trailingTokenStart2(node.data, anyCjk); if (s > 0) { node.splitText(s); } @@ -905,13 +1094,21 @@ var Juzhen = (() => { } for (let i2 = 0; i2 < n; i2 += 1) { const u = units[i2]; - if (u.nodeType !== 3) { - continue; - } const needFirst = i2 > 0 && forbidden[i2 - 1]; const needLast = i2 < n - 1 && forbidden[i2]; - if (needFirst || needLast) { + if (!needFirst && !needLast) { + continue; + } + if (u.nodeType === 3) { splitBoundaries(u, needFirst, needLast, anyCjk); + } else if (u.nodeType === 1) { + const el = u; + if (needLast) { + isolateBoundaryToken(el, "tail", anyCjk, PASS2); + } + if (needFirst) { + isolateBoundaryToken(el, "head", anyCjk, PASS2); + } } } const kids = []; @@ -1004,7 +1201,14 @@ var Juzhen = (() => { m.delete(key); } } - var INLINE_WRAP = /* @__PURE__ */ new Set(["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK"]); + var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS"]; + function buildWrapSet(options) { + const s = new Set(JZ_WRAP_TAGS); + for (const name of options.finder.styleInlines) { + s.add(name); + } + return s; + } function rectOf(n, side) { if (n.nodeType === 3 && n.data.length > 0) { const t = n; @@ -1028,7 +1232,7 @@ var Juzhen = (() => { } return null; } - function edgeRect(start, side, from) { + function edgeRect(start, side, from, wrapSet) { let n = start; let parentRef = from.parentNode; for (; ; ) { @@ -1042,7 +1246,7 @@ var Juzhen = (() => { if (!parentRef || parentRef.nodeType !== 1) { return null; } - if (!INLINE_WRAP.has(parentRef.nodeName)) { + if (!wrapSet.has(parentRef.nodeName)) { return null; } n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling; @@ -1087,6 +1291,7 @@ var Juzhen = (() => { } function relayout(root, finder, options) { clearEdge(root); + const wrapSet = buildWrapSet(options); const chars = Array.from(root.querySelectorAll("jz-char")).filter( (c) => finder.inScope(c) && !finder.isAvoided(c) ); @@ -1120,12 +1325,12 @@ var Juzhen = (() => { continue; } if (isHead) { - const prev = edgeRect(el.previousSibling, "prev", el); + const prev = edgeRect(el.previousSibling, "prev", el, wrapSet); if (!prev || higherLine(r, prev)) { decisions.push({ el, edge: "head", mode }); } } else { - const next = edgeRect(el.nextSibling, "next", el); + const next = edgeRect(el.nextSibling, "next", el, wrapSet); if (!next || lowerLine(r, next)) { decisions.push({ el, edge: "tail", mode }); } @@ -1153,7 +1358,7 @@ var Juzhen = (() => { } }; var TRIM = "jz-hws-trim"; - function trimGaps(root, finder) { + function trimGaps(root, finder, wrapSet) { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") ); @@ -1163,7 +1368,7 @@ var Juzhen = (() => { gaps.forEach((g) => g.classList.add(TRIM)); for (const g of gaps) { const r = lastRect(g); - const next = r ? edgeRect(g.nextSibling, "next", g) : null; + const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null; const atLineEnd = !r || !next || next.top > r.top + EPS; if (!atLineEnd) { g.classList.remove(TRIM); @@ -1180,7 +1385,8 @@ var Juzhen = (() => { if (!hasLayout()) { return; } - installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder)); + const wrapSet = buildWrapSet(ctx.options); + installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet)); }, revert(ctx) { uninstallLayout(ctx.root, "gapTrim"); @@ -1300,6 +1506,16 @@ var Juzhen = (() => { if (boundary.offset > 0) { startChild = boundary.textNode.splitText(boundary.offset); } + } else if (boundary.textNode && startChild.nodeType === 1) { + const clone = splitElementAt( + startChild, + boundary.textNode, + boundary.offset, + PASS4 + ); + if (clone) { + startChild = clone; + } } const orphan = createJz("jz-orphan", PASS4, "wrap"); block.insertBefore(orphan, startChild); @@ -1510,6 +1726,9 @@ var Juzhen = (() => { if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; } + if (!finder.featureEnabledFor(pill, PASS5)) { + return; + } if (!pill.parentNode) { return; } diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 18f92da..8d08718 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -20,10 +20,18 @@ var Finder = class { return !!(el.matches && this.pillSelector && el.matches(this.pillSelector)); } /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 - * jz-*(jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 - * 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ + * jz-*(jz-hws 間隙、jz-jinze 禁則群組、jz-char/jz-inner 標點原子)。穿越這些 + * 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 + * 鄰字仍須可達;I7)。 + * ⚠ jz-char/jz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 + * 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。` 經 jinze + * 綁定為 `汉`,pill 之左鄰字應 + * 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 + * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend + * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 + * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ isTransparentInline(name) { - return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE"; + return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE" || name === "JZ-CHAR" || name === "JZ-INNER"; } /** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / * data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */ @@ -443,8 +451,11 @@ function revertPass(pass, root) { if (!parent) { continue; } - touched.add(parent); const kind = el.getAttribute(KIND_ATTR); + if (kind === "splitoff") { + continue; + } + touched.add(parent); if (kind === "marker") { parent.removeChild(el); } else { @@ -454,6 +465,30 @@ function revertPass(pass, root) { parent.removeChild(el); } } + const splitoffs = Array.from( + root.querySelectorAll( + "[" + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]' + ) + ); + for (const so of splitoffs) { + const parent = so.parentNode; + if (!parent) { + continue; + } + touched.add(parent); + const prev = so.previousSibling; + if (prev && prev.nodeType === 1 && prev.nodeName === so.nodeName) { + while (so.firstChild) { + prev.appendChild(so.firstChild); + } + parent.removeChild(so); + } else { + while (so.firstChild) { + parent.insertBefore(so.firstChild, so); + } + parent.removeChild(so); + } + } for (const p of touched) { if (p.normalize) { p.normalize(); @@ -775,6 +810,8 @@ var jiyaAdjacencyPass = { squeeze(prev, el); } prev = el; + } else if (finder.pillMatches(el)) { + prev = null; } else if (finder.isAvoided(el)) { prev = null; } else if (blockSet.has(nm)) { @@ -792,27 +829,60 @@ var jiyaAdjacencyPass = { } }; -// src/typeset/jinze.ts -var PASS2 = "jinze"; -var AVOID_HEAD = /* @__PURE__ */ new Set(["bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]); -var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); -function isCharEl(n) { - return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; +// src/core/split.ts +var PASS_ATTR2 = "data-jz"; +var KIND_ATTR2 = "data-jz-kind"; +var SPLITTABLE = /* @__PURE__ */ new Set([ + "STRONG", + "EM", + "B", + "I", + "U", + "S", + "SPAN", + "MARK", + "SMALL", + "INS", + "DEL", + "SUB", + "SUP" +]); +function canSplit(el) { + return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id"); } -function forbiddenBreak(a, b, finder) { - if (isCharEl(b)) { - const c = bdClassOf(b); - if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { - return true; +function firstText(el) { + for (let c = el.firstChild; c; c = c.nextSibling) { + if (c.nodeType === 3 && c.data.length > 0) { + return c; + } + if (c.nodeType === 1) { + if (!canSplit(c)) { + return null; + } + const r = firstText(c); + if (r) { + return r; + } } } - if (isCharEl(a)) { - const c = bdClassOf(a); - if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { - return true; + return null; +} +function lastText(el) { + for (let c = el.lastChild; c; c = c.previousSibling) { + if (c.nodeType === 3 && c.data.length > 0) { + return c; + } + if (c.nodeType === 1) { + if (!canSplit(c)) { + return null; + } + const r = lastText(c); + if (r) { + return r; + } } } - return false; + return null; } function isTokenChar(ch, anyCjk) { return !anyCjk.test(ch) && !/\s/.test(ch); @@ -838,16 +908,135 @@ function trailingTokenStart(d, anyCjk) { } return s; } +function splitTreeAfter(top, textNode, offset, pass) { + if (offset <= 0 && firstText(top) === textNode) { + return null; + } + let rightAtLevel; + if (offset >= textNode.data.length) { + rightAtLevel = textNode.nextSibling; + } else if (offset <= 0) { + rightAtLevel = textNode; + } else { + rightAtLevel = textNode.splitText(offset); + } + let ancestor = textNode.parentNode; + let topClone = null; + while (ancestor && ancestor.nodeType === 1) { + const anc = ancestor; + let clone = null; + if (rightAtLevel) { + clone = anc.cloneNode(false); + clone.removeAttribute("id"); + clone.setAttribute(PASS_ATTR2, pass); + clone.setAttribute(KIND_ATTR2, "splitoff"); + let n = rightAtLevel; + while (n) { + const next = n.nextSibling; + clone.appendChild(n); + n = next; + } + } + if (anc === top) { + if (!clone) { + return null; + } + if (top.parentNode) { + top.parentNode.insertBefore(clone, top.nextSibling); + } + topClone = clone; + break; + } + if (clone) { + if (anc.parentNode) { + anc.parentNode.insertBefore(clone, anc.nextSibling); + } + rightAtLevel = clone; + } else { + rightAtLevel = anc.nextSibling; + } + ancestor = anc.parentNode; + } + return topClone; +} +function isolateBoundaryToken(el, side, anyCjk, pass) { + if (!canSplit(el)) { + return el; + } + const text = side === "tail" ? lastText(el) : firstText(el); + if (!text) { + return el; + } + const point = side === "tail" ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; + const clone = splitTreeAfter(el, point.textNode, point.offset, pass); + if (!clone) { + return el; + } + return side === "tail" ? clone : el; +} +function splitElementAt(el, textNode, offset, pass) { + if (!canSplit(el)) { + return null; + } + return splitTreeAfter(el, textNode, offset, pass); +} + +// src/typeset/jinze.ts +var PASS2 = "jinze"; +var AVOID_HEAD = /* @__PURE__ */ new Set(["bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga"]); +var AVOID_TAIL = /* @__PURE__ */ new Set(["bd-open"]); +function isCharEl(n) { + return !!n && n.nodeType === 1 && n.nodeName === "JZ-CHAR"; +} +function forbiddenBreak(a, b, finder) { + if (isCharEl(b)) { + const c = bdClassOf(b); + if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS2)) { + return true; + } + } + if (isCharEl(a)) { + const c = bdClassOf(a); + if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS2)) { + return true; + } + } + return false; +} +function isTokenChar2(ch, anyCjk) { + return !anyCjk.test(ch) && !/\s/.test(ch); +} +function leadingTokenEnd2(d, anyCjk) { + if (anyCjk.test(d.charAt(0))) { + return 1; + } + let e = 1; + while (e < d.length && isTokenChar2(d.charAt(e), anyCjk)) { + e += 1; + } + return e; +} +function trailingTokenStart2(d, anyCjk) { + const last = d.length - 1; + if (anyCjk.test(d.charAt(last))) { + return last; + } + let s = last; + while (s > 0 && isTokenChar2(d.charAt(s - 1), anyCjk)) { + s -= 1; + } + return s; +} function splitBoundaries(t, needFirst, needLast, anyCjk) { let node = t; if (needFirst) { - const e = leadingTokenEnd(node.data, anyCjk); + const e = leadingTokenEnd2(node.data, anyCjk); if (e < node.data.length) { node = node.splitText(e); } } if (needLast) { - const s = trailingTokenStart(node.data, anyCjk); + const s = trailingTokenStart2(node.data, anyCjk); if (s > 0) { node.splitText(s); } @@ -877,13 +1066,21 @@ function processParent(parent, finder, anyCjk) { } for (let i2 = 0; i2 < n; i2 += 1) { const u = units[i2]; - if (u.nodeType !== 3) { - continue; - } const needFirst = i2 > 0 && forbidden[i2 - 1]; const needLast = i2 < n - 1 && forbidden[i2]; - if (needFirst || needLast) { + if (!needFirst && !needLast) { + continue; + } + if (u.nodeType === 3) { splitBoundaries(u, needFirst, needLast, anyCjk); + } else if (u.nodeType === 1) { + const el = u; + if (needLast) { + isolateBoundaryToken(el, "tail", anyCjk, PASS2); + } + if (needFirst) { + isolateBoundaryToken(el, "head", anyCjk, PASS2); + } } } const kids = []; @@ -976,7 +1173,14 @@ function uninstallLayout(root, key) { m.delete(key); } } -var INLINE_WRAP = /* @__PURE__ */ new Set(["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK"]); +var JZ_WRAP_TAGS = ["JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS"]; +function buildWrapSet(options) { + const s = new Set(JZ_WRAP_TAGS); + for (const name of options.finder.styleInlines) { + s.add(name); + } + return s; +} function rectOf(n, side) { if (n.nodeType === 3 && n.data.length > 0) { const t = n; @@ -1000,7 +1204,7 @@ function rectOf(n, side) { } return null; } -function edgeRect(start, side, from) { +function edgeRect(start, side, from, wrapSet) { let n = start; let parentRef = from.parentNode; for (; ; ) { @@ -1014,7 +1218,7 @@ function edgeRect(start, side, from) { if (!parentRef || parentRef.nodeType !== 1) { return null; } - if (!INLINE_WRAP.has(parentRef.nodeName)) { + if (!wrapSet.has(parentRef.nodeName)) { return null; } n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling; @@ -1059,6 +1263,7 @@ function modeForChar(el, options) { } function relayout(root, finder, options) { clearEdge(root); + const wrapSet = buildWrapSet(options); const chars = Array.from(root.querySelectorAll("jz-char")).filter( (c) => finder.inScope(c) && !finder.isAvoided(c) ); @@ -1092,12 +1297,12 @@ function relayout(root, finder, options) { continue; } if (isHead) { - const prev = edgeRect(el.previousSibling, "prev", el); + const prev = edgeRect(el.previousSibling, "prev", el, wrapSet); if (!prev || higherLine(r, prev)) { decisions.push({ el, edge: "head", mode }); } } else { - const next = edgeRect(el.nextSibling, "next", el); + const next = edgeRect(el.nextSibling, "next", el, wrapSet); if (!next || lowerLine(r, next)) { decisions.push({ el, edge: "tail", mode }); } @@ -1125,7 +1330,7 @@ var lineEdgePass = { } }; var TRIM = "jz-hws-trim"; -function trimGaps(root, finder) { +function trimGaps(root, finder, wrapSet) { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph") ); @@ -1135,7 +1340,7 @@ function trimGaps(root, finder) { gaps.forEach((g) => g.classList.add(TRIM)); for (const g of gaps) { const r = lastRect(g); - const next = r ? edgeRect(g.nextSibling, "next", g) : null; + const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null; const atLineEnd = !r || !next || next.top > r.top + EPS; if (!atLineEnd) { g.classList.remove(TRIM); @@ -1152,7 +1357,8 @@ var gapTrimPass = { if (!hasLayout()) { return; } - installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder)); + const wrapSet = buildWrapSet(ctx.options); + installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet)); }, revert(ctx) { uninstallLayout(ctx.root, "gapTrim"); @@ -1272,6 +1478,16 @@ function processBlock(block, finder, blockSet, anyCjk, biaodian, chars) { if (boundary.offset > 0) { startChild = boundary.textNode.splitText(boundary.offset); } + } else if (boundary.textNode && startChild.nodeType === 1) { + const clone = splitElementAt( + startChild, + boundary.textNode, + boundary.offset, + PASS4 + ); + if (clone) { + startChild = clone; + } } const orphan = createJz("jz-orphan", PASS4, "wrap"); block.insertBefore(orphan, startChild); @@ -1482,6 +1698,9 @@ function processPills(root, finder, pillSelector, pillNeighbor) { if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; } + if (!finder.featureEnabledFor(pill, PASS5)) { + return; + } if (!pill.parentNode) { return; } diff --git a/src/core/dom.ts b/src/core/dom.ts index 4bea7ab..f59b2e6 100644 --- a/src/core/dom.ts +++ b/src/core/dom.ts @@ -80,6 +80,10 @@ export function createMarker(tag: JzTag, pass: string): HTMLElement /** * 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後 * 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。 + * + * splitoff(克隆切分之邊界詞元,見 core/split.ts)須在 wrap 全部解包**之後**才併回: + * 否則其前兄弟可能仍是 wrap 殼(如 jz-jinze)而非原元素。故主迴圈跳過 splitoff,於 + * 解包後第二階段以文件序逐一併回前兄弟(同標籤元素),還原為單一元素(I6 一致)。 */ export function revertPass(pass: string, root: ParentNode): void { @@ -93,8 +97,9 @@ export function revertPass(pass: string, root: ParentNode): void const el = nodes[i]!; const parent = el.parentNode; if (!parent) { continue; } - touched.add(parent); const kind = el.getAttribute(KIND_ATTR); + if (kind === "splitoff") { continue; } // 延後階段處理。 + touched.add(parent); if (kind === "marker") { parent.removeChild(el); @@ -109,6 +114,32 @@ export function revertPass(pass: string, root: ParentNode): void parent.removeChild(el); } } + // 第二階段:splitoff 併回(此時 wrap 已全解包,前兄弟即原元素)。文件序,使同一 + // 原元素之多個 splitoff 依序併入。 + const splitoffs = Array.from( + root.querySelectorAll( + '[' + PASS_ATTR + '="' + pass + '"][' + KIND_ATTR + '="splitoff"]', + ), + ); + for (const so of splitoffs) + { + const parent = so.parentNode; + if (!parent) { continue; } + touched.add(parent); + const prev = so.previousSibling; + if (prev && prev.nodeType === 1 && (prev as Element).nodeName === so.nodeName) + { + // 併入前兄弟(同標籤原元素)尾部。 + while (so.firstChild) { prev.appendChild(so.firstChild); } + parent.removeChild(so); + } + else + { + // 前兄弟非預期同標籤元素(罕見,結構曾被外力改動):原地解包,仍移除標記。 + while (so.firstChild) { parent.insertBefore(so.firstChild, so); } + parent.removeChild(so); + } + } for (const p of touched) { if ((p as Element).normalize) { (p as Element).normalize(); } diff --git a/src/core/finder.ts b/src/core/finder.ts index d6f71db..005f72d 100644 --- a/src/core/finder.ts +++ b/src/core/finder.ts @@ -68,11 +68,21 @@ export class Finder } /** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類 - * jz-*(jz-hws 間隙、jz-jinze 禁則群組)。穿越這些才能正確找到跨包裝之邏輯 - * 鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左鄰字仍須可達;I7)。 */ + * jz-*(jz-hws 間隙、jz-jinze 禁則群組、jz-char/jz-inner 標點原子)。穿越這些 + * 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左 + * 鄰字仍須可達;I7)。 + * ⚠ jz-char/jz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既 + * 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。` 經 jinze + * 綁定為 `汉`,pill 之左鄰字應 + * 為「。」、不補間隙;舊邏輯卻越過 jz-char 取到「字」→ 誤包 jz-hws 於「字。」之間)。 + * jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend + * 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則 + * descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */ private isTransparentInline(name: string): boolean { - return this.styleSet.has(name) || name === "JZ-HWS" || name === "JZ-JINZE"; + return this.styleSet.has(name) + || name === "JZ-HWS" || name === "JZ-JINZE" + || name === "JZ-CHAR" || name === "JZ-INNER"; } /** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable / diff --git a/src/core/split.ts b/src/core/split.ts new file mode 100644 index 0000000..c4915ba --- /dev/null +++ b/src/core/split.ts @@ -0,0 +1,206 @@ +// 行內邊界詞元隔離(架構 §6.4/§6.5.1;下游 Bug:行內樣式標記須對排版透明)。 +// +// 禁則(jinze)與垂懸字(orphan)須把「邊界字/詞元」綁進不可斷之群組(jz-jinze +// 之 nowrap、jz-orphan 之 nowrap)。若該邊界字落於樣式行內元素( 等) +// 內,舊版**整個元素**入綁——`很長一串粗體,` 會把整串粗體塞進 +// nowrap inline-block,行末放不下即整體掉行、上行留巨大空隙;orphan 更可致長元素溢出 +// 容器。本模組把邊界詞元**克隆切分**出來,只綁該克隆(最小綁定),原元素其餘部分留在 +// 原位,渲染視覺無縫(相鄰同樣式行內元素)。 +// +// 機制:splitTreeAfter 沿「文本節點 → 邊界元素」之祖先鏈逐層把「切點之後」之內容剝入 +// 一個淺克隆(保留標籤/class/style/lang/dir,去 id),克隆作為原元素之後兄弟。克隆 +// 標 data-jz-kind="splitoff"+data-jz="",供 revertPass 之延後階段併回原元素(先解 +// 包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼;I6 還原一致)。 +// +// 僅純樣式行內標籤可切(白名單);帶語義/引用者(A 連結會克隆出兩個錨點、ABBR 之 +// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。 + +const PASS_ATTR = "data-jz"; +const KIND_ATTR = "data-jz-kind"; + +// 可安全克隆切分之純樣式行內標籤(切分不改變語義、相鄰兩份視覺無縫)。 +// 刻意排除:A(克隆出兩個連結)、ABBR/Q(title/引號語義重複)、RUBY 系(結構性)、 +// 帶 id 者(id 重複)——皆回退整元素綁定。 +const SPLITTABLE = new Set([ + "STRONG", "EM", "B", "I", "U", "S", "SPAN", "MARK", + "SMALL", "INS", "DEL", "SUB", "SUP", +]); + +interface Point +{ + textNode: Text; + offset: number; +} + +/** 元素是否可克隆切分(純樣式行內標籤、無 id)。 */ +function canSplit(el: Element): boolean +{ + return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id"); +} + +/** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */ +function firstText(el: Element): Text | null +{ + for (let c = el.firstChild; c; c = c.nextSibling) + { + if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; } + if (c.nodeType === 1) + { + if (!canSplit(c as Element)) { return null; } + const r = firstText(c as Element); + if (r) { return r; } + } + } + return null; +} + +/** el 內**末**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */ +function lastText(el: Element): Text | null +{ + for (let c = el.lastChild; c; c = c.previousSibling) + { + if (c.nodeType === 3 && (c as Text).data.length > 0) { return c as Text; } + if (c.nodeType === 1) + { + if (!canSplit(c as Element)) { return null; } + const r = lastText(c as Element); + if (r) { return r; } + } + } + return null; +} + +function isTokenChar(ch: string, anyCjk: RegExp): boolean +{ + return !anyCjk.test(ch) && !/\s/.test(ch); +} + +/** 文本節點之首詞元結束位置(CJK 字=1;連續非 CJK 非空白=整詞)。 */ +function leadingTokenEnd(d: string, anyCjk: RegExp): number +{ + if (anyCjk.test(d.charAt(0))) { return 1; } + let e = 1; + while (e < d.length && isTokenChar(d.charAt(e), anyCjk)) { e += 1; } + return e; +} + +/** 文本節點之末詞元起始位置。 */ +function trailingTokenStart(d: string, anyCjk: RegExp): number +{ + const last = d.length - 1; + if (anyCjk.test(d.charAt(last))) { return last; } + let s = last; + while (s > 0 && isTokenChar(d.charAt(s - 1), anyCjk)) { s -= 1; } + return s; +} + +/** + * 把 top 自切點(textNode, offset)起至末尾之內容剝入一個淺克隆鏈,克隆作為 top 之 + * 後兄弟插入並標記 splitoff。回傳該頂層克隆;若切點位於 top 內容之最起點(左側無內容、 + * 即整個 top 都在切點之後)則回 null(呼叫端據此綁整個 top)。 + * + * 沿祖先鏈逐層剝離:每層把「切點側之首節點及其後兄弟」搬入該層元素之淺克隆,空克隆 + * 跳過(避免殘留空元素破壞 revert 還原)。 + */ +function splitTreeAfter(top: Element, textNode: Text, offset: number, pass: string): Element | null +{ + // 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。 + if (offset <= 0 && firstText(top) === textNode) { return null; } + + // 該文本層之右側首節點。 + let rightAtLevel: Node | null; + if (offset >= textNode.data.length) + { + rightAtLevel = textNode.nextSibling; + } + else if (offset <= 0) + { + rightAtLevel = textNode; + } + else + { + rightAtLevel = textNode.splitText(offset); + } + + let ancestor: Node | null = textNode.parentNode; + let topClone: Element | null = null; + while (ancestor && ancestor.nodeType === 1) + { + const anc = ancestor as Element; + let clone: Element | null = null; + if (rightAtLevel) + { + clone = anc.cloneNode(false) as Element; + clone.removeAttribute("id"); + clone.setAttribute(PASS_ATTR, pass); + clone.setAttribute(KIND_ATTR, "splitoff"); + let n: Node | null = rightAtLevel; + while (n) + { + const next: Node | null = n.nextSibling; + clone.appendChild(n); + n = next; + } + } + if (anc === top) + { + if (!clone) { return null; } + if (top.parentNode) { top.parentNode.insertBefore(clone, top.nextSibling); } + topClone = clone; + break; + } + if (clone) + { + if (anc.parentNode) { anc.parentNode.insertBefore(clone, anc.nextSibling); } + rightAtLevel = clone; // 下一層之右側起點:克隆(緊隨 anc,原後兄弟在克隆之後) + } + else + { + rightAtLevel = anc.nextSibling; // 本子樹無右側內容;上一層右側起點為 anc 之後兄弟 + } + ancestor = anc.parentNode; + } + return topClone; +} + +/** + * 隔離 el 之邊界詞元供綁定(最小綁定)。side="tail" 取末詞元、="head" 取首詞元。 + * 回傳**應綁定之節點**: + * · 可切且確實切分 → tail 回克隆(末詞元)、head 回原 el(已只剩首詞元,其餘入克隆); + * · 不可切/無法定位切點/整元素即該詞元 → 回 el 本身(綁整個元素,退化但安全)。 + * 切出之克隆標 splitoff,由 revertPass 併回;故 render→revert 還原一致(I6)。 + */ +export function isolateBoundaryToken( + el: Element, + side: "head" | "tail", + anyCjk: RegExp, + pass: string, +): Element +{ + if (!canSplit(el)) { return el; } + const text = side === "tail" ? lastText(el) : firstText(el); + if (!text) { return el; } + + const point: Point = side === "tail" + ? { textNode: text, offset: trailingTokenStart(text.data, anyCjk) } + : { textNode: text, offset: leadingTokenEnd(text.data, anyCjk) }; + + const clone = splitTreeAfter(el, point.textNode, point.offset, pass); + if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。 + return side === "tail" ? clone : el; +} + +/** + * 把 el 之**指定切點**(精確字位)起至末尾剝入克隆並回傳(供 orphan:自實義字綁至塊末)。 + * 不可切或切點在最起點 → 回 null(呼叫端綁整個直接子)。 + */ +export function splitElementAt( + el: Element, + textNode: Text, + offset: number, + pass: string, +): Element | null +{ + if (!canSplit(el)) { return null; } + return splitTreeAfter(el, textNode, offset, pass); +} diff --git a/src/typeset/jinze.ts b/src/typeset/jinze.ts index 2866538..c0f1640 100644 --- a/src/typeset/jinze.ts +++ b/src/typeset/jinze.ts @@ -12,6 +12,7 @@ import { bdClassOf, createJz, revertPass } from "../core/dom.js"; import type { Finder } from "../core/finder.js"; +import { isolateBoundaryToken } from "../core/split.js"; import type { RenderContext, StandalonePass } from "../types.js"; const PASS = "jinze"; @@ -110,10 +111,22 @@ function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void for (let i = 0; i < n; i += 1) { const u = units[i]!; - if (u.nodeType !== 3) { continue; } const needFirst = i > 0 && forbidden[i - 1]!; const needLast = i < n - 1 && forbidden[i]!; - if (needFirst || needLast) { splitBoundaries(u as Text, needFirst, needLast, anyCjk); } + if (!needFirst && !needLast) { continue; } + if (u.nodeType === 3) + { + splitBoundaries(u as Text, needFirst, needLast, anyCjk); + } + else if (u.nodeType === 1) + { + // 元素單元(樣式行內如 )最小綁定(下游 Bug:整元素入 nowrap inline-block + // 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先 + // tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素退化整綁。 + const el = u as Element; + if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS); } + if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS); } + } } // 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。 diff --git a/src/typeset/jiya.ts b/src/typeset/jiya.ts index 0ac859a..9930f4a 100644 --- a/src/typeset/jiya.ts +++ b/src/typeset/jiya.ts @@ -158,6 +158,7 @@ export const jiyaAdjacencyPass: StandalonePass = { if (prev) { squeeze(prev, el); } prev = el; // jz-char 為原子,不下探(內含 jz-inner) } + else if (finder.pillMatches(el)) { prev = null; } // pill(code/kbd):斷相鄰、不下探(其內容對排版不可見,不應被當透明行內穿越) else if (finder.isAvoided(el)) { prev = null; } // avoid/skip 子樹:斷、不下探 else if (blockSet.has(nm)) { diff --git a/src/typeset/lineedge.ts b/src/typeset/lineedge.ts index f99eda7..330d51c 100644 --- a/src/typeset/lineedge.ts +++ b/src/typeset/lineedge.ts @@ -62,9 +62,20 @@ function uninstallLayout(root: Element, key: string): void if (ro) { ro.disconnect(); m.delete(key); } } -// 我們的行內包裹:跨越它們找相鄰流內容(jz-char 可能被 jz-jinze 包住,其流 -// 鄰居在群組之外)。遇 block 邊界即止(回 null,視為行端)。 -const INLINE_WRAP = new Set([ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK" ]); +// 行內包裹集:edgeRect 攀爬時須跨越「不中斷行內流」之元素找相鄰內容(jz-char 可能被 +// jz-jinze 包住,其流鄰居在群組外)。遇 block 邊界即止(回 null,視為行端)。 +// **由 options.finder.styleInlines 派生**+聚珍自身之 jz 包裹標籤——舊版硬編碼僅含 +// SPAN/STRONG/EM/A/B/I/U/MARK 子集,遇未列之樣式行內(S/INS/DEL/SUB/SUP/SMALL/ +// ABBR/CITE/DFN/Q/VAR/SAMP/TIME/BDO/BDI/RUBY 等)會提前止步、誤判行端而錯加行端 +// 半形(透明性不一致之同根隱患 T4)。 +const JZ_WRAP_TAGS = [ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS" ]; + +function buildWrapSet(options: ResolvedOptions): Set +{ + const s = new Set(JZ_WRAP_TAGS); + for (const name of options.finder.styleInlines) { s.add(name); } + return s; +} function rectOf(n: Node, side: "prev" | "next"): DOMRect | null { @@ -90,7 +101,12 @@ function rectOf(n: Node, side: "prev" | "next"): DOMRect | null /** 取某節點側邊界字元之矩形(橫排:判定相鄰內容是否同行);跨我們的行內包裹 * 向上攀爬,遇 block 邊界回 null(視為行端)。 */ -function edgeRect(start: Node | null, side: "prev" | "next", from: Node): DOMRect | null +function edgeRect( + start: Node | null, + side: "prev" | "next", + from: Node, + wrapSet: Set, +): DOMRect | null { let n = start; let parentRef: Node | null = from.parentNode; @@ -104,7 +120,7 @@ function edgeRect(start: Node | null, side: "prev" | "next", from: Node): DOMRec } // 本層無內容:若父為行內包裹,攀出續找其鄰居。 if (!parentRef || parentRef.nodeType !== 1) { return null; } - if (!INLINE_WRAP.has((parentRef as Element).nodeName)) { return null; } + if (!wrapSet.has((parentRef as Element).nodeName)) { return null; } n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling; parentRef = parentRef.parentNode; } @@ -189,6 +205,7 @@ interface EdgeDecision { el: Element; edge: "head" | "tail"; mode: LineEndMode; function relayout(root: Element, finder: Finder, options: ResolvedOptions): void { clearEdge(root); + const wrapSet = buildWrapSet(options); const chars = Array.from(root.querySelectorAll("jz-char")).filter( (c) => finder.inScope(c) && !finder.isAvoided(c), @@ -219,12 +236,12 @@ function relayout(root: Element, finder: Finder, options: ResolvedOptions): void if (isHead) { - const prev = edgeRect(el.previousSibling, "prev", el); + const prev = edgeRect(el.previousSibling, "prev", el, wrapSet); if (!prev || higherLine(r, prev)) { decisions.push({ el, edge: "head", mode }); } } else { - const next = edgeRect(el.nextSibling, "next", el); + const next = edgeRect(el.nextSibling, "next", el, wrapSet); if (!next || lowerLine(r, next)) { decisions.push({ el, edge: "tail", mode }); } } } @@ -259,7 +276,7 @@ export const lineEdgePass: StandalonePass = { const TRIM = "jz-hws-trim"; -function trimGaps(root: Element, finder: Finder): void +function trimGaps(root: Element, finder: Finder, wrapSet: Set): void { const gaps = Array.from(root.querySelectorAll("jz-hws")).filter( (g) => finder.inScope(g) && !finder.isAvoided(g) && finder.levelAllows(g, "paragraph"), @@ -285,7 +302,7 @@ function trimGaps(root: Element, finder: Finder): void for (const g of gaps) { const r = lastRect(g); - const next = r ? edgeRect(g.nextSibling, "next", g) : null; + const next = r ? edgeRect(g.nextSibling, "next", g, wrapSet) : null; const atLineEnd = !r || !next || next.top > r.top + EPS; if (!atLineEnd) { g.classList.remove(TRIM); } } @@ -300,7 +317,8 @@ export const gapTrimPass: StandalonePass = { render(ctx: RenderContext): void { if (!hasLayout()) { return; } - installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder)); + const wrapSet = buildWrapSet(ctx.options); + installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder, wrapSet)); }, revert(ctx: RenderContext): void { diff --git a/src/typeset/orphan.ts b/src/typeset/orphan.ts index 9d18408..d459b33 100644 --- a/src/typeset/orphan.ts +++ b/src/typeset/orphan.ts @@ -14,6 +14,7 @@ import { createJz, revertPass } from "../core/dom.js"; import type { Finder } from "../core/finder.js"; +import { splitElementAt } from "../core/split.js"; import { classifyBiaodian } from "../core/unicode.js"; import type { RenderContext, Ruleset, StandalonePass } from "../types.js"; @@ -96,8 +97,9 @@ function processBlock( const boundary = subst[subst.length - chars]!; let startChild: Node = boundary.directChild; - // 邊界落於裸文本直接子 → 精確切分(最小綁定);否則綁定整個直接子(含其內行內元素, - // 不切割,跨 jz-jinze 等攀爬)。 + // 邊界落於裸文本直接子 → 精確切分(最小綁定);落於行內元素內 → 克隆切分出 + // [邊界字..元素末](避免整個長元素入 nowrap 之 jz-orphan 致溢出容器);不可切之 + // 元素退化為綁定整個直接子(跨 jz-jinze 等攀爬,不切割)。 if (boundary.textNode && boundary.textNode === startChild) { if (boundary.offset > 0) @@ -106,6 +108,13 @@ function processBlock( } // offset === 0:實義字即文本起點,整個文本節點入綁,無需切分。 } + else if (boundary.textNode && startChild.nodeType === 1) + { + const clone = splitElementAt( + startChild as Element, boundary.textNode, boundary.offset, PASS, + ); + if (clone) { startChild = clone; } + } // 把 startChild 及其後全部直接子搬入 jz-orphan。 const orphan = createJz("jz-orphan", PASS, "wrap"); diff --git a/src/typeset/spacing.ts b/src/typeset/spacing.ts index e5a475e..f39bfe1 100644 --- a/src/typeset/spacing.ts +++ b/src/typeset/spacing.ts @@ -251,6 +251,9 @@ function processPills(root: Element, finder: Finder, pillSelector: string, pillN root.querySelectorAll(pillSelector).forEach((pill) => { if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; } + // per-node 功能閘(T6):data-juzhen-off="spacing" 等區域內之 pill 不補間隙 + // (與 Pass A 逐 block 之 featureEnabledFor 一致;舊版漏判)。 + if (!finder.featureEnabledFor(pill, PASS)) { return; } if (!pill.parentNode) { return; } const prev = finder.adjacentLogicalChar(pill, -1); diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 4f6bef5..803818a 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -128,6 +128,27 @@ test("spacing:pill(code)兩側與 CJK 相鄰插入 jz-hws", () => assert.equal(countTag(document.querySelector("p"), "jz-hws"), 2); }); +// T6:processPills 須過 per-node 功能閘——data-juzhen-off="spacing" 區域內 pill 不補間隙。 +test("spacing T6:data-juzhen-off=spacing 區域內 pill 邊界不補 jz-hws", () => +{ + const { document } = setupDom('

vim編輯

'); + createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body); + assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "spacing 停用區域內 pill 不補隙"); +}); + +// T5:jiyaAdjacency 須把 pill 當相鄰邊界(斷相鄰、不穿越)——pill 兩側之標點不應被當 +// 連續標點對而擠壓(pill 內容對排版不可見)。空 pill 才能凸顯此路徑(含文本之 pill 由 +// 其內文本天然斷相鄰)。 +test("jiya T5:pill 隔斷標點相鄰——,「不互擠(pill 斷相鄰)", () => +{ + const { document } = setupDom('

甲,「乙」

'); + createJuzhen({ spacing: false, longWord: false, jinze: false, lang: { style: "quanjiao" } }) + .render(document.body); + const comma = document.querySelector("jz-char.bd-pause"); // , + assert.ok(comma, ",已 charify"); + assert.ok(!comma.classList.contains("jz-half"), ",與「之間隔 pill → 不視為相鄰、不擠壓"); +}); + // ----- 標點擠壓(jiya)----- test("jiya:標點 charify 為 jz-char>jz-inner,標 bd-* class,textContent 不變(copy-clean)", () => @@ -394,6 +415,58 @@ test("jinze 避頭:孤立逗號亦綁前字(逗號不可置行首)", () => assert.equal(group.textContent, "文,", "前字 文 + , 綁定(中 在外可斷)"); }); +// 行內樣式元素最小綁定(下游 Bug:整個 入 nowrap inline-block 致行末整體掉行)。 +// 邊界詞元經 core/split 克隆切分,只綁末/首詞元,餘下留原位(splitoff 克隆)。 +test("jinze 元素最小綁定:長串,只綁末字+逗號(餘下留原位)", () => +{ + const { document } = setupDom('

前文粗体文字,后续

'); + createJuzhen({ spacing: false, longWord: false }).render(document.body); + const p = document.querySelector("p"); + const group = p.querySelector("jz-jinze"); + assert.ok(group, "應有綁定群組"); + assert.equal(group.textContent, "字,", "只綁 strong 末字「字」+逗號(非整個 strong)"); + const splitoff = p.querySelector('strong[data-jz-kind="splitoff"]'); + assert.ok(splitoff && splitoff.textContent === "字", "切出之克隆持末字「字」"); + const orig = Array.from(p.querySelectorAll("strong")) + .find((s) => !s.hasAttribute("data-jz-kind")); + assert.ok(orig && orig.textContent === "粗体文", "原 strong 餘「粗体文」留原位"); + assert.equal(p.textContent, "前文粗体文字,后续", "textContent 不變"); +}); + +test("jinze 元素最小綁定:「引文 只綁開引號+首字", () => +{ + const { document } = setupDom('

他說「引文內容結束

'); + createJuzhen({ spacing: false, longWord: false }).render(document.body); + const p = document.querySelector("p"); + const group = p.querySelector("jz-jinze"); + assert.ok(group, "應有綁定群組"); + assert.equal(group.textContent, "「引", "開引號「綁 strong 首字「引」(非整個 strong)"); + assert.equal(p.textContent, "他說「引文內容結束", "textContent 不變"); +}); + +test("jinze 元素最小綁定 revert:還原為單一 ,文字回原樣", () => +{ + const { document } = setupDom('

前文粗体文字,后续

'); + const jz = createJuzhen({ spacing: false, longWord: false }); + jz.render(document.body); + jz.revert(document.body); + const p = document.querySelector("p"); + assert.equal(countTag(p, "jz-jinze"), 0, "無殘留 jz-jinze"); + assert.equal(p.querySelectorAll("strong").length, 1, "兩個 strong 併回單一"); + assert.equal(p.querySelector("strong").textContent, "粗体文字", "strong 內容還原"); + assert.equal(p.textContent, "前文粗体文字,后续"); +}); + +test("jinze 不可切元素退化整綁: 連結整體綁定(不克隆出兩個錨點)", () => +{ + const { document } = setupDom('

链接文字,详情

'); + createJuzhen({ spacing: false, longWord: false }).render(document.body); + const p = document.querySelector("p"); + assert.equal(p.querySelectorAll("a").length, 1, "連結不被切分(仍單一 )"); + const group = p.querySelector("jz-jinze"); + assert.ok(group && group.querySelector("a"), "整個 入綁(退化但安全)"); +}); + test("jinze:revert 還原(無 jz-jinze/jz-char,文字回原樣)", () => { const { document } = setupDom('

他說好。」嗎

'); @@ -473,6 +546,41 @@ test("1b:CJK↔CJK 不插 jz-hws(僅剝空格,無留隙)", () => assert.equal(document.querySelector("p").textContent, "事件元件"); }); +// pill 左鄰為標點(經 jinze 包入 jz-char/jz-jinze):不應於標點前補間隙。 +// 根因:adjacentLogicalChar 之 descend 舊版跳過不透明之 jz-char、越過標點取到更前之 +// 漢字,誤包 jz-hws;修復=jz-char/jz-inner 透明,descend 回傳標點(非 pillNeighbor)。 +test("pill 左鄰標點:汉字。 不於「字」「。」間補 jz-hws(jz-char 透明)", () => +{ + const { document } = setupDom("

汉字。x

"); + createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body); + const p = document.querySelector("p"); + assert.equal(countTag(p, "jz-hws"), 0, "pill 左鄰為句號(非 pillNeighbor),不補間隙"); +}); + +test("pill 左鄰漢字(無標點):汉字 仍於「字」補 jz-hws", () => +{ + const { document } = setupDom("

汉字x

"); + createJuzhen({ lang: { default: "zh-Hans" } }).render(document.body); + const hws = document.querySelector("jz-hws"); + assert.ok(hws && hws.textContent === "字", "pill 左鄰為漢字,正常補間隙(修復無回歸)"); +}); + +test("pill 右鄰標點:x。汉 不於 pill 與句號間補 jz-hws", () => +{ + const { document } = setupDom("

x。汉字

"); + createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body); + const p = document.querySelector("p"); + assert.equal(countTag(p, "jz-hws"), 0, "pill 右鄰為句號(非 pillNeighbor),不補間隙"); +}); + +test("pill 左鄰句內點號:事项: 不補 jz-hws(bd-pause 同理)", () => +{ + const { document } = setupDom("

事项:x

"); + createJuzhen({ lang: { default: "zh-Hans", style: "quanjiao" } }).render(document.body); + const p = document.querySelector("p"); + assert.equal(countTag(p, "jz-hws"), 0, "pill 左鄰為冒號(非 pillNeighbor),不補間隙"); +}); + test("問題二:justifyAtoms:false → root 設 data-jz-atoms=inline,revert 清除", () => { const { document } = setupDom("

测试,(甲)。

"); @@ -783,6 +891,40 @@ test("垂懸字:段落級——data-jz-level=text 子樹跳過", () => assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "text-level 不跑垂懸字"); }); +// 邊界實義字落於可切行內元素內 → 克隆切分出 [邊界字..元素末],避免整個長元素入 +// nowrap 之 jz-orphan(可溢出容器)。 +test("垂懸字 元素最小綁定:段末長 只綁末 N 實義字(非整個元素)", () => +{ + const { document } = setupDom( + '

這是比較長的開頭文字而結尾為一段很長的加粗文字

', + ); + createJuzhen().render(document.body); + const p = document.querySelector("p"); + const orphan = p.querySelector("jz-orphan"); + assert.ok(orphan, "應產生 jz-orphan"); + assert.equal(orphan.textContent, "文字", "只綁 strong 末 2 實義字「文字」"); + const splitoff = p.querySelector('strong[data-jz-kind="splitoff"]'); + assert.ok(splitoff && splitoff.textContent === "文字", "切出克隆持末 2 字"); + const orig = Array.from(p.querySelectorAll("strong")) + .find((s) => !s.hasAttribute("data-jz-kind")); + assert.ok(orig && orig.textContent === "一段很長的加粗", "原 strong 餘部留原位(未入 orphan)"); + assert.equal(p.textContent, "這是比較長的開頭文字而結尾為一段很長的加粗文字", "textContent 不變"); +}); + +test("垂懸字 元素最小綁定 revert:兩個 併回單一、文字還原", () => +{ + const { document } = setupDom( + '

這是比較長的開頭文字而結尾為一段很長的加粗文字

', + ); + const jz = createJuzhen(); + jz.render(document.body); + jz.revert(document.body); + const p = document.querySelector("p"); + assert.equal(countTag(p, "jz-orphan"), 0, "無殘留 jz-orphan"); + assert.equal(p.querySelectorAll("strong").length, 1, "克隆併回單一 strong"); + assert.equal(p.querySelector("strong").textContent, "一段很長的加粗文字", "strong 還原"); +}); + // ----- 標點懸掛(§6.5.2,行端模式接線;版面凸出量於 headless Chrome 驗證)----- test("懸掛:已全面停用——hanging 預設關,行端模式 squeeze", () =>