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
+92
View File
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { JSDOM } from "jsdom";
function openDemo(name)
{
const html = readFileSync(new URL("../demo/" + name, import.meta.url), "utf8");
const dom = new JSDOM(html, { runScripts: "outside-only" });
const { window } = dom;
const observers = [];
window.ResizeObserver = class
{
constructor(callback) { this.callback = callback; this.disconnected = false; observers.push(this); }
observe(root) { this.root = root; }
disconnect() { this.disconnected = true; }
};
// 此處驗證事件刷新與觀察目標;矩形模型不作真實 layout 驗收。
const rect = { top: 0, bottom: 20, left: 0, right: 20, width: 20, height: 20 };
window.Element.prototype.getClientRects = () => [ rect ];
window.Range.prototype.getClientRects = () => [ rect ];
window.eval(readFileSync(new URL("../dist/juzhen.iife.js", import.meta.url), "utf8"));
for (const script of window.document.querySelectorAll("script:not([src])"))
{
window.eval(script.textContent);
}
return { dom, window, document: window.document, observers };
}
function change(window, id, checked)
{
const input = window.document.getElementById(id);
input.checked = checked;
input.dispatchEvent(new window.Event("change"));
}
test("index demo:對齊與寬度明確重排,revert 後開關不重新啟用", () =>
{
const { dom, window, document, observers } = openDemo("index.html");
try
{
for (const [ id, checked ] of [ [ "justify", true ], [ "wide", true ] ])
{
const oldGap = document.querySelector("jz-hws");
const oldObservers = observers.filter((observer) => !observer.disconnected);
assert.ok(oldGap);
change(window, id, checked);
assert.equal(oldGap.isConnected, false, id + " 撤回舊產物");
assert.ok(document.querySelector("jz-hws"), id + " 重新建立產物");
assert.ok(oldObservers.every((observer) => observer.disconnected), id + " 更換 layout 安裝");
}
assert.ok(document.body.classList.contains("justify"));
assert.equal(document.querySelector(".narrow").style.maxWidth, "none");
document.getElementById("revert").click();
const count = observers.length;
assert.equal(document.querySelector("[data-jz]"), null);
change(window, "justify", false);
change(window, "wide", false);
change(window, "force-kaiming", true);
assert.equal(document.querySelector("[data-jz]"), null, "revert 狀態仍保持");
assert.equal(observers.length, count, "停用時不新增 layout 安裝");
document.getElementById("render").click();
assert.ok(document.querySelector("jz-hws"), "手動 render 可重新啟用");
assert.ok(document.getElementById("status").textContent.includes("开明式"));
}
finally { dom.window.close(); }
});
test("resize demo:各拖曳框各自被觀察,切換對齊刷新所有框", () =>
{
const { dom, window, document, observers } = openDemo("resize-check.html");
try
{
const boxes = Array.from(document.querySelectorAll(".box.juzhen"));
const targets = new Set(observers.map((observer) => observer.root));
assert.equal(targets.size, boxes.length);
assert.ok(boxes.every((box) => targets.has(box)));
assert.equal(targets.has(document.body), false);
const oldGaps = boxes.flatMap((box) => Array.from(box.querySelectorAll("jz-hws")));
const oldObservers = observers.slice();
assert.ok(oldGaps.length > 0);
change(window, "t-justify", true);
assert.ok(oldGaps.every((gap) => !gap.isConnected), "所有框撤回舊間隙");
assert.ok(oldObservers.every((observer) => observer.disconnected));
const activeTargets = new Set(observers.filter((observer) => !observer.disconnected)
.map((observer) => observer.root));
assert.equal(activeTargets.size, boxes.length);
assert.ok(boxes.every((box) => activeTargets.has(box)));
assert.ok(document.querySelector("jz-hws"), "新產物仍存在");
}
finally { dom.window.close(); }
});
+95
View File
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
const python = "python3";
const available = spawnSync(python, [ "-c", "import fontTools" ]).status === 0;
const tool = fileURLToPath(new URL("../tools/measure-bias.py", import.meta.url));
const fixture = `
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
import sys
fb = FontBuilder(1000, isTTF=True)
names = ['.notdef', 'comma', 'commaHant', 'fullstop']
fb.setupGlyphOrder(names)
fb.setupCharacterMap({0xff0c: 'comma', 0xff0e: 'fullstop'})
glyphs = {}
for name, left, right in [('.notdef', 0, 0), ('comma', 50, 250),
('commaHant', 450, 550), ('fullstop', 650, 850)]:
p = TTGlyphPen(None)
if right > left:
p.moveTo((left, 0)); p.lineTo((right, 0)); p.lineTo((right, 150))
p.lineTo((left, 150)); p.closePath()
glyphs[name] = p.glyph()
fb.setupGlyf(glyphs)
fb.setupHorizontalMetrics({'.notdef': (1000, 0), 'comma': (1000, 50),
'commaHant': (1000, 450), 'fullstop': (1000, 650)})
fb.setupHorizontalHeader(ascent=800, descent=-200)
fb.setupNameTable({'familyName': 'BiasFixture', 'styleName': 'Regular',
'uniqueFontIdentifier': 'BiasFixture', 'fullName': 'BiasFixture',
'psName': 'BiasFixture'})
fb.setupOS2(sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200)
fb.setupPost(); fb.setupMaxp()
features = 'languagesystem hani ZHT; feature locl { script hani; language ZHT; sub comma by commaHant; } locl;'
if sys.argv[2] == 'halt':
features += ' feature halt { sub fullstop by comma; } halt;'
addOpenTypeFeaturesFromString(fb.font, features)
fb.save(sys.argv[1])
`;
function run(args)
{
const result = spawnSync(python, args, { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr || String(result.error));
return result.stdout;
}
function withFont(feature, check)
{
const dir = mkdtempSync(join(tmpdir(), "juzhen-bias-test-"));
try
{
const font = join(dir, "fixture.ttf");
run([ "-c", fixture, font, feature ]);
check(font);
}
finally { rmSync(dir, { recursive: true, force: true }); }
}
function table(output)
{
return JSON.parse(output.slice(output.indexOf("\n{") + 1));
}
test("measure-bias:全角句點入表,lang 僅選鍵且輸出說明 cmap/locl 限制",
{ skip: available ? false : "需工具本身的 Python fonttools 依賴" }, () =>
{
withFont("none", (font) =>
{
for (const lang of [ "zh-Hans", "zh-Hant" ])
{
const output = run([ tool, font, lang ]);
assert.equal(table(output)[lang]["."], "right");
assert.equal(table(output)[lang][","], "left", "仍量預設 cmap,未套 ZHT locl 替代字形");
assert.match(output, /未做 shaping.*未應用 locl/);
assert.match(output, /語言參數僅選 JSON 分類鍵/);
assert.match(output, /瀏覽器實際字形逐字微調/);
}
});
});
test("measure-bias:halt feature 存在只報存在,不承諾逐字覆蓋",
{ skip: available ? false : "需工具本身的 Python fonttools 依賴" }, () =>
{
withFont("halt", (font) =>
{
const output = run([ tool, font ]);
assert.match(output, /halt:有.*不保證涵蓋所有標點/);
assert.equal(table(output)["zh-Hans"]["."], "right", "feature 偵測不改預設輪廓分類");
});
});
+9 -1
View File
@@ -447,7 +447,15 @@ test("observer provenance: external writes on owned products are not consumed",
splitoff.classList.add("consumer-splitoff");
await tick();
assert.equal(counting.instances[0].callbackCount, 3, "owned splitoff 外部 class 觸發");
assert.equal(splitoff.isConnected, false);
// 外部樣式使片段無法安全併回;rerender 須保留作者元素與字序,不能丟掉 class。
assert.equal(splitoff.isConnected, true);
assert.ok(splitoff.classList.contains("consumer-splitoff"));
assert.equal(splitoff.hasAttribute("data-jz-kind"), false, "舊 splitoff 憑證已撤銷");
assert.equal(root.textContent, "前文粗体文字,后续");
jz.revert(root);
assert.equal(splitoff.isConnected, true);
assert.ok(splitoff.classList.contains("consumer-splitoff"));
assert.equal(root.querySelectorAll("[data-jz]").length, 0);
}
finally
{
+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(); }
}
}
});
+13 -1
View File
@@ -187,7 +187,7 @@ test("shared rules: Ajv 2020 strict validation, rejection cases, exports, and co
assert.equal(rulesSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
assert.equal(vectorsSchema.$schema, "https://json-schema.org/draft/2020-12/schema");
assert.equal(rules.schemaVersion, 1);
assert.equal(rules.rulesetVersion, "1.0.0");
assert.equal(rules.rulesetVersion, "1.0.1");
assert.equal(rules.unicodeProfile.version, "17.0.0");
assert.equal(vectorsDoc.schemaVersion, 1);
assert.equal(vectorsDoc.rulesetVersion, rules.rulesetVersion);
@@ -312,6 +312,18 @@ test("shared rules: explicit script identifiers match whole subtags", () =>
}
});
test("Web locale: own lang and explicit unknown stop inheritance", () =>
{
const dom = new JSDOM('<main lang="zh-Hans"><p lang=""><span>甲</span></p></main>');
const paragraph = dom.window.document.querySelector("p");
assert.equal(adapter.effectiveLang(paragraph, "ja"), "");
assert.equal(adapter.effectiveLang(paragraph.firstChild.firstChild, "ja"), "");
assert.equal(adapter.effectiveLang(dom.window.document.querySelector("main"), "ja"), "zh-hans");
paragraph.removeAttribute("lang");
assert.equal(adapter.effectiveLang(paragraph, "ja"), "zh-hans");
dom.window.close();
});
test("shared rules: locale defaults and all language vectors match the Web adapter", () =>
{
const config = { default: "zh-Hant", policy: {} };