聚珍(Juzhen)

現代簡/繁中文網頁排版套件。Han.css 之現代化重寫,沿其功能版圖,並針對 數種情況作調整:複製乾淨(中西間隙不入剪貼板)、依 lang 全角/開明 自動切換、justify 下句末標點只佔一份間距、跨瀏覽器一致 (Chrome/Edge/Safari/WebKit/Firefox)。

倉庫名仍為 cjk-autospace;公開 API 為 createJuzhen / 全域 Juzhen;注入之 自訂元素前綴 jz-。完整設計見 ARCHITECTURE.md。

本版(2.x)為從零重寫。v1 之 createCjkAutospace 相容層已移除(最後一批 v1 消費端已遷移 v2 API);v1 原始碼保存於 v1-autospace 分支。


安裝與構建

npm install
npm run build   # esbuild → dist/juzhen.{mjs,iife.js,css};tsc → dist/index.d.ts
npm test        # jsdom DOM 正確性測試
node --test test/shared-rules.test.mjs  # 共享規則 Schema/黃金向量/Web 漂移守門
npm run test:wkwebview  # macOS 原生 WKWebView 版面/observer E2E(需 Xcode 與 GUI 會話)

產物(dist/,已入庫):

檔 用途
dist/juzhen.mjs ESM,供打包器/TypeScript
dist/juzhen.iife.js IIFE,全域 Juzhen,原生 <script> 直跑
dist/juzhen.css 合併樣式(必須引入,視覺尺寸全在此)
dist/index.d.ts TypeScript 型別宣告
rules/juzhen-rules.json 平台中立之共享排版規則(Web/XeLaTeX 共用)
rules/golden-vectors.json 跨實作語義黃金向量

共享規則契約

rules/ 是 Web/CSS 與並行 XeLaTeX 實作之唯一規則權威源;初版規則版本為 1.0.0。 規則以 Unicode code-point 區間、語言政策、標點寬度、間隙、禁斷與段末約束描述「應發生 什麼」,不規定 DOM/CSS、TeX node/glue/penalty 或其他 adapter 的實作機制。

套件提供四個唯讀子路徑:

import rules from "cjk-autospace/rules" with { type: "json" };
import schema from "cjk-autospace/rules/schema" with { type: "json" };
import vectors from "cjk-autospace/rules/vectors" with { type: "json" };
import vectorsSchema from "cjk-autospace/rules/vectors-schema" with { type: "json" };

規則版本、Schema 版本、變更程序與 adapter 邊界詳見 rules/README.md。 任何規則變更必須同步更新 canonical JSON、Schema、黃金向量,並讓 Web 與 XeLaTeX adapter 同時通過相同語義向量;任一實作不得單邊分叉規則。


用法

呼叫端接入時須做兩個獨立選擇:

  1. 元件如何參與排版:依「行內元素三分類」選 pill(保護內部、處理交界)、 isolate(內部也排版、保持盒子完整)或 avoid(完全排除)。
  2. 文字需要哪些功能:依「功能分級」選 text(標題/UI 文字)或 paragraph(正文)。

完整操作範例與衝突規則見下方兩節;不要用其中一種分類代替另一種。

原生 <script>

<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

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。

動態頁面:增量恢復(局部變化免全樹重掃)

render(root)/revert(root) 皆可傳入局部 Element——頁面局部變化時,只重處理受影響 之子樹即可,成本 O(該塊) 而非 O(全文)(實測編輯 400 段中之一段,局部恢復較全樹快數百倍)。

const jz = createJuzhen();
jz.render(document.body);        // 初次全樹

// 局部變化後(手動):
jz.rerender(changedBlock);       // = revert(changedBlock) + render(changedBlock)
jz.render(newlyAppendedBlock);   // 新增之塊:直接 render(無須 revert)

// 自動(opt-in):觀察 DOM,內容/分類屬性變化時自動只重處理受影響之最近塊。
jz.observe(document.body);        // 之後 DOM 變動即自動增量恢復
// jz.disconnect();               // 停止觀察
  • rerender(root?):revert 後 render 同一(子)樹(便利方法)。
  • observe(root?)/disconnect():以 MutationObserver 自動增量。juzhen 自身之注入 於處理期間暫停觀察、絕不自觸發;新增之塊直接 render(免全容器重掃)。需 MutationObserver (瀏覽器/jsdom 有;無則 no-op)。
  • invalidate(root?):清該子樹之內部快取。revert 已自動失效其子樹,故僅在「改了既有 元素之分類相關屬性(class/data-jz-*/lang 等)但不走 revert」時才需顯式調用,以 免下次 render 沿用陳舊分類。內容(子節點/文本)變化不改分類、無需 invalidate(但須 revert+render 該塊方能重處理)。

元素變化之正確性:實例內部對「元素分類(透明/pill/隔離/avoid)/功能級別/功能開關」 快取以加速跨 render 復用(快取隨實例持久、非每次 render 重建)。改變既有元素之上列屬性後, 須經 revert/rerender/invalidate/observe 任一使該子樹快取失效,否則沿用舊分類。 新增/移除元素與純內容編輯無此顧慮(新元素重新計算、移除者自動回收)。

