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:
2026-06-12 17:15:00 +08:00
parent a3c94ddee5
commit c4e8c6cfd5
21 changed files with 732 additions and 546 deletions
-96
View File
@@ -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
View File
@@ -1,8 +1,15 @@
// 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。
//
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集(跨樣式
// 行內元素透明、pillblockavoid 中斷)、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 選擇器(三分類之「整體」:codekbd/行內公式等)。 */
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 內部曾被 jiyaspacing 直接變更)。 */
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-charjz-inner 標點原子)。穿越這些
* 才能正確找到跨包裝之邏輯鄰字(如 jinze 已把 pill 納入 jz-jinze 後,pill 之左
* 鄰字仍須可達;I7)。
* ⚠ 以**元素**而非標籤名判定:pillisolate/avoid 縱使標籤名在樣式集亦為邊界、
* 不可穿越(下游問題六:KaTeX 全樹皆 SPAN,名字判定令整棵公式被當透明下潛而
* 肢解;scope.avoidskipAttribute 命中自身者同理)。
* ⚠ jz-charjz-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
// isolateavoidblock 邊界——邏輯鄰居非「字」,終止整個查找)。不可把邊界
// 當無內容跳過續找更遠之字:那是「越過 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; // pillisolateavoidblock — 邊界,止。
}
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
View File
@@ -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、非 pillisolateavoid 邊界)。 */
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
View File
@@ -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
View File
@@ -66,9 +66,6 @@ export interface JuzhenOptions
text?: string;
};
/** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */
autospaceClass?: string;
/** justify 原子化:jz-charjz-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;
};
+5 -3
View File
@@ -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
// isolateavoid,由 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
View File
@@ -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; } // pillcode/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 邊界:前後皆斷相鄰,內部另計
+7 -3
View File
@@ -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
View File
@@ -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 Bpill 邊界。
processPills(ctx.root, finder, options.finder.pillSelector, R.pillNeighbor);
// Pass Bpillisolate 邊界。
const boundarySelector = [ options.finder.pillSelector, options.finder.isolateSelector ]
.filter(Boolean)
.join(", ");
if (boundarySelector)
{
processPills(ctx.root, finder, boundarySelector, R.pillNeighbor);
}
},
revert(ctx: RenderContext): void
{