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:
+57
-27
@@ -76,8 +76,9 @@ jz.revert(root?); // 還原
|
||||
|
||||
```
|
||||
{
|
||||
// 全域
|
||||
lang: { default: "zh-Hant", policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" } },
|
||||
// 全域。style = 全域擠壓預設(quanjiao|kaiming|banjiao);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-clean(I1)+ 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-block(justify 三難)**:純 CSS 下,CJK↔西文間隙在
|
||||
`justify` 必有一個邊緣瑕疵——①真實空白→被當詞間空白**暴增**;②inline-block 固定
|
||||
寬→罕見落**行首縮排**;③`margin-right`→**行末右緣內縮 0.25em**。選 ③(margin),
|
||||
因其行首乾淨、不撐框、不暴增、`textContent` 完全不變(純 copy-clean,複製緊湊),
|
||||
並以 §6.1a 之 layout pass 消除行末內縮。
|
||||
- **跨瀏覽器**:`margin`/inline 普遍支援。
|
||||
- **copy-clean(I1,較 Han 更純)**:間隙為純視覺 margin,`textContent` 不增不減
|
||||
語義字元,剪貼板得緊湊原文。
|
||||
- **切片**:涵蓋 v1 Pass A、Pass B(pill `code/kbd` 邊界亦走同一機制——左字或
|
||||
pill 元素包入 `jz-hws`;pill 由 avoid 與鄰接判定處理)。
|
||||
|
||||
### 6.1a 行末去隙(gapTrim,justify 修正)— 切片 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`,故 Chrome/Safari/FF 一致。(註: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`——三大引擎
|
||||
(Blink/WebKit/Gecko)皆穩定支援。
|
||||
- **一致性關鍵層**(間隙、擠壓、justify):只用 `margin`、`inline-block`、
|
||||
`user-select`、`white-space`、`:where()`、`hyphens`,及字型 OpenType
|
||||
`font-feature-settings:"halt"`(標點半形;各引擎一致,效果僅依字型是否含 halt,
|
||||
Noto CJK 具備,缺則優雅降級為全形)——三大引擎(Blink/WebKit/Gecko)皆穩定
|
||||
支援。**注意**:標點半形不用負 `margin`(對居中字形會重疊),改用 `halt`(見
|
||||
§6.3 修訂)。
|
||||
- **漸進增強(非必要)**:`text-spacing-trim`(Blink)、`hanging-punctuation`
|
||||
(WebKit)、`text-emphasis`、`text-autospace`——僅在支援時錦上添花,缺席不影響
|
||||
基本排版(I4)。
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# 聚珍(Juzhen)
|
||||
|
||||
> 現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對
|
||||
> 數種情況作調整:**複製乾淨**(中西間隙不入剪貼板)、**依 `lang` 全角/開明
|
||||
> 自動切換**、**`justify` 下句末標點只佔一份間距**、**跨瀏覽器一致**
|
||||
> (Chrome/Edge/Safari/WebKit/Firefox)。
|
||||
|
||||
倉庫名仍為 `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>
|
||||
```
|
||||
|
||||
### ESM/TypeScript
|
||||
|
||||
```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` 基線)須於
|
||||
Chrome/Safari/Firefox 三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。
|
||||
|
||||
---
|
||||
|
||||
## 授權
|
||||
|
||||
MIT。借鑑 Han.css/Heti 之思路與分類表(MIT),成品自含、不依賴其執行期程式碼。
|
||||
@@ -0,0 +1,68 @@
|
||||
// 構建腳本:esbuild 產出 ESM / IIFE / CSS;d.ts 由 `tsc --emitDeclarationOnly`
|
||||
// 另行產出(見 package.json scripts)。
|
||||
//
|
||||
// dist/juzhen.mjs ESM,供打包器/TypeScript/jsdom 測試。
|
||||
// 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
@@ -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-HK/zh-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 = "已 revert(DOM 还原)";
|
||||
}
|
||||
document.getElementById("force-kaiming").addEventListener("change", function (e)
|
||||
{
|
||||
forceKaiming = e.target.checked;
|
||||
render();
|
||||
});
|
||||
// 量測每個 jz-char 之實際渲染寬度(em)。輸出純 ASCII(碼位+class+數值),
|
||||
// 並按 lang/codepoint/class 去重,便於回報診斷 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>
|
||||
Vendored
+29
@@ -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 {};
|
||||
Vendored
+25
@@ -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 {};
|
||||
Vendored
+60
@@ -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-* 透明穿越;block/pill/avoid/間隙 marker(jz-hws)中斷 run。
|
||||
* 回傳 text.length >= 2 之 run(短於 2 無間隙可言)。
|
||||
*/
|
||||
collectRuns(block: Element): LogicalRun[];
|
||||
/**
|
||||
* pill 之邏輯鄰字(架構 §4.1):經樣式鏈遞迴,遇 pill/block/avoid/間隙
|
||||
* 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;
|
||||
}
|
||||
Vendored
+29
@@ -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;含 Hant/Hans 子標籤者優先。
|
||||
*/
|
||||
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;
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
import type { Pass, RenderContext } from "../types.js";
|
||||
/** 對 ctx.root 套用 passes 中已啟用者。 */
|
||||
export declare function runPasses(passes: Pass[], ctx: RenderContext): void;
|
||||
/** 還原 passes 之全部產物(逆 order;revert 對無產物者安全)。 */
|
||||
export declare function revertPasses(passes: Pass[], ctx: RenderContext): void;
|
||||
Vendored
+41
@@ -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;
|
||||
};
|
||||
Vendored
+12
@@ -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;
|
||||
Vendored
+79
@@ -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 */
|
||||
Vendored
+1462
File diff suppressed because it is too large
Load Diff
Vendored
+1436
File diff suppressed because it is too large
Load Diff
Vendored
+119
@@ -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;
|
||||
};
|
||||
}
|
||||
/** render/revert 之統一上下文(§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;
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import type { StandalonePass } from "../types.js";
|
||||
export declare const jinzePass: StandalonePass;
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import type { CharifyPass, StandalonePass } from "../types.js";
|
||||
export declare const jiyaPass: CharifyPass;
|
||||
export declare const jiyaAdjacencyPass: StandalonePass;
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import type { StandalonePass } from "../types.js";
|
||||
export declare const lineEdgePass: StandalonePass;
|
||||
export declare const gapTrimPass: StandalonePass;
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import type { CharifyPass } from "../types.js";
|
||||
export declare const longWordPass: CharifyPass;
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
import type { StandalonePass } from "../types.js";
|
||||
export declare const spacingPass: StandalonePass;
|
||||
Generated
+1335
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// 文本遍歷引擎(架構 §4.1,取代 v1 之內聯 DFS / Fibre.js 角色)。
|
||||
//
|
||||
// 所有 pass 之共同地基:avoid 名單、scope.include、邏輯文本 run 收集(跨樣式
|
||||
// 行內元素透明、pill/block/avoid 中斷)、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-* 透明穿越;block/pill/avoid/間隙 marker(jz-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):經樣式鏈遞迴,遇 pill/block/avoid/間隙
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -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;含 Hant/Hans 子標籤者優先。
|
||||
*/
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Pass 管線執行器(架構 §3.2/§3.4)。
|
||||
//
|
||||
// 兩類 pass:charify(共享單次 P1 文本走訪,collect 純判定 + 批次 apply)與
|
||||
// standalone(自跑 render)。聚合僅為執行優化:邏輯上仍一功能一 pass、可獨立
|
||||
// 開關與 revert(I6/I9)。
|
||||
//
|
||||
// 執行順序:先 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 之全部產物(逆 order;revert 對無產物者安全)。 */
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// 字元分類與規則字集(架構 §4.2)。
|
||||
//
|
||||
// 集中所有 Unicode 範圍與標點子類,供各 pass 共用,杜絕分類散落不一致。
|
||||
// CJK/西文字集沿用 v1(cjk-autospace)已驗證之 Pangu 範圍;標點子類為新增,
|
||||
// 對應 CSS class(jz-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+00A1–00B6, 00B8–00FF),刻意跳過 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* 禁則 + justify 綁定(§6.4,切片 1 基礎)。
|
||||
連續標點綁為不可斷之單一原子(clreq 連續標點不斷行),在 justify 下整體
|
||||
作為一項。完整避頭尾列後續,沿用同一 jz-jinze 結構。 */
|
||||
|
||||
jz-jinze
|
||||
{
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* 標點擠壓 + justify 原子化(§6.3/§8)。
|
||||
擠壓量以負 margin 託管,跨瀏覽器一致(I4),不依賴 halt/text-spacing-trim。 */
|
||||
|
||||
/* §8:每個 jz-char 為 justify 之單一原子項——句末標點只貢獻一份對齊伸縮量
|
||||
(I5)。inline-block 使標點與其字面留白同屬一項,對齊伸縮只落於項與項之間。
|
||||
⚠ R1:inline-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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* 依 lang 之字體堆疊(§6.9)。以 :where() 零特異度,易被消費端覆寫;限於
|
||||
.juzhen 容器,非破壞、opt-in。簡繁日各取對應 Noto CJK,使 locl 字形正確。
|
||||
名稱同時涵蓋 webfont(Google 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* 聚珍(Juzhen)樣式入口。視覺尺寸一律在此(I2):JS 只注入 jz-* 結構,
|
||||
間隙寬度/擠壓量/原子化/字體堆疊皆由 CSS 給定。
|
||||
切片 1:normalize(禁用層)+ lang(字體)+ spacing + jiya + jinze。 */
|
||||
|
||||
@import "./_normalize.css";
|
||||
@import "./_lang.css";
|
||||
@import "./_spacing.css";
|
||||
@import "./_jiya.css";
|
||||
@import "./_jinze.css";
|
||||
+145
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
/** render/revert 之統一上下文(§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;
|
||||
@@ -0,0 +1,161 @@
|
||||
// 禁則:行首行末避頭尾(架構 §6.4)+ justify 綁定。standalone pass,須在
|
||||
// charify(jiya)之後(依賴 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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
// 標點擠壓(架構 §6.3)。完整 clreq 擠壓之「行內」部分——三條規則並集,
|
||||
// 以方向性 jz-cl(收左半形空白)/jz-cr(收右)落地,CSS 以負 margin 渲染,
|
||||
// 跨瀏覽器一致(I4),不依賴 halt/text-spacing-trim。
|
||||
//
|
||||
// 機制 C baseline 寬度政策(依 lang,I3):
|
||||
// 全角式 quanjiao — 一律不收;
|
||||
// 開明式 kaiming — 句內點號(,、;:)+ 開類/閉類括號引號半形,
|
||||
// 句末點號(。!?)保持全形;
|
||||
// 半角式 banjiao — 全部標點半形。
|
||||
// 機制 A 行內連續擠壓(jiyaAdjacencyPass,lang 無關):相鄰標點之對向空白
|
||||
// 對消一次(每相鄰對省半形),如 。」→ 。收右 → 1.5em。
|
||||
// 機制 B 行端擠壓 → typeset/lineedge.ts(layout 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,隨其解包清除。
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
// 行端擠壓(架構 §6.4/§6.5 之行端部分;clreq 行首行尾擠壓)。機制 B。
|
||||
// standalone layout pass:量測各 jz-char 之行盒位置,對「行首之開類」收左、
|
||||
// 「行尾之閉類/點號」收右(加 jz-le-cl/jz-le-cr,CSS 同 -0.5em margin)。
|
||||
// 掛 ResizeObserver 於 root,容器寬度變動時重算;字型載入後亦重算。
|
||||
//
|
||||
// ⚠ 純版面、無法於 jsdom 驗證(getClientRects 回傳 0);故無 ResizeObserver
|
||||
// 之環境(jsdom)直接跳過,僅於真實瀏覽器生效(架構 §12/R1/R2)。須跨
|
||||
// Chrome/Safari/Firefox 目視核對。
|
||||
|
||||
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-trim(CSS 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));
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
// 中西間隙(架構 §6.1,切片 1)。standalone pass。
|
||||
//
|
||||
// 沿用 v1(cjk-autospace)已驗證之 Pangu 規則管線與 per-block 邏輯文本處理,
|
||||
// 改用 finder 提供之 collectRuns/adjacentLogicalChar,並把間隙 marker 換為
|
||||
// copy-clean 之 jz-hws(user-select 由 CSS 關閉,修正 Han.css 之選中缺陷,I1)。
|
||||
//
|
||||
// 涵蓋 v1 Pass A(跨樣式邊界間隙)與 Pass B(pill 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 B:pill 邊界。
|
||||
processPills(ctx.root, finder, options.finder.pillSelector);
|
||||
},
|
||||
revert(ctx: RenderContext): void
|
||||
{
|
||||
revertPass(PASS, ctx.root);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
// 診斷:印出指定句子 charify 後每個 jz-char 的碼位與 class(ASCII 輸出,
|
||||
// 不受 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", "完了……怎麼辦?!真是的——他竟然走了。");
|
||||
|
||||
// #3:200% 是否被拆斷。印出 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 倍。");
|
||||
@@ -0,0 +1,26 @@
|
||||
// jsdom 測試輔助:建立 DOM 並把必要全域掛上,再回傳 document。
|
||||
// 注入結構正確性可於 jsdom 驗證;版面(justify/擠壓量/行端)須瀏覽器目視。
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
/**
|
||||
* 建立一個帶 body 內容之 jsdom,並把 document / NodeFilter / Node 掛到
|
||||
* globalThis(dom.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;
|
||||
}
|
||||
@@ -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("spacing:CJK↔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("spacing:textContent 完全不變(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("spacing:revert 還原至原文(無 jz-hws,textContent 回到原樣)", () =>
|
||||
{
|
||||
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("spacing:avoid 子樹(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("spacing:pill(code)兩側與 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-* class,textContent 不變(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-Hant)baseline 一律不收(孤立標點無 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("jiya:revert 還原標點(無 jz-char,textContent 不變)", () =>
|
||||
{
|
||||
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("jinze:revert 還原(無 jz-jinze/jz-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("compat:createCjkAutospace().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("compat:punctuationSqueeze: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 不退化:仍擠句內");
|
||||
});
|
||||
@@ -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("strip:CJK↔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 margin(copy 緊湊)");
|
||||
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("pill:code 與 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("pill:code 與 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, "· 兩側皆不應有間隙");
|
||||
});
|
||||
@@ -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" ]
|
||||
}
|
||||
Reference in New Issue
Block a user