增量之粒度為「塊」:換塊內容(textContent/innerHTML)、增刪塊、改分類屬性——皆與 全量重繪一致。不支持對聚珍已切分之文本節點做外科式 characterData 直改(如持有 render 前之 p.firstChild 引用、事後直改其 .data):render 後原文本節點已被 jz-* 切分/ 替換,該引用已非原整段,直改單一碎片會令 revert 合併出之內容偏離作者意圖。此為後處理排版 器之固有限制(任何外科式文本 patch 之框架皆會與注入後之 DOM 失步)、非缺陷——聚珍對當前 DOM 之處理始終自洽。欲改文字,以塊為單位替換內容即可。

revert 之範圍:revert 乾淨移除聚珍注入之全部結構(jz-*/data-jz-*),DOM 回到邏輯 等價態;但不還原 spacing 依契約規範化之贅餘作者空格(CJK↔西文吸收為 margin 間隙、 CJK↔CJK 剝除;見上表「中西間隙」)。即 render→revert 對含 CJK↔西文作者空格之源,還原後語義 一致、無 jz-* 殘留,唯該空格已正規化(此差異穩定、不隨反覆 render/revert 累積)。若消費端需 保留原始作者空格以供編輯,應自存原文、勿以 revert 當「還原到未處理源碼」。


切片 1 已實作功能

功能 說明
中西間隙 spacing CJK↔西文邊界以 copy-clean 之 jz-hws 與 margin-right 留隙;不插入空白字元,故剪貼板不多出空格。唯跨節點 IVS 產物以 ::after 產生的 U+200B 斷行機會設 user-select:none;它不進入 DOM/textContent。「西文」涵蓋拉丁(含擴充 A/B,拼音聲調 ā ǎ ē)、希臘、西里爾、數字、貨幣(€ ₩ ₿)、數學運算符等;CJK 標點(。,「」()…… 等)不誤判為西文、不生隙。含跨樣式行內邊界與 pill/隔離元素邊界(code/kbd/.katex 與 mark,兩端補隙口徑一致;pill 緊鄰標點時穿越 jz-jinze 仍補間隙;pill 跨行右隙、間隙落行末之自我應驗皆已修)。pill 之邏輯鄰字為標點時(如 漢字。<code>)不誤加間隙(標點非 pill 鄰字;jz-char/jz-inner 透明穿越,與 collectRuns 一致)。贅餘作者空格一律正規化:CJK↔西文吸收為受管間隙、CJK↔CJK(含跨 inline 標籤)剝除不留隙。
長英文詞 longWord [A-Za-z]{6,} 包入 <span lang="en">,配合 CSS hyphens:auto 原生斷詞(DOM 無連字符 → 複製不污染)。
標點擠壓 jiya 標點 charify 為 jz-char+bd-* class。三機制:①baseline 政策(全角式不擠/開明式擠句內點號+括號引號/半角式全擠);②行內連續擠壓(相鄰標點雙側獨立收——各標點若其有空白之側緊鄰另一標點即收半,故 ,「 全角式與開明式皆 1em、。」 全角式 1.5em/開明式 1em;唯句末點號接開類為句界——。「/?「 保留句末點號右空白、僅收開類,全角式與開明式皆 1.5em;透明穿越 strong/em 等行內邊界);③行端擠壓(lineedge layout pass)。半形預設以字型 halt 渲染(按字形正確定位,居中字形如 !?/繁體 。, 不重疊;不依賴 text-spacing-trim)。字型缺 halt 時可切 margin 後備模式(見下節)。
禁則 jinze 行首行末避頭尾:閉類/點號綁前一字(不置行首)、開類綁後一字(不置行末),只隔離邊界字故長引文中段仍可斷。亦為 justify 綁定載體。邊界字落於透明行內樣式元素(strong/em 等)內時克隆切分只綁邊界詞元(避免整個元素入 nowrap 致行末整體掉行、上行留巨大空隙),切出之克隆標 data-jz-kind="splitoff"、revert 時自動併回原元素;不可切元素(a/abbr/ruby/帶 id,以及 pill/隔離/avoid——見「行內元素三分類」)退化為整元素綁定——pill(如行內公式 .katex)即作為原子鄰字單位整體綁入 jz-jinze,絕不下潛肢解其內部。
垂懸字避免 orphan 段末行 CJK 實義字 ≥ N(預設 2;標點/符號不計)。方案 C(斷行層、無測量):把段末「第 N 個實義字起至段末」包入 jz-orphan(white-space:nowrap),整體不可斷 → 末行恒含之 ⇒ 末行實義字 ≥ N,任意寬度/字型成立。單行段無害。邊界字落於行內元素內時同樣克隆切分(只綁末 N 字、避免整個長元素入 nowrap 溢出容器;機制同禁則)。預設開(orphan:false 關,{chars:n} 調)。段落級功能。
功能分級 level 功能分文本級(中西間隙、標點擠壓——任意文本片段適用)與段落級(禁則、垂懸字、長詞——需行/段布局)。塊級元素預設全功能;data-jz-level="text" 子樹或選項 level:{text:"選擇器"}(如標題/按鈕)僅跑文本級。
justify 單份間距 每個 jz-char 為 inline-block 原子項,句末標點只貢獻一份對齊伸縮量。

相容選項 hanging/biaodian/emphasis/ruby 仍被接受,但當前均為 no-op:沒有對應 pass、DOM 產物或 CSS。後三者僅屬明確標記的未來設計; hanging 之舊負 margin 實作已移除,僅保留 API 相容。


選項

