fix: 行內元素三分類(透明/隔離/整體)——jinze 下潛肢解 KaTeX 修復(下游問題六);v1 相容層移除
下游問題六:行內公式緊鄰禁則標點時,jinze 為綁鄰字透明下潛 .katex(全樹皆 SPAN,
僅憑標籤名判透明),把公式內側末字連同完整殼層逐層克隆切出(splitoff),上標漂移
錯位;pillSelector/skipAttribute/scope.avoid 三層防護全被刺穿。復現另揭兩向量:
相鄰 pill 時 descend 刺穿致 jz-hws 插入公式內部;pill 內部標點被 jiya charify。
確立三分類(§4.1,所有遍歷原語共用同一判定):
透明(styleInlines+jz-*)— 穿越/run 延續/可克隆切分;
隔離(isolateSelector,預設 "mark",新增選項)— 邊界斷 run/斷相鄰/不可切分、
兩端補隙(同 pill),內部照常處理(自成脈絡);
整體(pillSelector)— 內部對一切 pass 不可見(insidePill 過濾),禁則時作為
原子鄰字單位整體綁入 jz-jinze。
機制修復:
finder — transparentInline 改以元素判定(先排除 pill/isolate/avoid 再查標籤集);
avoidsSelf 自身判定(isAvoided 僅查祖先,自身命中 avoid 曾被穿越);descend 三態
(邊界字/無內容續找兄弟/blocked 終止——不可越界跳找更遠之字);eachTextNode
過濾 pill 內部;collectRuns 隔離元素內部自成 run。
core/split — canSplit 諮詢 finder(pill/isolate/avoid 不可切 → 退化整綁);
MARK 移出 SPLITTABLE(背景盒切分有可見接縫)。
jiyaAdjacency — pill/avoid(自身判定)斷相鄰不下探;isolate 斷而內部自成脈絡。
spacing Pass B — 邊界選擇器擴為 pill ∪ isolate;巢於 pill 內者跳過。
orphan — avoidsSelf 邊界;avoid 塊不綁(舊漏判:avoid 子樹內裸文本曾被計數包綁)。
v1 相容層移除(消費端已全數遷移 v2 API):src/compat/v1.ts、IIFE 全域別名
createCjkAutospace、死選項 autospaceClass(經查從未被任何 pass 讀取)一併刪;
I8 退役。dist/compat 殘留聲明清除、dist/core/split.d.ts 補入庫(前次遺漏)。
測試 112 全綠(問題六 ×7、isolate ×6、compat 移除 ×1);headless Chrome 實證
公式完整(splitoff=0、內部無 jz-hws/jz-char)、mark 與公式整體入 jz-jinze。
文檔:README 三分類節+選項示例+功能表(hanging 行補「已移除」);ARCHITECTURE
§4.1 三分類/§3.5 相容層移除/§6.1/§6.3/§6.4/I8 退役。
This commit is contained in:
@@ -1,96 +0,0 @@
|
||||
// v1 向下相容層(架構 §3.5,I8)。
|
||||
//
|
||||
// 導出 createCjkAutospace(options),把 v1 API 映射到 createJuzhen,保證既有
|
||||
// 消費端(build.sh 單檔串接 / Nvim peek / Claude 模板)不 break、無功能倒退。
|
||||
// 行為等價但不要求逐位一致——主要差異:
|
||||
// - 間隙標記由 <span class="cjk-autospace"> 變為 <jz-hws class="cjk-autospace">
|
||||
// (仍帶原 class,故既有 `.cjk-autospace{user-select:none}` CSS 仍命中)。
|
||||
// - 擠壓由消費端 `font-feature-settings:"halt"` 改為 juzhen.css 之負 margin
|
||||
// (消費端須改用 juzhen.css;機制不同但同為「句內點號半形」之開明式效果)。
|
||||
// 上述差異於 README 明列。
|
||||
|
||||
import { createJuzhen } from "../index.js";
|
||||
import type { JuzhenOptions, LongWordOptions } from "../types.js";
|
||||
|
||||
interface V1LongWord
|
||||
{
|
||||
minLength?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
interface V1Squeeze
|
||||
{
|
||||
squeezeClass?: string;
|
||||
marks?: string[];
|
||||
langWhitelist?: string[];
|
||||
}
|
||||
|
||||
export interface V1Options
|
||||
{
|
||||
styleInlines?: string[];
|
||||
blockTags?: string[];
|
||||
skipTags?: string[];
|
||||
pillSelector?: string;
|
||||
autospaceClass?: string;
|
||||
skipAttribute?: string;
|
||||
isSkipped?: (node: Node) => boolean;
|
||||
longWordWrap?: boolean | V1LongWord;
|
||||
punctuationSqueeze?: boolean | V1Squeeze;
|
||||
}
|
||||
|
||||
export interface V1Instance
|
||||
{
|
||||
apply(root?: Element): void;
|
||||
revert(root?: Element): void;
|
||||
}
|
||||
|
||||
function mapLongWord(v: boolean | V1LongWord | undefined): boolean | LongWordOptions
|
||||
{
|
||||
if (v === false) { return false; }
|
||||
if (v === undefined || v === true || v === null) { return true; }
|
||||
const out: LongWordOptions = {};
|
||||
if (typeof v.minLength === "number") { out.minLength = v.minLength; }
|
||||
if (typeof v.lang === "string") { out.lang = v.lang; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 相容工廠。回傳 { apply, revert };apply→render、revert→revert。
|
||||
*/
|
||||
export function createCjkAutospace(options: V1Options = {}): V1Instance
|
||||
{
|
||||
const o = options || {};
|
||||
|
||||
const mapped: JuzhenOptions = {
|
||||
// finder 設定直通。
|
||||
...(o.styleInlines ? { styleInlines: o.styleInlines } : {}),
|
||||
...(o.blockTags ? { blockTags: o.blockTags } : {}),
|
||||
...(o.skipTags ? { skipTags: o.skipTags } : {}),
|
||||
pillSelector: o.pillSelector || "code, kbd",
|
||||
autospaceClass: o.autospaceClass || "cjk-autospace",
|
||||
skipAttribute: o.skipAttribute || "data-md-key",
|
||||
...(o.isSkipped ? { isSkipped: o.isSkipped } : {}),
|
||||
|
||||
spacing: true,
|
||||
longWord: mapLongWord(o.longWordWrap),
|
||||
// v1 Pass D 不分 lang 地擠句內點號(開明式);compat 強制 kaiming
|
||||
// 政策以避免功能倒退(預設 zh-Hant→quanjiao 不擠壓)。
|
||||
jiya: o.punctuationSqueeze === false ? false : true,
|
||||
// v1 無連續標點綁定,保持 DOM 與 v1 一致:關閉 jinze。
|
||||
jinze: false,
|
||||
lang: {
|
||||
policy: {
|
||||
"zh-Hant": "kaiming",
|
||||
"zh-Hans": "kaiming",
|
||||
"ja": "kaiming",
|
||||
"other": "kaiming",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const jz = createJuzhen(mapped);
|
||||
return {
|
||||
apply(root?: Element): void { jz.render(root); },
|
||||
revert(root?: Element): void { jz.revert(root); },
|
||||
};
|
||||
}
|
||||
+97
-33
@@ -1,8 +1,15 @@
|
||||
// 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。
|
||||
//
|
||||
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集(跨樣式
|
||||
// 行內元素透明、pill/block/avoid 中斷)、pill 鄰字查找、per-node 功能閘
|
||||
// (featureEnabledFor)。finder 不知具體排版規則,只提供遍歷與安全變更原語。
|
||||
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集、pill 鄰字
|
||||
// 查找、per-node 功能閘(featureEnabledFor)。finder 不知具體排版規則,只提供
|
||||
// 遍歷與安全變更原語。
|
||||
//
|
||||
// 行內元素對排版有且僅有**三種口徑**(§4.1,所有遍歷原語共用同一判定):
|
||||
// 透明(styleInlines + jz-* wrap)— 穿越、run 延續、可克隆切分;
|
||||
// 隔離(isolateSelector,預設 mark)— 邊界斷 run/斷相鄰/不可切分、兩端補隙,
|
||||
// 內部照常處理(自成脈絡);
|
||||
// 整體(pillSelector)— 原子單位:內部對一切 pass 不可見,邊界同隔離。
|
||||
// avoid(skip 名單/屬性/選擇器)疊加其上:自身即邊界、內外皆不處理。
|
||||
|
||||
import type { FeatureLevel, ResolvedOptions } from "../types.js";
|
||||
|
||||
@@ -37,6 +44,7 @@ export class Finder
|
||||
private readonly blockSet: Set<string>;
|
||||
private readonly skipSet: Set<string>;
|
||||
private readonly pillSelector: string;
|
||||
private readonly isolateSelector: string;
|
||||
private readonly avoidSelector: string | null;
|
||||
private readonly includeSelector: string | null;
|
||||
private readonly skipAttr: string | null;
|
||||
@@ -52,6 +60,7 @@ export class Finder
|
||||
this.blockSet = opts.finder.blockTags;
|
||||
this.skipSet = opts.finder.skipTags;
|
||||
this.pillSelector = opts.finder.pillSelector;
|
||||
this.isolateSelector = opts.finder.isolateSelector;
|
||||
this.skipAttr = opts.finder.skipAttribute;
|
||||
this.userIsSkipped = opts.finder.isSkipped;
|
||||
this.levelTextSelector = opts.levelText;
|
||||
@@ -61,16 +70,40 @@ export class Finder
|
||||
this.levelCache = new WeakMap();
|
||||
}
|
||||
|
||||
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */
|
||||
/** 該元素是否匹配 pill 選擇器(三分類之「整體」:code/kbd/行內公式等)。 */
|
||||
pillMatches(el: Element): boolean
|
||||
{
|
||||
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
|
||||
}
|
||||
|
||||
/** 該元素是否匹配 isolate 選擇器(三分類之「隔離」:帶盒樣式之行內,如背景色
|
||||
* <mark>)。邊界口徑同 pill(斷 run/斷相鄰/不可切分/兩端補隙),內部照常處理。 */
|
||||
isolateMatches(el: Element): boolean
|
||||
{
|
||||
return !!(el.matches && this.isolateSelector && el.matches(this.isolateSelector));
|
||||
}
|
||||
|
||||
/** 節點是否位於 pill **內部**(僅查祖先,不含自身)。pill 為整體元素:內部對
|
||||
* 一切 pass 不可見——不 charify、不收 run、不切分、不於其內插間隙(下游問題六:
|
||||
* .katex 內部曾被 jiya/spacing 直接變更)。 */
|
||||
insidePill(node: Node): boolean
|
||||
{
|
||||
let p: Node | null = node.parentNode;
|
||||
while (p && p.nodeType === 1)
|
||||
{
|
||||
if (this.pillMatches(p as Element)) { return true; }
|
||||
p = p.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 邏輯文本應**透明穿越**之行內元素:樣式行內標籤,以及聚珍自身之 wrap 類
|
||||
* jz-*(jz-hws 間隙、jz-jinze 禁則群組、jz-char/jz-inner 標點原子)。穿越這些
|
||||
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
|
||||
* 鄰字仍須可達;I7)。
|
||||
* ⚠ 以**元素**而非標籤名判定:pill/isolate/avoid 縱使標籤名在樣式集亦為邊界、
|
||||
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而
|
||||
* 肢解;scope.avoid/skipAttribute 命中自身者同理)。
|
||||
* ⚠ jz-char/jz-inner 必須透明:否則 adjacentLogicalChar 之 descend 遇標點原子既
|
||||
* 不進入亦不終止、而是**跳過**它續找更前之兄弟(下游 Bug:`汉字。<code>` 經 jinze
|
||||
* 綁定為 `汉<jz-jinze>字<jz-char>。</jz-char></jz-jinze><code>`,pill 之左鄰字應
|
||||
@@ -78,31 +111,47 @@ export class Finder
|
||||
* jz-char 僅含標點(jiya 只 charify biaodian),標點非 pillNeighbor,故透明後 descend
|
||||
* 回傳標點 → 不補隙,與 collectRuns 之既有透明遞迴一致。jz-inner 亦須列入,否則
|
||||
* descend 進入 jz-char 後因 jz-inner 不透明而跳過其內標點。 */
|
||||
private isTransparentInline(name: string): boolean
|
||||
private transparentInline(el: Element): boolean
|
||||
{
|
||||
if (this.pillMatches(el) || this.isolateMatches(el) || this.avoidsSelf(el))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const name = el.nodeName;
|
||||
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)。 */
|
||||
/** 該元素**自身**是否為 avoid 邊界(內建 skip 名單 / SVG / contentEditable /
|
||||
* data-jz-skip / skipAttribute / avoid 選擇器 / isSkipped)。isAvoided 沿祖先
|
||||
* 鏈逐層呼叫本判定;遍歷原語另須對「正要進入」之元素直接判自身——舊版僅查
|
||||
* 祖先,元素自身命中 avoid 時被當透明穿越(下游問題六:scope.avoid 對 .katex
|
||||
* 形同虛設)。 */
|
||||
avoidsSelf(el: Element): boolean
|
||||
{
|
||||
if (this.userIsSkipped && this.userIsSkipped(el)) { return true; }
|
||||
if (this.skipSet.has(el.nodeName)) { return true; }
|
||||
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
|
||||
if ((el as HTMLElement).isContentEditable) { return true; }
|
||||
if (el.hasAttribute("data-jz-skip")) { return true; }
|
||||
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
|
||||
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 節點是否處於 avoid 子樹(祖先鏈上任一元素 avoidsSelf;自身不計——元素
|
||||
* 自身之邊界判定由遍歷原語以 avoidsSelf 執行)。 */
|
||||
isAvoided(node: Node): boolean
|
||||
{
|
||||
if (this.userIsSkipped && this.userIsSkipped(node)) { return true; }
|
||||
let p: Node | null = node.parentNode;
|
||||
while (p && p.nodeType === 1)
|
||||
{
|
||||
const el = p as Element;
|
||||
if (this.skipSet.has(el.nodeName)) { return true; }
|
||||
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
|
||||
if ((el as HTMLElement).isContentEditable) { return true; }
|
||||
if (el.hasAttribute("data-jz-skip")) { return true; }
|
||||
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
|
||||
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (this.avoidsSelf(p as Element)) { return true; }
|
||||
p = p.parentNode;
|
||||
}
|
||||
return false;
|
||||
@@ -119,7 +168,7 @@ export class Finder
|
||||
return !!(el.closest && el.closest(this.includeSelector));
|
||||
}
|
||||
|
||||
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */
|
||||
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / pill 內部 / 非作用域)。 */
|
||||
eachTextNode(root: Element, cb: (node: Text) => void): void
|
||||
{
|
||||
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
|
||||
@@ -130,7 +179,10 @@ export class Finder
|
||||
while (n)
|
||||
{
|
||||
const t = n as Text;
|
||||
if (!this.isAvoided(t) && this.inScope(t)) { nodes.push(t); }
|
||||
if (!this.isAvoided(t) && !this.insidePill(t) && this.inScope(t))
|
||||
{
|
||||
nodes.push(t);
|
||||
}
|
||||
n = walker.nextNode();
|
||||
}
|
||||
// 先收集後回呼:容許 cb 變更 DOM 而不擾動走訪。
|
||||
@@ -199,13 +251,21 @@ export class Finder
|
||||
const el = node as Element;
|
||||
// jz-hws 現包裹左側邊界字(以 margin-right 留隙)→ 透明遞迴,使其內
|
||||
// 之字仍計入邏輯 run;冪等改由「該字已在 jz-hws 內則不重複包裝」保證。
|
||||
if (this.isAvoided(el)) { flush(); return; }
|
||||
if (this.skipSet.has(el.nodeName) || this.blockSet.has(el.nodeName))
|
||||
if (this.avoidsSelf(el) || this.blockSet.has(el.nodeName))
|
||||
{
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
if (this.pillMatches(el)) { flush(); return; }
|
||||
if (this.pillMatches(el)) { flush(); return; } // 整體:邊界斷 run、內部不入
|
||||
if (this.isolateMatches(el))
|
||||
{
|
||||
// 隔離:邊界斷 run,內部自成獨立 run(照常處理、不與外部相鄰)。
|
||||
flush();
|
||||
let ic = el.firstChild;
|
||||
while (ic) { visit(ic); ic = ic.nextSibling; }
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
// 樣式行內 / 未知行內 / wrap 類 jz-* / span[lang]:透明遞迴。
|
||||
let c = el.firstChild;
|
||||
while (c) { visit(c); c = c.nextSibling; }
|
||||
@@ -236,7 +296,10 @@ export class Finder
|
||||
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
|
||||
};
|
||||
|
||||
const descend = (into: Element): AdjacentChar | null =>
|
||||
// descend 三態:邊界字/null(子樹無內容,續找兄弟)/"blocked"(遇 pill/
|
||||
// isolate/avoid/block 邊界——邏輯鄰居非「字」,終止整個查找)。不可把邊界
|
||||
// 當無內容跳過續找更遠之字:那是「越過 jz-char 取更前字 → 誤隙」之同型錯誤。
|
||||
const descend = (into: Element): AdjacentChar | "blocked" | null =>
|
||||
{
|
||||
let n: Node | null = direction === -1 ? into.lastChild : into.firstChild;
|
||||
while (n)
|
||||
@@ -245,10 +308,11 @@ export class Finder
|
||||
{
|
||||
return pickEnd(n as Text);
|
||||
}
|
||||
if (n.nodeType === 1 && this.isTransparentInline((n as Element).nodeName))
|
||||
if (n.nodeType === 1)
|
||||
{
|
||||
if (!this.transparentInline(n as Element)) { return "blocked"; }
|
||||
const r = descend(n as Element);
|
||||
if (r) { return r; }
|
||||
if (r) { return r; } // 字或 blocked 皆上拋
|
||||
}
|
||||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
@@ -270,19 +334,19 @@ export class Finder
|
||||
if (n.nodeType === 1)
|
||||
{
|
||||
const e = n as Element;
|
||||
if (this.isAvoided(e)) { return null; }
|
||||
if (this.isTransparentInline(e.nodeName))
|
||||
if (!this.transparentInline(e))
|
||||
{
|
||||
const r = descend(e);
|
||||
if (r) { return r; }
|
||||
n = direction === -1 ? e.previousSibling : e.nextSibling;
|
||||
continue;
|
||||
return null; // pill/isolate/avoid/block — 邊界,止。
|
||||
}
|
||||
return null; // pill 或 block — 止。
|
||||
const r = descend(e);
|
||||
if (r === "blocked") { return null; }
|
||||
if (r) { return r; }
|
||||
n = direction === -1 ? e.previousSibling : e.nextSibling;
|
||||
continue;
|
||||
}
|
||||
n = direction === -1 ? n.previousSibling : n.nextSibling;
|
||||
}
|
||||
if (!p || p.nodeType !== 1 || !this.isTransparentInline((p as Element).nodeName))
|
||||
if (!p || p.nodeType !== 1 || !this.transparentInline(p as Element))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
+37
-19
@@ -13,16 +13,22 @@
|
||||
// 包 wrap、後併 splitoff,確保前兄弟為原元素而非 wrap 殼;I6 還原一致)。
|
||||
//
|
||||
// 僅純樣式行內標籤可切(白名單);帶語義/引用者(A 連結會克隆出兩個錨點、ABBR 之
|
||||
// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。
|
||||
// title、RUBY 結構、帶 id 者)一律回退整元素綁定——退化但安全。可切性另須諮詢
|
||||
// finder 三分類(§4.1):pill(整體,如 .katex——KaTeX 全樹皆 SPAN,僅憑標籤名會
|
||||
// 整棵下潛肢解;下游問題六)、isolate(隔離,帶盒樣式切分有可見接縫)、avoid 自身
|
||||
// 命中者,皆不可切——回退整元素綁定。
|
||||
|
||||
import type { Finder } from "./finder.js";
|
||||
|
||||
const PASS_ATTR = "data-jz";
|
||||
const KIND_ATTR = "data-jz-kind";
|
||||
|
||||
// 可安全克隆切分之純樣式行內標籤(切分不改變語義、相鄰兩份視覺無縫)。
|
||||
// 刻意排除:A(克隆出兩個連結)、ABBR/Q(title/引號語義重複)、RUBY 系(結構性)、
|
||||
// 帶 id 者(id 重複)——皆回退整元素綁定。
|
||||
// MARK(盒樣式:背景色切分有可見接縫;預設亦列為 isolate)、帶 id 者(id 重複)——
|
||||
// 皆回退整元素綁定。
|
||||
const SPLITTABLE = new Set([
|
||||
"STRONG", "EM", "B", "I", "U", "S", "SPAN", "MARK",
|
||||
"STRONG", "EM", "B", "I", "U", "S", "SPAN",
|
||||
"SMALL", "INS", "DEL", "SUB", "SUP",
|
||||
]);
|
||||
|
||||
@@ -32,22 +38,26 @@ interface Point
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** 元素是否可克隆切分(純樣式行內標籤、無 id)。 */
|
||||
function canSplit(el: Element): boolean
|
||||
/** 元素是否可克隆切分(純樣式行內標籤、無 id、非 pill/isolate/avoid 邊界)。 */
|
||||
function canSplit(el: Element, finder: Finder): boolean
|
||||
{
|
||||
return SPLITTABLE.has(el.nodeName) && !el.hasAttribute("id");
|
||||
return SPLITTABLE.has(el.nodeName)
|
||||
&& !el.hasAttribute("id")
|
||||
&& !finder.pillMatches(el)
|
||||
&& !finder.isolateMatches(el)
|
||||
&& !finder.avoidsSelf(el);
|
||||
}
|
||||
|
||||
/** el 內**首**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
|
||||
function firstText(el: Element): Text | null
|
||||
function firstText(el: Element, finder: Finder): 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 (!canSplit(c as Element, finder)) { return null; }
|
||||
const r = firstText(c as Element, finder);
|
||||
if (r) { return r; }
|
||||
}
|
||||
}
|
||||
@@ -55,15 +65,15 @@ function firstText(el: Element): Text | null
|
||||
}
|
||||
|
||||
/** el 內**末**個非空文本節點(僅下探可切標籤;遇非可切元素回 null=放棄切分)。 */
|
||||
function lastText(el: Element): Text | null
|
||||
function lastText(el: Element, finder: Finder): 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 (!canSplit(c as Element, finder)) { return null; }
|
||||
const r = lastText(c as Element, finder);
|
||||
if (r) { return r; }
|
||||
}
|
||||
}
|
||||
@@ -102,10 +112,16 @@ function trailingTokenStart(d: string, anyCjk: RegExp): number
|
||||
* 沿祖先鏈逐層剝離:每層把「切點側之首節點及其後兄弟」搬入該層元素之淺克隆,空克隆
|
||||
* 跳過(避免殘留空元素破壞 revert 還原)。
|
||||
*/
|
||||
function splitTreeAfter(top: Element, textNode: Text, offset: number, pass: string): Element | null
|
||||
function splitTreeAfter(
|
||||
top: Element,
|
||||
textNode: Text,
|
||||
offset: number,
|
||||
pass: string,
|
||||
finder: Finder,
|
||||
): Element | null
|
||||
{
|
||||
// 切點位於 top 最起點(首文本節點之 offset 0)→ 左側無內容,不切。
|
||||
if (offset <= 0 && firstText(top) === textNode) { return null; }
|
||||
if (offset <= 0 && firstText(top, finder) === textNode) { return null; }
|
||||
|
||||
// 該文本層之右側首節點。
|
||||
let rightAtLevel: Node | null;
|
||||
@@ -175,17 +191,18 @@ export function isolateBoundaryToken(
|
||||
side: "head" | "tail",
|
||||
anyCjk: RegExp,
|
||||
pass: string,
|
||||
finder: Finder,
|
||||
): Element
|
||||
{
|
||||
if (!canSplit(el)) { return el; }
|
||||
const text = side === "tail" ? lastText(el) : firstText(el);
|
||||
if (!canSplit(el, finder)) { return el; }
|
||||
const text = side === "tail" ? lastText(el, finder) : firstText(el, finder);
|
||||
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);
|
||||
const clone = splitTreeAfter(el, point.textNode, point.offset, pass, finder);
|
||||
if (!clone) { return el; } // 整元素即邊界詞元 → 綁整個 el。
|
||||
return side === "tail" ? clone : el;
|
||||
}
|
||||
@@ -199,8 +216,9 @@ export function splitElementAt(
|
||||
textNode: Text,
|
||||
offset: number,
|
||||
pass: string,
|
||||
finder: Finder,
|
||||
): Element | null
|
||||
{
|
||||
if (!canSplit(el)) { return null; }
|
||||
return splitTreeAfter(el, textNode, offset, pass);
|
||||
if (!canSplit(el, finder)) { return null; }
|
||||
return splitTreeAfter(el, textNode, offset, pass, finder);
|
||||
}
|
||||
|
||||
+5
-4
@@ -4,8 +4,8 @@
|
||||
// jz.render(root?); // 套用已啟用之 pass
|
||||
// jz.revert(root?); // 還原
|
||||
//
|
||||
// 全域(IIFE):window.Juzhen.createJuzhen(...);並掛 v1 相容別名
|
||||
// globalThis.createCjkAutospace(見 build.mjs footer 與 compat/v1.ts)。
|
||||
// 全域(IIFE):window.Juzhen.createJuzhen(...)。
|
||||
// v1 相容層(createCjkAutospace)已隨最後一批 v1 消費端遷移完成而移除。
|
||||
|
||||
import { Finder } from "./core/finder.js";
|
||||
import { runPasses, revertPasses } from "./core/pass.js";
|
||||
@@ -20,7 +20,6 @@ import type { BiasTable, JiyaOptions, JuzhenOptions, LongWordOptions, OrphanOpti
|
||||
|
||||
export type { JuzhenOptions, ResolvedOptions, Pass };
|
||||
export type { CharClassOverride, BdSubclass, Ruleset, Bias, BiasTable } from "./types.js";
|
||||
export { createCjkAutospace } from "./compat/v1.js";
|
||||
|
||||
const DEFAULT_STYLE_INLINES = [
|
||||
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
|
||||
@@ -95,7 +94,6 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
|
||||
policy: langOpt.policy || {},
|
||||
},
|
||||
ruleset: resolveRuleset(opts.charClass),
|
||||
autospaceClass: opts.autospaceClass || "",
|
||||
justifyAtoms: opts.justifyAtoms === false ? false : true,
|
||||
levelText: (opts.level && opts.level.text) || null,
|
||||
scope: {
|
||||
@@ -125,6 +123,9 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
|
||||
blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS),
|
||||
skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS),
|
||||
pillSelector: opts.pillSelector || "code, kbd",
|
||||
// 內外隔離(§4.1 三分類):預設 <mark>(瀏覽器預設帶背景,切分有可見
|
||||
// 接縫、邊界需留隙)。顯式判 undefined,使 "" 可停用。
|
||||
isolateSelector: opts.isolateSelector !== undefined ? opts.isolateSelector : "mark",
|
||||
skipAttribute: opts.skipAttribute || null,
|
||||
isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null,
|
||||
},
|
||||
|
||||
+12
-5
@@ -66,9 +66,6 @@ export interface JuzhenOptions
|
||||
text?: string;
|
||||
};
|
||||
|
||||
/** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */
|
||||
autospaceClass?: string;
|
||||
|
||||
/** justify 原子化:jz-char/jz-jinze 是否以 inline-block 作對齊單一原子
|
||||
* (預設 true)。設 false 改 display:inline——用於分頁器(如 Paged.js)之
|
||||
* 對齊引擎會把每個 inline-block 邊界當伸縮點、與 CJK inter-ideograph 疊加
|
||||
@@ -99,11 +96,21 @@ export interface JuzhenOptions
|
||||
/** 分塊功能選擇(§3.6)。 */
|
||||
blocks?: BlockRule[];
|
||||
|
||||
// ----- v1 相容用之 finder 設定 -----
|
||||
// ----- finder 設定(行內元素三分類,§4.1)-----
|
||||
//
|
||||
// 行內元素對排版有且僅有三種口徑:
|
||||
// 透明(styleInlines) — 遍歷穿越、run 延續、可克隆切分(<strong>/<em> 等純樣式);
|
||||
// 隔離(isolateSelector)— 兩端如 pill 補間隙、斷 run/斷相鄰、不可切分,
|
||||
// **內部照常處理**(帶盒樣式者,如背景色 <mark>);
|
||||
// 整體(pillSelector) — 原子單位:內部對一切 pass 不可見,禁則整體綁定,
|
||||
// 兩端補間隙(code/kbd/行內公式 .katex 等)。
|
||||
styleInlines?: string[];
|
||||
blockTags?: string[];
|
||||
skipTags?: string[];
|
||||
/** 整體(pill)元素選擇器(預設 "code, kbd")。 */
|
||||
pillSelector?: string;
|
||||
/** 內外隔離元素選擇器(預設 "mark")。傳 "" 停用(mark 回歸透明)。 */
|
||||
isolateSelector?: string;
|
||||
skipAttribute?: string;
|
||||
isSkipped?: (node: Node) => boolean;
|
||||
}
|
||||
@@ -114,7 +121,6 @@ export interface ResolvedOptions
|
||||
locale: LocaleConfig;
|
||||
/** 最終字符分類規則集(內置默認 + charClass 覆寫)。 */
|
||||
ruleset: Ruleset;
|
||||
autospaceClass: string;
|
||||
justifyAtoms: boolean;
|
||||
/** 功能分級之 text-level 選擇器(§6.5.0a);null 表示未設。 */
|
||||
levelText: string | null;
|
||||
@@ -144,6 +150,7 @@ export interface ResolvedOptions
|
||||
blockTags: Set<string>;
|
||||
skipTags: Set<string>;
|
||||
pillSelector: string;
|
||||
isolateSelector: string;
|
||||
skipAttribute: string | null;
|
||||
isSkipped: ((node: Node) => boolean) | null;
|
||||
};
|
||||
|
||||
@@ -122,10 +122,12 @@ function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void
|
||||
{
|
||||
// 元素單元(樣式行內如 <strong>)最小綁定(下游 Bug:整元素入 nowrap inline-block
|
||||
// 致行末整體掉行):只隔離與 jz-char 相鄰側之邊界詞元,餘下留原位。雙側禁斷時先
|
||||
// tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素退化整綁。
|
||||
// tail 後 head——head 作用於切剩之左半(仍為原元素 u)。不可切之元素(含 pill/
|
||||
// isolate/avoid,由 canSplit 諮詢 finder 判定)退化整綁——pill 即「作為原子
|
||||
// 鄰字單位整體綁入 jz-jinze」之期望行為(下游問題六)。
|
||||
const el = u as Element;
|
||||
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS); }
|
||||
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS); }
|
||||
if (needLast) { isolateBoundaryToken(el, "tail", anyCjk, PASS, finder); }
|
||||
if (needFirst) { isolateBoundaryToken(el, "head", anyCjk, PASS, finder); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -158,8 +158,12 @@ 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 (finder.pillMatches(el)) { prev = null; } // pill(整體):斷相鄰、不下探(內容對排版不可見)
|
||||
else if (finder.avoidsSelf(el)) { prev = null; } // avoid 邊界(自身判定):斷、不下探
|
||||
else if (finder.isolateMatches(el))
|
||||
{
|
||||
prev = null; visit(el); prev = null; // 隔離:前後皆斷相鄰,內部自成脈絡(同 block)
|
||||
}
|
||||
else if (blockSet.has(nm))
|
||||
{
|
||||
prev = null; visit(el); prev = null; // block 邊界:前後皆斷相鄰,內部另計
|
||||
|
||||
@@ -70,7 +70,9 @@ function collectSubst(
|
||||
}
|
||||
if (node.nodeType !== 1) { return; }
|
||||
const el = node as Element;
|
||||
if (finder.isAvoided(el)) { return; }
|
||||
// avoid 以自身判定(舊版僅查祖先,自身命中 avoid 之元素被下潛);pill 整體
|
||||
// 不計不下探;isolate 內部為正常文本,照常下探計數(綁定時不可切、整綁)。
|
||||
if (finder.avoidsSelf(el)) { return; }
|
||||
if (blockSet.has(el.nodeName) || finder.pillMatches(el)) { return; } // 邊界:不計、不下探
|
||||
for (let c = el.firstChild; c; c = c.nextSibling) { visit(c, directChild); }
|
||||
};
|
||||
@@ -111,7 +113,7 @@ function processBlock(
|
||||
else if (boundary.textNode && startChild.nodeType === 1)
|
||||
{
|
||||
const clone = splitElementAt(
|
||||
startChild as Element, boundary.textNode, boundary.offset, PASS,
|
||||
startChild as Element, boundary.textNode, boundary.offset, PASS, finder,
|
||||
);
|
||||
if (clone) { startChild = clone; }
|
||||
}
|
||||
@@ -144,9 +146,11 @@ export const orphanPass: StandalonePass = {
|
||||
|
||||
finder.eachBlock(ctx.root, (block) =>
|
||||
{
|
||||
// 段落級閘 + per-node 功能閘。
|
||||
// 段落級閘 + per-node 功能閘 + avoid(自身或祖先命中皆不綁——舊版
|
||||
// 漏判,avoid 子樹內塊之裸文本直接子曾被計數並包入 jz-orphan)。
|
||||
if (!finder.levelAllows(block, "paragraph")) { return; }
|
||||
if (!finder.featureEnabledFor(block, PASS)) { return; }
|
||||
if (finder.avoidsSelf(block) || finder.isAvoided(block)) { return; }
|
||||
// 僅處理葉段落(無塊級後代):含巢狀塊之容器,其實義字屬內層段落,另行處理。
|
||||
const sel = Array.from(blockSet).map((t) => t.toLowerCase()).join(",");
|
||||
if (sel && block.querySelector(sel)) { return; }
|
||||
|
||||
+16
-5
@@ -246,11 +246,16 @@ function processBlock(block: Element, finder: Finder, R: Rules): void
|
||||
}
|
||||
}
|
||||
|
||||
function processPills(root: Element, finder: Finder, pillSelector: string, pillNeighbor: RegExp): void
|
||||
// boundarySelector = pill(整體)∪ isolate(隔離):兩端補隙之口徑一致(§4.1
|
||||
// 三分類);差異僅在內部(pill 不可見、isolate 照常處理),不在此 pass。
|
||||
function processPills(root: Element, finder: Finder, boundarySelector: string, pillNeighbor: RegExp): void
|
||||
{
|
||||
root.querySelectorAll(pillSelector).forEach((pill) =>
|
||||
root.querySelectorAll(boundarySelector).forEach((pill) =>
|
||||
{
|
||||
if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; }
|
||||
if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// per-node 功能閘(T6):data-juzhen-off="spacing" 等區域內之 pill 不補間隙
|
||||
// (與 Pass A 逐 block 之 featureEnabledFor 一致;舊版漏判)。
|
||||
if (!finder.featureEnabledFor(pill, PASS)) { return; }
|
||||
@@ -331,8 +336,14 @@ export const spacingPass: StandalonePass = {
|
||||
{
|
||||
processBlock(ctx.root, finder, R);
|
||||
}
|
||||
// Pass B:pill 邊界。
|
||||
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
|
||||
// Pass B:pill/isolate 邊界。
|
||||
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
if (boundarySelector)
|
||||
{
|
||||
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
|
||||
}
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user