feat: lineedge 行端模式(squeeze);標點懸掛實作後全面停用(§6.5.2)
行端 lineedge 升級為可配模式(squeeze|hang|none),抽出共用行端標點偵測; squeeze(現有行端半形)保留並修正「行歸屬判定」bug:jz-char 為 inline-block,其行盒 top 與相鄰純文本字形 top 因基線差約 5px、遠小於行高卻超舊容差(EPS=4px),致同行誤判 為下一行;改判「垂直區間」(下一行=b.top≥a.bottom、上一行=b.bottom≤a.top,容 2px)。 標點懸掛 hang 模式經 headless + 真實瀏覽器實測**確認不可行、全面停用**(純負 margin): 1. 句號墨色偏框左下,安全凸出量(<1em)只推出空白半框、墨色仍在版心內(視覺不懸掛); 2. 凸出滿 1em 釋放一整字寬、把下一字拉上本行致重疊; 3. 與 inline-block 對齊原子互斥(justifyAtoms:true 下 margin 被 justify 吸收); 4. ResizeObserver 重排時兩階段標記錯亂。 真正的解為原生 hanging-punctuation,惟 Chrome 不支援、且與本庫原子結構衝突。 **三重防逃逸**(下游以任何方式皆無法啟用):① index 鎖 lineEnd:"squeeze"(忽略 hanging 選項);② lineedge.modeForChar 無條件降 squeeze;③ applyEdge 之 HANG_ENABLED=false 總開關。 hang 之 lineedge 程式碼、CSS(jz-hang-*)、LineEndMode 型別保留休眠,俟原生支援再議。 hanging 選項仍被接受(API 相容)但無效果。 單元測試 90 通過,含防逃逸覆蓋(hanging:true/data-juzhen/blocks 各路徑皆不產生 jz-hang-*)。
This commit is contained in:
Vendored
+10
@@ -85,6 +85,16 @@ jz-char.jz-half-le jz-inner {
|
||||
[data-jz-halfwidth=halt] jz-char.jz-half-le {
|
||||
width: auto;
|
||||
}
|
||||
jz-char.bd-stop.jz-hang-r,
|
||||
jz-char.bd-pause.jz-hang-r {
|
||||
margin-right: calc(-1 * var(--jz-hang-stop, 0.5em));
|
||||
}
|
||||
jz-char.bd-close.jz-hang-r {
|
||||
margin-right: calc(-1 * var(--jz-hang-bracket, 0.5em));
|
||||
}
|
||||
jz-char.bd-open.jz-hang-l {
|
||||
margin-left: calc(-1 * var(--jz-hang-bracket, 0.5em));
|
||||
}
|
||||
|
||||
/* src/css/_jinze.css */
|
||||
jz-jinze {
|
||||
|
||||
Vendored
+70
-11
@@ -963,7 +963,13 @@ var Juzhen = (() => {
|
||||
|
||||
// src/typeset/lineedge.ts
|
||||
var LE = "jz-half-le";
|
||||
var EPS = 4;
|
||||
var HANG_L = "jz-hang-l";
|
||||
var HANG_R = "jz-hang-r";
|
||||
var ALL_EDGE = [LE, HANG_L, HANG_R];
|
||||
var EPS = 2;
|
||||
var HEAD_CLASSES = /* @__PURE__ */ new Set(["bd-open"]);
|
||||
var TAIL_SQUEEZE = /* @__PURE__ */ new Set(["bd-close"]);
|
||||
var TAIL_HANG = /* @__PURE__ */ new Set(["bd-close", "bd-stop", "bd-pause"]);
|
||||
var observers = /* @__PURE__ */ new WeakMap();
|
||||
function hasLayout() {
|
||||
return typeof ResizeObserver !== "undefined";
|
||||
@@ -1047,15 +1053,44 @@ var Juzhen = (() => {
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[0] : null;
|
||||
}
|
||||
function lowerLine(a, b) {
|
||||
return b.top >= a.bottom - EPS;
|
||||
}
|
||||
function higherLine(a, b) {
|
||||
return b.bottom <= a.top + EPS;
|
||||
}
|
||||
function lastRect(el) {
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[rects.length - 1] : null;
|
||||
}
|
||||
function relayout(root, finder) {
|
||||
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
var HANG_ENABLED = false;
|
||||
function applyEdge(el, edge, mode) {
|
||||
if (mode === "squeeze") {
|
||||
el.classList.add(LE);
|
||||
return;
|
||||
}
|
||||
if (mode === "hang" && HANG_ENABLED) {
|
||||
el.classList.add(edge === "head" ? HANG_L : HANG_R);
|
||||
}
|
||||
}
|
||||
function clearEdge(root) {
|
||||
for (const cls of ALL_EDGE) {
|
||||
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
|
||||
}
|
||||
}
|
||||
function modeForChar(el, options) {
|
||||
const base = options.lineEnd;
|
||||
if (base !== "hang") {
|
||||
return base;
|
||||
}
|
||||
return "squeeze";
|
||||
}
|
||||
function relayout(root, finder, options) {
|
||||
clearEdge(root);
|
||||
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
|
||||
(c) => finder.inScope(c) && !finder.isAvoided(c)
|
||||
);
|
||||
const decisions = [];
|
||||
for (const el of chars) {
|
||||
if (!finder.levelAllows(el, "paragraph")) {
|
||||
continue;
|
||||
@@ -1063,26 +1098,42 @@ var Juzhen = (() => {
|
||||
if (!finder.featureEnabledFor(el, "jiya")) {
|
||||
continue;
|
||||
}
|
||||
const mode = modeForChar(el, options);
|
||||
if (mode === "none") {
|
||||
continue;
|
||||
}
|
||||
if (mode === "hang" && el.classList.contains("jz-half")) {
|
||||
continue;
|
||||
}
|
||||
const bd = bdClassOf(el);
|
||||
if (bd !== "bd-open" && bd !== "bd-close") {
|
||||
if (!bd) {
|
||||
continue;
|
||||
}
|
||||
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
|
||||
const isHead = HEAD_CLASSES.has(bd);
|
||||
const isTail = tailSet.has(bd);
|
||||
if (!isHead && !isTail) {
|
||||
continue;
|
||||
}
|
||||
const r = firstRect(el);
|
||||
if (!r) {
|
||||
continue;
|
||||
}
|
||||
if (bd === "bd-open") {
|
||||
if (isHead) {
|
||||
const prev = edgeRect(el.previousSibling, "prev", el);
|
||||
if (!prev || prev.top < r.top - EPS) {
|
||||
el.classList.add(LE);
|
||||
if (!prev || higherLine(r, prev)) {
|
||||
decisions.push({ el, edge: "head", mode });
|
||||
}
|
||||
} else {
|
||||
const next = edgeRect(el.nextSibling, "next", el);
|
||||
if (!next || next.top > r.top + EPS) {
|
||||
el.classList.add(LE);
|
||||
if (!next || lowerLine(r, next)) {
|
||||
decisions.push({ el, edge: "tail", mode });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const d of decisions) {
|
||||
applyEdge(d.el, d.edge, d.mode);
|
||||
}
|
||||
}
|
||||
var lineEdgePass = {
|
||||
name: "jiyaLineEdge",
|
||||
@@ -1094,11 +1145,11 @@ var Juzhen = (() => {
|
||||
if (!hasLayout()) {
|
||||
return;
|
||||
}
|
||||
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder));
|
||||
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options));
|
||||
},
|
||||
revert(ctx) {
|
||||
uninstallLayout(ctx.root, "lineEdge");
|
||||
ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
clearEdge(ctx.root);
|
||||
}
|
||||
};
|
||||
var TRIM = "jz-hws-trim";
|
||||
@@ -1739,6 +1790,14 @@ var Juzhen = (() => {
|
||||
},
|
||||
longWord: resolveLongWord(opts.longWord),
|
||||
orphan: resolveOrphan(opts.orphan),
|
||||
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
|
||||
// 經 headless + 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
|
||||
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
|
||||
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
|
||||
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
|
||||
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
|
||||
// hang 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。
|
||||
lineEnd: "squeeze",
|
||||
jiyaConfig: resolveJiya(opts.jiya),
|
||||
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
||||
finder: {
|
||||
|
||||
Vendored
+70
-11
@@ -935,7 +935,13 @@ var jinzePass = {
|
||||
|
||||
// src/typeset/lineedge.ts
|
||||
var LE = "jz-half-le";
|
||||
var EPS = 4;
|
||||
var HANG_L = "jz-hang-l";
|
||||
var HANG_R = "jz-hang-r";
|
||||
var ALL_EDGE = [LE, HANG_L, HANG_R];
|
||||
var EPS = 2;
|
||||
var HEAD_CLASSES = /* @__PURE__ */ new Set(["bd-open"]);
|
||||
var TAIL_SQUEEZE = /* @__PURE__ */ new Set(["bd-close"]);
|
||||
var TAIL_HANG = /* @__PURE__ */ new Set(["bd-close", "bd-stop", "bd-pause"]);
|
||||
var observers = /* @__PURE__ */ new WeakMap();
|
||||
function hasLayout() {
|
||||
return typeof ResizeObserver !== "undefined";
|
||||
@@ -1019,15 +1025,44 @@ function firstRect(el) {
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[0] : null;
|
||||
}
|
||||
function lowerLine(a, b) {
|
||||
return b.top >= a.bottom - EPS;
|
||||
}
|
||||
function higherLine(a, b) {
|
||||
return b.bottom <= a.top + EPS;
|
||||
}
|
||||
function lastRect(el) {
|
||||
const rects = el.getClientRects();
|
||||
return rects.length ? rects[rects.length - 1] : null;
|
||||
}
|
||||
function relayout(root, finder) {
|
||||
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
var HANG_ENABLED = false;
|
||||
function applyEdge(el, edge, mode) {
|
||||
if (mode === "squeeze") {
|
||||
el.classList.add(LE);
|
||||
return;
|
||||
}
|
||||
if (mode === "hang" && HANG_ENABLED) {
|
||||
el.classList.add(edge === "head" ? HANG_L : HANG_R);
|
||||
}
|
||||
}
|
||||
function clearEdge(root) {
|
||||
for (const cls of ALL_EDGE) {
|
||||
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
|
||||
}
|
||||
}
|
||||
function modeForChar(el, options) {
|
||||
const base = options.lineEnd;
|
||||
if (base !== "hang") {
|
||||
return base;
|
||||
}
|
||||
return "squeeze";
|
||||
}
|
||||
function relayout(root, finder, options) {
|
||||
clearEdge(root);
|
||||
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
|
||||
(c) => finder.inScope(c) && !finder.isAvoided(c)
|
||||
);
|
||||
const decisions = [];
|
||||
for (const el of chars) {
|
||||
if (!finder.levelAllows(el, "paragraph")) {
|
||||
continue;
|
||||
@@ -1035,26 +1070,42 @@ function relayout(root, finder) {
|
||||
if (!finder.featureEnabledFor(el, "jiya")) {
|
||||
continue;
|
||||
}
|
||||
const mode = modeForChar(el, options);
|
||||
if (mode === "none") {
|
||||
continue;
|
||||
}
|
||||
if (mode === "hang" && el.classList.contains("jz-half")) {
|
||||
continue;
|
||||
}
|
||||
const bd = bdClassOf(el);
|
||||
if (bd !== "bd-open" && bd !== "bd-close") {
|
||||
if (!bd) {
|
||||
continue;
|
||||
}
|
||||
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
|
||||
const isHead = HEAD_CLASSES.has(bd);
|
||||
const isTail = tailSet.has(bd);
|
||||
if (!isHead && !isTail) {
|
||||
continue;
|
||||
}
|
||||
const r = firstRect(el);
|
||||
if (!r) {
|
||||
continue;
|
||||
}
|
||||
if (bd === "bd-open") {
|
||||
if (isHead) {
|
||||
const prev = edgeRect(el.previousSibling, "prev", el);
|
||||
if (!prev || prev.top < r.top - EPS) {
|
||||
el.classList.add(LE);
|
||||
if (!prev || higherLine(r, prev)) {
|
||||
decisions.push({ el, edge: "head", mode });
|
||||
}
|
||||
} else {
|
||||
const next = edgeRect(el.nextSibling, "next", el);
|
||||
if (!next || next.top > r.top + EPS) {
|
||||
el.classList.add(LE);
|
||||
if (!next || lowerLine(r, next)) {
|
||||
decisions.push({ el, edge: "tail", mode });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const d of decisions) {
|
||||
applyEdge(d.el, d.edge, d.mode);
|
||||
}
|
||||
}
|
||||
var lineEdgePass = {
|
||||
name: "jiyaLineEdge",
|
||||
@@ -1066,11 +1117,11 @@ var lineEdgePass = {
|
||||
if (!hasLayout()) {
|
||||
return;
|
||||
}
|
||||
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder));
|
||||
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options));
|
||||
},
|
||||
revert(ctx) {
|
||||
uninstallLayout(ctx.root, "lineEdge");
|
||||
ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
clearEdge(ctx.root);
|
||||
}
|
||||
};
|
||||
var TRIM = "jz-hws-trim";
|
||||
@@ -1711,6 +1762,14 @@ function normalizeOptions(opts = {}) {
|
||||
},
|
||||
longWord: resolveLongWord(opts.longWord),
|
||||
orphan: resolveOrphan(opts.orphan),
|
||||
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
|
||||
// 經 headless + 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
|
||||
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
|
||||
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
|
||||
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
|
||||
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
|
||||
// hang 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。
|
||||
lineEnd: "squeeze",
|
||||
jiyaConfig: resolveJiya(opts.jiya),
|
||||
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
||||
finder: {
|
||||
|
||||
Vendored
+8
@@ -12,6 +12,9 @@ export interface OrphanOptions {
|
||||
/** 段末行至少保留之 CJK 實義字數(標點/符號不計;預設 2)。 */
|
||||
chars?: number;
|
||||
}
|
||||
/** 行端標點模式(§6.5.2):squeeze=行端半形(現有,預設);hang=標點懸掛右/左凸;
|
||||
* none=不處理。squeeze 與 hang 同屬渲染層、互斥(共用行端偵測碼)。 */
|
||||
export type LineEndMode = "squeeze" | "hang" | "none";
|
||||
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
|
||||
export interface JiyaOptions {
|
||||
/** 半形渲染機制:
|
||||
@@ -68,6 +71,9 @@ export interface JuzhenOptions {
|
||||
jiya?: boolean | JiyaOptions;
|
||||
jinze?: boolean;
|
||||
orphan?: boolean | OrphanOptions;
|
||||
/** @deprecated 標點懸掛**已全面停用**(§6.5.6):純負 margin 機制不可行(墨色不出版心/
|
||||
* 滿凸拉升後字重疊/resize 重排錯亂),原生 hanging-punctuation 為正解但 Chrome 不支援。
|
||||
* 此選項仍被接受以保 API 相容,但**無任何效果**(行端恒 squeeze);俟原生支援成熟再議。 */
|
||||
hanging?: boolean;
|
||||
biaodian?: boolean;
|
||||
emphasis?: boolean;
|
||||
@@ -114,6 +120,8 @@ export interface ResolvedOptions {
|
||||
orphan: {
|
||||
chars: number;
|
||||
};
|
||||
/** 行端標點模式(解析後;§6.5.2)。 */
|
||||
lineEnd: LineEndMode;
|
||||
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
|
||||
jiyaConfig: {
|
||||
halfWidth: "halt" | "margin";
|
||||
|
||||
@@ -120,3 +120,35 @@ jz-char.jz-half-le jz-inner
|
||||
{
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* 標點懸掛(§6.5.2,hang 模式;由 lineEdge layout pass 標 jz-hang-*)。
|
||||
行尾點號/閉類右凸出右緣(jz-hang-r)、行首開類左凸出左緣(jz-hang-l),令文字格線
|
||||
齊版心、標點溢出。**自管負 margin**——不用 CSS hanging-punctuation(瀏覽器支援差+
|
||||
已被原子透明 reset)。
|
||||
凸出量 **0.5em**(句末/句內點號 --jz-hang-stop、括號引號 --jz-hang-bracket,CSS 變數
|
||||
可逐字型調校)。**關鍵約束(headless 實測):凸出量必須 < 一個 CJK 字寬(1em)**——
|
||||
負 margin 會減少該行斷行寬度,若釋放出 ≥1em 空間,下一字會被拉上本行、與懸掛標點
|
||||
重疊(實測 1em 時每個行末標點皆與後字重疊一整字)。0.5em 釋放半字、不足容下後字,
|
||||
故後字不上移、標點穩定凸出半形。代價:正文格線距版心約剩 0.5em(非完全齊邊)——純
|
||||
負 margin 機制無法在不拉升後字下做到全凸,全凸須改用原生 hanging-punctuation(見 §6.5.6)。
|
||||
已 baseline 半形者(jz-half)不懸掛(JS 端跳過:無留白可凸;連續標點經鄰接挤压為半形
|
||||
者亦在此列)。繁體預設不懸掛(逐 lang 閘)。
|
||||
⚠ **需 justifyAtoms:false(jz-char 為 inline)**:預設 inline-block 之 justify 對齊
|
||||
原子模型下,負 margin 會被 justify 重分配吸收、標點不凸出(headless 實測)。懸掛與
|
||||
inline-block 對齊原子互斥;欲懸掛須設 justifyAtoms:false(見 README)。容器宜留左右
|
||||
padding ≥0.5em 以容納凸出之標點。 */
|
||||
jz-char.bd-stop.jz-hang-r,
|
||||
jz-char.bd-pause.jz-hang-r
|
||||
{
|
||||
margin-right: calc(-1 * var(--jz-hang-stop, 0.5em));
|
||||
}
|
||||
|
||||
jz-char.bd-close.jz-hang-r
|
||||
{
|
||||
margin-right: calc(-1 * var(--jz-hang-bracket, 0.5em));
|
||||
}
|
||||
|
||||
jz-char.bd-open.jz-hang-l
|
||||
{
|
||||
margin-left: calc(-1 * var(--jz-hang-bracket, 0.5em));
|
||||
}
|
||||
|
||||
@@ -116,6 +116,14 @@ export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
|
||||
},
|
||||
longWord: resolveLongWord(opts.longWord),
|
||||
orphan: resolveOrphan(opts.orphan),
|
||||
// 行端模式恒為 squeeze(行端半形)。**標點懸掛 hang 已全面停用**(§6.5.2/§6.5.6):
|
||||
// 經 headless + 真實瀏覽器實測,純負 margin 機制不可行——句號墨色偏框左下角,安全
|
||||
// 凸出量(<1em)只把空白半框推出版心、墨色仍在內(視覺不懸掛);凸出滿 1em 又會釋放
|
||||
// 一字寬、把下一字拉上本行致重疊;且 ResizeObserver 重排時兩階段標記錯亂。原生
|
||||
// hanging-punctuation 為正解但 Chrome 不支援、且與本庫 inline-block 原子衝突。
|
||||
// 故此處**唯一關卡**鎖死為 squeeze——即使 opts.hanging 為 true 亦不啟用 hang(無逃逸);
|
||||
// hang 之 CSS/lineedge 程式碼保留(休眠),俟原生支援成熟再議。
|
||||
lineEnd: "squeeze",
|
||||
jiyaConfig: resolveJiya(opts.jiya),
|
||||
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
|
||||
finder: {
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface OrphanOptions
|
||||
chars?: number;
|
||||
}
|
||||
|
||||
/** 行端標點模式(§6.5.2):squeeze=行端半形(現有,預設);hang=標點懸掛右/左凸;
|
||||
* none=不處理。squeeze 與 hang 同屬渲染層、互斥(共用行端偵測碼)。 */
|
||||
export type LineEndMode = "squeeze" | "hang" | "none";
|
||||
|
||||
/** 標點擠壓設定(架構 §6.3)。布林 true 等同 { halfWidth: "halt" }。 */
|
||||
export interface JiyaOptions
|
||||
{
|
||||
@@ -88,6 +92,9 @@ export interface JuzhenOptions
|
||||
jiya?: boolean | JiyaOptions;
|
||||
jinze?: boolean;
|
||||
orphan?: boolean | OrphanOptions;
|
||||
/** @deprecated 標點懸掛**已全面停用**(§6.5.6):純負 margin 機制不可行(墨色不出版心/
|
||||
* 滿凸拉升後字重疊/resize 重排錯亂),原生 hanging-punctuation 為正解但 Chrome 不支援。
|
||||
* 此選項仍被接受以保 API 相容,但**無任何效果**(行端恒 squeeze);俟原生支援成熟再議。 */
|
||||
hanging?: boolean;
|
||||
biaodian?: boolean;
|
||||
emphasis?: boolean;
|
||||
@@ -134,6 +141,8 @@ export interface ResolvedOptions
|
||||
longWord: { minLength: number; lang: string };
|
||||
/** 垂懸字避免設定(解析後)。 */
|
||||
orphan: { chars: number };
|
||||
/** 行端標點模式(解析後;§6.5.2)。 */
|
||||
lineEnd: LineEndMode;
|
||||
/** 擠壓半形渲染設定(halt 預設/margin 後備)。 */
|
||||
jiyaConfig: { halfWidth: "halt" | "margin"; bias: BiasTable };
|
||||
blocks: BlockRule[];
|
||||
|
||||
+106
-21
@@ -1,7 +1,11 @@
|
||||
// 行端擠壓(架構 §6.4/§6.5 之行端部分;clreq 行首行尾擠壓)。機制 B。
|
||||
// standalone layout pass:量測各 jz-char 之行盒位置,對「行首之開類」收左、
|
||||
// 「行尾之閉類/點號」收右(加 jz-le-cl/jz-le-cr,CSS 同 -0.5em margin)。
|
||||
// 掛 ResizeObserver 於 root,容器寬度變動時重算;字型載入後亦重算。
|
||||
// 行端標點處理(架構 §6.4/§6.5;clreq 行首行尾擠壓 + 標點懸掛)。機制 B。
|
||||
// standalone layout pass:量測各 jz-char 之行盒位置,偵測「行首開類」「行尾閉類/
|
||||
// 點號」,按**行端模式**套用——
|
||||
// · squeeze(現有半形,預設):行首開類收左、行尾閉類收右半形(jz-half-le,同 halt);
|
||||
// · hang(標點懸掛,§6.5.2):行首開類左凸、行尾閉類/點號右凸出版心(jz-hang-*,負 margin);
|
||||
// · none:不處理。
|
||||
// 行端偵測碼共用、僅「合格 bd 子類集」與「套用之 class」隨模式而異。掛 ResizeObserver
|
||||
// 於 root,容器寬度變動時重算;字型載入後亦重算。
|
||||
//
|
||||
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
|
||||
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
|
||||
@@ -9,10 +13,20 @@
|
||||
|
||||
import { bdClassOf } from "../core/dom.js";
|
||||
import type { Finder } from "../core/finder.js";
|
||||
import type { RenderContext, StandalonePass } from "../types.js";
|
||||
import type { LineEndMode, RenderContext, ResolvedOptions, StandalonePass } from "../types.js";
|
||||
|
||||
const LE = "jz-half-le"; // 行端半形(CSS 同 halt;獨立 class 以便重算時只清行端結果)
|
||||
const EPS = 4; // px:判定「不同行」之 top 差容差。
|
||||
const LE = "jz-half-le"; // 行端半形(squeeze 模式;CSS 同 halt)
|
||||
const HANG_L = "jz-hang-l"; // 行首開類左凸(hang 模式;負 margin-left)
|
||||
const HANG_R = "jz-hang-r"; // 行尾閉類/點號右凸(hang 模式;負 margin-right)
|
||||
const ALL_EDGE = [ LE, HANG_L, HANG_R ];
|
||||
const EPS = 2; // px:行盒比較之亞像素容差。
|
||||
|
||||
// 行端合格之 bd 子類(隨模式):head=行首左緣偵測,tail=行尾右緣偵測。
|
||||
// squeeze 僅括號引號(沿用現有:點號於行端保持全形,避免孤立句末誤收);
|
||||
// hang 之 tail 增點號(行末句號/逗號右凸為懸掛之經典用例)。
|
||||
const HEAD_CLASSES = new Set([ "bd-open" ]);
|
||||
const TAIL_SQUEEZE = new Set([ "bd-close" ]);
|
||||
const TAIL_HANG = new Set([ "bd-close", "bd-stop", "bd-pause" ]);
|
||||
|
||||
// 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。
|
||||
const observers = new WeakMap<Element, Map<string, ResizeObserver>>();
|
||||
@@ -102,6 +116,21 @@ function firstRect(el: Element): DOMRect | null
|
||||
return rects.length ? rects[0]! : null;
|
||||
}
|
||||
|
||||
// 行歸屬比較須以**垂直區間**而非單純 top 差判定:jz-char 為 inline-block,其行盒矩形
|
||||
// 之 top 與相鄰純文本字形矩形之 top 因基線/盒高差異常差數像素(實測 ~5px),遠小於
|
||||
// 行高(如 2em=40px)卻足以超過小容差 → 誤判同行為「下一行」(hang 模式句末 。 過度
|
||||
// 觸發之根因)。改判「b 是否整體落於 a 之下一行」= b.top ≥ a.bottom(容 EPS 亞像素),
|
||||
// 同行(垂直重疊)則否。對稱地,higherLine 判 b 在 a 之上一行。
|
||||
function lowerLine(a: DOMRect, b: DOMRect): boolean
|
||||
{
|
||||
return b.top >= a.bottom - EPS;
|
||||
}
|
||||
|
||||
function higherLine(a: DOMRect, b: DOMRect): boolean
|
||||
{
|
||||
return b.bottom <= a.top + EPS;
|
||||
}
|
||||
|
||||
// 元素之**最後**一個行盒矩形。jz-hws 之 margin-right 落在其內容末尾之 fragment,
|
||||
// 故行末判定須以此 rect(而非 firstRect)為準——否則包裹會跨行之 pill 時,firstRect
|
||||
// 取到首行矩形、與末行之後續內容比較會誤判(見 trimGaps)。
|
||||
@@ -111,40 +140,96 @@ function lastRect(el: Element): DOMRect | null
|
||||
return rects.length ? rects[rects.length - 1]! : null;
|
||||
}
|
||||
|
||||
function relayout(root: Element, finder: Finder): void
|
||||
// ⚠ **標點懸掛總開關(防逃逸第三層)**。hang 已全面停用(§6.5.2/§6.5.6):經實測,純
|
||||
// 負 margin 機制不可行(墨色不出版心/滿凸拉升後字重疊/resize 重排錯亂)。此常數為 false
|
||||
// 時,applyEdge 永不施加 jz-hang-*,與「index 鎖 lineEnd=squeeze」「modeForChar 降 squeeze」
|
||||
// 三重保證:縱使下游以任何方式(hanging 選項/data-juzhen/blocks/直接改 lineEnd)要求,
|
||||
// 亦絕無可能產生懸掛標記。hang 之程式碼與 CSS 保留休眠,俟原生 hanging-punctuation 成熟再議。
|
||||
const HANG_ENABLED = false;
|
||||
|
||||
/** 套用行端結果之 class(按模式):head=行首左緣、tail=行尾右緣。 */
|
||||
function applyEdge(el: Element, edge: "head" | "tail", mode: LineEndMode): void
|
||||
{
|
||||
// 清除上次行端 class。
|
||||
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
if (mode === "squeeze") { el.classList.add(LE); return; }
|
||||
if (mode === "hang" && HANG_ENABLED) { el.classList.add(edge === "head" ? HANG_L : HANG_R); }
|
||||
}
|
||||
|
||||
function clearEdge(root: Element): void
|
||||
{
|
||||
for (const cls of ALL_EDGE)
|
||||
{
|
||||
root.querySelectorAll("jz-char." + cls).forEach((e) => e.classList.remove(cls));
|
||||
}
|
||||
}
|
||||
|
||||
/** 該標點 jz-char 之有效行端模式。
|
||||
* ⚠ **防逃逸**:標點懸掛 hang 已全面停用(見 index.ts/§6.5.6)——縱使 base 為 "hang"
|
||||
* (理論上不會,index 已鎖 squeeze),此處一律降回 squeeze,確保不產生任何 jz-hang-*。
|
||||
* hang 之逐 lang 閘邏輯保留於註解供日後重啟:繁體橫排降 squeeze、簡體可 hang。 */
|
||||
function modeForChar(el: Element, options: ResolvedOptions): LineEndMode
|
||||
{
|
||||
const base = options.lineEnd;
|
||||
if (base !== "hang") { return base; }
|
||||
// hang 已停用:無條件降 squeeze(防逃逸)。下行為日後重啟之逐 lang 閘(現不可達):
|
||||
// if (classify(effectiveLang(el, options.locale.default)) === "zh-Hant") return "squeeze";
|
||||
return "squeeze";
|
||||
}
|
||||
|
||||
interface EdgeDecision { el: Element; edge: "head" | "tail"; mode: LineEndMode; }
|
||||
|
||||
/**
|
||||
* 行端偵測 + 模式套用(共用偵測碼;合格 bd 集與套用 class 隨模式而異)。
|
||||
*
|
||||
* **兩階段免自我應驗**(§6.5.2 重點風險):先 clearEdge 清除全部行端 margin/半形,
|
||||
* 再於「自然(無行端效果)佈局」下量測**全部**標點之行端狀態(階段 1,不變更 DOM),
|
||||
* 最後一次性套用(階段 2)。如此每次量測皆見無污染之自然佈局——既不受自身、亦不受
|
||||
* 其他標點之 hang 負 margin/halt 收縮影響(避免迴圈內逐一 add class 致下游量測偏移,
|
||||
* 同 gapTrim 之雙向污染教訓)。ResizeObserver 僅觀 root 尺寸、不因套用後內部重排再
|
||||
* 觸發,故終態穩定(基於自然斷行決定懸掛者,正是所欲)。 */
|
||||
function relayout(root: Element, finder: Finder, options: ResolvedOptions): void
|
||||
{
|
||||
clearEdge(root);
|
||||
|
||||
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
|
||||
(c) => finder.inScope(c) && !finder.isAvoided(c),
|
||||
);
|
||||
|
||||
const decisions: EdgeDecision[] = [];
|
||||
for (const el of chars)
|
||||
{
|
||||
// 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不收行端半形。
|
||||
// 行端為段落級(§6.5.0a):text-level 子樹(單行標題等)不處理行端。
|
||||
if (!finder.levelAllows(el, "paragraph")) { continue; }
|
||||
if (!finder.featureEnabledFor(el, "jiya")) { continue; }
|
||||
// 行端僅處理括號引號:開類於行首收左、閉類於行尾收右。
|
||||
// **不**處理句末/句內點號(。!?,等)——行尾/行首之點號保持全形,
|
||||
// 避免「孤立句末 。/?!之 ! 落在行末」被誤收為半形(實測之困惑來源)。
|
||||
// `。」` 行尾仍為 1em:其中 。 由鄰接(在 」 前)收,非由行端收。
|
||||
const mode = modeForChar(el, options);
|
||||
if (mode === "none") { continue; }
|
||||
// hang 模式跳過已 baseline 半形(jz-half)之標點:其字身已收為 0.5em、無留白可
|
||||
// 凸出,再加負 margin 會把墨色整體拉出版心。故僅全形標點(有留白者)懸掛;
|
||||
// 已半形者保持原狀(行端本就緊湊)。
|
||||
if (mode === "hang" && el.classList.contains("jz-half")) { continue; }
|
||||
const bd = bdClassOf(el);
|
||||
if (bd !== "bd-open" && bd !== "bd-close") { continue; }
|
||||
if (!bd) { continue; }
|
||||
const tailSet = mode === "hang" ? TAIL_HANG : TAIL_SQUEEZE;
|
||||
const isHead = HEAD_CLASSES.has(bd);
|
||||
const isTail = tailSet.has(bd);
|
||||
if (!isHead && !isTail) { continue; }
|
||||
// squeeze 不處理點號(行尾/行首點號保持全形,避免孤立句末誤收,實測困惑來源):
|
||||
// 已由 TAIL_SQUEEZE 僅含 bd-close 保證;hang 之 tail 始含點號(懸掛經典用例)。
|
||||
const r = firstRect(el);
|
||||
if (!r) { continue; }
|
||||
|
||||
if (bd === "bd-open")
|
||||
if (isHead)
|
||||
{
|
||||
const prev = edgeRect(el.previousSibling, "prev", el);
|
||||
if (!prev || prev.top < r.top - EPS) { el.classList.add(LE); }
|
||||
if (!prev || higherLine(r, prev)) { decisions.push({ el, edge: "head", mode }); }
|
||||
}
|
||||
else
|
||||
{
|
||||
const next = edgeRect(el.nextSibling, "next", el);
|
||||
if (!next || next.top > r.top + EPS) { el.classList.add(LE); }
|
||||
if (!next || lowerLine(r, next)) { decisions.push({ el, edge: "tail", mode }); }
|
||||
}
|
||||
}
|
||||
|
||||
for (const d of decisions) { applyEdge(d.el, d.edge, d.mode); }
|
||||
}
|
||||
|
||||
export const lineEdgePass: StandalonePass = {
|
||||
@@ -156,12 +241,12 @@ export const lineEdgePass: StandalonePass = {
|
||||
render(ctx: RenderContext): void
|
||||
{
|
||||
if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。
|
||||
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder));
|
||||
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder, ctx.options));
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
uninstallLayout(ctx.root, "lineEdge");
|
||||
ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
|
||||
clearEdge(ctx.root);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -782,3 +782,47 @@ test("垂懸字:段落級——data-jz-level=text 子樹跳過", () =>
|
||||
createJuzhen().render(document.body);
|
||||
assert.equal(countTag(document.querySelector("p"), "jz-orphan"), 0, "text-level 不跑垂懸字");
|
||||
});
|
||||
|
||||
// ----- 標點懸掛(§6.5.2,行端模式接線;版面凸出量於 headless Chrome 驗證)-----
|
||||
|
||||
test("懸掛:已全面停用——hanging 預設關,行端模式 squeeze", () =>
|
||||
{
|
||||
const jz = createJuzhen();
|
||||
assert.equal(jz.options.lineEnd, "squeeze", "行端模式恒 squeeze");
|
||||
});
|
||||
|
||||
test("懸掛:防逃逸——即使 hanging:true,行端模式仍鎖死 squeeze(不啟用 hang)", () =>
|
||||
{
|
||||
const jz = createJuzhen({ hanging: true });
|
||||
// option 仍被接受(API 不破),但 lineEnd 鎖死 squeeze——hang 不可能激活(§6.5.6 停用)。
|
||||
assert.equal(jz.options.lineEnd, "squeeze", "hanging:true 亦不轉 hang(無逃逸)");
|
||||
});
|
||||
|
||||
test("懸掛:防逃逸——hanging:true render 後不產生任何 jz-hang-* 標記", () =>
|
||||
{
|
||||
const { document } = setupDom('<p lang="zh-Hans">春。夏。秋。冬。</p>');
|
||||
createJuzhen({ hanging: true, justifyAtoms: false }).render(document.body);
|
||||
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "無 jz-hang-r");
|
||||
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "無 jz-hang-l");
|
||||
});
|
||||
|
||||
test("懸掛:防逃逸——下游 data-juzhen=\"hanging\" 亦不啟用(lineEnd 不受 per-node 影響)", () =>
|
||||
{
|
||||
const { document } = setupDom('<p lang="zh-Hans" data-juzhen="spacing jiya jinze hanging">春。夏。秋。冬。</p>');
|
||||
const jz = createJuzhen({ hanging: true, justifyAtoms: false });
|
||||
jz.render(document.body);
|
||||
assert.equal(jz.options.lineEnd, "squeeze", "lineEnd 恒 squeeze");
|
||||
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "data-juzhen 要求亦無 jz-hang-r");
|
||||
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "data-juzhen 要求亦無 jz-hang-l");
|
||||
});
|
||||
|
||||
test("懸掛:防逃逸——blocks features:[\"hanging\"] 亦不啟用", () =>
|
||||
{
|
||||
const { document } = setupDom('<p class="q" lang="zh-Hans">春。夏。秋。冬。</p>');
|
||||
createJuzhen({
|
||||
hanging: true, justifyAtoms: false,
|
||||
blocks: [ { selector: ".q", features: [ "spacing", "jiya", "hanging" ] } ],
|
||||
}).render(document.body);
|
||||
assert.equal(countTag(document.body, "jz-char.jz-hang-r"), 0, "blocks 要求亦無 jz-hang-r");
|
||||
assert.equal(countTag(document.body, "jz-char.jz-hang-l"), 0, "blocks 要求亦無 jz-hang-l");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user