createJuzhen({
    lang: {
        default: "zh-Hant",                                  // 無 lang 祖先時之預設
        style: "kaiming",                                    // 全域政策一鍵覆寫(quanjiao|kaiming|banjiao)
        policy: { "zh-Hant": "quanjiao", "zh-Hans": "kaiming" }, // 逐 lang(最高優先)
    },
    scope: {
        root: document.body,        // 處理根;render(root) 可逐次覆寫
        include: ".prose",          // 啟用選擇器(opt-in):僅處理命中子樹
        avoid: "blockquote.raw",    // 追加排除選擇器
    },
    spacing: true,
    longWord: { minLength: 6, lang: "en" },
    jiya: true,                     // 或物件:{ halfWidth: "halt" | "margin", bias }
    jinze: true,
    orphan: true,                   // 垂懸字避免(預設開);或 { chars: 2 };false 關
    level: { text: "h1,h2,h3,button,.ui" }, // 此選擇器子樹僅跑文本級功能(見下)
    pillSelector: "code, kbd, .katex",      // 整體(pill)元素(見「行內元素三分類」)
    isolateSelector: "mark",                // 內外隔離元素(預設 mark;"" 停用)
    justifyAtoms: true,             // jz-char/jz-jinze 以 inline-block 作對齊原子;
                                    // 分頁器(Paged.js)下設 false 改 inline(見下)
    blocks: [                       // 分塊功能選擇
        { selector: ".code-comment", features: [ "spacing" ] },
    ],
});

全部選項速查(含少見但強力者)

完整選項一覽。「標籤名」型別只吃大寫標籤名陣列(精確比對 nodeName),與 pillSelector/isolateSelector/scope.*(CSS 選擇器,可用 class/屬性)不同機制, 勿混。斜體「詳見」指向本文檔展開該項之小節。

語言與擠壓政策(優先序:逐元素 data-jz-style/stylePolicy > policy > style > 逐語言內建)

選項 型別 預設 作用
lang.default string "zh-Hant" 無 lang 祖先時之預設語言標籤
lang.style quanjiao|kaiming|banjiao 逐語言內建(繁→全角、簡→開明) 全域政策一鍵覆寫
lang.policy Record<lang, style> {} 逐 lang 政策
lang.stylePolicy {selector,style}[] [] 逐元素樣式覆寫(選擇器命中之子樹用指定樣式;如 [{selector:"h1,button,.ui",style:"banjiao"}]);配合 data-jz-style 屬性,優先於 lang

作用域

選項 型別 預設 作用
scope.root Element|選擇器 document.body 處理根(render(root) 可逐次覆寫)
scope.include 選擇器 null opt-in:僅處理命中此選擇器之子樹
scope.avoid 選擇器 null 追加排除子樹

功能開關 / 對齊 / 分級 / 分塊(詳見「已實作功能」表及各節)

選項 型別 預設 作用
spacing bool true 中西間隙
longWord bool|{minLength,lang} true({6,"en"}) 長英文詞斷字
jiya bool|{halfWidth,bias} true 標點擠壓(詳見「標點半形」)
jinze bool true 禁則避頭尾
orphan bool|{chars} true({chars:2}) 垂懸字避免
justifyAtoms bool true inline-block 對齊原子(分頁器設 false)
level.text 選擇器 null 命中子樹僅跑文本級(詳見「功能分級」)
blocks {selector,features}[] [] 分塊功能選擇
charClass {cjk,western,biaodian} — 追加字符分類(詳見「自定義字符分類」)

行內三分類與排除(詳見「行內元素三分類」)

選項 型別 預設 作用
pillSelector CSS 選擇器 "code, kbd" 整體元素(內部不可見、兩端補隙)
isolateSelector CSS 選擇器 "mark"("" 停用) 隔離元素(內部照常、兩端補隙)
styleInlines 標籤名[] 27 個標準行內標籤 透明穿越白名單(未列之行內標籤=邊界)
blockTags 標籤名[] 36 個塊標籤 塊級邊界集
skipTags 標籤名[] PRE,SAMP,TT,VAR,SCRIPT,STYLE,TEXTAREA,INPUT avoid 標籤集(不含 CODE/KBD,由 pill 涵蓋)
skipAttribute string null 自訂「跳過」屬性名(命中即 avoid)
isSkipped (node)=>bool null 自訂跳過謂詞(函數級排除鉤子)

HTML 屬性(各屬性之繼承/衝突規則見對應小節)

屬性 值 作用
data-jz-level text|paragraph 逐子樹功能分級
data-jz-style quanjiao|kaiming|banjiao 逐子樹標點樣式覆寫(近祖先勝、同層優先於選擇器,整體優先於 lang)
data-juzhen 功能名(空格分隔) 白名單:僅列出之功能生效
data-juzhen-off 功能名 黑名單:停用列出之功能
data-jz-skip (存在即可) 標記元素為 avoid(內外皆不處理)
data-jz-halfwidth halt|margin 逐子樹半形渲染模式

相容 no-op 選項:hanging/biaodian/emphasis/ruby 均可傳入,但當前不註冊 pass,也不產生 DOM/CSS 效果。注意頂層 biaodian 與 charClass.biaodian(已實作的 標點子類擴充表)同名異義。

逐元素標點樣式(data-jz-style/stylePolicy)

