feat: 聚珍切片 1 — 中西間隙/標點擠壓/禁則 + v1 相容層

從零重寫之第一垂直切片(依 ARCHITECTURE.md 契約),涵蓋並超越 v1 全部功能。

核心基座(src/core):unicode 字元表+標點子類+字形側位;locale 語言與
全角/開明政策(全域 lang.style +逐 lang policy);dom(jz-* 工廠+可還原
標記);finder 遍歷引擎(avoid/scope.include/邏輯 run/pill 鄰字/分塊閘);
pass 聚合管線。

功能 pass(src/typeset):
- spacing:中西間隙採 margin 模型——包裹邊界左側字、以 margin-right 留隙,
  不注入空白字元(textContent 完全不變、複製緊湊、justify 不暴增、不撐框、
  行首乾淨)。含跨樣式邊界、pill、原檔空格移除。
- gapTrim(lineedge):layout pass,行末去隙,修正 justify 右緣內縮。
- longword:長英文詞 <span lang> + hyphens。
- jiya:標點 charify + 全角/開明/半角(全域 + 逐 lang);半形以字型 OpenType
  halt 渲染(按字形正確定位、不重疊,居中字形 !? 與繁體 locl 句逗點號皆正確);
  行內連續擠壓。
- jinze:行首行末避頭尾(閉類/點號綁前字、開類綁後字,以詞元為邊界、長引文
  中段可斷);亦為 justify 綁定載體。
- lineedge:行端擠壓 layout pass(括號引號;ResizeObserver 重算,瀏覽器專用)。

