fix: 修复行缘、切分还原与语言解析并同步文档
保留逐字 margin/jiya.bias 字体后备方案;补齐 demo 重排、量测边界说明及共享规则 1.0.1 反例向量。 验证:完整构建通过,Node 376 项、WKWebView 64 项及原生 demo 9 项通过;Firefox 仍暂缓。
This commit is contained in:
@@ -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(); }
|
||||
});
|
||||
@@ -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 偵測不改預設輪廓分類");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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: {} };
|
||||
|
||||
Reference in New Issue
Block a user