標點樣式(quanjiao 全角式/kaiming 開明式/banjiao 半角式)預設由 lang 決定,但可 逐元素/逐範圍覆寫——典型:非段落/UI 元素(標題、按鈕)用半角式,段落用全角/開明。

// 選擇器批量:h1/h2/button 及 .ui 子樹用半角式,其餘(段落)走 lang
createJuzhen({
    lang: {
        default: "zh-Hant",              // 段落 → 全角式(繁)
        stylePolicy: [ { selector: "h1, h2, button, .ui", style: "banjiao" } ],
    },
});
<!-- 或以 HTML 屬性逐元素/逐範圍標記(標於元素或任一祖先,該子樹生效) -->
<h1 data-jz-style="banjiao">標題:半角標點更緊湊</h1>
<p>段落沿用 lang 之全角/開明式。</p>

優先序:逐元素覆寫(data-jz-style 屬性/stylePolicy 選擇器,近祖先勝、同層屬性優先於 選擇器)> lang.policy > lang.style > 內建逐語言預設。屬性值非法(拼錯)則忽略、回退 lang。半角式渲染沿用既有 halt/margin 機制(見下節),本功能只做「按元素選樣式」、無新增 渲染邏輯。

行內元素三分類:透明/隔離/整體

先決定哪些內容交給聚珍處理,再決定它與前後文字如何銜接。 呼叫端須主動選擇 pill/isolate/avoid;不要把「保護內部內容」一律寫成跳過,也不要只依元素外觀猜分類。 普通 strong/em/span 等只改文字樣式的元素通常保持透明,無須額外標記。

你的需求 選擇 元素內部 與外部文字的交界 如何啟用
內部由程式碼/公式等元件自己管理,但仍要接入正文排版 pill(整體) 不進入、不改寫內部文字、不拆分元素 視為整體;依相鄰字符規則補間隙,禁則需要時連同整個元素綁定 pillSelector
內部是普通文字,仍要補間隙、處理標點,但盒子不能被拆開 isolate(隔離) 照常排版,內部自成文字脈絡 與外部隔開;依相鄰字符規則補間隙,禁則需要時整體綁定 isolateSelector
這一區完全不交給聚珍,連交界間隙也自行管理 avoid(排除) 不處理整個子樹 停止跨界排版,不替它建立 pill/isolate 式交界間隙 data-jz-skip 或 scope.avoid
只是粗體、斜體等樣式,文字仍屬同一句 透明(一般文字) 照常排版 前後文字連續判斷;安全的樣式元素可能被拆分後於回退時合併 預設 styleInlines 中的標籤

pill 與 avoid 都保護內部,差別在外部:pill 仍參與正文交界排版;avoid 完全退出。 pill 與 isolate 都保持元素完整,差別在內部:pill 不處理;isolate 照常處理。 這裡的「完整」指聚珍不拆分元素,不代表替任意元件提供 CSS white-space:nowrap。 交界也不是無條件各加一格:須符合相鄰字符規則,例如句號緊接 pill 不會因此多補間隙。

主動指定你的元件

以下 class 名稱由呼叫端自行命名,透過選擇器接入;它們不是聚珍內建標記。 data-jz-skip 則是內建屬性,存在即排除,寫成 data-jz-skip="false" 仍會排除。

<article class="juzhen" lang="zh-Hant">
    <p>執行<code>npm test</code>後查看結果。</p>
    <p>公式<span class="formula">x+y</span>由公式元件管理。</p>
    <p>狀態<mark class="badge">版本API,已更新</mark>仍要處理內部中西間隙與標點。</p>
    <p>外部元件<span data-jz-skip>原樣保留 API ,</span>的交界由呼叫端自行處理。</p>
    <div class="raw-widget">這個子樹也完全排除。</div>
</article>
const jz = Juzhen.createJuzhen({
    scope: { include: ".juzhen", avoid: ".raw-widget" },
    pillSelector: "code, kbd, .formula, .cite-ref",
    isolateSelector: "mark, .badge",
});
jz.render();
  • pill 預設只有 code, kbd。公式 .katex、引用標 .cite-ref 等須自行加入。 自訂 pillSelector 是取代預設選擇器;要保留 code/kbd,須像上例一併列出。
  • isolate 預設為 mark。自訂 isolateSelector 同樣取代預設;傳 "" 停用此選擇器。 只有背景色的 span 不會因外觀自動變成 isolate,應加 class 並列入選擇器。
  • avoid 可用 data-jz-skip 或 scope.avoid 主動指定;另外 skipTags、SVG、可編輯內容、 skipAttribute 和 isSkipped 也可能使元素被排除。子元素不能以 pill/isolate 或 data-jz-level="paragraph" 重新打開已被排除的祖先子樹。
  • 同一元素多重命中時:avoid > pill > isolate。例如同時命中 .formula 與 .badge 的元素採 pill;再加 data-jz-skip 則採 avoid。請避免互相矛盾的選擇器。
  • 從 pillSelector 移除 code/kbd 不等於排除它們:預設 skipTags 不含二者。 若要完全跳過,須另列入 scope.avoid/skipTags。
  • 未列在 styleInlines 的未知行內標籤會形成隔離式邊界,不會自動當透明樣式。 styleInlines 接受大寫標籤名陣列,與 pill/isolate 的 CSS 選擇器不同。 內部結構由第三方管理的元件宜明確指定 pill 或 avoid,不要為了穿越它而加入透明名單。

