fix: 修复行缘、切分还原与语言解析并同步文档

保留逐字 margin/jiya.bias 字体后备方案;补齐 demo 重排、量测边界说明及共享规则 1.0.1 反例向量。

验证:完整构建通过,Node 376 项、WKWebView 64 项及原生 demo 9 项通过;Firefox 仍暂缓。
This commit is contained in:
2026-10-03 21:15:54 +08:00
parent 024ad5da93
commit 3a4fa1c932
33 changed files with 983 additions and 140 deletions
+189
View File
@@ -0,0 +1,189 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
function sameLine(document)
{
const view = document.defaultView;
const previous = globalThis.ResizeObserver;
const elementRects = view.Element.prototype.getClientRects;
const rangeRects = view.Range.prototype.getClientRects;
const rect = { top: 0, bottom: 20, left: 0, right: 20, width: 20, height: 20 };
globalThis.ResizeObserver = class { observe() {} disconnect() {} };
view.Element.prototype.getClientRects = () => [ rect ];
view.Range.prototype.getClientRects = () => [ rect ];
return () =>
{
view.Element.prototype.getClientRects = elementRects;
view.Range.prototype.getClientRects = rangeRects;
if (previous === undefined) { delete globalThis.ResizeObserver; }
else { globalThis.ResizeObserver = previous; }
};
}
for (const orphan of [ false, true ])
{
test("line edge:同行 orphan 不製造行首 orphan=" + orphan, () =>
{
const { dom, document } = setupDom('<main lang="zh-Hant"><p>前甲「乙丙」</p></main>');
const restore = sameLine(document);
const jz = createJuzhen({ orphan, spacing: false, longWord: false });
try
{
jz.render(document.querySelector("main"));
assert.equal(document.querySelector(".bd-open").classList.contains("jz-half-le"), false);
}
finally { jz.revert(); restore(); dom.window.close(); }
});
}
for (const display of [ "inline", "inline-block", "contents", "block" ])
{
test("line edge:自訂隔離元素按實際物理盒攀爬 display=" + display, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">前甲<x-box style="display:'
+ display + '">「乙」</x-box>丙</p>');
const restore = sameLine(document);
const jz = createJuzhen({ isolateSelector: "x-box", orphan: false, spacing: false, longWord: false });
try
{
jz.render();
for (const char of document.querySelectorAll("jz-char"))
{
assert.equal(char.classList.contains("jz-half-le"), display === "block");
}
}
finally { jz.revert(); restore(); dom.window.close(); }
});
}
for (const hidden of [ false, true ])
{
test("line edge:BR 為強制行緣,隱藏時可穿越 hidden=" + hidden, () =>
{
const { dom, document } = setupDom('<p lang="zh-Hant">甲」<br'
+ (hidden ? ' style="display:none"' : '') + '>「乙</p>');
const restore = sameLine(document);
const jz = createJuzhen({ orphan: false, spacing: false, longWord: false });
try
{
jz.render();
for (const char of document.querySelectorAll("jz-char"))
{
assert.equal(char.classList.contains("jz-half-le"), !hidden);
}
}
finally { jz.revert(); restore(); dom.window.close(); }
});
}
for (const action of [ "insert", "replace-source", "edit-fragment", "edit-source" ])
{
test("splitoff:回退須驗來源身份並保留外部樣式 action=" + action, () =>
{
const { dom, document } = setupDom('<p><strong class="original">甲乙丙</strong>,丁</p>');
const p = document.querySelector("p");
const source = p.querySelector("strong");
const jz = createJuzhen({ orphan: false, spacing: false, longWord: false });
try
{
jz.render(p);
const fragment = p.querySelector('[data-jz-kind="splitoff"]');
assert.ok(fragment);
if (action === "insert")
{
const added = document.createElement("strong");
added.className = "external";
added.textContent = "新";
fragment.before(added);
}
else if (action === "replace-source")
{
const replacement = source.cloneNode(false);
replacement.textContent = "替";
source.replaceWith(replacement);
}
else if (action === "edit-fragment") { fragment.className = "edited"; }
else { source.className = "edited"; }
jz.revert(p);
assert.equal(fragment.parentNode, p, "來源失效時保留片段當前父與樣式");
assert.equal(fragment.textContent, "丙");
assert.equal(fragment.className, action === "edit-fragment" ? "edited" : "original");
assert.equal(p.querySelector("[data-jz]"), null);
if (action === "insert")
{
assert.equal(p.querySelector(".external").textContent, "新");
assert.equal(p.textContent, "甲乙新丙,丁");
}
if (action === "replace-source") { assert.equal(p.textContent, "替丙,丁"); }
jz.revert(p);
assert.equal(fragment.textContent, "丙", "撤回所有權後再次 revert 不碰片段");
}
finally { jz.revert(p); dom.window.close(); }
});
}
test("splitoff:同來源多層切分仍完整還原作者結構", () =>
{
const { dom, document } = setupDom('<p>「<strong><em>甲乙丙丁</em></strong>」</p>');
const p = document.querySelector("p");
const original = p.innerHTML;
const jz = createJuzhen();
try { jz.render(p); jz.revert(p); assert.equal(p.innerHTML, original); }
finally { jz.revert(p); dom.window.close(); }
});
for (const [ html, defaultLang, half ] of [
[ '<main lang="zh-Hans"><p lang="">甲,乙</p></main>', "zh-Hans", false ],
[ '<main><p>甲,乙</p></main>', "", false ],
[ '<main lang="zh-Hant"><p lang="zh-Hans-x-hant">甲,乙</p></main>', "zh-Hant", true ],
[ '<main lang="zh-Hans"><p lang="zh-TW-x-hans">甲,乙</p></main>', "zh-Hans", false ],
])
{
test("locale:空值及私用內容不冒充繼承語言 " + html, () =>
{
const { dom, document } = setupDom(html);
const jz = createJuzhen({ lang: { default: defaultLang }, jinze: false, orphan: false });
try
{
jz.render(document.querySelector("main"));
assert.equal(document.querySelector("jz-char").classList.contains("jz-half"), half);
}
finally { jz.revert(); dom.window.close(); }
});
}
test("splitoff:多實例六種回退順序恢復原結構,36 組合", () =>
{
const fixtures = [
'<strong>甲乙丙丁</strong>,戊己',
'「<strong>甲乙丙丁</strong>」',
'<strong><em>甲乙丙丁</em></strong>,戊己',
'<strong class="author" style="color:red">甲乙丙丁</strong>,戊己',
'<strong>甲乙𠀀\uDB40\uDD00丙丁</strong>,戊己',
'<strong data-jz="author" data-jz-kind="author">甲乙丙丁</strong>,戊己',
];
const orders = [ [ 0, 1, 2 ], [ 0, 2, 1 ], [ 1, 0, 2 ], [ 1, 2, 0 ], [ 2, 0, 1 ], [ 2, 1, 0 ] ];
for (const html of fixtures)
{
for (const order of orders)
{
const { dom, document } = setupDom('<p lang="zh-Hant">' + html + '</p>');
const p = document.querySelector("p");
const original = p.innerHTML;
const owners = [
createJuzhen({ jiya: false, spacing: false, orphan: false, longWord: false }),
createJuzhen({ jiya: false, spacing: false, jinze: false, longWord: false }),
createJuzhen({ spacing: false, orphan: false, longWord: false }),
];
try
{
owners.forEach((owner) => owner.render(p));
order.forEach((index) => owners[index].revert(p));
assert.equal(p.innerHTML, original, html + " order=" + order.join(","));
}
finally { owners.forEach((owner) => owner.disconnect()); dom.window.close(); }
}
}
});