From 30a11b5d6ea468e296e7b3e3c4d1897f257718aa Mon Sep 17 00:00:00 2001 From: commilitia Date: Tue, 7 Jul 2026 20:28:11 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B0=88=E6=A1=88=E6=95=B4?= =?UTF-8?q?=E7=90=86=E9=9A=8E=E6=AE=B5=E4=B8=80=E2=80=94=E2=80=94=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=BE=A9=EF=BC=88jinze=20?= =?UTF-8?q?=E5=86=AA=E7=AD=89=EF=BC=8Fpill=20=E8=87=AA=E8=BA=AB=20avoid?= =?UTF-8?q?=EF=BC=89=EF=BC=8B=E6=AD=BB=E4=BB=A3=E7=A2=BC=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下游審計(4 路並行測繪 + 逐條源碼複驗)之階段一:落地已複驗確認之缺陷與低風險 清理;架構統一(透明判定單一真相源)與文檔重整留待階段二/三。 修復(複驗確認): ・jinze 二次 render(未 revert)產生巢狀 jz-jinze、違反 I6 冪等——守衛改判 parent 自身即本 pass 產物(isJz(parent, PASS)),已綁定內容不再重包(實測 jz-jinze 3→6 已修)。 ・processPills 漏檢 avoidsSelf——自身命中 avoid(如 )之 pill 仍被補 間隙(isAvoided 只查祖先、不含自身,同「下游問題六」型)。processPills + root 自處理 分支補 avoidsSelf 檢查。 ・三分類互斥:CODE/KBD 自 DEFAULT_SKIP_TAGS 移除——二者已由預設 pillSelector "code, kbd" 以 pill 機制涵蓋(內部不可見+兩端補隙+斷 run/相鄰),再列 skipTags 致 同元素既 pill 又 avoid、pill 兩端間隙被 avoid 抹掉(違反互斥,且令上一項修復誤傷普通 code)。移除後普通 code 為純 pill、正常補隙;唯自身 data-jz-skip 者不補(即上項修復)。 清理: ・刪零引用死代碼 createMarker/ANY_CJK/BD_ALL/BD_CLASS_NAMES/CreateOptions.attrs; isJz 由 jinze 冪等守衛啟用、脫離死代碼。 ・死旗標 biaodian/emphasis/ruby 補 @reserved 註記(比照 hanging;點明與 charClass.biaodian 撞名異義,避免混淆)。 ・JzKind 補註 data-jz-kind 第三值 "splitoff"(split.ts 直接設、不經 createJz); longWord/orphan 冗餘 ===false 特判簡化(feature() 已對 false 回 false)。 複驗後重新定性(不照單全收): ・jinze 跨透明元素邊界避頭尾漏綁(你好。世界 之句末點號漏綁)——**真 缺陷**,但正解須改 jinze 之父級處理模型(穿越透明包裹上攀評估跨層綁定),與階段二 「透明判定單一真相源」一併處理更穩;forbiddenBreak 下探草稿已還原、缺口以註解標記。 ・lineedge edgeRect 攀穿 isolate——**非缺陷**:edgeRect 為物理版面測量(getClientRects), isolate 斷的是邏輯 run/相鄰,其內部字元之物理行首/行尾判定須跨 找真實相鄰 內容(否則「文字「引」」之 「 誤判行首而錯收左半)。初評誤判、已還原並補註。 新增 3 回歸測試(jinze 冪等、預設 code 為 pill 非 skipTags、自身 avoid 之 pill 不補隙); 119 測試全綠、tsc + esbuild 構建乾淨。 --- dist/core/dom.d.ts | 4 ---- dist/core/unicode.d.ts | 6 ------ dist/juzhen.iife.js | 34 ++++++++++++++++++---------------- dist/juzhen.mjs | 34 ++++++++++++++++++---------------- dist/types.d.ts | 4 ++++ src/core/dom.ts | 21 +++------------------ src/core/unicode.ts | 13 ------------- src/index.ts | 11 ++++++++--- src/types.ts | 4 ++++ src/typeset/jinze.ts | 14 +++++++++++--- src/typeset/lineedge.ts | 6 ++++++ src/typeset/spacing.ts | 8 ++++++-- test/juzhen.test.mjs | 36 ++++++++++++++++++++++++++++++++++++ 13 files changed, 114 insertions(+), 81 deletions(-) diff --git a/dist/core/dom.d.ts b/dist/core/dom.d.ts index d09bb60..1ed9fab 100644 --- a/dist/core/dom.d.ts +++ b/dist/core/dom.d.ts @@ -6,13 +6,9 @@ export interface CreateOptions { classes?: string | string[]; /** 文本內容。 */ text?: string; - /** 其餘屬性。 */ - attrs?: Record; } /** 造一個帶 pass 標記之 jz-* 元素。 */ export declare function createJz(tag: JzTag, pass: string, kind: JzKind, opts?: CreateOptions): HTMLElement; -/** 造一個 copy-clean 間隙標記(內含真實空白,user-select 由 CSS 關閉)。 */ -export declare function createMarker(tag: JzTag, pass: string): HTMLElement; /** * 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後 * 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。 diff --git a/dist/core/unicode.d.ts b/dist/core/unicode.d.ts index cd004e7..eb2f540 100644 --- a/dist/core/unicode.d.ts +++ b/dist/core/unicode.d.ts @@ -5,8 +5,6 @@ export declare const CJK: string; export declare const ALNUM = "A-Za-z0-9"; /** 西文字母。 */ export declare const ALPHA = "A-Za-z"; -/** 任一 CJK 字元測試。 */ -export declare const ANY_CJK: RegExp; /** CJK 之後可接間隙之西文集(含 @)。 */ export declare const ANS_AFTER_CJK: string; /** CJK 之前可接間隙之西文集(去 @)。 */ @@ -25,8 +23,6 @@ export declare const BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u301 export declare const BD_MIDDLE = "\u00B7\u30FB\u2027"; /** 連續省略/破折:…—。 */ export declare const BD_LIGA = "\u2026\u2014"; -/** 全體標點字元(供 charify 走訪偵測)。 */ -export declare const BD_ALL: string; /** 全寬標點之六子類鍵。 */ export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga"; /** @@ -66,8 +62,6 @@ export declare function resolveRuleset(override?: CharClassOverride): Ruleset; * biaodian 取自規則集(預設 DEFAULT_RULESET.biaodian,向下兼容無參數呼叫)。 */ export declare function classifyBiaodian(ch: string, biaodian?: Record): string | null; -/** 全體 bd-* 子類名(DOM 端讀取 jz-char 之 bd class 用)。 */ -export declare const BD_CLASS_NAMES: readonly ["bd-stop", "bd-pause", "bd-open", "bd-close", "bd-middle", "bd-liga"]; /** * 標點之「可壓縮空白」側(橫排)。字面偏左者空白在右(句內/句末/閉類), * 開類字面偏右、空白在左。間隔號/連接號不壓縮。擠壓即收掉對應側之半形空白。 diff --git a/dist/juzhen.iife.js b/dist/juzhen.iife.js index b1ab048..f29a69e 100644 --- a/dist/juzhen.iife.js +++ b/dist/juzhen.iife.js @@ -502,14 +502,6 @@ var Juzhen = (() => { } } } - if (opts.attrs) { - for (const k in opts.attrs) { - const v = opts.attrs[k]; - if (v !== void 0) { - el.setAttribute(k, v); - } - } - } if (opts.text !== void 0) { el.textContent = opts.text; } @@ -579,6 +571,16 @@ var Juzhen = (() => { } return null; } + function isJz(node, pass) { + if (!node || node.nodeType !== 1) { + return false; + } + const el = node; + if (!el.hasAttribute(PASS_ATTR)) { + return false; + } + return pass === void 0 || el.getAttribute(PASS_ATTR) === pass; + } // src/core/locale.ts var DEFAULT_POLICY = { @@ -640,7 +642,6 @@ var Juzhen = (() => { var CJK = "\u2E80-\u2EFF\u2F00-\u2FDF\u3040-\u309F\u30A0-\u30FA\u30FC-\u30FF\u3100-\u312F\u3200-\u32FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF"; var ALNUM = "A-Za-z0-9"; var ALPHA = "A-Za-z"; - var ANY_CJK = new RegExp("[" + CJK + "]"); var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf"; var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED; var ANS_BEFORE_CJK = ALPHA + WEST_SHARED; @@ -650,7 +651,6 @@ var Juzhen = (() => { var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019"; var BD_MIDDLE = "\xB7\u30FB\u2027"; var BD_LIGA = "\u2026\u2014"; - var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA; var BD_ORDER = [ "stop", "pause", @@ -1124,6 +1124,9 @@ var Juzhen = (() => { } } function processParent(parent, finder, anyCjk) { + if (isJz(parent, PASS2)) { + return; + } for (let c = parent.firstChild; c; c = c.nextSibling) { if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") { return; @@ -1752,7 +1755,7 @@ var Juzhen = (() => { } function processPills(root, finder, boundarySelector, pillNeighbor) { root.querySelectorAll(boundarySelector).forEach((pill) => { - if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { + if (finder.avoidsSelf(pill) || finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { return; } if (!finder.featureEnabledFor(pill, PASS5)) { @@ -1814,7 +1817,7 @@ var Juzhen = (() => { } processBlock2(block, finder, R); }); - if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { + if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.avoidsSelf(ctx.root) && !finder.isAvoided(ctx.root)) { processBlock2(ctx.root, finder, R); } const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", "); @@ -1904,8 +1907,6 @@ var Juzhen = (() => { "HR" ]; var DEFAULT_SKIP_TAGS = [ - "CODE", - "KBD", "PRE", "SAMP", "TT", @@ -1974,10 +1975,11 @@ var Juzhen = (() => { }, features: { spacing: feature(opts.spacing, true), - longWord: opts.longWord === false ? false : feature(opts.longWord, true), + // feature(v,dflt) 對 v===false 即回 false,故無需 ===false 特判(與 jiya/jinze 同構)。 + longWord: feature(opts.longWord, true), jiya: feature(opts.jiya, true), jinze: feature(opts.jinze, true), - orphan: opts.orphan === false ? false : feature(opts.orphan, true), + orphan: feature(opts.orphan, true), biaodian: feature(opts.biaodian, false), emphasis: feature(opts.emphasis, false), ruby: feature(opts.ruby, false) diff --git a/dist/juzhen.mjs b/dist/juzhen.mjs index 89ad1b8..f17778f 100644 --- a/dist/juzhen.mjs +++ b/dist/juzhen.mjs @@ -475,14 +475,6 @@ function createJz(tag, pass, kind, opts = {}) { } } } - if (opts.attrs) { - for (const k in opts.attrs) { - const v = opts.attrs[k]; - if (v !== void 0) { - el.setAttribute(k, v); - } - } - } if (opts.text !== void 0) { el.textContent = opts.text; } @@ -552,6 +544,16 @@ function bdClassOf(el) { } return null; } +function isJz(node, pass) { + if (!node || node.nodeType !== 1) { + return false; + } + const el = node; + if (!el.hasAttribute(PASS_ATTR)) { + return false; + } + return pass === void 0 || el.getAttribute(PASS_ATTR) === pass; +} // src/core/locale.ts var DEFAULT_POLICY = { @@ -613,7 +615,6 @@ function resolveStyle(node, config) { var CJK = "\u2E80-\u2EFF\u2F00-\u2FDF\u3040-\u309F\u30A0-\u30FA\u30FC-\u30FF\u3100-\u312F\u3200-\u32FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF"; var ALNUM = "A-Za-z0-9"; var ALPHA = "A-Za-z"; -var ANY_CJK = new RegExp("[" + CJK + "]"); var WEST_SHARED = "0-9\\$%\\^&\\*\\-\\+\\\\=\\u0370-\\u03ff\\u0100-\\u024f\\u0400-\\u04ff\\u00a1-\\u00b6\\u00b8-\\u00ff\\u20a0-\\u20bf\\u2150-\\u21ff\\u2200-\\u22ff\\u2700-\\u27bf"; var ANS_AFTER_CJK = ALPHA + "@" + WEST_SHARED; var ANS_BEFORE_CJK = ALPHA + WEST_SHARED; @@ -623,7 +624,6 @@ var BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201 var BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019"; var BD_MIDDLE = "\xB7\u30FB\u2027"; var BD_LIGA = "\u2026\u2014"; -var BD_ALL = BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA; var BD_ORDER = [ "stop", "pause", @@ -1097,6 +1097,9 @@ function splitBoundaries(t, needFirst, needLast, anyCjk) { } } function processParent(parent, finder, anyCjk) { + if (isJz(parent, PASS2)) { + return; + } for (let c = parent.firstChild; c; c = c.nextSibling) { if (c.nodeType === 1 && c.nodeName === "JZ-JINZE") { return; @@ -1725,7 +1728,7 @@ function processBlock2(block, finder, R) { } function processPills(root, finder, boundarySelector, pillNeighbor) { root.querySelectorAll(boundarySelector).forEach((pill) => { - if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { + if (finder.avoidsSelf(pill) || finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) { return; } if (!finder.featureEnabledFor(pill, PASS5)) { @@ -1787,7 +1790,7 @@ var spacingPass = { } processBlock2(block, finder, R); }); - if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) { + if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS5) && finder.inScope(ctx.root) && !finder.avoidsSelf(ctx.root) && !finder.isAvoided(ctx.root)) { processBlock2(ctx.root, finder, R); } const boundarySelector = [options.finder.pillSelector, options.finder.isolateSelector].filter(Boolean).join(", "); @@ -1877,8 +1880,6 @@ var DEFAULT_BLOCK_TAGS = [ "HR" ]; var DEFAULT_SKIP_TAGS = [ - "CODE", - "KBD", "PRE", "SAMP", "TT", @@ -1947,10 +1948,11 @@ function normalizeOptions(opts = {}) { }, features: { spacing: feature(opts.spacing, true), - longWord: opts.longWord === false ? false : feature(opts.longWord, true), + // feature(v,dflt) 對 v===false 即回 false,故無需 ===false 特判(與 jiya/jinze 同構)。 + longWord: feature(opts.longWord, true), jiya: feature(opts.jiya, true), jinze: feature(opts.jinze, true), - orphan: opts.orphan === false ? false : feature(opts.orphan, true), + orphan: feature(opts.orphan, true), biaodian: feature(opts.biaodian, false), emphasis: feature(opts.emphasis, false), ruby: feature(opts.ruby, false) diff --git a/dist/types.d.ts b/dist/types.d.ts index 9a98389..9948abb 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -70,8 +70,12 @@ export interface JuzhenOptions { * 守恆矛盾,CSS 接觸不到斷行器),原生 hanging-punctuation 為唯一正解但僅 Safari 支援、 * 且與本庫 inline-block 原子互斥。此選項仍被接受以保 API 相容,但**無任何效果**。 */ hanging?: boolean; + /** @reserved 標點修正(簡/繁正規化)——架構已預留(切片 #7),**當前無任何效果**。 + * 注意:此為功能開關,與 `charClass.biaodian`(標點子類擴充表,語義完全不同)同名,勿混。 */ biaodian?: boolean; + /** @reserved 著重號/裝飾——架構已預留(切片 #8),**當前無任何效果**。 */ emphasis?: boolean; + /** @reserved Ruby/注音——架構已預留(切片 #8),**當前無任何效果**。 */ ruby?: boolean; /** 分塊功能選擇(§3.6)。 */ blocks?: BlockRule[]; diff --git a/src/core/dom.ts b/src/core/dom.ts index f59b2e6..8fc913a 100644 --- a/src/core/dom.ts +++ b/src/core/dom.ts @@ -17,6 +17,9 @@ export type JzTag = | "jz-rb" | "jz-rt"; +// createJz 之 kind:marker(純注入,還原時移除)/wrap(包裝原內容,還原時解包)。 +// data-jz-kind 另有第三種值 "splitoff"(克隆切分之邊界詞元),由 core/split.ts 直接以 +// setAttribute 設定、不經 createJz;revertPass 於 wrap 全解包後之第二階段併回(見下)。 type JzKind = "marker" | "wrap"; export interface CreateOptions @@ -25,8 +28,6 @@ export interface CreateOptions classes?: string | string[]; /** 文本內容。 */ text?: string; - /** 其餘屬性。 */ - attrs?: Record; } const KIND_ATTR = "data-jz-kind"; @@ -57,26 +58,10 @@ export function createJz( const list = Array.isArray(opts.classes) ? opts.classes : [ opts.classes ]; for (const c of list) { if (c) { el.classList.add(c); } } } - if (opts.attrs) - { - for (const k in opts.attrs) - { - const v = opts.attrs[k]; - if (v !== undefined) { el.setAttribute(k, v); } - } - } if (opts.text !== undefined) { el.textContent = opts.text; } return el; } -/** 造一個 copy-clean 間隙標記(內含真實空白,user-select 由 CSS 關閉)。 */ -export function createMarker(tag: JzTag, pass: string): HTMLElement -{ - const el = createJz(tag, pass, "marker", { text: " " }); - el.setAttribute("aria-hidden", "true"); - return el; -} - /** * 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後 * 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。 diff --git a/src/core/unicode.ts b/src/core/unicode.ts index b250ef4..9fc7020 100644 --- a/src/core/unicode.ts +++ b/src/core/unicode.ts @@ -23,9 +23,6 @@ export const ALNUM = "A-Za-z0-9"; /** 西文字母。 */ export const ALPHA = "A-Za-z"; -/** 任一 CJK 字元測試。 */ -export const ANY_CJK = new RegExp("[" + CJK + "]"); - // ----- Pangu 中西邊界用之擴充西文字集(沿用 v1)----- // 西文(外文)字集——CJK 與其相鄰時補中西間隙。以 regex 字元類片段表示,供 @@ -80,11 +77,6 @@ export const BD_MIDDLE = "·・‧"; /** 連續省略/破折:…—。 */ export const BD_LIGA = "…—"; -/** 全體標點字元(供 charify 走訪偵測)。 */ -export const BD_ALL = ( - BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA -); - /** 全寬標點之六子類鍵。 */ export type BdSubclass = "stop" | "pause" | "open" | "close" | "middle" | "liga"; @@ -173,11 +165,6 @@ export function classifyBiaodian( return null; } -/** 全體 bd-* 子類名(DOM 端讀取 jz-char 之 bd class 用)。 */ -export const BD_CLASS_NAMES = [ - "bd-stop", "bd-pause", "bd-open", "bd-close", "bd-middle", "bd-liga", -] as const; - /** * 標點之「可壓縮空白」側(橫排)。字面偏左者空白在右(句內/句末/閉類), * 開類字面偏右、空白在左。間隔號/連接號不壓縮。擠壓即收掉對應側之半形空白。 diff --git a/src/index.ts b/src/index.ts index 244152a..4b35234 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,8 +34,12 @@ const DEFAULT_BLOCK_TAGS = [ "DL", "TABLE", "TR", "THEAD", "TBODY", "TFOOT", "FORM", "FIELDSET", "LEGEND", "LABEL", "BR", "HR", ]; +// 跳過標籤(avoidsSelf):內部完全不處理、兩端不補間隙。**不含 CODE/KBD**——二者由 +// 預設 pillSelector "code, kbd" 以 pill 機制涵蓋(內部不可見+兩端補間隙+斷 run/相鄰), +// 若再列入 skipTags 會與 pill 語義衝突(同元素既 pill 又 avoid、pill 兩端間隙被 avoid 抹掉, +// 違反三分類互斥)。消費端若把 code 移出 pillSelector,須自行加入 skipTags/scope.avoid。 const DEFAULT_SKIP_TAGS = [ - "CODE", "KBD", "PRE", "SAMP", "TT", "VAR", + "PRE", "SAMP", "TT", "VAR", "SCRIPT", "STYLE", "TEXTAREA", "INPUT", ]; @@ -103,10 +107,11 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions }, features: { spacing: feature(opts.spacing, true), - longWord: opts.longWord === false ? false : feature(opts.longWord, true), + // feature(v,dflt) 對 v===false 即回 false,故無需 ===false 特判(與 jiya/jinze 同構)。 + longWord: feature(opts.longWord, true), jiya: feature(opts.jiya, true), jinze: feature(opts.jinze, true), - orphan: opts.orphan === false ? false : feature(opts.orphan, true), + orphan: feature(opts.orphan, true), biaodian: feature(opts.biaodian, false), emphasis: feature(opts.emphasis, false), ruby: feature(opts.ruby, false), diff --git a/src/types.ts b/src/types.ts index afb4ffd..1fa267b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,8 +89,12 @@ export interface JuzhenOptions * 守恆矛盾,CSS 接觸不到斷行器),原生 hanging-punctuation 為唯一正解但僅 Safari 支援、 * 且與本庫 inline-block 原子互斥。此選項仍被接受以保 API 相容,但**無任何效果**。 */ hanging?: boolean; + /** @reserved 標點修正(簡/繁正規化)——架構已預留(切片 #7),**當前無任何效果**。 + * 注意:此為功能開關,與 `charClass.biaodian`(標點子類擴充表,語義完全不同)同名,勿混。 */ biaodian?: boolean; + /** @reserved 著重號/裝飾——架構已預留(切片 #8),**當前無任何效果**。 */ emphasis?: boolean; + /** @reserved Ruby/注音——架構已預留(切片 #8),**當前無任何效果**。 */ ruby?: boolean; /** 分塊功能選擇(§3.6)。 */ diff --git a/src/typeset/jinze.ts b/src/typeset/jinze.ts index 8a5e37d..6a2c30d 100644 --- a/src/typeset/jinze.ts +++ b/src/typeset/jinze.ts @@ -10,7 +10,7 @@ // 兩階段:①依原始相鄰關係算「禁斷邊界」,對涉及之文本單元切出邊界字; // ②重掃當前子節點,把以禁斷邊界相連之相鄰原子併入同一 jz-jinze。 -import { bdClassOf, createJz, revertPass } from "../core/dom.js"; +import { bdClassOf, createJz, isJz, 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"; @@ -27,7 +27,12 @@ function isCharEl(n: Node | null): n is Element return !!n && n.nodeType === 1 && (n as Element).nodeName === "JZ-CHAR"; } -/** 相鄰兩節點之間是否禁止斷行(僅由 jz-char 之類別決定)。 */ +/** 相鄰兩節點之間是否禁止斷行(僅由 jz-char 之類別決定)。 + * 已知缺口(下游審計,待階段二):邊界標點若落於透明樣式行內元素邊緣(如 + * 「你好。世界」之句末點號位於 首位),因 jinze 僅處理 + * jz-char 之直接父(此處為 ),外層

之「好↔strong」綁定不被評估、 + * 致跨邊界避頭尾漏綁。完整修復須改 jinze 之父級處理模型(穿越透明包裹上攀), + * 與階段二「透明判定單一真相源」一併處理。 */ function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean { if (isCharEl(b)) @@ -86,7 +91,10 @@ function splitBoundaries(t: Text, needFirst: boolean, needLast: boolean, anyCjk: function processParent(parent: Node, finder: Finder, anyCjk: RegExp): void { - // 冪等:已含 jz-jinze(前次 render 未 revert)則跳過。 + // 冪等(I6):parent 自身即本 pass 產物——二次 render 未 revert 時,jz-char 之父 + // 正是上輪產生之 jz-jinze,若不擋則對已綁定內容再包一層、巢狀增殖(下游審計實測 3→6)。 + if (isJz(parent, PASS)) { return; } + // 冪等:parent 已含 jz-jinze 子(上輪已處理此 parent)則跳過。 for (let c = parent.firstChild; c; c = c.nextSibling) { if (c.nodeType === 1 && (c as Element).nodeName === "JZ-JINZE") { return; } diff --git a/src/typeset/lineedge.ts b/src/typeset/lineedge.ts index 9992ae5..83eb774 100644 --- a/src/typeset/lineedge.ts +++ b/src/typeset/lineedge.ts @@ -98,6 +98,12 @@ function rectOf(n: Node, side: "prev" | "next"): DOMRect | null /** 取某節點側邊界字元之矩形(橫排:判定相鄰內容是否同行);跨我們的行內包裹 * 向上攀爬,遇 block 邊界回 null(視為行端)。 */ +// 註:edgeRect 為**版面**測量(getClientRects 之物理相鄰),非邏輯文本流。故攀爬邊界 +// 僅依 wrapSet(行內包裹之標籤集),**刻意不**排除 isolate()——isolate 斷的是 +// 邏輯 run/相鄰,其內部字元之物理行首行尾判定仍須跨 mark 找真實相鄰內容(如 +// 「文字「引」」之 「 物理上接在「文字」之後、非行首,須攀出 mark 取「文字」 +// 之 rect,否則誤判行首而錯收左半)。pill 之內部無 jz-char(eachTextNode 已濾),且 pill +// 標籤(code/kbd)不在 styleInlines、自然不在 wrapSet,攀爬遇之即止,無需另判。 function edgeRect( start: Node | null, side: "prev" | "next", diff --git a/src/typeset/spacing.ts b/src/typeset/spacing.ts index 27c886c..88988c1 100644 --- a/src/typeset/spacing.ts +++ b/src/typeset/spacing.ts @@ -252,7 +252,10 @@ function processPills(root: Element, finder: Finder, boundarySelector: string, p { root.querySelectorAll(boundarySelector).forEach((pill) => { - if (finder.isAvoided(pill) || finder.insidePill(pill) || !finder.inScope(pill)) + // 自身即 avoid(如 pill 帶 data-jz-skip)亦不補間隙——isAvoided 只查祖先鏈、 + // 不含自身,須另判 avoidsSelf(下游問題六同型:漏此半則 avoid 對自身形同虛設)。 + if (finder.avoidsSelf(pill) || finder.isAvoided(pill) + || finder.insidePill(pill) || !finder.inScope(pill)) { return; } @@ -332,7 +335,8 @@ export const spacingPass: StandalonePass = { // 遇巢狀 block 仍會 flush,故與上面逐 block 不重複;行內 root 通常無 block 子)。 if (!options.finder.blockTags.has(ctx.root.nodeName) && finder.featureEnabledFor(ctx.root, PASS) - && finder.inScope(ctx.root) && !finder.isAvoided(ctx.root)) + && finder.inScope(ctx.root) + && !finder.avoidsSelf(ctx.root) && !finder.isAvoided(ctx.root)) { processBlock(ctx.root, finder, R); } diff --git a/test/juzhen.test.mjs b/test/juzhen.test.mjs index 09def1b..14b6d55 100644 --- a/test/juzhen.test.mjs +++ b/test/juzhen.test.mjs @@ -520,6 +520,21 @@ test("jinze:revert 還原(無 jz-jinze/jz-char,文字回原樣)", () = assert.equal(document.querySelector("p").textContent, "他說好。」嗎"); }); +test("jinze 冪等:二次 render(未 revert)不巢狀增殖 jz-jinze(守衛 parent 自身即產物)", () => +{ + // 缺守衛時,二次 render 之 jz-char 其父正是上輪之 jz-jinze,會被再包一層(3→6 巢狀)。 + const { document } = setupDom('

漢字。「引文」結束

'); + const jz = createJuzhen({ spacing: false, longWord: false }); + jz.render(document.body); + const after1 = countTag(document.body, "jz-jinze"); + jz.render(document.body); // 二次 render,未 revert + assert.equal(countTag(document.body, "jz-jinze"), after1, "jz-jinze 數不增(不重複包裹)"); + assert.equal(countTag(document.body, "jz-jinze jz-jinze"), 0, "無巢狀 jz-jinze"); + jz.revert(document.body); + assert.equal(countTag(document.body, "jz-jinze"), 0, "revert 完全還原"); + assert.equal(document.querySelector("p").textContent, "漢字。「引文」結束"); +}); + // ----- 作用域與分塊(§3.6)----- test("scope.include:未命中子樹不處理", () => @@ -1037,6 +1052,27 @@ test("問題六:skipAttribute 自身命中——同 avoid,不切分不下潛 assert.equal(countTag(document.body, '[data-jz-kind="splitoff"]'), 0); }); +test("三分類互斥:預設 code 為 pill(非 skipTags)——兩端補間隙、內部不可見", () => +{ + // code/kbd 由預設 pillSelector 涵蓋、**不再列入 skipTags**(避免同元素既 pill 又 + // avoid、pill 兩端間隙被 avoid 抹掉之互斥違反)。故預設下 code 為純 pill。 + const { document } = setupDom("

x

"); + createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body); + const p = document.querySelector("p"); + assert.equal(countTag(p, "jz-hws"), 2, "code(pill)兩端補間隙(看|code、code|這)"); + assert.equal(countTag(p, "code jz-hws"), 0, "code 內部無間隙(pill 內部不可見)"); +}); + +test("三分類互斥:pill 自身 avoid(data-jz-skip)→ 兩端不補間隙(processPills 查 avoidsSelf)", () => +{ + // isAvoided 只查祖先鏈、不含自身;自身命中 avoid 之 pill 須另判 avoidsSelf,否則 + // avoid 對自身形同虛設(下游問題六同型)。 + const { document } = setupDom("

x

"); + createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body); + assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, + "自身 avoid 之 pill 兩端不補間隙"); +}); + test("問題六:相鄰兩 pill——descend 不刺穿,jz-hws 絕不插入公式內部", () => { const { document } = setupDom("

公式" + KATEX + KATEX + "文字

");