分類不等於功能級別。isolate 內部是否執行禁則、避孤字等,仍由下節的 text/paragraph 及功能開關決定;pill/avoid 內部則根本不進入文字處理。

分頁器(Paged.js 等)相容:justifyAtoms

預設 jz-char/jz-jinze 為 inline-block,作 justify 之單一對齊原子(句末標點 單份間距)。但分頁器(如 Paged.js)之對齊引擎會把每個 inline-block 邊界當伸縮點、 與 CJK inter-ideograph 疊加,於窄欄混排時字距失控、整段散架。此時設 justifyAtoms: false——jz-char/jz-jinze 改 display:inline(半形 halt 與禁則 nowrap 綁定均保留)。常規流式版面無需設定。

注意:margin 後備半形依賴 inline-block(width:0.5em),故 justifyAtoms:false 與 margin 模式不可兼得——分頁器場景請用含 halt 之字型。

分塊功能選擇(HTML 屬性,就近繼承)

先按下一節選擇 text/paragraph,再用功能白名單或黑名單微調。 它們不改變 pill/isolate/avoid 分類,也不會繞過級別限制。

<p data-juzhen="spacing jiya">只選中西間隙與標點擠壓。</p>
<p data-juzhen-off="orphan longWord">保留其他功能,停用避孤字與長詞斷字。</p>

功能名區分大小寫,以空白分隔。一般呼叫端使用 spacing、jiya、jinze、orphan、 longWord;前述相容 no-op 名稱不會產生效果。

對每個功能,從元素自身向上找:同一元素先取 data-juzhen 白名單,再檢查 data-juzhen-off 黑名單,接著取第一條命中的 blocks 規則。白名單未列入的功能停用; 黑名單未列入的功能繼續查找;皆無決定時沿祖先向上,最後回退全域開關。 因此同一元素同時寫白名單與黑名單時,白名單優先;data-juzhen="" 是空白名單, 不是恢復預設。全域停用的功能不會被局部白名單重新啟用。

功能分級:文本級 vs 段落級(level/data-jz-level)

text/paragraph 決定允許哪些排版功能,不是 HTML 標籤分類,也不設定 CSS display。 呼叫端應按內容用途選擇,不能把「現在只有一行」當作 text 的唯一判準。

內容用途 選擇 允許的功能(仍受功能開關控制) 不會替你做什麼
標題、按鈕、選單、短標籤等 UI 文字 text(文本級) spacing 中西間隙、jiya 標點擠壓 不做 jinze 禁則、orphan 避孤字、longWord 長詞斷字;不保證單行
正文、說明段落、完整句子的列表項等連續閱讀內容 paragraph(段落級) 上述文本級功能,加上禁則、避孤字、長詞斷字 不自動兩端對齊,不設定字型或容器寬度

沒有任何級別訊號時預設 paragraph。 h1/button 不會因名稱自動變成 text; p 也可以明確標成 text。即使一段正文在寬螢幕恰好只有一行,仍可選 paragraph; 即使標題在窄螢幕換行,也可保留 text。需不需段落約束由呼叫端決定。

HTML 標記與選擇器

<article class="juzhen" lang="zh-Hant">
    <h1 data-jz-level="text">聚珍API:使用說明</h1>
    <p data-jz-level="paragraph">正文採段落級,允許禁則與段末避孤字。</p>
    <div class="ui" data-jz-level="text">
        <button>開啟API</button>
        <p data-jz-level="paragraph">這段較長的說明重新採用段落級。</p>
    </div>
</article>

也可在建立實例時用 level: { text: "h1,h2,h3,button,.ui" } 批量選定文本級子樹, 不必逐個寫屬性;已有 pill/isolate/avoid 設定時,將 level 加入同一個選項物件。

解析順序如下:

  1. 從當前元素(文字則從父元素)開始往上找,包含自身。
  2. 每一層先讀合法的 data-jz-level="text|paragraph",再看是否命中 level.text。
  3. 找到第一個訊號即停止;都沒有才用 paragraph。非法屬性值不構成訊號,繼續查找。

所以距離優先、同層屬性優先:近處選擇器可以勝過遠處祖先屬性;同一元素標 paragraph 則可以蓋過該元素命中的 level.text。在 text 子樹中寫 data-juzhen="orphan" 不能開啟避孤字,須先明確切回 paragraph,且該功能全域已啟用。

與 pill/isolate/avoid 搭配

組合 實際意思
paragraph 正文中的 pill 公式 正文有段落約束;公式內部由元件管理,公式整體仍參與正文交界排版
text 標題中的 isolate 標籤 標籤內部仍補間隙、處理標點;不因 isolate 就開啟段落功能
paragraph 容器中的 avoid 元件 元件子樹仍完全排除;paragraph 不會取消 avoid

不要用 avoid 代替 text:若只是不想讓按鈕跑避孤字,應標 text,這樣仍保留間隙與標點處理。 也不要用 text 代替 pill:text 仍會改寫內部文字排版,不能用來保護公式或程式碼元件。

CSS 可查之 resolved 分類:data-jz-block-level(輸出屬性)

作者只寫 data-jz-level。render 後聚珍會對承載可處理非空白文字的塊級元素產生 data-jz-block-level,供呼叫端 CSS 使用;不要把輸出屬性當作設定值手寫。

