feat(rules): 建立共享排版契约并收束 WebKit 与 Unicode 基线

This commit is contained in:
2026-09-04 21:44:11 +08:00
parent 8ee9895247
commit 993cf576c6
42 changed files with 8511 additions and 1422 deletions
+374
View File
@@ -0,0 +1,374 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>聚珍浏览器验收</title>
<link rel="stylesheet" href="../dist/juzhen.css">
<style>
body
{
margin: 0;
padding: 2rem;
color: #172033;
background: #f5f7fb;
font: 16px/1.75 system-ui, sans-serif;
}
h1 { margin-top: 0; font-size: 1.5rem; }
#summary, .panel
{
max-width: 52rem;
margin: 1rem 0;
padding: 1rem 1.25rem;
border: 1px solid #cbd4e2;
border-radius: .5rem;
background: #fff;
}
#summary[data-state="pass"] { border-color: #238636; }
#summary[data-state="fail"] { border-color: #cf222e; }
#results { padding-left: 1.5rem; }
#results li[data-state="pass"] { color: #1a7f37; }
#results li[data-state="fail"] { color: #cf222e; font-weight: 600; }
#fixture
{
max-width: 18em;
font-family: "Noto Serif SC", "Noto Serif CJK SC", serif;
}
#fixture p { text-align: justify; }
code { font-family: ui-monospace, monospace; }
</style>
</head>
<body>
<h1>聚珍浏览器验收</h1>
<section id="summary" data-state="running" aria-live="polite">
<strong id="summary-text">正在运行……</strong>
<button id="run-core" type="button">运行核心验收</button>
<button id="run-observer" type="button" disabled>运行 MutationObserver 验收</button>
<ul id="results"></ul>
</section>
<main id="fixture" class="panel juzhen" lang="zh-Hans">
<p id="spacing-case">中文ABC与TypeScript混排。</p>
<p id="punctuation-case">开明式:中文,测试(括号)与句号。</p>
<p id="ivs-forward"><strong id="author-forward">甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-forward-no-orphan" data-juzhen-off="orphan"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-forward-same-node" data-juzhen-off="orphan">&#x20000;&#xE0100;A</p>
<p id="ivs-forward-same-node-prefix" data-juzhen-off="orphan">甲&#x20000;&#xE0100;A</p>
<p id="ivs-reverse">A<strong id="author-reverse">&#x20000;</strong>&#xE0100;乙</p>
<p id="ivs-line-break" data-juzhen-off="orphan" style="width:2.5em;text-align:justify"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-nowrap" data-juzhen-off="orphan" style="width:1em;white-space:nowrap"><strong>甲&#x20000;</strong>&#xE0100;A</p>
<p id="ivs-lineedge-open" lang="zh-Hant">「&#x20000;&#xE0100;甲</p>
<p id="ivs-lineedge-close" lang="zh-Hant">甲&#x20000;&#xE0100;」</p>
<p id="observer-case">观察初始内容</p>
</main>
<script src="../dist/juzhen.iife.js"></script>
<script>
(function ()
{
"use strict";
var results = document.getElementById("results");
var summary = document.getElementById("summary");
var summaryText = document.getElementById("summary-text");
var coreButton = document.getElementById("run-core");
var observerButton = document.getElementById("run-observer");
var fixture = document.getElementById("fixture");
var authorForward = document.getElementById("author-forward");
var authorReverse = document.getElementById("author-reverse");
var originalHtml = fixture.innerHTML;
var originalText = fixture.textContent;
var clickCount = 0;
var failures = 0;
function record(name, condition, detail)
{
var item = document.createElement("li");
item.dataset.state = condition ? "pass" : "fail";
item.textContent = (condition ? "PASS " : "FAIL ") + name
+ (detail ? " — " + detail : "");
results.appendChild(item);
if (!condition) { failures += 1; }
}
function hasUnpairedSurrogate(text)
{
for (var i = 0; i < text.length; i += 1)
{
var unit = text.charCodeAt(i);
if (unit >= 0xD800 && unit <= 0xDBFF)
{
if (i + 1 >= text.length) { return true; }
var next = text.charCodeAt(i + 1);
if (next < 0xDC00 || next > 0xDFFF) { return true; }
i += 1;
}
else if (unit >= 0xDC00 && unit <= 0xDFFF)
{
return true;
}
}
return false;
}
function delay()
{
return new Promise(function (resolve)
{
setTimeout(resolve, 30);
});
}
authorForward.addEventListener("click", function () { clickCount += 1; });
authorReverse.addEventListener("click", function () { clickCount += 1; });
coreButton.addEventListener("click", async function ()
{
coreButton.disabled = true;
try
{
// 浏览器扩展可能在 DOMContentLoaded 后异步补写作者属性;以验收
// 按钮真正启动前的 DOM 为 revert 基线,避免把外部并发变更误报为库回归。
originalHtml = fixture.innerHTML;
originalText = fixture.textContent;
var jz = Juzhen.createJuzhen({
scope: { include: "#fixture" },
jiya: { halfWidth: "margin" },
});
jz.render(fixture);
var firstGapCount = fixture.querySelectorAll("jz-hws").length;
var firstCharCount = fixture.querySelectorAll("jz-char").length;
record("已生成中西间隙", firstGapCount > 0, firstGapCount + " 个");
record("已生成标点原子", firstCharCount > 0, firstCharCount + " 个");
record("copy-clean 文本保持", fixture.textContent === originalText);
record("无孤立 UTF-16 代理项", !hasUnpairedSurrogate(fixture.textContent));
var ivs = String.fromCodePoint(0x20000, 0xE0100);
var forward = document.getElementById("ivs-forward");
var forwardNoOrphan = document.getElementById("ivs-forward-no-orphan");
var forwardSameNode = document.getElementById("ivs-forward-same-node");
var forwardSameNodePrefix = document.getElementById("ivs-forward-same-node-prefix");
var reverse = document.getElementById("ivs-reverse");
var forwardCarrier = forward.querySelector("jz-hws");
var carrierKind = forwardCarrier?.getAttribute("data-jz-kind") ?? "none";
record("跨节点 IVS 正向完整", forward.textContent.includes(ivs + "A"));
record(
"关闭 orphan 时 IVS 正向间隙保留",
forwardNoOrphan.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardNoOrphan.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardNoOrphan.innerHTML,
);
record(
"同一 Text 节点的 IVS 正向间隙保留",
forwardSameNode.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardSameNode.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardSameNode.innerHTML,
);
record(
"带前导 CJK 的同节点 IVS 正向间隙保留",
forwardSameNodePrefix.querySelectorAll("jz-hws").length === 1,
"gaps=" + forwardSameNodePrefix.querySelectorAll("jz-hws").length
+ ",HTML=" + forwardSameNodePrefix.innerHTML,
);
record("跨节点 IVS 反向完整", reverse.textContent.includes("A" + ivs));
record(
"跨节点 IVS 在间隙载体中保持完整",
carrierKind === "wrap"
&& forwardCarrier?.textContent === ivs
&& forwardCarrier?.hasAttribute("data-jz-cross-node-atom")
&& Array.from(forwardCarrier.childNodes)
.filter(function (node) { return node.nodeType === Node.TEXT_NODE; })
.length === 2,
"forward gaps=" + forward.querySelectorAll("jz-hws").length
+ ",reverse gaps=" + reverse.querySelectorAll("jz-hws").length
+ ",carrier kind=" + carrierKind
+ ",carrier text=" + JSON.stringify(forwardCarrier?.textContent ?? null)
+ ",HTML=" + forward.innerHTML,
);
authorForward.click();
authorReverse.click();
record(
"作者元素身份与监听器保持",
document.getElementById("author-forward") === authorForward
&& document.getElementById("author-reverse") === authorReverse
&& document.querySelectorAll("#author-forward").length === 1
&& document.querySelectorAll("#author-reverse").length === 1
&& clickCount === 2,
);
var gap = fixture.querySelector("jz-hws");
var gapStyle = getComputedStyle(gap);
var gapRatio = parseFloat(gapStyle.marginRight) / parseFloat(gapStyle.fontSize);
record("间隙视觉宽度约为四分之一字", gapRatio >= 0.2 && gapRatio <= 0.3, gapRatio.toFixed(3) + "em");
var selection = window.getSelection();
var selectionRange = document.createRange();
selectionRange.selectNodeContents(forwardNoOrphan);
selection?.removeAllRanges();
selection?.addRange(selectionRange);
var selectionText = selection?.toString() ?? "";
record(
"Selection 文本不含生成断行字符",
selectionText === forwardNoOrphan.textContent,
"zwsp=" + selectionText.includes("\u200B")
+ ",length delta=" + (selectionText.length - forwardNoOrphan.textContent.length),
);
selection?.removeAllRanges();
await delay();
await delay();
var lineBreak = document.getElementById("ivs-line-break");
var lineGap = lineBreak.querySelector("jz-hws");
var lineNext = lineBreak.lastChild;
var lineGapRect = lineGap?.getBoundingClientRect();
var lineNextRange = document.createRange();
if (lineNext?.nodeType === Node.TEXT_NODE)
{
lineNextRange.setStart(lineNext, 0);
lineNextRange.setEnd(lineNext, 1);
}
var lineNextRect = lineNextRange.getBoundingClientRect();
var lineBoxRect = lineBreak.getBoundingClientRect();
var lineGapStyle = lineGap ? getComputedStyle(lineGap) : null;
var lineGapMargin = lineGapStyle
? parseFloat(lineGapStyle.marginRight) / parseFloat(lineGapStyle.fontSize)
: -1;
var nextLineIndent = (lineNextRect.left - lineBoxRect.left)
/ parseFloat(lineGapStyle?.fontSize || "16");
record(
"行末间隙载体留在前行并清零,下一行无缩进",
!!lineGapRect
&& lineNextRect.top > lineGapRect.top + 2
&& lineGap.classList.contains("jz-hws-trim")
&& Math.abs(lineGapMargin) <= 0.01
&& Math.abs(nextLineIndent) <= 0.05,
"trim=" + !!lineGap?.classList.contains("jz-hws-trim")
+ ",margin=" + lineGapMargin.toFixed(3)
+ "em,indent=" + nextLineIndent.toFixed(3) + "em",
);
var nowrapCase = document.getElementById("ivs-nowrap");
var nowrapGap = nowrapCase.querySelector("jz-hws");
var nowrapNext = nowrapCase.lastChild;
var nowrapNextRange = document.createRange();
if (nowrapNext?.nodeType === Node.TEXT_NODE)
{
nowrapNextRange.setStart(nowrapNext, 0);
nowrapNextRange.setEnd(nowrapNext, 1);
}
var nowrapGapRect = nowrapGap?.getBoundingClientRect();
var nowrapNextRect = nowrapNextRange.getBoundingClientRect();
record(
"生成断行机会不突破 nowrap",
!!nowrapGapRect && Math.abs(nowrapGapRect.top - nowrapNextRect.top) <= 2,
);
var ivsOpen = document.getElementById("ivs-lineedge-open").querySelector("jz-char");
var ivsClose = document.getElementById("ivs-lineedge-close").querySelector("jz-char");
record(
"IVS 相邻行首开标点保持行缘半形",
!!ivsOpen?.classList.contains("jz-half-le"),
);
record(
"IVS 相邻行尾闭标点保持行缘半形",
!!ivsClose?.classList.contains("jz-half-le"),
);
var comma = Array.from(fixture.querySelectorAll("jz-char"))
.find(function (item) { return item.textContent === ","; });
var commaStyle = comma ? getComputedStyle(comma) : null;
var commaRatio = comma && commaStyle
? comma.getBoundingClientRect().width / parseFloat(commaStyle.fontSize)
: 0;
record(
"margin 后备的句内逗号为半形",
commaRatio >= 0.45 && commaRatio <= 0.55,
commaRatio.toFixed(3) + "em",
);
jz.render(fixture);
record(
"重复 render 幂等",
fixture.querySelectorAll("jz-hws").length === firstGapCount
&& fixture.querySelectorAll("jz-char").length === firstCharCount,
);
jz.revert(fixture);
var internalComments = [];
var commentWalker = document.createTreeWalker(fixture, NodeFilter.SHOW_COMMENT);
while (commentWalker.nextNode())
{
if (commentWalker.currentNode.textContent?.startsWith("jz-spacing-") === true)
{
internalComments.push(commentWalker.currentNode);
}
}
record(
"revert 清除全部产物",
fixture.querySelectorAll("[data-jz]").length === 0
&& internalComments.length === 0
&& document.getElementById("author-forward") === authorForward
&& document.getElementById("author-reverse") === authorReverse
&& fixture.innerHTML === originalHtml
&& fixture.textContent === originalText,
);
jz.render(fixture);
observerButton.disabled = false;
observerButton.addEventListener("click", async function ()
{
observerButton.disabled = true;
try
{
jz.observe(fixture);
document.getElementById("observer-case").textContent = "观察Observer恢复";
await delay();
await delay();
record(
"MutationObserver 增量重排",
document.getElementById("observer-case").querySelectorAll("jz-hws").length > 0,
);
}
catch (error)
{
record(
"MutationObserver 增量重排",
false,
error instanceof Error ? error.message : String(error),
);
}
finally
{
jz.disconnect();
summary.dataset.state = failures === 0 ? "pass" : "fail";
summaryText.textContent = failures === 0
? "PASS:全部浏览器验收项通过"
: "FAIL:" + failures + " 项浏览器验收失败";
document.title = failures === 0
? "PASS — 聚珍浏览器验收"
: "FAIL — 聚珍浏览器验收";
}
});
}
catch (error)
{
record("验收脚本未抛错", false, error instanceof Error ? error.message : String(error));
}
summary.dataset.state = failures === 0 ? "pass" : "fail";
summaryText.textContent = failures === 0
? "核心验收通过;请运行 MutationObserver 验收"
: "FAIL:" + failures + " 项浏览器验收失败";
document.title = failures === 0
? "CORE PASS — 聚珍浏览器验收"
: "FAIL — 聚珍浏览器验收";
});
summaryText.textContent = "待运行核心验收";
document.title = "READY — 聚珍浏览器验收";
}());
</script>
</body>
</html>