§8 justify:jz-char inline-block 原子,句末標點單份間距。
v1 相容層(compat/v1):createCjkAutospace().apply 映射,IIFE 全域別名。
構建:esbuild → ESM/IIFE/CSS;tsc → d.ts。測試:39 個 jsdom 案例。
demo:簡繁並列+justify+強制繁體開明+標點寬度量測,載 Noto webfont 作參考
(擠壓依賴字型 halt)。間隔號 · 不觸發中西間隙;200% 等詞元不被禁則切斷。
This commit is contained in:
2026-06-01 21:31:07 +08:00
parent 8098991b6b
commit 2d42c7ecf2
46 changed files with 7737 additions and 27 deletions
+57 -27
View File
@@ -76,8 +76,9 @@ jz.revert(root?); // 還原
```
{
// 全域
lang: { default: "zh-Hant", policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" } },
// 全域。style = 全域擠壓預設(quanjiaokaimingbanjiao);policy = 逐 lang
// 覆寫。優先序:policy[lang] > style > 內建預設(繁→全角、簡→開明)。
lang: { default: "zh-Hant", style: "kaiming", policy: { "zh-Hant": "quanjiao" } },
classPrefix: "jz",
// 作用域限定(§3.6
@@ -313,13 +314,28 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
- **Han**:插入 `<h-hws hidden>`,內含**真實空格** + `::before{content:' '}`
**未加 `user-select:none`** → 選取/複製會帶上空格(其已知缺陷)。
- **我們**finder 邊界感知地在 CJK↔西文邊界插入 `jz-hws`;間隙寬度由 CSS
`::before` 或內含空白 + 負微調)給定,元素 `user-select:none`I1**修正
Han 的選中缺陷**)。WHERE 之規則沿用 v1 pangu 規則表(已驗證)
- **跨瀏覽器**`user-select`inline 普遍支援
- **lang**:間隙量可依 `:lang` 微調(如日文與中文略異),預設一致。
- **調整**copy-cleanI1)+ justify 安全(I5/§8)。涵蓋 v1 Pass A、Pass B
pill `code/kbd` 邊界亦走同一機制,pill 由 avoid 與鄰接判定處理)。
- **我們margin 模型)**finder 邊界感知地把邊界**左側之一個字**(CJK 或西文)
包進 `jz-hws`,間隙由 CSS `margin-right`(0.25em)提供——**不注入任何空白字元**。
WHERE 之規則沿用 v1 pangu 規則表(已驗證);作者原寫之 CJK↔西文空格一律移除
(改由 margin 留隙)
- **為何不用空白字元/inline-blockjustify 三難)**:純 CSS 下,CJK↔西文間隙在
`justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定
寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin),
因其行首乾淨、不撐框、不暴增、`textContent` 完全不變(純 copy-clean,複製緊湊),
並以 §6.1a 之 layout pass 消除行末內縮。
- **跨瀏覽器**`margin`inline 普遍支援。
- **copy-cleanI1,較 Han 更純)**:間隙為純視覺 margin,`textContent` 不增不減
語義字元,剪貼板得緊湊原文。
- **切片**:涵蓋 v1 Pass A、Pass Bpill `code/kbd` 邊界亦走同一機制——左字或
pill 元素包入 `jz-hws`pill 由 avoid 與鄰接判定處理)。
### 6.1a 行末去隙(gapTrimjustify 修正)— 切片 1
- **問題**margin-right 落在行末時,justify 下使右緣內縮 0.25em。
- **我們**layout pass `gapTrim` 量測各 `jz-hws`,對「其字落於行末(後一內容在
更低行)」者加 `jz-hws-trim`CSS `margin-right:0`),該行遂齊右、無內縮。掛
`ResizeObserver`+字型載入後重算。純版面、瀏覽器專用(jsdom 跳過)。與 `lineedge`
共用量測工具與 layout 安裝器。
### 6.2 長英文詞斷詞包裝 — 切片 1
@@ -334,25 +350,36 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
- **Han**charify 標點為 `<h-char class="bd-jiya …"><h-inner>`,以負
`letter-spacing``margin` 擠壓;連續、行端、簡繁差異以 CSS + `:lang()` 落實
`bd-cop:lang(zh-Hant)` → 不擠=全角式)。
- **我們**:同構——charify → `jz-char.bd-jiya` + `jz-inner`擠壓量寫在
`juzhen.css`依 `bd-*` class 與 `:lang()` 套用。**全角式**zh-Hant`bd-cop`
不擠)/**開明式**zh-Hans`bd-cop` 擠半形),政策由 `locale.style` 與
`:lang()` 一致驅動(I3)。
- **跨瀏覽器(I4**:用負 `margin``letter-spacing`**不依賴** `halt`
`text-spacing-trim`,故 ChromeSafariFF 一致。(註:v1 Pass D 用 `halt`
跨瀏覽器不一致;此處即為改進。)
- **調整**:行端擠壓以 `jz-cs` 補償空白於行邊界折疊之機制達成(CSS 主導,免
逐行量測);justify 安全見 §8。
- **切片**:涵蓋並超越 v1 Pass D(開明式),且加入全角式與 lang 切換。
- **我們**:同構——charify → `jz-char.bd-*` + `jz-inner`半形與否寫在
`juzhen.css`由 JS 解析 `locale.style`(讀 lang)後加 `jz-half``jz-half-le`
class 觸發(I3;改用 class 而非 CSS `:lang()`,以避免 `:lang(zh-Hans)` 不匹配
`zh-CN` 等地區標籤之陷阱)。三機制並集落地:①**baseline 政策**(全角不擠/
開明擠句內點號+括號引號/半角全擠);②**行內連續擠壓**(`jiyaAdjacencyPass`
相鄰標點對向側收一字,lang 無關);③**行端擠壓**(`lineedge.ts` layout pass)。
- **跨瀏覽器(I4)— 修訂**:半形改以字型 OpenType **`halt`**`font-feature-
settings:"halt"`)渲染,**非**負 `margin`。原因:負 margin 假設可壓縮空白全在
單側,對**居中字形**`!?`、繁體 `locl` 下之 `。,`)會把字面拉入鄰字而**重疊**
`halt` 收的是字身寬度、由字型按字形正確定位(簡左/繁中/括號偏側皆對),故
不重疊。`font-feature-settings:"halt"` 各引擎一致(當初疑慮之不一致者為**自動**
的 `text-spacing-trim``chws`,非顯式 `halt`);缺 `halt` 之字型優雅降級為全形。
鄰接/行端之「收哪一字」之**決策**仍由 `blankSides` 邏輯定,渲染統一為 `halt`。
- **切片**:涵蓋並超越 v1 Pass D(開明式),加入全角式、lang 切換、行內連續與
行端擠壓。
### 6.4 禁則(Jinze,避頭尾)+ justify 綁定 — 切片 1(基礎)/後續(完整)
- **Han**`h-jinze` 綁定行首行尾不可出現之標點與鄰字,避免標點落行首/行尾。
- **我們**`jz-jinze` 綁定為不可斷單元CSS `display:inline-block` 或等價,內部
不開 justify gap)。**此亦為 §8 justify 單份間距之載體**:句末標點與其字面留白
成一原子單元,justify 伸縮只落單元之間。
- **切片**:切片 1 先做「足以保證 §8 與標點不破壞間距」之最小綁定;完整避頭尾
(所有禁則對象、行首縮排聯動)列後續,但用同一 `jz-jinze` 結構,無返工。
- **我們**`jz-jinze`CSS `display:inline-block; white-space:nowrap`)綁定不可斷
單元。**避頭尾已實作**`jinze.ts`):因 jiya 把標點原子化為 inline-block,原子
邊界成可斷點、破壞瀏覽器原生 UAX#14 避頭尾,故補回——把「不可置行首」之標點
(閉類/點號/間隔/連接省略)綁到**前一字**、「不可置行末」之開類綁到**後一字**,
且**只隔離單一邊界字**(長引文中段仍正常斷行,不溢出)。實作為兩階段:依原始
相鄰算禁斷邊界→切邊界字;重掃併入 nowrap 群組。亦為 §8 justify 綁定載體。
- **切片**:切片 1 完成行首行末避頭尾(閉類/點號避頭、開類避尾);行首縮排聯動、
更細之禁則對象列後續,沿用同一 `jz-jinze` 結構。
- **已知交互(待瀏覽器精修)**:`lineedge` 之行端偵測需跨 `jz-jinze` 群組邊界向上
攀爬找流內鄰字(已於 `edgeRect` 處理 jz-* 包裹之攀出);屬 layout 層,jsdom 無法
驗,須瀏覽器目視。
### 6.5 標點懸掛(Hanging)— 後續
@@ -439,9 +466,12 @@ class 約定:`bd`(標點)、`bd-open/close/cop/stop/sep/liga`、`cjk`、`j
## 9. 跨瀏覽器策略
- **一致性關鍵層**(間隙、擠壓、justify):只用 `margin`、`letter-spacing`、
`inline-block`、`user-select`、`:lang()`、`:where()`、`hyphens`——三大引擎
BlinkWebKitGecko)皆穩定支援。
- **一致性關鍵層**(間隙、擠壓、justify):只用 `margin`、`inline-block`、
`user-select`、`white-space`、`:where()`、`hyphens`,及字型 OpenType
`font-feature-settings:"halt"`(標點半形;各引擎一致,效果僅依字型是否含 halt,
Noto CJK 具備,缺則優雅降級為全形)——三大引擎(Blink/WebKitGecko)皆穩定
支援。**注意**:標點半形不用負 `margin`(對居中字形會重疊),改用 `halt`(見
§6.3 修訂)。
- **漸進增強(非必要)**`text-spacing-trim`Blink)、`hanging-punctuation`
WebKit)、`text-emphasis`、`text-autospace`——僅在支援時錦上添花,缺席不影響
基本排版(I4)。
+139
View File
@@ -0,0 +1,139 @@
# 聚珍(Juzhen
> 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對
> 數種情況作調整:**複製乾淨**(中西間隙不入剪貼板)、**依 `lang` 全角/開明
> 自動切換**、**`justify` 下句末標點只佔一份間距**、**跨瀏覽器一致**
> ChromeEdgeSafariWebKitFirefox)。
倉庫名仍為 `cjk-autospace`;公開 API 為 `createJuzhen` / 全域 `Juzhen`;注入之
自訂元素前綴 `jz-`。完整設計見 [`ARCHITECTURE.md`](./ARCHITECTURE.md)。
> 本版(2.x)為從零重寫。v1 之 `createCjkAutospace` API 由相容層保留(見下),
> 既有消費端不需改碼即可運行;v1 原始碼保存於 `v1-autospace` 分支。
---
## 安裝與構建
```sh
npm install
npm run build # esbuild → dist/juzhen.{mjs,iife.js,css}tsc → dist/index.d.ts
npm test # jsdom DOM 正確性測試
```
產物(`dist/`,已入庫):
| 檔 | 用途 |
|---|---|
| `dist/juzhen.mjs` | ESM,供打包器/TypeScript |
| `dist/juzhen.iife.js` | IIFE,全域 `Juzhen`,原生 `<script>` 直跑 |
| `dist/juzhen.css` | 合併樣式(**必須引入**,視覺尺寸全在此) |
| `dist/index.d.ts` | TypeScript 型別宣告 |
---
## 用法
### 原生 `<script>`
```html
<link rel="stylesheet" href="dist/juzhen.css">
<script src="dist/juzhen.iife.js"></script>
<script>
const jz = Juzhen.createJuzhen({ scope: { include: ".prose" } });
jz.render(); // 對 document.body 套用(僅命中 .prose 子樹)
// jz.revert(); // 還原
</script>
```
### ESMTypeScript
```ts
import { createJuzhen } from "cjk-autospace";
import "cjk-autospace/css";
const jz = createJuzhen({ lang: { default: "zh-Hant" } });
jz.render(document.querySelector("main"));
```
**JS 與 CSS 缺一不可**JS 只注入 `jz-*` 結構,所有視覺尺寸(間隙寬度、擠壓量、
`justify` 原子化、字體堆疊)都在 `juzhen.css`
---
## 切片 1 已實作功能
| 功能 | 說明 |
|---|---|
| **中西間隙** `spacing` | CJK↔西文邊界插入 copy-clean 之 `jz-hws``user-select:none`,剪貼板跳過)。含跨樣式行內邊界與 `code``kbd` pill 邊界。 |
| **長英文詞** `longWord` | `[A-Za-z]{6,}` 包入 `<span lang="en">`,配合 CSS `hyphens:auto` 原生斷詞(DOM 無連字符 → 複製不污染)。 |
| **標點擠壓** `jiya` | 標點 charify 為 `jz-char``bd-*` class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點對向側收);③行端擠壓(`lineedge` layout pass)。半形以字型 `halt` 渲染(按字形正確定位,居中字形如 `!?`/繁體 `。,` 不重疊;不依賴 `text-spacing-trim`)。 |
| **禁則** `jinze` | 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 `justify` 綁定載體。 |
| **justify 單份間距** | 每個 `jz-char``inline-block` 原子項,句末標點只貢獻一份對齊伸縮量。 |
後續(架構已預留,不返工):標點懸掛、標點修正(簡↔繁)、著重號、注音/Ruby。
---
## 選項
```ts
createJuzhen({
lang: {
default: "zh-Hant", // 無 lang 祖先時之預設
policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" }, // 全角/開明/半角
},
scope: {
root: document.body, // 處理根;render(root) 可逐次覆寫
include: ".prose", // 啟用選擇器(opt-in):僅處理命中子樹
avoid: "blockquote.raw", // 追加排除選擇器
},
spacing: true,
longWord: { minLength: 6, lang: "en" },
jiya: true,
jinze: true,
blocks: [ // 分塊功能選擇
{ selector: ".code-comment", features: [ "spacing" ] },
],
});
```
### 分塊功能選擇(HTML 屬性,就近繼承)
```html
<p data-juzhen="spacing jiya"></p> <!-- 白名單:僅此二功能 -->
<p data-juzhen-off="jiya"></p> <!-- 黑名單:停用 jiya,其餘繼承 -->
<div lang="zh-Hans"></div> <!-- lang 驅動全角/開明 -->
```
---
## v1 向下相容
```js
// 既有消費端零改動:
const cjk = createCjkAutospace({ pillSelector: "code, kbd" });
cjk.apply(document.body);
```
IIFE 構建另掛 `globalThis.createCjkAutospace` 別名。**與 v1 之差異**(行為等價、
不要求逐位一致):
- 間隙標記由 `<span class="cjk-autospace">` 變為 `<jz-hws class="cjk-autospace">`
(仍帶原 class,既有 `.cjk-autospace{user-select:none}` CSS 仍命中)。
- 標點擠壓由消費端 `font-feature-settings:"halt"` 改為 `juzhen.css` 之負 `margin`
(需改用 `juzhen.css`;機制不同,同為開明式「句內點號半形」之效果)。
---
## 目視驗證
`demo/index.html` 載入 `dist/` 產物,簡繁並列、可切 `justify`、附 copy-clean 測試區。
版面相關結論(justify 單份間距、擠壓視覺量、行端、`inline-block` 基線)須於
ChromeSafariFirefox 三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。
---
## 授權
MIT。借鑑 Han.cssHeti 之思路與分類表(MIT),成品自含、不依賴其執行期程式碼。
+68
View File
@@ -0,0 +1,68 @@
// 構建腳本:esbuild 產出 ESM / IIFE / CSSd.ts 由 `tsc --emitDeclarationOnly`
// 另行產出(見 package.json scripts)。
//
// dist/juzhen.mjs ESM,供打包器/TypeScriptjsdom 測試。
// dist/juzhen.iife.js IIFE,全域 `Juzhen`,原生 <script> 直跑;額外掛
// `globalThis.createCjkAutospace` 別名(v1 相容消費端)。
// dist/juzhen.css 合併之樣式(css/juzhen.css 之 @import 展開)。
import { build } from "esbuild";
const ENTRY = "src/index.ts";
const SHARED = (
{
bundle: true,
target: [ "es2020", "chrome111", "safari16", "firefox115" ],
logLevel: "info",
}
);
// IIFE:把 Juzhen 掛上 globalThis,並補 v1 全域別名。
const IIFE_FOOTER = (
"if(typeof globalThis!=='undefined'){"
+ "globalThis.Juzhen=Juzhen;"
+ "if(Juzhen&&Juzhen.createCjkAutospace&&!globalThis.createCjkAutospace)"
+ "{globalThis.createCjkAutospace=Juzhen.createCjkAutospace;}"
+ "}"
);
async function main()
{
await build(
{
...SHARED,
entryPoints: [ ENTRY ],
format: "esm",
outfile: "dist/juzhen.mjs",
}
);
await build(
{
...SHARED,
entryPoints: [ ENTRY ],
format: "iife",
globalName: "Juzhen",
outfile: "dist/juzhen.iife.js",
footer: { js: IIFE_FOOTER },
}
);
await build(
{
...SHARED,
entryPoints: [ "src/css/juzhen.css" ],
outfile: "dist/juzhen.css",
loader: { ".css": "css" },
}
);
}
main().catch(
(err) =>
{
console.error(err);
process.exit(1);
}
);
+175
View File
@@ -0,0 +1,175 @@
<!doctype html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>聚珍(Juzhen)切片 1 — 目视验证</title>
<!-- 明确载入 Noto 系列 webfont 作参考:擠壓依赖字型 OpenType halt
系统 fallback 字型(如 PingFang)可能无 halt,Safari 下尤其表现为开明式无效。
Noto Serif/Sans SC/TC 含 halt,确保跨浏览器一致参考。 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC&family=Noto+Sans+TC&family=Noto+Serif+SC&family=Noto+Serif+TC&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../dist/juzhen.css">
<style>
/* demo 自身样式(非套件之一)。 */
body { margin: 0; padding: 2rem; font-size: 18px; line-height: 2; background: #fafafa; color: #1a1a1a; }
h1 { font-size: 1.4rem; }
.toolbar { position: sticky; top: 0; background: #fff; border: 1px solid #ddd; padding: .75rem 1rem; margin-bottom: 1.5rem; display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; z-index: 10; }
.toolbar button, .toolbar label { font-size: .85rem; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
.panel { border: 1px solid #ddd; padding: 1rem 1.25rem; background: #fff; }
.panel h2 { font-size: .95rem; color: #666; margin: 0 0 .5rem; font-weight: 600; }
.ruler { border-left: 1px dashed #c00; border-right: 1px dashed #c00; }
.narrow { max-width: 20em; }
.primary { border-color: #06c; border-width: 2px; }
code { background: #eee; padding: 0 .25em; border-radius: 3px; font-family: ui-monospace, monospace; }
.note { font-size: .8rem; color: #888; margin-top: 1.5rem; }
.selbox { background: #fffbe6; }
body.justify .juzhen p { text-align: justify; }
.cases p { margin: .4rem 0; }
.label { font-size: .72rem; color: #999; }
</style>
</head>
<body>
<h1>聚珍(Juzhen)切片 1 — 目视验证页</h1>
<div class="toolbar">
<button id="render">render</button>
<button id="revert">revert</button>
<label><input type="checkbox" id="justify"> text-align: justify</label>
<label><input type="checkbox" id="wide"> 放宽栏(看行内擠壓)</label>
<label><input type="checkbox" id="force-kaiming"> 强制繁体开明式(兼容性检查)</label>
<button id="measure">测量标点宽度</button>
<span id="status" style="font-size:.8rem;color:#888;"></span>
</div>
<div class="cols">
<!-- 主:简体开明式。窄栏强制换行,以观察行端擠壓(机制 B)。 -->
<div class="panel juzhen narrow ruler primary cases" lang="zh-Hans">
<h2>主 · 简体(开明式)— 句内点号+括号引号半形</h2>
<p><span class="label">中西混排:</span>我用 Neovim 写 TypeScript,配合 ripgrep 与 fzf 做模糊搜索,效率提升了 200%,达到约 3.5 倍。</p>
<p><span class="label">句末+引号:</span>他问:“真的吗?”我说:“当然!”这是 GPT-4 与 state-of-the-art 工具的时代。</p>
<p><span class="label">连续标点:</span>完了……怎么办?!真是的——他竟然走了。</p>
<p><span class="label">括号嵌套:</span>(含括号)与【方括号】以及《标题》,还有“她讲过‘小心’二字”。</p>
<p><span class="label">间隔号:</span>卡尔·马克思与弗里德里希·恩格斯合著。</p>
<p><span class="label">列举:</span>第一、第二、第三;以及其他(杂项)的内容。</p>
</div>
<!-- 对比:繁体全角式。同样内容,用以发现政策分支差异。 -->
<div class="panel juzhen narrow ruler cases" lang="zh-Hant">
<h2>对比 · 繁體(全角式)— 句內標點不擠,僅鄰接/行端收縮</h2>
<p><span class="label">中西混排:</span>我用 Neovim 寫 TypeScript,配合 ripgrep 與 fzf 做模糊搜尋,效率提升了 200%,達到約 3.5 倍。</p>
<p><span class="label">句末+引號:</span>他問:「真的嗎?」我說:「當然!」這是 GPT-4 與 state-of-the-art 工具的時代。</p>
<p><span class="label">連續標點:</span>完了……怎麼辦?!真是的——他竟然走了。</p>
<p><span class="label">括號嵌套:</span>(含括號)與【方括號】以及《標題》,還有「她講過『小心』二字」。</p>
<p><span class="label">間隔號:</span>卡爾·馬克思與弗里德里希·恩格斯合著。</p>
<p><span class="label">列舉:</span>第一、第二、第三;以及其他(雜項)的內容。</p>
</div>
</div>
<div class="panel juzhen selbox" lang="zh-Hans" style="margin-top:1.5rem;">
<h2>copy-clean 测试 — 框选整段并复制到纯文本编辑器,中西间隙的空格不应被带入</h2>
<p>请选取此句:我用 Neovim 编辑配置文件,搭配 fzf 与 ripgrep 做模糊搜索。复制后粘到纯文本,应为“我用Neovim编辑配置文件,搭配fzf与ripgrep做模糊搜索。”般紧凑(标点原样保留,中西间隙不入剪贴板)。</p>
</div>
<p class="note">
验证重点(jsdom 无法覆盖之版面层):①justify 下句末标点只占一份间距(不被拉成两份);
②开明式句内点号与括号引号视觉半形、繁体全角式不擠(仅相邻/行端收缩);
③句末标点后接闭引号(如 。 接 ”)行内应约 1.5em、行尾应约 1em;
④长英文词在窄栏可原生连字断行(需字体支持);⑤复制粘贴中西间隙不污染原文。
请于 Chrome / Safari / Firefox 三引擎各核对一次。
<br><b>字型注意</b>:擠壓(开明式半形)依赖字型的 OpenType <code>halt</code> 特性。
本页已用 webfont 载入 Noto Serif SC/TC(含 halt)作参考;若改用无 halt 的字型
(含部分系统字型如 PingFang),Safari 等浏览器下开明式擠壓会不生效——这是字型限制,非逻辑错误。
</p>
<details open style="margin-top:1rem;">
<summary style="font-size:.85rem;color:#666;cursor:pointer;">标点实测宽度(诊断用——点「测量标点宽度」后把下表复制给我)</summary>
<pre id="measure-out" style="font-size:.75rem;background:#f4f4f4;padding:1rem;overflow:auto;white-space:pre-wrap;">(点上方「测量标点宽度」)</pre>
</details>
<script src="../dist/juzhen.iife.js"></script>
<script>
var forceKaiming = false;
function makeJz()
{
// 強制繁體開明式:policy 將 zh-Hant(含 zh-HKzh-TW,皆歸 zh-Hant)覆寫為
// 開明式,用以檢查開明政策套到繁體之相容性。
return Juzhen.createJuzhen({
scope: { include: ".juzhen" },
lang: forceKaiming ? { policy: { "zh-Hant": "kaiming" } } : {}
});
}
var jz = makeJz();
var statusEl = document.getElementById("status");
function render()
{
jz.revert(document.body); // 先還原(重套政策時清除上次產物;首次為 no-op)
jz = makeJz();
jz.render(document.body);
statusEl.textContent = "已 render" + document.querySelectorAll("jz-hws").length + " 间隙、" + document.querySelectorAll("jz-char").length + " 标点原子;繁体政策:"
+ (forceKaiming ? "开明式" : "全角式") + "";
}
function revert()
{
jz.revert(document.body);
statusEl.textContent = "已 revertDOM 还原)";
}
document.getElementById("force-kaiming").addEventListener("change", function (e)
{
forceKaiming = e.target.checked;
render();
});
// 量測每個 jz-char 之實際渲染寬度(em)。輸出純 ASCII(碼位+class+數值),
// 並按 langcodepointclass 去重,便於回報診斷 halt 是否生效、收哪側。
function measure()
{
var out = [];
var byPanel = {};
document.querySelectorAll(".juzhen").forEach(function (panel)
{
var lang = panel.getAttribute("lang") || "?";
var key = lang + " | " + (panel.querySelector("h2") ? panel.querySelector("h2").textContent.slice(0, 10) : "");
panel.querySelectorAll("jz-char").forEach(function (c)
{
var fs = parseFloat(getComputedStyle(c).fontSize) || 16;
var w = c.getBoundingClientRect().width / fs;
var cp = "U+" + c.textContent.codePointAt(0).toString(16).toUpperCase();
while (cp.length < 6) { cp = cp.slice(0, 2) + "0" + cp.slice(2); }
var cls = Array.prototype.slice.call(c.classList).join(",") || "-";
var line = key + " " + cp + " " + w.toFixed(3) + "em [" + cls + "]";
if (!byPanel[line]) { byPanel[line] = true; out.push(line); }
});
});
// 參考:一個漢字寬度(應 ≈ 1.000em)。
var ref = document.querySelector(".juzhen p");
if (ref)
{
var span = document.createElement("span");
span.textContent = "字";
ref.appendChild(span);
var fs2 = parseFloat(getComputedStyle(span).fontSize) || 16;
out.unshift("参考 漢字「字」 " + (span.getBoundingClientRect().width / fs2).toFixed(3) + "em(应 ≈ 1.000");
ref.removeChild(span);
}
document.getElementById("measure-out").textContent = out.join("\n");
}
document.getElementById("measure").addEventListener("click", measure);
document.getElementById("render").addEventListener("click", render);
document.getElementById("revert").addEventListener("click", revert);
document.getElementById("justify").addEventListener("change", function (e)
{
document.body.classList.toggle("justify", e.target.checked);
});
document.getElementById("wide").addEventListener("change", function (e)
{
document.querySelectorAll(".narrow").forEach(function (el)
{
el.style.maxWidth = e.target.checked ? "none" : "";
});
});
render();
</script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
interface V1LongWord {
minLength?: number;
lang?: string;
}
interface V1Squeeze {
squeezeClass?: string;
marks?: string[];
langWhitelist?: string[];
}
export interface V1Options {
styleInlines?: string[];
blockTags?: string[];
skipTags?: string[];
pillSelector?: string;
autospaceClass?: string;
skipAttribute?: string;
isSkipped?: (node: Node) => boolean;
longWordWrap?: boolean | V1LongWord;
punctuationSqueeze?: boolean | V1Squeeze;
}
export interface V1Instance {
apply(root?: Element): void;
revert(root?: Element): void;
}
/**
* v1 相容工廠。回傳 { apply, revert }apply→render、revert→revert。
*/
export declare function createCjkAutospace(options?: V1Options): V1Instance;
export {};
+25
View File
@@ -0,0 +1,25 @@
/** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */
export type JzTag = "jz-hws" | "jz-char" | "jz-inner" | "jz-cs" | "jz-jinze" | "jz-em" | "jz-ruby" | "jz-rb" | "jz-rt";
type JzKind = "marker" | "wrap";
export interface CreateOptions {
/** 追加之 class(空白分隔或陣列)。 */
classes?: string | string[];
/** 文本內容。 */
text?: string;
/** 其餘屬性。 */
attrs?: Record<string, string>;
}
/** 造一個帶 pass 標記之 jz-* 元素。 */
export declare function createJz(tag: JzTag, pass: string, kind: JzKind, opts?: CreateOptions): HTMLElement;
/** 造一個 copy-clean 間隙標記(內含真實空白,user-select 由 CSS 關閉)。 */
export declare function createMarker(tag: JzTag, pass: string): HTMLElement;
/**
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
*/
export declare function revertPass(pass: string, root: ParentNode): void;
/** 讀取 jz-char 之 bd-* 子類(無則 null)。 */
export declare function bdClassOf(el: Element): string | null;
/** 該元素是否為某/任一 pass 之 jz 產物。 */
export declare function isJz(node: Node | null, pass?: string): boolean;
export {};
+60
View File
@@ -0,0 +1,60 @@
import type { ResolvedOptions } from "../types.js";
/** 邏輯 run 中之單一字元來源位置。 */
export interface CharPos {
textNode: Text;
offset: number;
}
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐字回映射。 */
export interface LogicalRun {
text: string;
map: CharPos[];
}
/** pill 鄰接之邏輯鄰字。 */
export interface AdjacentChar {
textNode: Text;
offset: number;
ch: string;
}
export declare class Finder {
private readonly opts;
private readonly styleSet;
private readonly blockSet;
private readonly skipSet;
private readonly pillSelector;
private readonly avoidSelector;
private readonly includeSelector;
private readonly skipAttr;
private readonly userIsSkipped;
private readonly featureCache;
constructor(opts: ResolvedOptions);
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */
pillMatches(el: Element): boolean;
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable /
* data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */
isAvoided(node: Node): boolean;
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
inScope(node: Node): boolean;
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */
eachTextNode(root: Element, cb: (node: Text) => void): void;
/** 走訪 root 下所有 block 元素(含 root 本身若為 block)。 */
eachBlock(root: Element, cb: (block: Element) => void): void;
/**
* 收集 block 之邏輯文本 run(架構 §4.1,I7):樣式行內元素與 wrap 類
* jz-* 透明穿越;blockpillavoid/間隙 markerjz-hws)中斷 run。
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。
*/
collectRuns(block: Element): LogicalRun[];
/**
* pill 之邏輯鄰字(架構 §4.1):經樣式鏈遞迴,遇 pillblockavoid/間隙
* marker 即止。direction = -1 取前鄰字,+1 取後鄰字。
*/
adjacentLogicalChar(el: Element, direction: -1 | 1): AdjacentChar | null;
/**
* 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶
* 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中
* blocks 選擇器),無則回退全域 features。結果以元素為鍵快取。
*/
featureEnabledFor(node: Node, name: string): boolean;
private globalFeature;
private resolveFeature;
}
+29
View File
@@ -0,0 +1,29 @@
/** 標點寬度政策。 */
export type PunctStyle = "quanjiao" | "kaiming" | "banjiao";
/** 語言分類。 */
export type LangClass = "zh-Hans" | "zh-Hant" | "ja" | "other";
export interface LocaleConfig {
/** 無 lang 祖先時之預設語言。 */
default: string;
/** 全域標點擠壓預設(全角/開明/半角)。設則覆寫內建之逐語言預設,
* 但仍可被 policy 之逐 lang 設定覆寫。未設則回退內建預設。 */
style?: PunctStyle;
/** 語言分類 → 標點寬度政策之逐 lang 覆寫(最高優先)。 */
policy: Partial<Record<LangClass, PunctStyle>>;
}
/**
* 由節點向上找最近帶 lang 屬性之祖先,回傳小寫值;無則回傳 fallback。
*/
export declare function effectiveLang(node: Node, fallback: string): string;
/**
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant
* 'zhx' 不命中 zh)。香港/台灣繁體歸 zh-Hant;含 HantHans 子標籤者優先。
*/
export declare function classify(lang: string): LangClass;
/**
* 回傳該語言分類在給定 config 下之標點寬度政策。優先序:
* 逐 lang policy 全域 style 內建逐語言預設 DEFAULT_POLICY。
*/
export declare function styleFor(langClass: LangClass, config: LocaleConfig): PunctStyle;
/** 解析節點之有效政策(lang → 分類 → 政策)。 */
export declare function resolveStyle(node: Node, config: LocaleConfig): PunctStyle;
+5
View File
@@ -0,0 +1,5 @@
import type { Pass, RenderContext } from "../types.js";
/** 對 ctx.root 套用 passes 中已啟用者。 */
export declare function runPasses(passes: Pass[], ctx: RenderContext): void;
/** 還原 passes 之全部產物(逆 orderrevert 對無產物者安全)。 */
export declare function revertPasses(passes: Pass[], ctx: RenderContext): void;
+41
View File
@@ -0,0 +1,41 @@
/** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */
export declare const CJK: string;
/** 西文字母與數字。 */
export declare const ALNUM = "A-Za-z0-9";
/** 西文字母。 */
export declare const ALPHA = "A-Za-z";
/** 任一 CJK 字元測試。 */
export declare const ANY_CJK: RegExp;
/** CJK 之後可接間隙之西文集(含希臘字母、運算符、拉丁補充、箭號、裝飾符)。 */
export declare const ANS_AFTER_CJK: string;
/** CJK 之前可接間隙之西文集(同上,去 @)。 */
export declare const ANS_BEFORE_CJK: string;
/** 句末點號:。!?(開明式與全角式皆保持全形,不擠壓)。 */
export declare const BD_STOP = "\u3002\uFF01\uFF1F";
/** 句內點號:,、;:(開明式擠成半形;全角式保持全形)。 */
export declare const BD_PAUSE = "\uFF0C\u3001\uFF1B\uFF1A";
/** 開始括號/引號:「『(《〈【〖〔[{“‘。 */
export declare const BD_OPEN = "\u300C\u300E\uFF08\u300A\u3008\u3010\u3016\u3014\uFF3B\uFF5B\u201C\u2018";
/** 結束括號/引號:」』)》〉】〗〕]}”’。 */
export declare const BD_CLOSE = "\u300D\u300F\uFF09\u300B\u3009\u3011\u3017\u3015\uFF3D\uFF5D\u201D\u2019";
/** 間隔/分隔號:·・‧。 */
export declare const BD_MIDDLE = "\u00B7\u30FB\u2027";
/** 連續省略/破折:…—。 */
export declare const BD_LIGA = "\u2026\u2014";
/** 全體標點字元(供 charify 走訪偵測)。 */
export declare const BD_ALL: string;
/**
* 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。
* 供 jiya 之 charify 為 jz-char 標記 class。
*/
export declare function classifyBiaodian(ch: string): string | null;
/** 全體 bd-* 子類名(DOM 端讀取 jz-char 之 bd class 用)。 */
export declare const BD_CLASS_NAMES: readonly ["bd-stop", "bd-pause", "bd-open", "bd-close", "bd-middle", "bd-liga"];
/**
* 標點之「可壓縮空白」側(橫排)。字面偏左者空白在右(句內/句末/閉類),
* 開類字面偏右、空白在左。間隔號/連接號不壓縮。擠壓即收掉對應側之半形空白。
*/
export declare function blankSides(bdClass: string | null): {
left: boolean;
right: boolean;
};
+12
View File
@@ -0,0 +1,12 @@
import type { JuzhenOptions, Pass, ResolvedOptions } from "./types.js";
export type { JuzhenOptions, ResolvedOptions, Pass };
export { createCjkAutospace } from "./compat/v1.js";
/** 正規化公開選項為內部 ResolvedOptions。 */
export declare function normalizeOptions(opts?: JuzhenOptions): ResolvedOptions;
export interface Juzhen {
render(root?: Element | string): void;
revert(root?: Element | string): void;
readonly options: ResolvedOptions;
}
/** 建立一個聚珍實例。 */
export declare function createJuzhen(opts?: JuzhenOptions): Juzhen;
+79
View File
@@ -0,0 +1,79 @@
/* src/css/_normalize.css */
@supports (text-autospace: no-autospace) {
.juzhen {
text-autospace: no-autospace;
}
}
jz-char,
jz-inner,
jz-hws,
jz-cs,
jz-jinze,
jz-em {
font: inherit;
color: inherit;
}
/* src/css/_lang.css */
:where(.juzhen):lang(zh-Hans),
:where(.juzhen) :lang(zh-Hans) {
font-family:
"Noto Serif SC",
"Noto Serif CJK SC",
"Source Han Serif SC",
serif;
}
:where(.juzhen):lang(zh-Hant),
:where(.juzhen) :lang(zh-Hant) {
font-family:
"Noto Serif TC",
"Noto Serif CJK TC",
"Source Han Serif TC",
serif;
}
:where(.juzhen):lang(zh-HK),
:where(.juzhen) :lang(zh-HK) {
font-family:
"Noto Serif HK",
"Noto Serif CJK HK",
"Noto Serif TC",
serif;
}
:where(.juzhen):lang(ja),
:where(.juzhen) :lang(ja) {
font-family:
"Noto Serif JP",
"Noto Serif CJK JP",
serif;
}
/* src/css/_spacing.css */
jz-hws {
margin-right: 0.25em;
}
jz-hws.jz-hws-trim {
margin-right: 0;
}
/* src/css/_jiya.css */
jz-char {
display: inline-block;
vertical-align: baseline;
}
jz-inner {
display: inline;
}
jz-char.jz-half jz-inner,
jz-char.jz-half-le jz-inner {
font-feature-settings: "halt" 1;
-webkit-font-feature-settings: "halt" 1;
}
/* src/css/_jinze.css */
jz-jinze {
display: inline-block;
white-space: nowrap;
vertical-align: baseline;
}
/* src/css/juzhen.css */
+1462
View File
File diff suppressed because it is too large Load Diff
+1436
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
import type { Finder } from "./core/finder.js";
import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.js";
export type { LangClass, PunctStyle };
/** 長英文詞包裝設定。 */
export interface LongWordOptions {
minLength?: number;
lang?: string;
}
/** 分塊功能選擇:選擇器 → 該塊內生效之功能集(覆寫全域)。 */
export interface BlockRule {
selector: string;
features: string[];
}
/** createJuzhen 之公開選項。所有欄位皆可選;預設見 normalizeOptions。 */
export interface JuzhenOptions {
/** 語言與全角/開明政策。 */
lang?: {
/** 無 lang 祖先時之預設語言標籤(預設 "zh-Hant")。 */
default?: string;
/** 全域標點擠壓預設:quanjiao(全角式)|kaiming(開明式)|
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 lang 覆寫。 */
style?: PunctStyle;
/** 逐 lang 覆寫(最高優先)。 */
policy?: Partial<Record<LangClass, PunctStyle>>;
};
/** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */
autospaceClass?: string;
/** 作用域限定(§3.6)。 */
scope?: {
root?: Element | string;
include?: string | null;
avoid?: string;
};
spacing?: boolean;
longWord?: boolean | LongWordOptions;
jiya?: boolean;
jinze?: boolean;
hanging?: boolean;
biaodian?: boolean;
emphasis?: boolean;
ruby?: boolean;
/** 分塊功能選擇(§3.6)。 */
blocks?: BlockRule[];
styleInlines?: string[];
blockTags?: string[];
skipTags?: string[];
pillSelector?: string;
skipAttribute?: string;
isSkipped?: (node: Node) => boolean;
}
/** 正規化後之內部選項。 */
export interface ResolvedOptions {
locale: LocaleConfig;
autospaceClass: string;
scope: {
root: Element | string | null;
include: string | null;
avoid: string | null;
};
features: {
spacing: boolean;
longWord: boolean;
jiya: boolean;
jinze: boolean;
hanging: boolean;
biaodian: boolean;
emphasis: boolean;
ruby: boolean;
};
longWord: {
minLength: number;
lang: string;
};
blocks: BlockRule[];
finder: {
styleInlines: Set<string>;
blockTags: Set<string>;
skipTags: Set<string>;
pillSelector: string;
skipAttribute: string | null;
isSkipped: ((node: Node) => boolean) | null;
};
}
/** renderrevert 之統一上下文(§3.3)。 */
export interface RenderContext {
root: Element;
options: ResolvedOptions;
finder: Finder;
}
/**
* charify 階段之包裝請求:把 node.data[start, end) 包進 build() 造出之元素。
* 同階段各 pass 之請求須互不重疊(標點/字母段天然不交集)。
*/
export interface WrapRequest {
start: number;
end: number;
build: (text: string) => HTMLElement;
pass: string;
}
/** pass 種類。 */
export type PassKind = "charify" | "standalone";
interface BasePass {
name: string;
order: number;
kind: PassKind;
enabled: (options: ResolvedOptions) => boolean;
revert: (ctx: RenderContext) => void;
}
/** 共享 P1 走訪之字元包裝 pass。 */
export interface CharifyPass extends BasePass {
kind: "charify";
collect: (node: Text, ctx: RenderContext) => WrapRequest[];
}
/** 自跑走訪/版面後處理之 pass。 */
export interface StandalonePass extends BasePass {
kind: "standalone";
render: (ctx: RenderContext) => void;
}
export type Pass = CharifyPass | StandalonePass;
+2
View File
@@ -0,0 +1,2 @@
import type { StandalonePass } from "../types.js";
export declare const jinzePass: StandalonePass;
+3
View File
@@ -0,0 +1,3 @@
import type { CharifyPass, StandalonePass } from "../types.js";
export declare const jiyaPass: CharifyPass;
export declare const jiyaAdjacencyPass: StandalonePass;
+3
View File
@@ -0,0 +1,3 @@
import type { StandalonePass } from "../types.js";
export declare const lineEdgePass: StandalonePass;
export declare const gapTrimPass: StandalonePass;
+2
View File
@@ -0,0 +1,2 @@
import type { CharifyPass } from "../types.js";
export declare const longWordPass: CharifyPass;
+2
View File
@@ -0,0 +1,2 @@
import type { StandalonePass } from "../types.js";
export declare const spacingPass: StandalonePass;
+1335
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "cjk-autospace",
"version": "2.0.0",
"description": "聚珍(Juzhen)— 現代簡/繁中文網頁排版套件;Han.css 之現代化重寫。",
"type": "module",
"main": "dist/juzhen.iife.js",
"module": "dist/juzhen.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/juzhen.mjs",
"default": "./dist/juzhen.iife.js"
},
"./css": "./dist/juzhen.css"
},
"files": [
"dist",
"src",
"ARCHITECTURE.md",
"README.md"
],
"scripts": {
"build": "node build.mjs && tsc --emitDeclarationOnly",
"test": "node build.mjs && node --test test/*.test.mjs"
},
"keywords": [
"cjk",
"chinese",
"typography",
"autospace",
"han.css",
"clreq",
"punctuation"
],
"license": "MIT",
"devDependencies": {
"@types/jsdom": "^21.1.7",
"esbuild": "^0.24.0",
"jsdom": "^25.0.0",
"typescript": "^5.6.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
// v1 向下相容層(架構 §3.5,I8)。
//
// 導出 createCjkAutospace(options),把 v1 API 映射到 createJuzhen,保證既有
// 消費端(build.sh 單檔串接 / Nvim peek / Claude 模板)不 break、無功能倒退。
// 行為等價但不要求逐位一致——主要差異:
// - 間隙標記由 <span class="cjk-autospace"> 變為 <jz-hws class="cjk-autospace">
// (仍帶原 class,故既有 `.cjk-autospace{user-select:none}` CSS 仍命中)。
// - 擠壓由消費端 `font-feature-settings:"halt"` 改為 juzhen.css 之負 margin
// (消費端須改用 juzhen.css;機制不同但同為「句內點號半形」之開明式效果)。
// 上述差異於 README 明列。
import { createJuzhen } from "../index.js";
import type { JuzhenOptions, LongWordOptions } from "../types.js";
interface V1LongWord
{
minLength?: number;
lang?: string;
}
interface V1Squeeze
{
squeezeClass?: string;
marks?: string[];
langWhitelist?: string[];
}
export interface V1Options
{
styleInlines?: string[];
blockTags?: string[];
skipTags?: string[];
pillSelector?: string;
autospaceClass?: string;
skipAttribute?: string;
isSkipped?: (node: Node) => boolean;
longWordWrap?: boolean | V1LongWord;
punctuationSqueeze?: boolean | V1Squeeze;
}
export interface V1Instance
{
apply(root?: Element): void;
revert(root?: Element): void;
}
function mapLongWord(v: boolean | V1LongWord | undefined): boolean | LongWordOptions
{
if (v === false) { return false; }
if (v === undefined || v === true || v === null) { return true; }
const out: LongWordOptions = {};
if (typeof v.minLength === "number") { out.minLength = v.minLength; }
if (typeof v.lang === "string") { out.lang = v.lang; }
return out;
}
/**
* v1 相容工廠。回傳 { apply, revert }apply→render、revert→revert。
*/
export function createCjkAutospace(options: V1Options = {}): V1Instance
{
const o = options || {};
const mapped: JuzhenOptions = {
// finder 設定直通。
...(o.styleInlines ? { styleInlines: o.styleInlines } : {}),
...(o.blockTags ? { blockTags: o.blockTags } : {}),
...(o.skipTags ? { skipTags: o.skipTags } : {}),
pillSelector: o.pillSelector || "code, kbd",
autospaceClass: o.autospaceClass || "cjk-autospace",
skipAttribute: o.skipAttribute || "data-md-key",
...(o.isSkipped ? { isSkipped: o.isSkipped } : {}),
spacing: true,
longWord: mapLongWord(o.longWordWrap),
// v1 Pass D 不分 lang 地擠句內點號(開明式);compat 強制 kaiming
// 政策以避免功能倒退(預設 zh-Hant→quanjiao 不擠壓)。
jiya: o.punctuationSqueeze === false ? false : true,
// v1 無連續標點綁定,保持 DOM 與 v1 一致:關閉 jinze。
jinze: false,
lang: {
policy: {
"zh-Hant": "kaiming",
"zh-Hans": "kaiming",
"ja": "kaiming",
"other": "kaiming",
},
},
};
const jz = createJuzhen(mapped);
return {
apply(root?: Element): void { jz.render(root); },
revert(root?: Element): void { jz.revert(root); },
};
}
+136
View File
@@ -0,0 +1,136 @@
// jz-* 元素工廠與還原(架構 §4.4/§5)。
//
// 所有注入結構皆帶 data-jz="<pass>" 標記(冪等 + 可定址還原,I6),並以
// data-jz-kind 區分「marker(純注入,還原時移除)」與「wrap(包裝原內容,
// 還原時解包)」。視覺尺寸一律在 CSS(I2);本模組只造結構。
/** jz-* 自訂元素名(皆含連字號,合法 custom element,預設 inline)。 */
export type JzTag =
| "jz-hws"
| "jz-char"
| "jz-inner"
| "jz-cs"
| "jz-jinze"
| "jz-em"
| "jz-ruby"
| "jz-rb"
| "jz-rt";
type JzKind = "marker" | "wrap";
export interface CreateOptions
{
/** 追加之 class(空白分隔或陣列)。 */
classes?: string | string[];
/** 文本內容。 */
text?: string;
/** 其餘屬性。 */
attrs?: Record<string, string>;
}
const KIND_ATTR = "data-jz-kind";
const PASS_ATTR = "data-jz";
function doc(): Document
{
if (typeof document === "undefined")
{
throw new Error("juzhen: 無 document(需於瀏覽器或 jsdom 環境執行)。");
}
return document;
}
/** 造一個帶 pass 標記之 jz-* 元素。 */
export function createJz(
tag: JzTag,
pass: string,
kind: JzKind,
opts: CreateOptions = {},
): HTMLElement
{
const el = doc().createElement(tag);
el.setAttribute(PASS_ATTR, pass);
el.setAttribute(KIND_ATTR, kind);
if (opts.classes)
{
const list = Array.isArray(opts.classes) ? opts.classes : [ opts.classes ];
for (const c of list) { if (c) { el.classList.add(c); } }
}
if (opts.attrs)
{
for (const k in opts.attrs)
{
const v = opts.attrs[k];
if (v !== undefined) { el.setAttribute(k, v); }
}
}
if (opts.text !== undefined) { el.textContent = opts.text; }
return el;
}
/** 造一個 copy-clean 間隙標記(內含真實空白,user-select 由 CSS 關閉)。 */
export function createMarker(tag: JzTag, pass: string): HTMLElement
{
const el = createJz(tag, pass, "marker", { text: " " });
el.setAttribute("aria-hidden", "true");
return el;
}
/**
* 還原某 pass 之全部產物(I6)。以 data-jz 定址,逆文件序處理(先內層後
* 外層),marker 移除、wrap 解包,最後 normalize 受影響父節點合併文本。
*/
export function revertPass(pass: string, root: ParentNode): void
{
const nodes = Array.from(
root.querySelectorAll<HTMLElement>('[' + PASS_ATTR + '="' + pass + '"]'),
);
// 逆序:子元素先於父元素處理,避免解包父後子引用失效。
const touched = new Set<Node>();
for (let i = nodes.length - 1; i >= 0; i -= 1)
{
const el = nodes[i]!;
const parent = el.parentNode;
if (!parent) { continue; }
touched.add(parent);
const kind = el.getAttribute(KIND_ATTR);
if (kind === "marker")
{
parent.removeChild(el);
}
else
{
// wrap:把子節點搬出後移除外殼。
while (el.firstChild)
{
parent.insertBefore(el.firstChild, el);
}
parent.removeChild(el);
}
}
for (const p of touched)
{
if ((p as Element).normalize) { (p as Element).normalize(); }
}
}
const BD_CLASSES = [ "bd-open", "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga" ];
/** 讀取 jz-char 之 bd-* 子類(無則 null)。 */
export function bdClassOf(el: Element): string | null
{
for (const c of BD_CLASSES)
{
if (el.classList.contains(c)) { return c; }
}
return null;
}
/** 該元素是否為某/任一 pass 之 jz 產物。 */
export function isJz(node: Node | null, pass?: string): boolean
{
if (!node || node.nodeType !== 1) { return false; }
const el = node as Element;
if (!el.hasAttribute(PASS_ATTR)) { return false; }
return pass === undefined || el.getAttribute(PASS_ATTR) === pass;
}
+332
View File
@@ -0,0 +1,332 @@
// 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。
//
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集(跨樣式
// 行內元素透明、pillblockavoid 中斷)、pill 鄰字查找、per-node 功能閘
// featureEnabledFor)。finder 不知具體排版規則,只提供遍歷與安全變更原語。
import type { ResolvedOptions } from "../types.js";
/** 邏輯 run 中之單一字元來源位置。 */
export interface CharPos
{
textNode: Text;
offset: number;
}
/** 一段邏輯文本:剝離樣式後之連續字元,及其逐字回映射。 */
export interface LogicalRun
{
text: string;
map: CharPos[];
}
/** pill 鄰接之邏輯鄰字。 */
export interface AdjacentChar
{
textNode: Text;
offset: number;
ch: string;
}
const SVG_NS = "http://www.w3.org/2000/svg";
export class Finder
{
private readonly opts: ResolvedOptions;
private readonly styleSet: Set<string>;
private readonly blockSet: Set<string>;
private readonly skipSet: Set<string>;
private readonly pillSelector: string;
private readonly avoidSelector: string | null;
private readonly includeSelector: string | null;
private readonly skipAttr: string | null;
private readonly userIsSkipped: ((node: Node) => boolean) | null;
private readonly featureCache: WeakMap<Element, Record<string, boolean>>;
constructor(opts: ResolvedOptions)
{
this.opts = opts;
this.styleSet = opts.finder.styleInlines;
this.blockSet = opts.finder.blockTags;
this.skipSet = opts.finder.skipTags;
this.pillSelector = opts.finder.pillSelector;
this.skipAttr = opts.finder.skipAttribute;
this.userIsSkipped = opts.finder.isSkipped;
this.avoidSelector = opts.scope.avoid;
this.includeSelector = opts.scope.include;
this.featureCache = new WeakMap();
}
/** 該元素是否匹配 pill 選擇器(行內塊,如 code/kbd)。 */
pillMatches(el: Element): boolean
{
return !!(el.matches && this.pillSelector && el.matches(this.pillSelector));
}
/** 節點是否處於 avoid 子樹(內建 skip 名單 / SVG / contentEditable /
* data-jz-skip / 使用者 avoid 選擇器 / skipAttribute / isSkipped)。 */
isAvoided(node: Node): boolean
{
if (this.userIsSkipped && this.userIsSkipped(node)) { return true; }
let p: Node | null = node.parentNode;
while (p && p.nodeType === 1)
{
const el = p as Element;
if (this.skipSet.has(el.nodeName)) { return true; }
if ((el as { namespaceURI?: string }).namespaceURI === SVG_NS) { return true; }
if ((el as HTMLElement).isContentEditable) { return true; }
if (el.hasAttribute("data-jz-skip")) { return true; }
if (this.skipAttr && el.hasAttribute(this.skipAttr)) { return true; }
if (this.avoidSelector && el.matches && el.matches(this.avoidSelector))
{
return true;
}
p = p.parentNode;
}
return false;
}
/** scope.include 啟用判定:未設則恆 true;設則須有命中祖先(opt-in)。 */
inScope(node: Node): boolean
{
if (!this.includeSelector) { return true; }
const el = node.nodeType === 1
? (node as Element)
: node.parentElement;
if (!el) { return false; }
return !!(el.closest && el.closest(this.includeSelector));
}
/** 走訪 root 下所有「可處理」文本節點(已過濾 avoid / 非作用域)。 */
eachTextNode(root: Element, cb: (node: Text) => void): void
{
const ownerDoc = root.ownerDocument || (typeof document !== "undefined" ? document : null);
if (!ownerDoc) { return; }
const walker = ownerDoc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let n = walker.nextNode();
while (n)
{
const t = n as Text;
if (!this.isAvoided(t) && this.inScope(t)) { nodes.push(t); }
n = walker.nextNode();
}
// 先收集後回呼:容許 cb 變更 DOM 而不擾動走訪。
for (const node of nodes) { cb(node); }
}
/** 走訪 root 下所有 block 元素(含 root 本身若為 block)。 */
eachBlock(root: Element, cb: (block: Element) => void): void
{
const sel = Array.from(this.blockSet)
.map((t) => t.toLowerCase())
.join(",");
const blocks: Element[] = [];
if (sel)
{
root.querySelectorAll(sel).forEach((el) => blocks.push(el));
}
if (root.nodeType === 1 && this.blockSet.has(root.nodeName))
{
blocks.push(root);
}
for (const b of blocks)
{
if (this.includeSelector && !(b.closest && b.closest(this.includeSelector)))
{
continue;
}
cb(b);
}
}
/**
* 收集 block 之邏輯文本 run(架構 §4.1,I7):樣式行內元素與 wrap 類
* jz-* 透明穿越;blockpillavoid/間隙 markerjz-hws)中斷 run。
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。
*/
collectRuns(block: Element): LogicalRun[]
{
const runs: LogicalRun[] = [];
let curText = "";
let curMap: CharPos[] = [];
const flush = (): void =>
{
if (curText.length >= 2) { runs.push({ text: curText, map: curMap }); }
curText = "";
curMap = [];
};
const visit = (node: Node | null): void =>
{
if (!node) { return; }
if (node.nodeType === 3)
{
const t = node as Text;
if (this.isAvoided(t) || !this.inScope(t)) { return; }
const data = t.data;
for (let i = 0; i < data.length; i += 1)
{
curText += data.charAt(i);
curMap.push({ textNode: t, offset: i });
}
return;
}
if (node.nodeType !== 1) { return; }
const el = node as Element;
// jz-hws 現包裹左側邊界字(以 margin-right 留隙)→ 透明遞迴,使其內
// 之字仍計入邏輯 run;冪等改由「該字已在 jz-hws 內則不重複包裝」保證。
if (this.isAvoided(el)) { flush(); return; }
if (this.skipSet.has(el.nodeName) || this.blockSet.has(el.nodeName))
{
flush();
return;
}
if (this.pillMatches(el)) { flush(); return; }
// 樣式行內 / 未知行內 / wrap 類 jz-* / span[lang]:透明遞迴。
let c = el.firstChild;
while (c) { visit(c); c = c.nextSibling; }
};
let c = block.firstChild;
while (c) { visit(c); c = c.nextSibling; }
flush();
return runs;
}
/**
* pill 之邏輯鄰字(架構 §4.1):經樣式鏈遞迴,遇 pillblockavoid/間隙
* marker 即止。direction = -1 取前鄰字,+1 取後鄰字。
*/
adjacentLogicalChar(el: Element, direction: -1 | 1): AdjacentChar | null
{
const pickEnd = (tn: Text): AdjacentChar =>
{
if (direction === -1)
{
return {
textNode: tn,
offset: tn.data.length - 1,
ch: tn.data.charAt(tn.data.length - 1),
};
}
return { textNode: tn, offset: 0, ch: tn.data.charAt(0) };
};
const descend = (into: Element): AdjacentChar | null =>
{
let n: Node | null = direction === -1 ? into.lastChild : into.firstChild;
while (n)
{
if (n.nodeType === 3 && (n as Text).data.length > 0)
{
return pickEnd(n as Text);
}
if (
n.nodeType === 1
&& (this.styleSet.has((n as Element).nodeName) || (n as Element).nodeName === "JZ-HWS")
)
{
const r = descend(n as Element);
if (r) { return r; }
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
return null;
};
let n: Node | null = direction === -1 ? el.previousSibling : el.nextSibling;
let p: Node | null = el.parentNode;
for (;;)
{
while (n)
{
if (n.nodeType === 3)
{
if ((n as Text).data.length > 0) { return pickEnd(n as Text); }
n = direction === -1 ? n.previousSibling : n.nextSibling;
continue;
}
if (n.nodeType === 1)
{
const e = n as Element;
if (this.isAvoided(e)) { return null; }
if (this.styleSet.has(e.nodeName) || e.nodeName === "JZ-HWS")
{
const r = descend(e);
if (r) { return r; }
n = direction === -1 ? e.previousSibling : e.nextSibling;
continue;
}
return null; // pill 或 block — 止。
}
n = direction === -1 ? n.previousSibling : n.nextSibling;
}
if (!p || p.nodeType !== 1 || !this.styleSet.has((p as Element).nodeName))
{
return null;
}
const pe = p as Element;
n = direction === -1 ? pe.previousSibling : pe.nextSibling;
p = pe.parentNode;
}
}
/**
* 解析節點對某功能之有效啟用狀態(§3.6,per-node 閘)。向上找最近帶
* 功能指令之祖先(data-juzhen 白名單 / data-juzhen-off 黑名單 / 命中
* blocks 選擇器),無則回退全域 features。結果以元素為鍵快取。
*/
featureEnabledFor(node: Node, name: string): boolean
{
const el = node.nodeType === 1 ? (node as Element) : node.parentElement;
const globalOn = this.globalFeature(name);
if (!el) { return globalOn; }
const cached = this.featureCache.get(el);
if (cached && name in cached) { return cached[name]!; }
const result = this.resolveFeature(el, name, globalOn);
const bucket = cached || {};
bucket[name] = result;
this.featureCache.set(el, bucket);
return result;
}
private globalFeature(name: string): boolean
{
const f = this.opts.features as Record<string, boolean>;
return !!f[name];
}
private resolveFeature(start: Element, name: string, globalOn: boolean): boolean
{
let el: Element | null = start;
while (el)
{
// 屬性指令最近者優先。
if (el.hasAttribute("data-juzhen"))
{
const list = (el.getAttribute("data-juzhen") || "").split(/\s+/).filter(Boolean);
return list.indexOf(name) >= 0;
}
if (el.hasAttribute("data-juzhen-off"))
{
const off = (el.getAttribute("data-juzhen-off") || "").split(/\s+/).filter(Boolean);
if (off.indexOf(name) >= 0) { return false; }
// 黑名單未列出者:續向上找,最終回退全域。
}
// blocks 選擇器(程式式白名單)。
for (const rule of this.opts.blocks)
{
if (rule.selector && el.matches && el.matches(rule.selector))
{
return rule.features.indexOf(name) >= 0;
}
}
el = el.parentElement;
}
return globalOn;
}
}
+84
View File
@@ -0,0 +1,84 @@
// 語言與全角/開明政策(架構 §4.3)。
//
// 解析節點之有效 lang,歸類,並依政策決定標點寬度(全角式/開明式/半角式)。
// CSS 端以 :lang() 落實同一政策(I2/I3);本模組供 JS pass 在需要時查詢。
/** 標點寬度政策。 */
export type PunctStyle = "quanjiao" | "kaiming" | "banjiao";
/** 語言分類。 */
export type LangClass = "zh-Hans" | "zh-Hant" | "ja" | "other";
export interface LocaleConfig
{
/** 無 lang 祖先時之預設語言。 */
default: string;
/** 全域標點擠壓預設(全角/開明/半角)。設則覆寫內建之逐語言預設,
* 但仍可被 policy 之逐 lang 設定覆寫。未設則回退內建預設。 */
style?: PunctStyle;
/** 語言分類 → 標點寬度政策之逐 lang 覆寫(最高優先)。 */
policy: Partial<Record<LangClass, PunctStyle>>;
}
const DEFAULT_POLICY: Record<LangClass, PunctStyle> = {
"zh-Hant": "quanjiao",
"zh-Hans": "kaiming",
"ja": "quanjiao",
"other": "quanjiao",
};
/**
* 由節點向上找最近帶 lang 屬性之祖先,回傳小寫值;無則回傳 fallback。
*/
export function effectiveLang(node: Node, fallback: string): string
{
let p: Node | null = node.parentNode;
while (p && p.nodeType === 1)
{
const el = p as Element;
const v = el.getAttribute("lang");
if (v) { return v.toLowerCase(); }
p = p.parentNode;
}
return (fallback || "").toLowerCase();
}
/**
* 將 BCP-47 語言標籤歸類。前綴以子標籤邊界比對('zh-tw' 命中 zh-Hant
* 'zhx' 不命中 zh)。香港/台灣繁體歸 zh-Hant;含 HantHans 子標籤者優先。
*/
export function classify(lang: string): LangClass
{
const l = (lang || "").toLowerCase();
if (l === "ja" || l.indexOf("ja-") === 0) { return "ja"; }
if (l === "zh" || l.indexOf("zh-") === 0 || l.indexOf("zh") === 0)
{
// 顯式子標籤優先。
if (l.indexOf("hant") >= 0) { return "zh-Hant"; }
if (l.indexOf("hans") >= 0) { return "zh-Hans"; }
// 地區推斷:台灣/香港/澳門 → 繁體;其餘(含 cn/sg)→ 簡體。
if (/-(tw|hk|mo)\b/.test(l)) { return "zh-Hant"; }
if (l === "zh") { return "zh-Hant"; }
return "zh-Hans";
}
return "other";
}
/**
* 回傳該語言分類在給定 config 下之標點寬度政策。優先序:
* 逐 lang policy 全域 style 內建逐語言預設 DEFAULT_POLICY。
*/
export function styleFor(langClass: LangClass, config: LocaleConfig): PunctStyle
{
const perLang = config.policy ? config.policy[langClass] : undefined;
if (perLang) { return perLang; }
if (config.style) { return config.style; }
return DEFAULT_POLICY[langClass];
}
/** 解析節點之有效政策(lang → 分類 → 政策)。 */
export function resolveStyle(node: Node, config: LocaleConfig): PunctStyle
{
const lang = effectiveLang(node, config.default);
return styleFor(classify(lang), config);
}
+94
View File
@@ -0,0 +1,94 @@
// Pass 管線執行器(架構 §3.2/§3.4)。
//
// 兩類 passcharify(共享單次 P1 文本走訪,collect 純判定 + 批次 apply)與
// standalone(自跑 render)。聚合僅為執行優化:邏輯上仍一功能一 pass、可獨立
// 開關與 revertI6I9)。
//
// 執行順序:先 charify 群(一次走訪建立 jz-char/span 結構),後 standalone 群
// 依 order 升序。理由見 §3.2——間隙/禁則須在標點結構穩定後處理。
import type { CharifyPass, Pass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
interface NodeWork
{
node: Text;
requests: WrapRequest[];
}
/** 對 ctx.root 套用 passes 中已啟用者。 */
export function runPasses(passes: Pass[], ctx: RenderContext): void
{
const enabled = passes.filter((p) => p.enabled(ctx.options));
const charify = enabled
.filter((p): p is CharifyPass => p.kind === "charify")
.sort((a, b) => a.order - b.order);
const standalone = enabled
.filter((p): p is StandalonePass => p.kind === "standalone")
.sort((a, b) => a.order - b.order);
if (charify.length > 0) { runCharify(charify, ctx); }
for (const p of standalone) { p.render(ctx); }
}
/** 還原 passes 之全部產物(逆 orderrevert 對無產物者安全)。 */
export function revertPasses(passes: Pass[], ctx: RenderContext): void
{
const ordered = passes.slice().sort((a, b) => b.order - a.order);
for (const p of ordered) { p.revert(ctx); }
}
// ----- charify 階段:單次走訪收集,批次 apply -----
function runCharify(passes: CharifyPass[], ctx: RenderContext): void
{
const work: NodeWork[] = [];
ctx.finder.eachTextNode(ctx.root, (node) =>
{
let requests: WrapRequest[] = [];
for (const p of passes)
{
const got = p.collect(node, ctx);
if (got.length > 0) { requests = requests.concat(got); }
}
if (requests.length > 0) { work.push({ node, requests }); }
});
for (const item of work) { applyRequests(item.node, item.requests); }
}
/**
* 把單一文本節點依 requests 重建為 fragment:間隙以原文,命中段以 build()
* 之元素。requests 互不重疊;防禦性地丟棄與已採用段重疊者。
*/
function applyRequests(node: Text, requests: WrapRequest[]): void
{
const parent = node.parentNode;
if (!parent) { return; }
const sorted = requests.slice().sort((a, b) => a.start - b.start || a.end - b.end);
const data = node.data;
const ownerDoc = node.ownerDocument;
if (!ownerDoc) { return; }
const frag = ownerDoc.createDocumentFragment();
let cursor = 0;
for (const req of sorted)
{
if (req.start < cursor) { continue; } // 重疊,跳過。
if (req.start < 0 || req.end > data.length || req.end <= req.start) { continue; }
if (req.start > cursor)
{
frag.appendChild(ownerDoc.createTextNode(data.slice(cursor, req.start)));
}
frag.appendChild(req.build(data.slice(req.start, req.end)));
cursor = req.end;
}
if (cursor === 0) { return; } // 無有效段。
if (cursor < data.length)
{
frag.appendChild(ownerDoc.createTextNode(data.slice(cursor)));
}
parent.replaceChild(frag, node);
}
+120
View File
@@ -0,0 +1,120 @@
// 字元分類與規則字集(架構 §4.2)。
//
// 集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致。
// CJK/西文字集沿用 v1cjk-autospace)已驗證之 Pangu 範圍;標點子類為新增,
// 對應 CSS classjz-char 之 bd-* 標記)。
//
// 這些常數以「regex 字元類片段」形式給出(已就 RegExp 字元類做必要轉義),
// 故可直接 `new RegExp("[" + X + "]")`。
/** CJK 表意文字/假名/注音等範圍(沿用並擴充 v1 之 PANGU_CJK)。 */
export const CJK = (
"⺀-⻿⼀-⿟" // CJK 部首補充 + 康熙部首
+ "぀-ゟ゠-ヺー-ヿ" // 平假名 + 片假名
+ "㄀-ㄯ㈀-㋿" // 注音符號 + 圈號 CJK
+ "㐀-䶿一-鿿豈-﫿" // 擴展 A + 統一表意 + 相容
);
/** 西文字母與數字。 */
export const ALNUM = "A-Za-z0-9";
/** 西文字母。 */
export const ALPHA = "A-Za-z";
/** 任一 CJK 字元測試。 */
export const ANY_CJK = new RegExp("[" + CJK + "]");
// ----- Pangu 中西邊界用之擴充西文字集(沿用 v1)-----
// 拉丁補充以 ¡-¶¸-ÿ 表達(U+00A100B6, 00B800FF),刻意跳過 U+00B7 中間點
// `·`——它是 CJK 間隔號(姓名分隔,如 卡尔·马克思),屬 CJK 標點而非西文,
// 不應觸發中西間隙(否則圓點側會多一個空白)。
/** CJK 之後可接間隙之西文集(含希臘字母、運算符、拉丁補充、箭號、裝飾符)。 */
export const ANS_AFTER_CJK = (
ALPHA + "Ͱ-Ͽ0-9@\\$%\\^&\\*\\-\\+\\\\=¡-¶¸-ÿ⅐-↏✀-➿"
);
/** CJK 之前可接間隙之西文集(同上,去 @)。 */
export const ANS_BEFORE_CJK = (
ALPHA + "Ͱ-Ͽ0-9\\$%\\^&\\*\\-\\+\\\\=¡-¶¸-ÿ⅐-↏✀-➿"
);
// ----- 標點(biaodian)子類。對應 jz-char 之 bd-* class -----
//
// 注意:分類依「字形角色」,而開明式/全角式之別依「句法角色」。slice 1 之
// 擠壓集(句內點號)由 BD_PAUSE 表達——此即 v1 Pass D 所擠之 ,、;:。
/** 句末點號:。!?(開明式與全角式皆保持全形,不擠壓)。 */
export const BD_STOP = "。!?";
/** 句內點號:,、;:(開明式擠成半形;全角式保持全形)。 */
export const BD_PAUSE = ",、;:";
/** 開始括號/引號:「『(《〈【〖〔[{“‘。 */
export const BD_OPEN = "「『(《〈【〖〔[{“‘";
/** 結束括號/引號:」』)》〉】〗〕]}”’。 */
export const BD_CLOSE = "」』)》〉】〗〕]}”’";
/** 間隔/分隔號:·・‧。 */
export const BD_MIDDLE = "·・‧";
/** 連續省略/破折:…—。 */
export const BD_LIGA = "…—";
/** 全體標點字元(供 charify 走訪偵測)。 */
export const BD_ALL = (
BD_STOP + BD_PAUSE + BD_OPEN + BD_CLOSE + BD_MIDDLE + BD_LIGA
);
const BD_SETS: ReadonlyArray<readonly [ string, string ]> = [
[ "bd-stop", BD_STOP ],
[ "bd-pause", BD_PAUSE ],
[ "bd-open", BD_OPEN ],
[ "bd-close", BD_CLOSE ],
[ "bd-middle", BD_MIDDLE ],
[ "bd-liga", BD_LIGA ],
];
/**
* 將單一標點字元歸入其 bd-* 子類;非標點回傳 null。
* 供 jiya 之 charify 為 jz-char 標記 class。
*/
export function classifyBiaodian(ch: string): string | null
{
for (const [ name, set ] of BD_SETS)
{
if (set.indexOf(ch) >= 0) { return name; }
}
return null;
}
/** 全體 bd-* 子類名(DOM 端讀取 jz-char 之 bd class 用)。 */
export const BD_CLASS_NAMES = [
"bd-stop", "bd-pause", "bd-open", "bd-close", "bd-middle", "bd-liga",
] as const;
/**
* 標點之「可壓縮空白」側(橫排)。字面偏左者空白在右(句內/句末/閉類),
* 開類字面偏右、空白在左。間隔號/連接號不壓縮。擠壓即收掉對應側之半形空白。
*/
export function blankSides(bdClass: string | null): { left: boolean; right: boolean }
{
switch (bdClass)
{
case "bd-open":
{
return { left: true, right: false };
}
case "bd-close":
case "bd-pause":
case "bd-stop":
{
return { left: false, right: true };
}
default:
{
return { left: false, right: false };
}
}
}
+10
View File
@@ -0,0 +1,10 @@
/* 禁則 + justify 綁定(§6.4,切片 1 基礎)。
連續標點綁為不可斷之單一原子(clreq 連續標點不斷行),在 justify 下整體
作為一項。完整避頭尾列後續,沿用同一 jz-jinze 結構。 */
jz-jinze
{
display: inline-block;
white-space: nowrap;
vertical-align: baseline;
}
+32
View File
@@ -0,0 +1,32 @@
/* 標點擠壓 + justify 原子化(§6.3/§8)。
擠壓量以負 margin 託管,跨瀏覽器一致(I4),不依賴 halttext-spacing-trim。 */
/* §8:每個 jz-char 為 justify 之單一原子項——句末標點只貢獻一份對齊伸縮量
I5)。inline-block 使標點與其字面留白同屬一項,對齊伸縮只落於項與項之間。
⚠ R1inline-block 對基線/行高/行內換行之影響須跨 Chrome/Safari/FF 目視
驗證(見 demo / ARCHITECTURE §14)。 */
jz-char
{
display: inline-block;
vertical-align: baseline;
/* 標點本佔一個全形字框;inline-block 寬度即收斂為其字面 advance。 */
}
jz-inner
{
display: inline;
}
/* 半形擠壓:用字型 OpenType `halt`(橫排半形)收字身寬度——由字型按字形正確
定位(簡體點號偏左、繁體居中、括號偏側皆正確),不拉鄰居故不重疊(修正
負 margin 對居中字形如 !?/繁體 。, 之缺陷)。需字型含 halt(Noto CJK
具備);缺 halt 之字型則維持全形(優雅降級,不重疊)。三機制(baseline 政策
C/行內鄰接 A/行端 B)皆以「加半形 class」落地,並集疊加、不重壓。
jz-half 來自 baseline+鄰接;jz-half-le 來自行端 layout pass(分用不同 class
以便重算時僅清除行端結果,效果相同)。 */
jz-char.jz-half jz-inner,
jz-char.jz-half-le jz-inner
{
font-feature-settings: "halt" 1;
-webkit-font-feature-settings: "halt" 1;
}
+30
View File
@@ -0,0 +1,30 @@
/* 依 lang 之字體堆疊(§6.9)。以 :where() 零特異度,易被消費端覆寫;限於
.juzhen 容器,非破壞、opt-in。簡繁日各取對應 Noto CJK,使 locl 字形正確。
名稱同時涵蓋 webfontGoogle Fonts「Noto Serif SC」等)與本機安裝
(「Noto Serif CJK SC」「Source Han Serif SC」)——擠壓依賴字型之 OpenType
`halt`,Noto/思源具備;務必確保實際命中含 halt 之字型(Safari 對缺 halt
之 fallback 字型不會擠壓,故 demo 以 webfont 明確載入 Noto 作參考)。 */
:where(.juzhen):lang(zh-Hans),
:where(.juzhen) :lang(zh-Hans)
{
font-family: "Noto Serif SC", "Noto Serif CJK SC", "Source Han Serif SC", serif;
}
:where(.juzhen):lang(zh-Hant),
:where(.juzhen) :lang(zh-Hant)
{
font-family: "Noto Serif TC", "Noto Serif CJK TC", "Source Han Serif TC", serif;
}
:where(.juzhen):lang(zh-HK),
:where(.juzhen) :lang(zh-HK)
{
font-family: "Noto Serif HK", "Noto Serif CJK HK", "Noto Serif TC", serif;
}
:where(.juzhen):lang(ja),
:where(.juzhen) :lang(ja)
{
font-family: "Noto Serif JP", "Noto Serif CJK JP", serif;
}
+30
View File
@@ -0,0 +1,30 @@
/* §9.1 顯式禁用支援不一致/會與我們託管版面重複作用之原生原語,使三引擎
收斂於同一條一致路徑(I4)。清單隨功能演進維護於此。 */
/* 標點擠壓改以字型 OpenType `halt` 託管(見 _jiya.css)。實測(demo 量測):
不設 text-spacing-trim 時,未加 jz-half 之全形標點仍為 1.000em(原生
text-spacing-trim 並未自動 trim 我們 inline-block 隔離之孤立標點),且 halt
正常生效(半形字 = 0.500em)。故**不設** text-spacing-trim——設 space-all 反而
有抵銷 halt 之風險,且非必要。 */
/* 關閉原生中西自動間距(我們以 jz-hws 託管間隙)。限於 .juzhen 容器,
避免外溢;消費端把內容掛在 .juzhen 下即啟用此禁用層(與 scope 同域)。 */
@supports (text-autospace: no-autospace)
{
.juzhen
{
text-autospace: no-autospace;
}
}
/* jz-* 自訂元素皆語義中性、預設行內,可被 revert 移除。 */
jz-char,
jz-inner,
jz-hws,
jz-cs,
jz-jinze,
jz-em
{
font: inherit;
color: inherit;
}
+20
View File
@@ -0,0 +1,20 @@
/* 中西間隙(§6.1)。jz-hws 包裹邊界**左側**之一個字(CJK 或西文)或 pill,
間隙以 margin-right 提供——不注入空白字元:
- copy-clean:複製得到緊湊原文(無多餘空白),textContent 完全不變(I1)。
- justify 安全:邊界無空白字元,justify 僅施均勻字間量,間隙不會像詞間空白
那樣被放大(修正中西間隙隨 justify 暴增之缺陷)。
- 永不落行首:margin-right 尾隨左側字,換行時落於行尾(不顯示),絕不在
行首造成縮排。
斷行由 CJK 字本具之斷點提供,無需空白字元。 */
jz-hws
{
margin-right: 0.25em; /* clreq 四分之一字寬 */
}
/* 行末去隙(由 gapTrim layout pass 標記):落在行末之間隙移除其 margin-right
使該行於 justify 下齊右、不內縮 0.25em。 */
jz-hws.jz-hws-trim
{
margin-right: 0;
}
+9
View File
@@ -0,0 +1,9 @@
/* 聚珍(Juzhen)樣式入口。視覺尺寸一律在此(I2):JS 只注入 jz-* 結構,
間隙寬度/擠壓量/原子化/字體堆疊皆由 CSS 給定。
切片 1normalize(禁用層)+ lang(字體)+ spacing + jiya + jinze。 */
@import "./_normalize.css";
@import "./_lang.css";
@import "./_spacing.css";
@import "./_jiya.css";
@import "./_jinze.css";
+145
View File
@@ -0,0 +1,145 @@
// 聚珍(Juzhen)公開入口(架構 §3.1)。
//
// const jz = createJuzhen(options);
// jz.render(root?); // 套用已啟用之 pass
// jz.revert(root?); // 還原
//
// 全域(IIFE):window.Juzhen.createJuzhen(...);並掛 v1 相容別名
// globalThis.createCjkAutospace(見 build.mjs footer 與 compat/v1.ts)。
import { Finder } from "./core/finder.js";
import { runPasses, revertPasses } from "./core/pass.js";
import { jiyaAdjacencyPass, jiyaPass } from "./typeset/jiya.js";
import { jinzePass } from "./typeset/jinze.js";
import { gapTrimPass, lineEdgePass } from "./typeset/lineedge.js";
import { longWordPass } from "./typeset/longword.js";
import { spacingPass } from "./typeset/spacing.js";
import type { JuzhenOptions, LongWordOptions, Pass, RenderContext, ResolvedOptions } from "./types.js";
export type { JuzhenOptions, ResolvedOptions, Pass };
export { createCjkAutospace } from "./compat/v1.js";
const DEFAULT_STYLE_INLINES = [
"STRONG", "EM", "A", "B", "I", "U", "S", "MARK", "INS", "DEL",
"SUB", "SUP", "SMALL", "ABBR", "CITE", "DFN", "Q", "SPAN",
"VAR", "SAMP", "TIME", "BDO", "BDI", "RUBY", "RB", "RT", "RP", "WBR",
];
const DEFAULT_BLOCK_TAGS = [
"BODY", "P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI",
"DT", "DD", "BLOCKQUOTE", "FIGURE", "FIGCAPTION", "TD", "TH",
"CAPTION", "SUMMARY", "DETAILS", "HEADER", "FOOTER", "SECTION",
"ARTICLE", "ASIDE", "NAV", "MAIN", "ADDRESS", "PRE", "UL", "OL",
"DL", "TABLE", "TR", "THEAD", "TBODY", "TFOOT", "FORM",
"FIELDSET", "LEGEND", "LABEL", "BR", "HR",
];
const DEFAULT_SKIP_TAGS = [
"CODE", "KBD", "PRE", "SAMP", "TT", "VAR",
"SCRIPT", "STYLE", "TEXTAREA", "INPUT",
];
const PASSES: Pass[] = [
jiyaPass, jiyaAdjacencyPass, longWordPass, spacingPass, jinzePass,
lineEdgePass, gapTrimPass,
];
function feature(v: unknown, dflt: boolean): boolean
{
return v === undefined ? dflt : !!v;
}
function resolveLongWord(v: boolean | LongWordOptions | undefined): { minLength: number; lang: string }
{
if (v === false) { return { minLength: 6, lang: "en" }; } // 值不用(enabled 為 false
if (v === undefined || v === true || v === null) { return { minLength: 6, lang: "en" }; }
const minLength = typeof v.minLength === "number" && v.minLength >= 2 ? v.minLength : 6;
const lang = typeof v.lang === "string" && v.lang ? v.lang : "en";
return { minLength, lang };
}
/** 正規化公開選項為內部 ResolvedOptions。 */
export function normalizeOptions(opts: JuzhenOptions = {}): ResolvedOptions
{
const langOpt = opts.lang || {};
const scope = opts.scope || {};
return {
locale: {
default: langOpt.default || "zh-Hant",
...(langOpt.style ? { style: langOpt.style } : {}),
policy: langOpt.policy || {},
},
autospaceClass: opts.autospaceClass || "",
scope: {
root: scope.root || null,
include: scope.include || null,
avoid: scope.avoid || null,
},
features: {
spacing: feature(opts.spacing, true),
longWord: opts.longWord === false ? false : feature(opts.longWord, true),
jiya: feature(opts.jiya, true),
jinze: feature(opts.jinze, true),
hanging: feature(opts.hanging, false),
biaodian: feature(opts.biaodian, false),
emphasis: feature(opts.emphasis, false),
ruby: feature(opts.ruby, false),
},
longWord: resolveLongWord(opts.longWord),
blocks: Array.isArray(opts.blocks) ? opts.blocks.slice() : [],
finder: {
styleInlines: new Set(opts.styleInlines || DEFAULT_STYLE_INLINES),
blockTags: new Set(opts.blockTags || DEFAULT_BLOCK_TAGS),
skipTags: new Set(opts.skipTags || DEFAULT_SKIP_TAGS),
pillSelector: opts.pillSelector || "code, kbd",
skipAttribute: opts.skipAttribute || null,
isSkipped: typeof opts.isSkipped === "function" ? opts.isSkipped : null,
},
};
}
function resolveRoot(
arg: Element | string | undefined,
options: ResolvedOptions,
): Element | null
{
const pick = arg !== undefined ? arg : options.scope.root;
if (pick && typeof pick !== "string") { return pick; }
if (typeof pick === "string" && typeof document !== "undefined")
{
return document.querySelector(pick);
}
return typeof document !== "undefined" ? document.body : null;
}
export interface Juzhen
{
render(root?: Element | string): void;
revert(root?: Element | string): void;
readonly options: ResolvedOptions;
}
/** 建立一個聚珍實例。 */
export function createJuzhen(opts: JuzhenOptions = {}): Juzhen
{
const options = normalizeOptions(opts);
function makeCtx(root: Element): RenderContext
{
return { root, options, finder: new Finder(options) };
}
return {
options,
render(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
runPasses(PASSES, makeCtx(el));
},
revert(root?: Element | string): void
{
const el = resolveRoot(root, options);
if (!el) { return; }
revertPasses(PASSES, makeCtx(el));
},
};
}
+146
View File
@@ -0,0 +1,146 @@
// 公開選項型別與內部上下文型別(架構 §3.1/§3.3)。
import type { Finder } from "./core/finder.js";
import type { LangClass, LocaleConfig, PunctStyle } from "./core/locale.js";
export type { LangClass, PunctStyle };
/** 長英文詞包裝設定。 */
export interface LongWordOptions
{
minLength?: number;
lang?: string;
}
/** 分塊功能選擇:選擇器 → 該塊內生效之功能集(覆寫全域)。 */
export interface BlockRule
{
selector: string;
features: string[];
}
/** createJuzhen 之公開選項。所有欄位皆可選;預設見 normalizeOptions。 */
export interface JuzhenOptions
{
/** 語言與全角/開明政策。 */
lang?: {
/** 無 lang 祖先時之預設語言標籤(預設 "zh-Hant")。 */
default?: string;
/** 全域標點擠壓預設:quanjiao(全角式)|kaiming(開明式)|
* banjiao(半角式)。覆寫內建逐語言預設,可被 policy 逐 lang 覆寫。 */
style?: PunctStyle;
/** 逐 lang 覆寫(最高優先)。 */
policy?: Partial<Record<LangClass, PunctStyle>>;
};
/** 間隙標記沿用之 class(v1 相容;預設無額外 class)。 */
autospaceClass?: string;
/** 作用域限定(§3.6)。 */
scope?: {
root?: Element | string;
include?: string | null;
avoid?: string;
};
// 全域逐功能開關。
spacing?: boolean;
longWord?: boolean | LongWordOptions;
jiya?: boolean;
jinze?: boolean;
hanging?: boolean;
biaodian?: boolean;
emphasis?: boolean;
ruby?: boolean;
/** 分塊功能選擇(§3.6)。 */
blocks?: BlockRule[];
// ----- v1 相容用之 finder 設定 -----
styleInlines?: string[];
blockTags?: string[];
skipTags?: string[];
pillSelector?: string;
skipAttribute?: string;
isSkipped?: (node: Node) => boolean;
}
/** 正規化後之內部選項。 */
export interface ResolvedOptions
{
locale: LocaleConfig;
autospaceClass: string;
scope: {
root: Element | string | null;
include: string | null;
avoid: string | null;
};
features: {
spacing: boolean;
longWord: boolean;
jiya: boolean;
jinze: boolean;
hanging: boolean;
biaodian: boolean;
emphasis: boolean;
ruby: boolean;
};
longWord: { minLength: number; lang: string };
blocks: BlockRule[];
finder: {
styleInlines: Set<string>;
blockTags: Set<string>;
skipTags: Set<string>;
pillSelector: string;
skipAttribute: string | null;
isSkipped: ((node: Node) => boolean) | null;
};
}
/** renderrevert 之統一上下文(§3.3)。 */
export interface RenderContext
{
root: Element;
options: ResolvedOptions;
finder: Finder;
}
/**
* charify 階段之包裝請求:把 node.data[start, end) 包進 build() 造出之元素。
* 同階段各 pass 之請求須互不重疊(標點/字母段天然不交集)。
*/
export interface WrapRequest
{
start: number;
end: number;
build: (text: string) => HTMLElement;
pass: string;
}
/** pass 種類。 */
export type PassKind = "charify" | "standalone";
interface BasePass
{
name: string;
order: number;
kind: PassKind;
enabled: (options: ResolvedOptions) => boolean;
revert: (ctx: RenderContext) => void;
}
/** 共享 P1 走訪之字元包裝 pass。 */
export interface CharifyPass extends BasePass
{
kind: "charify";
collect: (node: Text, ctx: RenderContext) => WrapRequest[];
}
/** 自跑走訪/版面後處理之 pass。 */
export interface StandalonePass extends BasePass
{
kind: "standalone";
render: (ctx: RenderContext) => void;
}
export type Pass = CharifyPass | StandalonePass;
+161
View File
@@ -0,0 +1,161 @@
// 禁則:行首行末避頭尾(架構 §6.4)+ justify 綁定。standalone pass,須在
// charifyjiya)之後(依賴 jz-char)。
//
// 瀏覽器原生(UAX#14)本會避免「行首閉類/點號、行末開類」,但 jiya 把標點
// 原子化為 inline-block jz-char 後,原子邊界成為可斷點、破壞原生避頭尾。本 pass
// 補回:把「不可置行首」之標點(閉類/點號/間隔/連接省略)綁到其前一字,
// 「不可置行末」之標點(開類)綁到其後一字——以 nowrap 之 jz-jinze 包裹,且只
// 隔離單一邊界字(長引文內部仍可正常斷行,不致溢出)。亦為 justify 綁定載體。
//
// 兩階段:①依原始相鄰關係算「禁斷邊界」,對涉及之文本單元切出邊界字;
// ②重掃當前子節點,把以禁斷邊界相連之相鄰原子併入同一 jz-jinze。
import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import { ANY_CJK } from "../core/unicode.js";
import type { RenderContext, StandalonePass } from "../types.js";
const PASS = "jinze";
// 不可置於行首(避頭):閉括號引號、點號、間隔號、連接/省略號。
const AVOID_HEAD = new Set([ "bd-close", "bd-pause", "bd-stop", "bd-middle", "bd-liga" ]);
// 不可置於行末(避尾):開括號引號。
const AVOID_TAIL = new Set([ "bd-open" ]);
function isCharEl(n: Node | null): n is Element
{
return !!n && n.nodeType === 1 && (n as Element).nodeName === "JZ-CHAR";
}
/** 相鄰兩節點之間是否禁止斷行(僅由 jz-char 之類別決定)。 */
function forbiddenBreak(a: Node, b: Node, finder: Finder): boolean
{
if (isCharEl(b))
{
const c = bdClassOf(b);
if (c && AVOID_HEAD.has(c) && finder.featureEnabledFor(b, PASS)) { return true; }
}
if (isCharEl(a))
{
const c = bdClassOf(a);
if (c && AVOID_TAIL.has(c) && finder.featureEnabledFor(a, PASS)) { return true; }
}
return false;
}
// 「詞元」:CJK 字為單字詞元(可任意斷);連續非 CJK 非空白(拉丁詞/數字/
// 百分號/GPT-4/3.5 等)為一個不可內斷之詞元。綁定須以整個邊界詞元為單位,
// 否則會把 200% 拆成 200|%,在其間製造斷點(修正:閉類綁定不應切斷數字/詞)。
function isTokenChar(ch: string): boolean
{
return !ANY_CJK.test(ch) && !/\s/.test(ch);
}
function leadingTokenEnd(d: string): number
{
if (ANY_CJK.test(d.charAt(0))) { return 1; }
let e = 1;
while (e < d.length && isTokenChar(d.charAt(e))) { e += 1; }
return e;
}
function trailingTokenStart(d: string): number
{
const last = d.length - 1;
if (ANY_CJK.test(d.charAt(last))) { return last; }
let s = last;
while (s > 0 && isTokenChar(d.charAt(s - 1))) { s -= 1; }
return s;
}
/** 隔離文本節點之首/末「詞元」為獨立節點(供綁定,使中段仍可斷行)。 */
function splitBoundaries(t: Text, needFirst: boolean, needLast: boolean): void
{
let node: Text = t;
if (needFirst)
{
const e = leadingTokenEnd(node.data);
if (e < node.data.length) { node = node.splitText(e); }
}
if (needLast)
{
const s = trailingTokenStart(node.data);
if (s > 0) { node.splitText(s); }
}
}
function processParent(parent: Node, finder: Finder): void
{
// 冪等:已含 jz-jinze(前次 render 未 revert)則跳過。
for (let c = parent.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 1 && (c as Element).nodeName === "JZ-JINZE") { return; }
}
// 階段 1:原始單元(非空文本 / 元素)→ 算禁斷邊界 → 切邊界字。
const units: Node[] = [];
for (let c = parent.firstChild; c; c = c.nextSibling)
{
if (c.nodeType === 3 && (c as Text).data.length > 0) { units.push(c); }
else if (c.nodeType === 1) { units.push(c); }
}
const n = units.length;
if (n < 2) { return; }
const forbidden: boolean[] = [];
for (let i = 0; i < n - 1; i += 1)
{
forbidden.push(forbiddenBreak(units[i]!, units[i + 1]!, finder));
}
for (let i = 0; i < n; i += 1)
{
const u = units[i]!;
if (u.nodeType !== 3) { continue; }
const needFirst = i > 0 && forbidden[i - 1]!;
const needLast = i < n - 1 && forbidden[i]!;
if (needFirst || needLast) { splitBoundaries(u as Text, needFirst, needLast); }
}
// 階段 2:重掃當前子節點,把禁斷相連之相鄰原子併成 jz-jinze。
const kids: Node[] = [];
for (let c = parent.firstChild; c; c = c.nextSibling) { kids.push(c); }
let i = 0;
while (i < kids.length)
{
const run: Node[] = [ kids[i]! ];
while (i + 1 < kids.length && forbiddenBreak(kids[i]!, kids[i + 1]!, finder))
{
run.push(kids[i + 1]!);
i += 1;
}
if (run.length >= 2)
{
const group = createJz("jz-jinze", PASS, "wrap");
parent.insertBefore(group, run[0]!);
for (const node of run) { group.appendChild(node); }
}
i += 1;
}
}
export const jinzePass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 40,
enabled: (o) => o.features.jinze,
render(ctx: RenderContext): void
{
const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) =>
{
if (c.parentNode) { parents.add(c.parentNode); }
});
for (const parent of parents) { processParent(parent, ctx.finder); }
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};
+149
View File
@@ -0,0 +1,149 @@
// 標點擠壓(架構 §6.3)。完整 clreq 擠壓之「行內」部分——三條規則並集,
// 以方向性 jz-cl(收左半形空白)/jz-cr(收右)落地,CSS 以負 margin 渲染,
// 跨瀏覽器一致(I4),不依賴 halttext-spacing-trim。
//
// 機制 C baseline 寬度政策(依 langI3):
// 全角式 quanjiao — 一律不收;
// 開明式 kaiming — 句內點號(,、;:)+ 開類/閉類括號引號半形,
// 句末點號(。!?)保持全形;
// 半角式 banjiao — 全部標點半形。
// 機制 A 行內連續擠壓(jiyaAdjacencyPasslang 無關):相鄰標點之對向空白
// 對消一次(每相鄰對省半形),如 。」→ 。收右 → 1.5em。
// 機制 B 行端擠壓 → typeset/lineedge.tslayout pass,瀏覽器專用)。
//
// 同時為 §8:所有 jz-char 經 CSS 設 inline-block,成 justify 單一原子項(I5)。
import { bdClassOf, createJz, revertPass } from "../core/dom.js";
import { resolveStyle } from "../core/locale.js";
import type { PunctStyle } from "../core/locale.js";
import { blankSides, classifyBiaodian } from "../core/unicode.js";
import type { CharifyPass, RenderContext, StandalonePass, WrapRequest } from "../types.js";
const PASS = "jiya";
/** baseline 政策下,該標點是否擠成半形(機制 C)。半形以字型 halt 渲染,
* 方向由字型決定,故此處只需「半形與否」之布林。 */
function baselineHalf(style: PunctStyle, bdClass: string): boolean
{
if (style === "quanjiao") { return false; }
if (style === "banjiao")
{
// 半角式:所有可壓縮標點半形(句末亦半)。
return bdClass === "bd-pause" || bdClass === "bd-stop"
|| bdClass === "bd-open" || bdClass === "bd-close";
}
// kaiming:句內點號+括號引號半形,句末點號保持全形。
return bdClass === "bd-pause" || bdClass === "bd-open" || bdClass === "bd-close";
}
/** parent 已是 jz-char(本 pass 產物)→ 冪等跳過。 */
function inJzChar(node: Node): boolean
{
let p = node.parentNode;
while (p && p.nodeType === 1)
{
const name = (p as Element).nodeName;
if (name === "JZ-CHAR" || name === "JZ-INNER") { return true; }
p = p.parentNode;
}
return false;
}
// ----- charify baseline(機制 C-----
export const jiyaPass: CharifyPass = {
name: PASS,
kind: "charify",
order: 20,
enabled: (o) => o.features.jiya,
collect(node, ctx): WrapRequest[]
{
if (inJzChar(node)) { return []; }
if (!ctx.finder.featureEnabledFor(node, PASS)) { return []; }
const data = node.data;
const out: WrapRequest[] = [];
let style: PunctStyle | null = null;
for (let i = 0; i < data.length; i += 1)
{
const bdClass = classifyBiaodian(data.charAt(i));
if (!bdClass) { continue; }
if (style === null) { style = resolveStyle(node, ctx.options.locale); }
const classes = baselineHalf(style, bdClass) ? [ bdClass, "jz-half" ] : [ bdClass ];
out.push({
start: i,
end: i + 1,
pass: PASS,
build: (text) =>
{
const outer = createJz("jz-char", PASS, "wrap", { classes });
const inner = createJz("jz-inner", PASS, "wrap", { text });
outer.appendChild(inner);
return outer;
},
});
}
return out;
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};
// ----- 行內連續擠壓(機制 A-----
//
// 對 DOM 直接相鄰(nextSibling 即下一 jz-char、其間無文本)之標點對 (L, R):
// 收掉「指向彼此」之那一側——L 若有右空白則收 L(半形),否則 R 若有左空白
// 則收 R。每相鄰對只收一字 → 該對約 1.5em。鏈狀連續標點逐對處理。加 jz-half
// 冪等(並集),與 baseline 疊加(已半形則 no-op)。class 掛於 charify 之
// jz-char,隨其解包清除,本 pass 無獨立產物。
//
// 半形以字型 halt 渲染(居中字形如 ?!亦正確、不重疊);例:?!→ 只收 ?,
// !保持全形 1em。
export const jiyaAdjacencyPass: StandalonePass = {
name: "jiyaAdjacency",
kind: "standalone",
order: 25,
enabled: (o) => o.features.jiya,
render(ctx: RenderContext): void
{
const { finder } = ctx;
const parents = new Set<Node>();
ctx.root.querySelectorAll("jz-char").forEach((c) =>
{
if (c.parentNode) { parents.add(c.parentNode); }
});
for (const parent of parents)
{
let child: Node | null = parent.firstChild;
while (child)
{
const next = child.nextSibling;
if (
child.nodeType === 1 && (child as Element).nodeName === "JZ-CHAR"
&& next && next.nodeType === 1 && (next as Element).nodeName === "JZ-CHAR"
)
{
const L = child as Element;
const R = next as Element;
if (finder.featureEnabledFor(L, PASS))
{
const lSides = blankSides(bdClassOf(L));
const rSides = blankSides(bdClassOf(R));
if (lSides.right) { L.classList.add("jz-half"); }
else if (rSides.left) { R.classList.add("jz-half"); }
}
}
child = next;
}
}
},
revert(): void
{
// 無獨立產物:class 掛於 jiya charify 之 jz-char,隨其解包清除。
},
};
+196
View File
@@ -0,0 +1,196 @@
// 行端擠壓(架構 §6.4/§6.5 之行端部分;clreq 行首行尾擠壓)。機制 B。
// standalone layout pass:量測各 jz-char 之行盒位置,對「行首之開類」收左、
// 「行尾之閉類/點號」收右(加 jz-le-cljz-le-crCSS 同 -0.5em margin)。
// 掛 ResizeObserver 於 root,容器寬度變動時重算;字型載入後亦重算。
//
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
// ChromeSafariFirefox 目視核對。
import { bdClassOf } from "../core/dom.js";
import type { Finder } from "../core/finder.js";
import type { RenderContext, StandalonePass } from "../types.js";
const LE = "jz-half-le"; // 行端半形(CSS 同 halt;獨立 class 以便重算時只清行端結果)
const EPS = 4; // px:判定「不同行」之 top 差容差。
// 每 root 各 layout pass(以 key 區分)一個 ResizeObserver。
const observers = new WeakMap<Element, Map<string, ResizeObserver>>();
function hasLayout(): boolean
{
return typeof ResizeObserver !== "undefined";
}
/** 安裝一個 layout 重算:立即跑一次,掛 ResizeObserver,字型載入後再跑。 */
function installLayout(root: Element, key: string, run: () => void): void
{
run();
let m = observers.get(root);
if (!m) { m = new Map(); observers.set(root, m); }
const existing = m.get(key);
if (existing) { existing.disconnect(); }
const ro = new ResizeObserver(() => run());
ro.observe(root);
m.set(key, ro);
const fonts = (root.ownerDocument as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
if (fonts && fonts.ready && typeof fonts.ready.then === "function")
{
fonts.ready.then(run, () => undefined);
}
}
function uninstallLayout(root: Element, key: string): void
{
const m = observers.get(root);
if (!m) { return; }
const ro = m.get(key);
if (ro) { ro.disconnect(); m.delete(key); }
}
// 我們的行內包裹:跨越它們找相鄰流內容(jz-char 可能被 jz-jinze 包住,其流
// 鄰居在群組之外)。遇 block 邊界即止(回 null,視為行端)。
const INLINE_WRAP = new Set([ "JZ-JINZE", "JZ-CHAR", "JZ-INNER", "JZ-HWS", "SPAN", "STRONG", "EM", "A", "B", "I", "U", "MARK" ]);
function rectOf(n: Node, side: "prev" | "next"): DOMRect | null
{
if (n.nodeType === 3 && (n as Text).data.length > 0)
{
const t = n as Text;
const doc = t.ownerDocument;
if (!doc) { return null; }
const range = doc.createRange();
const at = side === "prev" ? t.data.length - 1 : 0;
range.setStart(t, at);
range.setEnd(t, at + 1);
const rects = range.getClientRects();
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
}
else if (n.nodeType === 1)
{
const rects = (n as Element).getClientRects();
if (rects.length) { return rects[side === "prev" ? rects.length - 1 : 0]!; }
}
return null;
}
/** 取某節點側邊界字元之矩形(橫排:判定相鄰內容是否同行);跨我們的行內包裹
* 向上攀爬,遇 block 邊界回 null(視為行端)。 */
function edgeRect(start: Node | null, side: "prev" | "next", from: Node): DOMRect | null
{
let n = start;
let parentRef: Node | null = from.parentNode;
for (;;)
{
while (n)
{
const r = rectOf(n, side);
if (r) { return r; }
n = side === "prev" ? n.previousSibling : n.nextSibling;
}
// 本層無內容:若父為行內包裹,攀出續找其鄰居。
if (!parentRef || parentRef.nodeType !== 1) { return null; }
if (!INLINE_WRAP.has((parentRef as Element).nodeName)) { return null; }
n = side === "prev" ? parentRef.previousSibling : parentRef.nextSibling;
parentRef = parentRef.parentNode;
}
}
function firstRect(el: Element): DOMRect | null
{
const rects = el.getClientRects();
return rects.length ? rects[0]! : null;
}
function relayout(root: Element, finder: Finder): void
{
// 清除上次行端 class。
root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
const chars = Array.from(root.querySelectorAll("jz-char")).filter(
(c) => finder.inScope(c) && !finder.isAvoided(c),
);
for (const el of chars)
{
if (!finder.featureEnabledFor(el, "jiya")) { continue; }
// 行端僅處理括號引號:開類於行首收左、閉類於行尾收右。
// **不**處理句末/句內點號(。!?,等)——行尾/行首之點號保持全形,
// 避免「孤立句末 。/?!之 ! 落在行末」被誤收為半形(實測之困惑來源)。
// `。」` 行尾仍為 1em:其中 。 由鄰接(在 」 前)收,非由行端收。
const bd = bdClassOf(el);
if (bd !== "bd-open" && bd !== "bd-close") { continue; }
const r = firstRect(el);
if (!r) { continue; }
if (bd === "bd-open")
{
const prev = edgeRect(el.previousSibling, "prev", el);
if (!prev || prev.top < r.top - EPS) { el.classList.add(LE); }
}
else
{
const next = edgeRect(el.nextSibling, "next", el);
if (!next || next.top > r.top + EPS) { el.classList.add(LE); }
}
}
}
export const lineEdgePass: StandalonePass = {
name: "jiyaLineEdge",
kind: "standalone",
order: 90,
enabled: (o) => o.features.jiya,
render(ctx: RenderContext): void
{
if (!hasLayout()) { return; } // jsdom 等無版面環境:跳過。
installLayout(ctx.root, "lineEdge", () => relayout(ctx.root, ctx.finder));
},
revert(ctx: RenderContext): void
{
uninstallLayout(ctx.root, "lineEdge");
ctx.root.querySelectorAll("jz-char." + LE).forEach((e) => e.classList.remove(LE));
},
};
// ----- 中西間隙之行末去隙(機制:margin-right 模型之 justify 修正)-----
//
// jz-hws 以 margin-right 留隙;若其包裹之左字落在**行末**(下一內容在更低行),
// 該尾隨 margin 在 justify 下會使右緣內縮 0.25em。行末本不需間隙,故量測後對
// 行末之 jz-hws 加 jz-hws-trimCSS margin-right:0),該行遂齊右、無內縮。
// 純版面、瀏覽器專用(jsdom 跳過)。
const TRIM = "jz-hws-trim";
function trimGaps(root: Element, finder: Finder): void
{
root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
const gaps = Array.from(root.querySelectorAll("jz-hws")).filter(
(g) => finder.inScope(g) && !finder.isAvoided(g),
);
for (const g of gaps)
{
const r = firstRect(g);
if (!r) { continue; }
const next = edgeRect(g.nextSibling, "next", g);
// 行末:後無內容,或後一字在更低行。
if (!next || next.top > r.top + EPS) { g.classList.add(TRIM); }
}
}
export const gapTrimPass: StandalonePass = {
name: "spacingGapTrim",
kind: "standalone",
order: 92,
enabled: (o) => o.features.spacing,
render(ctx: RenderContext): void
{
if (!hasLayout()) { return; }
installLayout(ctx.root, "gapTrim", () => trimGaps(ctx.root, ctx.finder));
},
revert(ctx: RenderContext): void
{
uninstallLayout(ctx.root, "gapTrim");
ctx.root.querySelectorAll("jz-hws." + TRIM).forEach((e) => e.classList.remove(TRIM));
},
};
+62
View File
@@ -0,0 +1,62 @@
// 長英文詞斷詞包裝(架構 §6.2,切片 1)。charify pass。
//
// 把 [A-Za-z]{N,} 之長串包進 <span lang="…">,配合消費端 CSS
// `[lang|="en"]{hyphens:auto}` 由瀏覽器原生斷詞渲染連字符(DOM 不含連字符
// → 複製不污染,I1)。沿用 v1 Pass C,改以 charify 共享走訪(I9)。
import { revertPass } from "../core/dom.js";
import type { CharifyPass, RenderContext, WrapRequest } from "../types.js";
const PASS = "longWord";
/** parent 已是帶 lang 之 span(作者標語或本 pass 產物)→ 冪等跳過。 */
function inLangSpan(node: Node): boolean
{
const p = node.parentNode;
if (!p || p.nodeType !== 1) { return false; }
const el = p as Element;
if (el.nodeName !== "SPAN") { return false; }
return el.hasAttribute("lang");
}
export const longWordPass: CharifyPass = {
name: PASS,
kind: "charify",
order: 60,
enabled: (o) => o.features.longWord,
collect(node, ctx): WrapRequest[]
{
if (inLangSpan(node)) { return []; }
if (!ctx.finder.featureEnabledFor(node, PASS)) { return []; }
const cfg = ctx.options.longWord;
const re = new RegExp("[A-Za-z]{" + cfg.minLength + ",}", "g");
const data = node.data;
const out: WrapRequest[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(data)) !== null)
{
const start = m.index;
const end = m.index + m[0].length;
out.push({
start,
end,
pass: PASS,
build: (text) =>
{
const span = (node.ownerDocument as Document).createElement("span");
span.setAttribute("lang", cfg.lang);
span.setAttribute("data-jz", PASS);
span.setAttribute("data-jz-kind", "wrap");
span.textContent = text;
return span;
},
});
}
return out;
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};
+280
View File
@@ -0,0 +1,280 @@
// 中西間隙(架構 §6.1,切片 1)。standalone pass。
//
// 沿用 v1cjk-autospace)已驗證之 Pangu 規則管線與 per-block 邏輯文本處理,
// 改用 finder 提供之 collectRunsadjacentLogicalChar,並把間隙 marker 換為
// copy-clean 之 jz-hwsuser-select 由 CSS 關閉,修正 Han.css 之選中缺陷,I1)。
//
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass Bpill code/kbd 邊界間隙)。
// 規則正則為間隙專用,故與本 pass 同處;共用字集自 unicode.ts。
import { ALNUM, ALPHA, ANS_AFTER_CJK, ANS_BEFORE_CJK, ANY_CJK, CJK } from "../core/unicode.js";
import { createJz, revertPass } from "../core/dom.js";
import type { AdjacentChar, Finder } from "../core/finder.js";
import type { RenderContext, StandalonePass } from "../types.js";
const PASS = "spacing";
// 內部標記字元(PUA,不撞正常文本字形)。
const MARK = "";
const PH_OPEN = "";
const PH_CLOSE = "";
// ----- Pangu 規則正則(沿用 v1-----
const OPS_HYPHEN = "\\+\\*=&\\-";
const OPS_GRADE = "\\+\\-\\*";
const QUOTES_CLS = "`\"״";
const LBR_BASIC = "\\(\\[\\{";
const RBR_BASIC = "\\)\\]\\}";
const LBR_EXT = "\\(\\[\\{<>";
const RBR_EXT = "\\)\\]\\}<>";
const STRIP_CJK_SPACE_ANS = new RegExp("([" + CJK + "]) ([" + ANS_AFTER_CJK + "])", "g");
const STRIP_ANS_SPACE_CJK = new RegExp("([" + ANS_BEFORE_CJK + "]) ([" + CJK + "])", "g");
const DOTS_CJK = new RegExp("([\\.]{2,}|…)([" + CJK + "])", "g");
const CJK_PUNCTUATION = new RegExp("([" + CJK + "])([!;,\\?:]+)(?=[" + CJK + ALNUM + "])", "g");
const AN_PUNCTUATION_CJK = new RegExp("([" + ALNUM + "])([!;,\\?]+)([" + CJK + "])", "g");
const CJK_TILDE = new RegExp("([" + CJK + "])(~+)(?!=)(?=[" + CJK + ALNUM + "])", "g");
const CJK_TILDE_EQUALS = new RegExp("([" + CJK + "])(~=)", "g");
const CJK_PERIOD = new RegExp("([" + CJK + "])(\\.)(?![" + ALNUM + "\\./])(?=[" + CJK + ALNUM + "])", "g");
const AN_PERIOD_CJK = new RegExp("([" + ALNUM + "])(\\.)([" + CJK + "])", "g");
const AN_COLON_CJK = new RegExp("([" + ALNUM + "])(:)([" + CJK + "])", "g");
const CJK_QUOTE = new RegExp("([" + CJK + "])([" + QUOTES_CLS + "])", "g");
const QUOTE_CJK = new RegExp("([" + QUOTES_CLS + "])([" + CJK + "])", "g");
const QUOTE_AN = new RegExp("([”])([" + ALNUM + "])", "g");
const CJK_QUOTE_AN = new RegExp("([" + CJK + "])(\")([" + ALNUM + "])", "g");
const CJK_HASH = new RegExp("([" + CJK + "])(#([^ ]))", "g");
const HASH_CJK = new RegExp("(([^ ])#)([" + CJK + "])", "g");
const SINGLE_LETTER_GRADE = new RegExp("\\b([" + ALPHA + "])([" + OPS_GRADE + "])([" + CJK + "])", "g");
const CJK_OPERATOR_ANS = new RegExp("([" + CJK + "])([" + OPS_HYPHEN + "])([" + ALNUM + "])", "g");
const ANS_OPERATOR_CJK = new RegExp("([" + ALNUM + "])([" + OPS_HYPHEN + "])([" + CJK + "])", "g");
const CJK_LESS_THAN = new RegExp("([" + CJK + "])(<)([" + ALNUM + "])", "g");
const LESS_THAN_CJK = new RegExp("([" + ALNUM + "])(<)([" + CJK + "])", "g");
const CJK_GREATER_THAN = new RegExp("([" + CJK + "])(>)([" + ALNUM + "])", "g");
const GREATER_THAN_CJK = new RegExp("([" + ALNUM + "])(>)([" + CJK + "])", "g");
const CJK_LEFT_BRACKET = new RegExp("([" + CJK + "])([" + LBR_EXT + "])", "g");
const RIGHT_BRACKET_CJK = new RegExp("([" + RBR_EXT + "])([" + CJK + "])", "g");
const AN_LEFT_BRACKET = new RegExp("([" + ALNUM + "])(?<!\\.[" + ALNUM + "]*)([" + LBR_BASIC + "])", "g");
const RIGHT_BRACKET_AN = new RegExp("([" + RBR_BASIC + "])([" + ALNUM + "])", "g");
const CJK_ANS = new RegExp("([" + CJK + "])([" + ANS_AFTER_CJK + "])", "g");
const ANS_CJK = new RegExp("([" + ANS_BEFORE_CJK + "])([" + CJK + "])", "g");
const S_A = new RegExp("(%)([" + ALPHA + "])", "g");
// 複合詞(GPT-4 / state-of-the-art)不可被運算符間隙楔入:先遮罩,運算符
// 過後、括號 / CJK_ANS / ANS_CJK 過前還原。
const COMPOUND_WORD = /\b(?:[A-Za-z0-9]*[a-z][A-Za-z0-9]*-[A-Za-z0-9]+|[A-Za-z0-9]+-[A-Za-z0-9]*[a-z][A-Za-z0-9]*|[A-Za-z]+-[0-9]+|[A-Za-z]+[0-9]+-[A-Za-z0-9]+)(?:-[A-Za-z0-9]+)*\b/g;
const PH_PATTERN = new RegExp(PH_OPEN + "(\\d+)" + PH_CLOSE, "g");
function panguSpace(text: string): string
{
if (text.length <= 1 || !ANY_CJK.test(text)) { return text; }
let s = text;
s = s.replace(STRIP_CJK_SPACE_ANS, "$1$2");
s = s.replace(STRIP_ANS_SPACE_CJK, "$1$2");
s = s.replace(DOTS_CJK, "$1" + MARK + "$2");
s = s.replace(CJK_PUNCTUATION, "$1$2" + MARK);
s = s.replace(AN_PUNCTUATION_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_TILDE, "$1$2" + MARK);
s = s.replace(CJK_TILDE_EQUALS, "$1" + MARK + "$2" + MARK);
s = s.replace(CJK_PERIOD, "$1$2" + MARK);
s = s.replace(AN_PERIOD_CJK, "$1$2" + MARK + "$3");
s = s.replace(AN_COLON_CJK, "$1$2" + MARK + "$3");
s = s.replace(CJK_QUOTE, "$1" + MARK + "$2");
s = s.replace(QUOTE_CJK, "$1" + MARK + "$2");
s = s.replace(QUOTE_AN, "$1" + MARK + "$2");
s = s.replace(CJK_QUOTE_AN, "$1$2" + MARK + "$3");
s = s.replace(CJK_HASH, "$1" + MARK + "$2");
s = s.replace(HASH_CJK, "$1" + MARK + "$3");
const compounds: string[] = [];
s = s.replace(COMPOUND_WORD, (m) =>
{
compounds.push(m);
return PH_OPEN + (compounds.length - 1) + PH_CLOSE;
});
s = s.replace(SINGLE_LETTER_GRADE, "$1$2" + MARK + "$3");
s = s.replace(CJK_OPERATOR_ANS, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(ANS_OPERATOR_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_LESS_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(LESS_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(CJK_GREATER_THAN, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(GREATER_THAN_CJK, "$1" + MARK + "$2" + MARK + "$3");
s = s.replace(PH_PATTERN, (_m, idx: string) => compounds[Number(idx)] || "");
s = s.replace(CJK_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_CJK, "$1" + MARK + "$2");
s = s.replace(AN_LEFT_BRACKET, "$1" + MARK + "$2");
s = s.replace(RIGHT_BRACKET_AN, "$1" + MARK + "$2");
s = s.replace(CJK_ANS, "$1" + MARK + "$2");
s = s.replace(ANS_CJK, "$1" + MARK + "$2");
s = s.replace(S_A, "$1" + MARK + "$2");
return s;
}
interface SpaceAction
{
before: { textNode: Text; offset: number } | undefined;
space: { textNode: Text; offset: number } | null;
}
// 把一個節點(已隔離之單字文本,或元素如 pill)包進 jz-hws——間隙由 CSS 之
// margin-right 提供。不注入空白字元:故 justify 不把間隙當詞間空白放大(邊界
// 只吃均勻字間量),margin 尾隨左側內容(換行時落行尾、永不落行首),且
// textContent 完全不變(內容仍在)。冪等:已在 jz-hws 內則跳過。
function wrapNodeInGap(node: Node): void
{
const parent = node.parentNode;
if (!parent || parent.nodeName === "JZ-HWS") { return; }
const gap = createJz("jz-hws", PASS, "wrap");
parent.insertBefore(gap, node);
gap.appendChild(node);
}
// 隔離 pos 處之單一字元並包入 jz-hws(左側字 + margin-right 留隙)。
function wrapGapLeft(pos: { textNode: Text; offset: number }): void
{
let tn = pos.textNode;
if (!tn.parentNode || tn.parentNode.nodeName === "JZ-HWS") { return; }
if (pos.offset > 0) { tn = tn.splitText(pos.offset); }
if (tn.data.length > 1) { tn.splitText(1); }
wrapNodeInGap(tn);
}
function processBlock(block: Element, finder: Finder): void
{
const runs = finder.collectRuns(block);
for (const run of runs)
{
const { text, map } = run;
const spaced = panguSpace(text);
if (spaced === text) { continue; }
const actions: SpaceAction[] = [];
let origIdx = 0;
let spacedIdx = 0;
while (spacedIdx < spaced.length)
{
const sc = spaced.charAt(spacedIdx);
if (sc === MARK)
{
const oc = origIdx < text.length ? text.charAt(origIdx) : null;
if (oc === " ")
{
actions.push({ before: map[origIdx - 1], space: map[origIdx]! });
origIdx += 1;
}
else
{
actions.push({ before: map[origIdx - 1], space: null });
}
spacedIdx += 1;
continue;
}
origIdx += 1;
spacedIdx += 1;
}
// 倒序執行,避免前面動作之 offset 被後面變更影響。
for (let i = actions.length - 1; i >= 0; i -= 1)
{
const act = actions[i]!;
if (!act.before) { continue; }
if (act.space)
{
const tn = act.space.textNode;
tn.data = tn.data.slice(0, act.space.offset) + tn.data.slice(act.space.offset + 1);
}
wrapGapLeft(act.before);
}
}
}
const PILL_NEIGHBOR = new RegExp("[A-Za-z0-9" + CJK + "]");
function processPills(root: Element, finder: Finder, pillSelector: string): void
{
root.querySelectorAll(pillSelector).forEach((pill) =>
{
if (finder.isAvoided(pill) || !finder.inScope(pill)) { return; }
if (!pill.parentNode) { return; }
const prev = finder.adjacentLogicalChar(pill, -1);
if (prev) { handlePillSide(prev, pill, -1); }
const next = finder.adjacentLogicalChar(pill, 1);
if (next) { handlePillSide(next, pill, 1); }
});
}
// dir -1:間隙在 adj 字(左)與 pill(右)之間 → margin 掛 adj 字(包裝該字)。
// dir +1:間隙在 pill(左)與 adj 字(右)之間 → margin 掛 pill(包裝 pill)。
function handlePillSide(adj: AdjacentChar, pill: Element, dir: -1 | 1): void
{
if (dir === -1)
{
if (PILL_NEIGHBOR.test(adj.ch))
{
wrapGapLeft({ textNode: adj.textNode, offset: adj.offset });
return;
}
if (adj.ch === " " && adj.offset >= 1)
{
const tn = adj.textNode;
const bc = tn.data.charAt(adj.offset - 1);
if (bc !== " " && PILL_NEIGHBOR.test(bc))
{
tn.data = tn.data.slice(0, adj.offset) + tn.data.slice(adj.offset + 1);
wrapGapLeft({ textNode: tn, offset: adj.offset - 1 });
}
}
}
else
{
if (PILL_NEIGHBOR.test(adj.ch))
{
wrapNodeInGap(pill);
return;
}
if (adj.ch === " " && adj.textNode.data.length >= 2)
{
const ac = adj.textNode.data.charAt(1);
if (ac !== " " && PILL_NEIGHBOR.test(ac))
{
adj.textNode.data = adj.textNode.data.slice(1);
wrapNodeInGap(pill);
}
}
}
}
export const spacingPass: StandalonePass = {
name: PASS,
kind: "standalone",
order: 50,
enabled: (o) => o.features.spacing,
render(ctx: RenderContext): void
{
const { finder, options } = ctx;
// Pass A:逐 block 處理(功能閘以 block 為粒度)。
ctx.finder.eachBlock(ctx.root, (block) =>
{
if (!finder.featureEnabledFor(block, PASS)) { return; }
processBlock(block, finder);
});
// Pass Bpill 邊界。
processPills(ctx.root, finder, options.finder.pillSelector);
},
revert(ctx: RenderContext): void
{
revertPass(PASS, ctx.root);
},
};
+35
View File
@@ -0,0 +1,35 @@
// 診斷:印出指定句子 charify 後每個 jz-char 的碼位與 classASCII 輸出,
// 不受 Bash 非 ASCII 正規化影響)。非單元測試,手動執行。
import { setupDom } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
function dump(label, lang, text)
{
const { document } = setupDom('<p lang="' + lang + '">' + text + "</p>");
createJuzhen({ spacing: false, longWord: false }).render(document.body);
console.log("=== " + label + " (lang=" + lang + ") ===");
document.querySelectorAll("jz-char").forEach((c) =>
{
const cp = "U+" + c.textContent.codePointAt(0).toString(16).toUpperCase().padStart(4, "0");
const cls = Array.from(c.classList).join(" ");
console.log(" " + cp + " [" + cls + "]");
});
}
dump("简体开明", "zh-Hans", "完了……怎么办?!真是的——他竟然走了。");
dump("繁体全角", "zh-Hant", "完了……怎麼辦?!真是的——他竟然走了。");
// #3200% 是否被拆斷。印出 jinze 後 <p> 的子節點序列。
function dumpKids(label, lang, text)
{
const { document } = setupDom('<p lang="' + lang + '">' + text + "</p>");
createJuzhen({ spacing: true }).render(document.body);
console.log("=== kids " + label + " ===");
document.querySelector("p").childNodes.forEach((n) =>
{
if (n.nodeType === 3) { console.log(" text: " + JSON.stringify(n.data)); }
else { console.log(" <" + n.nodeName.toLowerCase() + "> " + JSON.stringify(n.textContent)); }
});
}
dumpKids("百分号", "zh-Hans", "效率提升了 200%,达到约 3.5 倍。");
+26
View File
@@ -0,0 +1,26 @@
// jsdom 測試輔助:建立 DOM 並把必要全域掛上,再回傳 document。
// 注入結構正確性可於 jsdom 驗證;版面(justify/擠壓量/行端)須瀏覽器目視。
import { JSDOM } from "jsdom";
/**
* 建立一個帶 body 內容之 jsdom,並把 document / NodeFilter / Node 掛到
* globalThisdom.ts 之 createJz 用全域 document)。回傳 { dom, document }。
*/
export function setupDom(bodyHtml = "")
{
const dom = new JSDOM(
"<!doctype html><html><head></head><body>" + bodyHtml + "</body></html>",
);
globalThis.document = dom.window.document;
globalThis.NodeFilter = dom.window.NodeFilter;
globalThis.Node = dom.window.Node;
globalThis.window = dom.window;
return { dom, document: dom.window.document };
}
/** 計算 root 下某 tag(大寫)之元素數。 */
export function countTag(root, tag)
{
return root.querySelectorAll(tag).length;
}
+318
View File
@@ -0,0 +1,318 @@
// 聚珍切片 1 jsdom 測試(架構 §12):驗證 DOM 變換正確性——包裝結構、
// bd-* class、邊界、冪等、revert 還原、copy-clean、lang 政策分支、作用域。
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom, countTag } from "./helpers.mjs";
import { createJuzhen, createCjkAutospace } from "../dist/juzhen.mjs";
// ----- 中西間隙(spacing-----
test("spacingCJK↔Latin 邊界以 jz-hws 包裹左側字(margin 留隙,不注入空白)", () =>
{
const { document } = setupDom("<p>我用Neovim寫程式</p>");
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
const p = document.querySelector("p");
assert.equal(countTag(p, "jz-hws"), 2, "用|Neovim 與 Neovim|寫 兩處邊界,各包左側字");
assert.equal(p.textContent, "我用Neovim寫程式", "textContent 完全不變(無注入空白,純 copy-clean");
assert.equal(p.querySelector("jz-hws").textContent.length, 1, "jz-hws 內為單一邊界字");
});
test("spacingtextContent 完全不變(margin 留隙,不注入也不刪語義字元)", () =>
{
const { document } = setupDom("<p>用Neovim,很爽。</p>");
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
assert.equal(document.querySelector("p").textContent, "用Neovim,很爽。");
});
test("spacing:冪等——重跑不重複插入 jz-hws", () =>
{
const { document } = setupDom("<p>我用Neovim寫程式</p>");
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false });
jz.render(document.body);
const first = countTag(document.body, "jz-hws");
jz.render(document.body);
assert.equal(countTag(document.body, "jz-hws"), first, "第二次 render 不應增加");
});
test("spacingrevert 還原至原文(無 jz-hwstextContent 回到原樣)", () =>
{
const { document } = setupDom("<p>我用Neovim寫程式</p>");
const jz = createJuzhen({ jiya: false, longWord: false, jinze: false });
jz.render(document.body);
jz.revert(document.body);
assert.equal(countTag(document.body, "jz-hws"), 0);
assert.equal(document.querySelector("p").textContent, "我用Neovim寫程式");
});
test("spacing:跨樣式行內邊界(<strong>是</strong>Neovim)亦識別(I7", () =>
{
const { document } = setupDom("<p>這<strong>是</strong>Neovim</p>");
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
// 是|Neovim 邊界(跨 </strong>)應插入 jz-hws。
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 1);
});
test("spacingavoid 子樹(code/pre)不處理", () =>
{
const { document } = setupDom("<p>看<code>foo是bar</code>這</p>");
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
// code 內 foo是bar 不應被 charify/spacing;但 pill 邊界(看|code、code|這)會加間隙。
assert.equal(document.querySelector("code").textContent, "foo是bar");
});
// ----- pill 邊界(spacing Pass B-----
test("spacingpillcode)兩側與 CJK 相鄰插入 jz-hws", () =>
{
const { document } = setupDom("<p>用<code>vim</code>編輯</p>");
createJuzhen({ jiya: false, longWord: false, jinze: false }).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 2);
});
// ----- 標點擠壓(jiya-----
test("jiya:標點 charify 為 jz-char>jz-inner,標 bd-* classtextContent 不變(copy-clean", () =>
{
const { document } = setupDom('<p lang="zh-Hans">他說:「好,走。」</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const p = document.querySelector("p");
assert.equal(p.textContent, "他說:「好,走。」", "標點被包裝但文字不變");
assert.ok(countTag(p, "jz-char") >= 5);
assert.ok(countTag(p, "jz-inner") >= 5);
const pause = p.querySelector("jz-char.bd-pause");
assert.ok(pause, ",/:應標 bd-pause");
const stop = p.querySelector("jz-char.bd-stop");
assert.ok(stop, "。應標 bd-stop");
});
test("jiya:開明式(zh-Hans)句內點號半形(jz-half),句末點號不收(機制 C", () =>
{
const { document } = setupDom('<p lang="zh-Hans">好,走。</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const p = document.querySelector("p");
const comma = p.querySelector("jz-char.bd-pause"); //
const period = p.querySelector("jz-char.bd-stop"); // 。
assert.ok(comma.classList.contains("jz-half"), ",開明式半形(halt");
assert.ok(!period.classList.contains("jz-half"), "。句末保持全形");
});
test("jiya:開明式括號引號半形(開類/閉類皆 jz-half)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">說「好」嗎</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const p = document.querySelector("p");
assert.ok(p.querySelector("jz-char.bd-open").classList.contains("jz-half"), "「半形");
assert.ok(p.querySelector("jz-char.bd-close").classList.contains("jz-half"), "」半形");
});
test("jiya:全角式(zh-Hantbaseline 一律不收(孤立標點無 jz-half", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好,走。</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-char.jz-half"), 0, "全角式不擠(無相鄰標點)");
});
test("jiya 機制 A:句末標點後接閉引號,前者半形(。」→ 。jz-half,全角亦然)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好。」嗎</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const period = document.querySelector("jz-char.bd-stop"); // 。
assert.ok(period.classList.contains("jz-half"), "。鄰接閉引號 → 半形(行內連續擠壓)");
});
test("jiya 機制 A:?!→ 僅 ?半形,!保持全形 1em(修正居中字形單側收縮缺陷)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">怎么办?!真</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const stops = document.querySelectorAll("jz-char.bd-stop"); //
assert.equal(stops.length, 2);
assert.ok(stops[0].classList.contains("jz-half"), "?鄰接 → 半形");
assert.ok(!stops[1].classList.contains("jz-half"), "!保持全形(1em,不被單側收縮拖拽重疊)");
});
test("jiya 機制 A:連續開類,後者半形(「「→ 第二個 jz-half)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">「「好</p>');
createJuzhen({ spacing: false, longWord: false, jinze: false }).render(document.body);
const opens = document.querySelectorAll("jz-char.bd-open");
assert.equal(opens.length, 2);
assert.ok(!opens[0].classList.contains("jz-half"), "首個開類左側對漢字/行首,不由鄰接收");
assert.ok(opens[1].classList.contains("jz-half"), "第二個開類內側 → 半形");
});
test("jiyarevert 還原標點(無 jz-chartextContent 不變)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">好,走。</p>');
const jz = createJuzhen({ spacing: false, longWord: false, jinze: false });
jz.render(document.body);
jz.revert(document.body);
assert.equal(countTag(document.body, "jz-char"), 0);
assert.equal(countTag(document.body, "jz-inner"), 0);
assert.equal(document.querySelector("p").textContent, "好,走。");
});
// ----- 全域擠壓政策(lang.style)+逐 lang 覆寫(lang.policy-----
const JIYA_ONLY = { spacing: false, longWord: false, jinze: false };
test("政策:全域 style=banjiao → 各 lang 句末點號亦半形(。得 jz-half", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好。</p>');
createJuzhen({ ...JIYA_ONLY, lang: { style: "banjiao" } }).render(document.body);
const period = document.querySelector("jz-char.bd-stop");
assert.ok(period.classList.contains("jz-half"), "半角式:句末亦半形");
});
test("政策:全域 style=kaiming 套用於繁體(覆寫內建全角預設,句內 ,半形)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好,走</p>');
createJuzhen({ ...JIYA_ONLY, lang: { style: "kaiming" } }).render(document.body);
assert.ok(document.querySelector("jz-char.bd-pause").classList.contains("jz-half"), "全域開明 → 繁體句內亦擠");
});
test("政策:全域 style=quanjiao,但 policy 逐 lang 覆寫 zh-Hans→kaiming(最高優先)", () =>
{
const { document } = setupDom(
'<p lang="zh-Hans">简,</p><p lang="zh-Hant">繁,</p>',
);
createJuzhen({
...JIYA_ONLY,
lang: { style: "quanjiao", policy: { "zh-Hans": "kaiming" } },
}).render(document.body);
const ps = document.querySelectorAll("p");
assert.ok(ps[0].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "簡體 policy 開明 → 擠");
assert.ok(!ps[1].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "繁體用全域全角 → 不擠");
});
test("政策:未設 style/policy 時回退內建預設(繁全角不擠、簡開明擠)", () =>
{
const { document } = setupDom(
'<p lang="zh-Hans">简,</p><p lang="zh-Hant">繁,</p>',
);
createJuzhen(JIYA_ONLY).render(document.body);
const ps = document.querySelectorAll("p");
assert.ok(ps[0].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "簡體內建開明");
assert.ok(!ps[1].querySelector("jz-char.bd-pause").classList.contains("jz-half"), "繁體內建全角");
});
// ----- 長英文詞(longWord-----
test("longWord[A-Za-z]{6,} 包進 span[lang]textContent 不變", () =>
{
const { document } = setupDom("<p>用 TypeScript 寫</p>");
createJuzhen({ spacing: false, jiya: false, jinze: false }).render(document.body);
const p = document.querySelector("p");
const span = p.querySelector('span[lang="en"]');
assert.ok(span, "TypeScript 應被包裝");
assert.equal(span.textContent, "TypeScript");
assert.equal(p.textContent, "用 TypeScript 寫");
});
test("longWord:短詞(<6)不包裝", () =>
{
const { document } = setupDom("<p>vim 好</p>");
createJuzhen({ spacing: false, jiya: false, jinze: false }).render(document.body);
assert.equal(countTag(document.body, 'span[lang="en"]'), 0);
});
// ----- 禁則(jinze):行首行末避頭尾 -----
test("jinze 避頭:閉類/點號綁到前一字(。」不置行首),中段文字仍可斷", () =>
{
const { document } = setupDom('<p lang="zh-Hant">他說好。」嗎</p>');
createJuzhen({ spacing: false, longWord: false }).render(document.body);
const p = document.querySelector("p");
const group = p.querySelector("jz-jinze");
assert.ok(group, "應有綁定群組");
assert.equal(group.textContent, "好。」", "前一字 好 。」 綁為 nowrap 單元");
assert.equal(countTag(group, "jz-char"), 2, "群組含 。與 」");
assert.equal(p.textContent, "他說好。」嗎", "文字不變(他說 在外、嗎 在外)");
});
test("jinze 避尾:開類綁到後一字(「不置行末),長引文中段可斷", () =>
{
const { document } = setupDom('<p lang="zh-Hant">說「好」嗎</p>');
createJuzhen({ spacing: false, longWord: false }).render(document.body);
const group = document.querySelector("jz-jinze");
assert.ok(group, "應有綁定群組");
assert.equal(group.textContent, "「好」", "「綁後字、」綁前字 → 「好」一體");
});
test("jinze 避頭:孤立逗號亦綁前字(逗號不可置行首)", () =>
{
const { document } = setupDom('<p lang="zh-Hans">中文,好</p>');
createJuzhen({ spacing: false, longWord: false }).render(document.body);
const group = document.querySelector("jz-jinze");
assert.equal(group.textContent, "文,", "前字 文 + , 綁定(中 在外可斷)");
});
test("jinzerevert 還原(無 jz-jinzejz-char,文字回原樣)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">他說好。」嗎</p>');
const jz = createJuzhen({ spacing: false, longWord: false });
jz.render(document.body);
jz.revert(document.body);
assert.equal(countTag(document.body, "jz-jinze"), 0);
assert.equal(countTag(document.body, "jz-char"), 0);
assert.equal(document.querySelector("p").textContent, "他說好。」嗎");
});
// ----- 作用域與分塊(§3.6-----
test("scope.include:未命中子樹不處理", () =>
{
const { document } = setupDom('<p>我用Vim</p><div class="jz"><p>他用Emacs寫</p></div>');
createJuzhen({
jiya: false, jinze: false,
scope: { include: ".jz" },
}).render(document.body);
const ps = document.querySelectorAll("p");
assert.equal(countTag(ps[0], "jz-hws"), 0, "include 外不處理");
assert.ok(countTag(ps[1], "jz-hws") >= 1, "include 內處理");
});
test("分塊:data-juzhen 白名單只啟用列出功能", () =>
{
const { document } = setupDom('<p data-juzhen="spacing" lang="zh-Hans">用Vim,好</p>');
createJuzhen({}).render(document.body);
const p = document.querySelector("p");
assert.ok(countTag(p, "jz-hws") >= 1, "spacing 啟用");
assert.equal(countTag(p, "jz-char"), 0, "jiya 未在白名單,停用");
});
test("分塊:data-juzhen-off 黑名單停用列出功能,其餘繼承", () =>
{
const { document } = setupDom('<p data-juzhen-off="jiya" lang="zh-Hans">用Vim,好</p>');
createJuzhen({}).render(document.body);
const p = document.querySelector("p");
assert.ok(countTag(p, "jz-hws") >= 1, "spacing 繼承啟用");
assert.equal(countTag(p, "jz-char"), 0, "jiya 被黑名單停用");
});
// ----- v1 相容層(§3.5-----
test("compatcreateCjkAutospace().apply() 可跑,產生中西間隙(margin 模型,copy-clean", () =>
{
const { document } = setupDom("<p>我用Neovim</p>");
createCjkAutospace().apply(document.body);
const p = document.querySelector("p");
assert.ok(p.querySelector("jz-hws"), "間隙仍產生(jz-hws 包左側字 + margin");
assert.equal(p.textContent, "我用Neovim", "textContent 不變(無注入空白)");
});
test("compatpunctuationSqueeze:false 關閉 jiya", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好,走</p>');
createCjkAutospace({ punctuationSqueeze: false }).apply(document.body);
assert.equal(countTag(document.body, "jz-char"), 0);
});
test("compat:預設強制 kaiming,句內點號半形(jz-half)(不因預設 zh-Hant 而退化)", () =>
{
const { document } = setupDom('<p lang="zh-Hant">好,走</p>');
createCjkAutospace().apply(document.body);
const comma = document.querySelector("jz-char.bd-pause");
assert.ok(comma && comma.classList.contains("jz-half"), "compat 不退化:仍擠句內");
});
+80
View File
@@ -0,0 +1,80 @@
// 針對性驗證:v1 之「行內塊(pill)邊界空格」與「原檔多餘空格收斂」是否
// 在新引擎落地(回應使用者提問)。
import assert from "node:assert/strict";
import { test } from "node:test";
import { setupDom, countTag } from "./helpers.mjs";
import { createJuzhen } from "../dist/juzhen.mjs";
const SPACING_ONLY = { jiya: false, longWord: false, jinze: false };
// ----- 原檔多餘空格收斂(STRIP_CJK_SPACE_ANS / STRIP_ANS_SPACE_CJK-----
test("stripCJK↔Latin 間之作者空格被移除(間隙改由 margin,複製緊湊)", () =>
{
const { document } = setupDom("<p>用 Neovim 寫</p>");
createJuzhen(SPACING_ONLY).render(document.body);
const p = document.querySelector("p");
assert.equal(p.textContent, "用Neovim寫", "作者空格移除,間隙改由 jz-hws margincopy 緊湊)");
assert.equal(countTag(p, "jz-hws"), 2);
});
test("strip:間隙 copy-clean(無注入空白,textContent 不含中西間空白)", () =>
{
const { document } = setupDom("<p>裝 fzf 工具</p>");
createJuzhen(SPACING_ONLY).render(document.body);
const p = document.querySelector("p");
assert.equal(p.textContent, "裝fzf工具", "中西間隙不入 textContent(純 copy-clean");
assert.equal(countTag(p, "jz-hws"), 2);
});
test("strip:全流程(含 charify)下作者空格仍收斂、不雙空格", () =>
{
const { document } = setupDom('<p lang="zh-Hans">用 TypeScript 寫,很好</p>');
createJuzhen({}).render(document.body);
const p = document.querySelector("p");
assert.ok(!/ {2,}/.test(p.textContent), "無雙空格:" + JSON.stringify(p.textContent));
});
// ----- 行內塊(pill)邊界空格(Pass B-----
test("pillcode 與 CJK 緊鄰兩側各插一 jz-hws", () =>
{
const { document } = setupDom("<p>用<code>vim</code>編輯</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 2);
});
test("pillcode 與 Latin 緊鄰亦插間隙(PILL_NEIGHBOR 含 A-Za-z0-9", () =>
{
const { document } = setupDom("<p>看<code>x</code>嗎</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 2);
});
test("pill:已有作者空格時收斂為單一 jz-hws(不雙空格)", () =>
{
const { document } = setupDom("<p>用 <code>vim</code> 編輯</p>");
createJuzhen(SPACING_ONLY).render(document.body);
const p = document.querySelector("p");
assert.ok(!/ {2,}/.test(p.textContent), "無雙空格:" + JSON.stringify(p.textContent));
assert.equal(countTag(p, "jz-hws"), 2);
});
test("pill:標點與 code 之間不強插間隙(鄰字非 PILL_NEIGHBOR", () =>
{
const { document } = setupDom("<p>他說:<code>vim</code>很好</p>");
createJuzhen(SPACING_ONLY).render(document.body);
// 非 PILL_NEIGHBOR → 左側不插;code|很 右側插 1。
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 1);
});
// ----- 間隔號 ·(U+00B7)不觸發中西間隙 -----
test("間隔號:姓名 · 兩側不插 jz-hws(· 屬 CJK 標點,非西文)", () =>
{
const { document } = setupDom("<p>卡尔·马克思</p>");
createJuzhen(SPACING_ONLY).render(document.body);
assert.equal(countTag(document.querySelector("p"), "jz-hws"), 0, "· 兩側皆不應有間隙");
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": [ "ES2020", "DOM", "DOM.Iterable" ],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"isolatedModules": true
},
"include": [ "src" ]
}