/* 依聚珍輸出分類選擇對齊;這是呼叫端的樣式決定。 */
.juzhen [data-jz-block-level="paragraph"] { text-align: justify; }
.juzhen .marginalia[data-jz-block-level="paragraph"] { text-align: start; }

純容器(例如只含子段落的 div/ul)不因包住正文就取得此輸出;域外文字、pill 內部與 avoid 子樹不計入。屬性描述所屬塊的級別,不是每個行內子元素的級別;同一段落內個別 span 標 text,不會把整個段落輸出改成 text。若需要段落 CSS 跟著切換,應標在該塊本身。

revert 會撤回本實例寫入的輸出,依所有權規則保留/恢復作者或其他實例的屬性。 輸入 data-jz-level 不由聚珍改寫;data-jz-block-level 不參與級別判定,也不觸發分類重算。

標記改動後如何更新

在第一次 render 之前準備好 class、排除與級別標記。已渲染後改動時,手動呼叫 jz.rerender(changedBlock);若開啟 jz.observe(root),受觀察範圍內的 class、 data-jz-skip、data-jz-level、data-juzhen/data-juzhen-off 變化會觸發增量更新。 自訂選擇器若依賴其他屬性(例如 data-widget),不要假設 observer 會追蹤該屬性,請手動 重排受影響區塊。更新時以塊為單位,詳見前面的「動態頁面」。

標點懸掛(hanging)— 已移除

原計劃之標點懸掛(行端標點凸出版心)經實測為機制級死路、已移除(負 margin 程式碼與 CSS 皆刪)。hanging 選項仍被接受(API 相容)但無任何效果——hanging:true/ data-juzhen/blocks 任一要求皆被靜默忽略,行端僅做半形擠壓。

為何機制級不可行(純 CSS 之寬度守恆矛盾):行端標點盒若保持非零寬,則「墨色出去多少、 版心內就空出多少」(行末實義字無法齊版心右緣);若收為零寬,則釋放一整字寬、斷行器把下一字 拉上本行與標點重疊(且 justifyAtoms 預設之 inline-block 下負 margin 被 justify 吸收)。 墨色出去多少版心內就空多少;想不空就得讓斷行器重排這塊寬度,而 CSS hack 接觸不到斷行器。 補償墊片、兩階段試錯都改變不了此恒等式。

唯一正解為原生 CSS hanging-punctuation,惟 Chrome/Firefox 至今不支援(僅 Safari),且 本庫之 jz-char 為 inline-block 原子、非文本,原生懸掛不作用——未來若做,須是繞開 jz-char 的「原生管線」新功能(text-spacing-trim + hanging-punctuation,按能力門控),與已移除之 程式碼無共享。詳見 ARCHITECTURE.md §6.5.6。


標點半形:halt 預設與 margin 後備

開明/半角式把標點收成「半形」,有兩種渲染機制:

機制 設定 原理 適用
halt(預設) jiya: true 字型 OpenType halt,由字型按字形正確定位、不重疊 字型含 halt(Noto CJK/思源系列)
margin(後備) jiya: { halfWidth: "margin" } width:0.5em+字身依墨色偏側溢出定位(以 CSS 重現 halt) 字型缺 halt(多數系統字型、GBK 老字型如方正書宋 GBK、PingFang)

為何需要後備:halt 缺席時開明式擠壓「完全無效」(標點仍佔全形)。實測 方正書宋 GBK 僅含 vert、無 halt → halt 模式不擠;切 margin 後備後, (開明式 zh-Hans)同句行寬 24.0em → 19.7em、單一句內點號 1.0em → 0.5em,半形生效。

句末標點含全角句點 .:句末點號為 。.!?——除表意句號 。(U+3002)外納入 全角句點 .(U+FF0E,實測 Noto CJK 有 halt)。政策行為與 。!? 同:全角式與開明式 句末保持全角 1em;唯 banjiao(或與鄰標點連續被收)時 halt 收為半形 advance 0.5em。 halt 即字型之「挤壓意願」:字型不提供某標點之 halt(其字形占滿字框、不宜挤半, 如某些字型之全角 ?、〈〉)時,聚珍信任字型、保持全角不強挤(優雅降級); margin 後備僅用於整體無 halt 之字型,逐字型以 jiya.bias 調教。

激活與粒度

模式由 data-jz-halfwidth 屬性所在子樹決定(CSS 後代選擇器),故粒度極靈活:

  • 全域:jiya: { halfWidth: "margin" } → render() 於處理 root 自動掛屬性。
  • 逐區塊/逐語言:在 HTML 任一祖先手寫 data-jz-halfwidth="margin"(或 "halt"),即令該子樹用對應機制——無需另建實例。常見如「全頁 halt,某用 無 halt 字型之區塊掛 margin」,或反之(margin 頁中嵌一塊 halt 島)。
<!-- 全頁 halt(預設),此塊改用無 halt 字型 → 局部 margin 後備 -->
<section data-jz-halfwidth="margin" style="font-family: '方正书宋_GBK'">…</section>

機制與字形偏側表(margin 模式專用,可覆寫)

margin 模式把標點 jz-char 之 inline-block 寬收為 0.5em(真半形 advance),字身 (jz-inner)以 overflow 溢出該盒,再依墨色偏側左移、使墨色落於 0.5em 盒內、 空白溢向外側——即以 CSS 盒模型重現 halt 之「收字身寬+按字形定位」。相鄰兩標點各 0.5em、合佔 1em,無鄰接疊加或單側重疊問題。

偏側須知每字之墨色中心。聚珍內建參考表(依方正書宋 GBK 實測之 glyph ink 位): 簡體點號 ,、。;:!? 偏左;繁體點號居中;彎引號 “”‘’、角括號 「」、尖括號 〈〉 墨色強偏一側(開→right、閉→left);圓/方/書名等括號 ()【】《》… 墨色 近中→center。於 charify 標為 jz-ink-{left,center,right}。因字型字身分布各異,可逐 lang 逐字覆寫:

createJuzhen({
    jiya: {
        halfWidth: "margin",
        bias: {
            "zh-Hans": { ",": "center", "(": "right" },  // 覆寫參考預設
            "zh-Hant": { "。": "center" },
        },
    },
});
// 偏側值(決定字身於 0.5em 盒內之左移):
//   left   墨偏左 → 不移(字身左對齊)
//   center 墨居中 → 左移 0.25em
//   right  墨偏右 → 左移 0.5em(右半入盒)
//   full   無可壓縮空白 → 不收、維持全形

halt 模式不讀偏側表(由字型負責定位)。偏側表僅在 margin 後備模式生效。 demo/fallback.html 用方正書宋 GBK 對照 halt 失效 vs margin 生效。配字工作流見下節。

字型適配工作流(margin 模式配字)

換用新字型時,依下列步驟確認半形渲染:

  1. 驗 halt:python3 tools/measure-bias.py <font.ttf>(需 pip install fonttools)。 首行報該字型是否含 OpenType halt。
    • 有 halt → 直接用預設(jiya: true,halt 模式),無需配字,到此為止。
    • 無 halt(多數系統字型、GBK 老字型)→ 用 margin 後備,續下。
  2. 取偏側建議:同一工具同時量各標點 glyph 之墨色中心、印出建議偏側表 (left|center|right|full)與可貼入 jiya.bias 之 JSON。
  3. 套用:createJuzhen({ jiya: { halfWidth: "margin", bias: <貼入 JSON> } }); 或全用內建參考表(多數宋體系 GBK 字型與參考表相符,未必需覆寫)。
  4. 目視微調:以 demo/fallback.html(或自有頁面)對照原文,逐字檢查——
    • 標點與相鄰字重疊 → 該字墨色比量測更偏該側,偏側往「不移」方向調 (right→center→left)。
    • 標點與內容仍有空隙、不夠半形 → 往「多移」方向調(left→center→right)。
    • 某標點根本無可壓空白(字面已滿全形)→ 設 full,不收。
  5. 記錄:把該字型之 bias 覆寫存入專案設定,作為該字型之適配結果。

偏側是「字身於 0.5em 盒內左移多少」的三檔量化(0/0.25/0.5em),量測值近門檻 (如 0.6 介於 center/right)時,以目視為準。halt 字型走 OpenType 由字型精確 定位,無此配字需求——margin 配字僅為缺 halt 字型之務實近似。


CSS 基礎設定(消費端須知)

JS 只注入 jz-* 結構,所有視覺尺寸在 juzhen.css——故 CSS 不可或缺。消費端 須備妥下列幾項:

  1. 引入樣式(必須):<link rel="stylesheet" href="dist/juzhen.css">(或 ESM import "cjk-autospace/css")。缺此則間隙/擠壓/justify 原子化全部失效。
  2. 設定 lang(強烈建議):在內容容器標 lang="zh-Hans"/"zh-Hant"/"ja", 驅動全角/開明政策與字形 locl。無 lang 時回退 options.lang.default。
  3. 作用域類(用 scope.include 時):若設 scope.include: ".juzhen",須給內容 容器加對應 class(如 class="juzhen");juzhen.css 之 normalize 亦收斂於此域, 不外溢。
  4. 長英文詞斷字:longWord 包出 <span lang="en"> 後,須由消費端 CSS 提供 hyphens: auto(含 -webkit-hyphens)才會原生斷詞顯示連字符。
  5. justify(選用):欲兩端對齊則自行設 text-align: justify;句末標點單份間距 與行末去隙由聚珍處理,無需額外設定。
  6. 字型由消費端負責(聚珍不管字型):聚珍不內建任何 font-family/字型堆疊 ——逐語言字型完全由消費端 CSS 決定。聚珍只確保原子(jz-*)以 font: inherit 繼承消費端字型、絕不強加,並顯式保留 locl(見下「字型與 locl」一節)。半形擠壓 依字型 OpenType halt:含 halt 之字型(Noto CJK/思源)預設即可;缺 halt 之字型(如方正書宋 GBK)須改用 jiya.halfWidth:"margin" 後備,並按字型以 jiya.bias 逐字微調墨色偏側(見「字型適配工作流」)。
  7. 自動處理、無需消費端介入:juzhen.css 已在 .juzhen 範圍內以 @supports 關閉 Blink 原生 text-spacing-trim/text-autospace(避免與託管擠壓重複作用), 間隙不注入空白字元;僅跨節點 IVS gap 之 U+200B ::after 設 user-select:none,避免該斷行提示進入選取/複製。
  8. 段落屬性照常設、原子透明:聚珍把標點拆成 inline-block 原子,但已確保這些原子對 消費端段落樣式透明——可在段落正常設 text-indent(首行縮排)、hanging-punctuation、 text-decoration(下劃線)等;庫內已歸零會被原子重複套用之繼承屬性、並令原子承接段落 裝飾,故首行縮排正常、下劃線連續不斷,無需任何額外配置(機制見 ARCHITECTURE「原子透明 原則」)。

字型與 locl(消費端設定)

聚珍不管字型;逐語言字型由你以 CSS 設定。推薦範式——逐 lang 設 font-family, 掛在 .juzhen 或帶 lang 之容器(勿直接設 jz-inner/jz-char,它們靠繼承取字型):

.juzhen:lang(zh-Hans), .juzhen [lang]:lang(zh-Hans) { font-family: "Noto Serif SC", "Source Han Serif SC", serif; }
.juzhen:lang(zh-Hant), .juzhen [lang]:lang(zh-Hant) { font-family: "Noto Serif TC", "Source Han Serif TC", serif; }
.juzhen:lang(ja),      .juzhen [lang]:lang(ja)      { font-family: "Noto Serif JP", serif; }

要點:

  • 字型須含 OpenType halt 才能半形擠壓;否則用 jiya.halfWidth:"margin" + 按字型 以 jiya.bias 逐字微調墨色偏側(見「字型適配工作流」)。
  • locl(地域字形):泛 CJK 字型(思源/Noto CJK)以 locl 依 lang 選地域字形 (如「直/海/骨」之簡繁日差異)。聚珍不改寫 lang、不關閉 locl,故只要你 ① 正確標 lang、② 用支援 locl 之字型,locl 即正常。body 文本之 locl 全由你的字型+ lang 決定;標點(jz-inner)之 halt 已顯式併入 locl,地域標點字形不致失。
  • 若只想全語言同一字型,直接 .juzhen { font-family: … } 即可(無需逐 lang)。

自定義字符分類(charClass)

每個字符的分類(中文/西文、全寬標點各子類)抽象為規則集:內置默認規則集即聚珍 的分類表,消費端可經 options.charClass 擴充,以覆寫 spacing/jiya/jinze 等規則對特定字符之判定。語義為追加——把當前未分類之字符、或新符號歸入對應類。

createJuzhen({
    charClass: {
        cjk: "〇",                    // 追加為中文(CJK):與西文邊界補中西間隙、禁則/justify 視為單字
        western: "★☆",               // 追加為西文(半寬標點亦歸此):與 CJK 邊界補間隙
        biaodian: {                   // 追加為全寬標點,按子類(jiya charify/擠壓、禁則避頭尾)
            stop: "‼",               //   追加為句末點號(。.!?已內置)
            pause: "",                //   句內(,、;:類)
            open: "", close: "",      //   開/閉括號引號
            middle: "", liga: "",     //   間隔號/省略破折
        },
    },
});

.(U+FF0E,全角句點)已是內置 BD_STOP 成員,無需再經 charClass 追加;其政策與 。!? 一致。charClass.biaodian 用於追加尚未內置的 符號(如上例 ‼),不用來重複宣告內置字符。

要點:

  • 向下兼容:不傳 charClass 時,分類與行為與內置默認完全一致。
  • 追加語義(v1):擴充內置類;不支援把內置字符「重分類/移除」(屬罕見需求,後續再議)。 勿把同一字符同時加入相衝之兩類。
  • 結構性 ASCII(字母數字 A-Za-z0-9、運算符 +-*/ 等)為固定集、不開放覆寫——它們 服務於運算符/複合詞等內部規則,非「字符類型」。
  • margin 後備模式下若新增開/閉括號,其墨色偏側未必命中內建參考表,可一併以 jiya.bias 指定(見「字型適配工作流」)。

v1 向下相容 — 已移除

v1 相容層(createCjkAutospace 導出、IIFE 之 globalThis.createCjkAutospace 別名、autospaceClass 選項)已隨最後一批 v1 消費端遷移完成而移除。既有消費端 請改用 createJuzhen(options).render(root) v2 API;v1 原始碼保存於 v1-autospace 分支。


目視驗證

demo/index.html 載入 dist/ 產物,簡繁並列、可切 justify、附 copy-clean 測試區。 demo/e2e.html 是可機讀的核心版面與 MutationObserver 驗收頁;macOS 上可以 npm run test:wkwebview 將同一頁面載入原生 WKWebView,自動執行全部斷言。 demo/fallback.html 以方正書宋 GBK(無 halt)對照 halt 模式失效 vs margin 後備生效。demo/resize-check.html 為可拖曳容器檢查頁:拖動方框寬度,驗證中西間隙在 各寬度下恆穩(含行末去隙之自我應驗、pill 跨行右隙、貨幣/拼音/西里爾外文補隙、全角省略號 與各式中文標點無誤隙),可切 justify 與「顯示間隙」描邊。版面相關結論(justify 單份間距、 擠壓視覺量、行端、inline-block 基線、間隙隨寬度之穩定性)須於 Chrome/Safari/Firefox 三引擎目視核對——jsdom 僅驗證 DOM 結構正確性。


授權

MIT。借鑑 Han.css/Heti 之思路與分類表(MIT),成品自含、不依賴其執行期程式碼。

S
Description
No description provided
Readme MIT 18 MiB
Languages
JavaScript 54.5%
TypeScript 40.9%
CSS 2.3%
Swift 1.1%
Python 0.7%
Other 0.5%