diff --git a/plugins/word-count/.gitignore b/plugins/word-count/.gitignore
new file mode 100644
index 000000000..f30a31d75
--- /dev/null
+++ b/plugins/word-count/.gitignore
@@ -0,0 +1,6 @@
+publish.log
+publish.err
+publish.pid
+dist/
+run-publish.ps1
+
diff --git a/plugins/word-count/CHANGELOG.md b/plugins/word-count/CHANGELOG.md
new file mode 100644
index 000000000..e884d95b0
--- /dev/null
+++ b/plugins/word-count/CHANGELOG.md
@@ -0,0 +1,13 @@
+# Changelog
+
+## 1.0.1 - 2026-09-28
+
+- 移除界面左上角「AI生成」水印
+
+## 1.0.0 - 2026-09-27
+
+- 主界面粘贴/输入实时统计
+- 写作向指标:汉字、英文单词、预估阅读时长
+- 辅助指标:总字符、不含空白、数字、标点、行、段落
+- 搜索框 over 指令与主搜索即时结果
+- 复制统计报告、深浅色主题适配
diff --git a/plugins/word-count/count.js b/plugins/word-count/count.js
new file mode 100644
index 000000000..042bd6242
--- /dev/null
+++ b/plugins/word-count/count.js
@@ -0,0 +1,140 @@
+/**
+ * 写作向字数统计核心(纯函数,无 DOM / 无 ztools 依赖)
+ *
+ * 口径说明:
+ * - 字符数:Unicode 码点数(emoji 不拆成代理对)
+ * - 汉字:Unicode Script=Han
+ * - 英文单词:拉丁字母连续段(含常见撇号缩写),数字不计入单词
+ * - 数字:连续数字段按 1 个「数」计
+ * - 标点:Unicode P* 标点类别(中英标点均计入)
+ * - 阅读时长:汉字 400 字/分 + 英文单词 200 词/分,向上取整
+ */
+(function (global) {
+ "use strict";
+
+ var RE_HAN = /\p{Script=Han}/gu;
+ var RE_LATIN_WORD = /\p{Script=Latin}+(?:['’]\p{Script=Latin}+)*/gu;
+ var RE_NUMBER = /\d+/g;
+ var RE_PUNCT = /\p{P}/gu;
+ var RE_SPACE = /\s/u;
+
+ function toCodePoints(text) {
+ return Array.from(text);
+ }
+
+ function countMatches(text, re) {
+ var m = text.match(re);
+ return m ? m.length : 0;
+ }
+
+ function formatReadingTime(minutes) {
+ if (!isFinite(minutes) || minutes <= 0) return "0 秒";
+ if (minutes < 1) {
+ var secs = Math.max(1, Math.round(minutes * 60));
+ return secs + " 秒";
+ }
+ if (minutes < 60) {
+ var mins = Math.round(minutes);
+ return mins + " 分钟";
+ }
+ var h = Math.floor(minutes / 60);
+ var rest = Math.round(minutes % 60);
+ return rest > 0 ? h + " 小时 " + rest + " 分钟" : h + " 小时";
+ }
+
+ /**
+ * @param {string} text
+ * @returns {{
+ * charCount: number,
+ * charNoSpace: number,
+ * whitespace: number,
+ * han: number,
+ * latinWords: number,
+ * numbers: number,
+ * punctuation: number,
+ * lines: number,
+ * paragraphs: number,
+ * readingMinutes: number,
+ * readingLabel: string,
+ * summary: string
+ * }}
+ */
+ function countText(text) {
+ if (typeof text !== "string") text = "";
+ var codePoints = toCodePoints(text);
+ var charCount = codePoints.length;
+ var whitespace = 0;
+ for (var i = 0; i < codePoints.length; i++) {
+ if (RE_SPACE.test(codePoints[i])) whitespace++;
+ }
+ var charNoSpace = charCount - whitespace;
+
+ var han = countMatches(text, RE_HAN);
+ var latinWords = countMatches(text, RE_LATIN_WORD);
+ var numbers = countMatches(text, RE_NUMBER);
+ var punctuation = countMatches(text, RE_PUNCT);
+
+ var lines = text.length === 0 ? 0 : text.split(/\r\n|\r|\n/).length;
+ var paragraphs =
+ text.trim() === ""
+ ? 0
+ : text
+ .split(/\r\n[\s]*\r\n|\r[\s]*\r|\n[\s]*\n/)
+ .filter(function (p) {
+ return p.trim() !== "";
+ }).length;
+
+ var readingMinutes = han / 400 + latinWords / 200;
+ if (charCount > 0 && readingMinutes < 1 / 60) {
+ readingMinutes = 1 / 60;
+ }
+
+ var summary =
+ "汉字 " +
+ han +
+ " · 英文 " +
+ latinWords +
+ " · 阅读约 " +
+ formatReadingTime(readingMinutes);
+
+ return {
+ charCount: charCount,
+ charNoSpace: charNoSpace,
+ whitespace: whitespace,
+ han: han,
+ latinWords: latinWords,
+ numbers: numbers,
+ punctuation: punctuation,
+ lines: lines,
+ paragraphs: paragraphs,
+ readingMinutes: readingMinutes,
+ readingLabel: formatReadingTime(readingMinutes),
+ summary: summary,
+ };
+ }
+
+ function formatFullReport(stats) {
+ return [
+ "总字符 " + stats.charCount,
+ "不含空白 " + stats.charNoSpace,
+ "汉字 " + stats.han,
+ "英文单词 " + stats.latinWords,
+ "数字 " + stats.numbers,
+ "标点 " + stats.punctuation,
+ "行 " + stats.lines,
+ "段落 " + stats.paragraphs,
+ "预估阅读 " + stats.readingLabel,
+ ].join(" · ");
+ }
+
+ var api = {
+ countText: countText,
+ formatReadingTime: formatReadingTime,
+ formatFullReport: formatFullReport,
+ };
+
+ if (typeof module !== "undefined" && module.exports) {
+ module.exports = api;
+ }
+ global.WordCount = api;
+})(typeof globalThis !== "undefined" ? globalThis : this);
diff --git a/plugins/word-count/index.css b/plugins/word-count/index.css
new file mode 100644
index 000000000..d831e1c25
--- /dev/null
+++ b/plugins/word-count/index.css
@@ -0,0 +1,305 @@
+:root {
+ --bg: #f6f4f0;
+ --bg-elevated: #ffffff;
+ --ink: #171614;
+ --ink-muted: #8a847c;
+ --line: #ebe6de;
+ --accent: #2b5cff;
+ --hero: #f0f3ff;
+ --hero-ink: #1a3fa0;
+ --shadow: 0 1px 2px rgba(23, 22, 20, 0.04), 0 8px 20px rgba(23, 22, 20, 0.05);
+ --radius: 12px;
+ --font: "SF Pro Text", "PingFang SC", "Microsoft YaHei", "Segoe UI", system-ui,
+ sans-serif;
+}
+
+html[data-theme="dark"] {
+ --bg: #111110;
+ --bg-elevated: #1b1a18;
+ --ink: #f2efe9;
+ --ink-muted: #8f8880;
+ --line: #2a2825;
+ --accent: #6b8cff;
+ --hero: #1c2233;
+ --hero-ink: #c5d2ff;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 20px rgba(0, 0, 0, 0.28);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ background: var(--bg);
+ color: var(--ink);
+ font-family: var(--font);
+ -webkit-font-smoothing: antialiased;
+}
+
+body {
+ line-height: 1.4;
+ overflow: hidden;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+.app {
+ max-width: 920px;
+ margin: 0 auto;
+ height: 100vh;
+ height: 100dvh;
+ padding: 12px 14px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ position: relative;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.brand-mark {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ background: var(--ink);
+ color: var(--bg);
+ display: grid;
+ place-items: center;
+ font-weight: 700;
+ font-size: 13px;
+}
+
+.brand h1 {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 650;
+ letter-spacing: -0.01em;
+}
+
+.header-actions {
+ display: flex;
+ gap: 6px;
+}
+
+.btn {
+ appearance: none;
+ border: 1px solid var(--line);
+ background: var(--bg-elevated);
+ color: var(--ink);
+ border-radius: 999px;
+ padding: 5px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ font-family: inherit;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
+}
+
+.btn:hover {
+ border-color: color-mix(in srgb, var(--ink-muted) 55%, var(--line));
+}
+
+.btn:active {
+ transform: scale(0.97);
+}
+
+.btn.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+.btn.primary:hover {
+ filter: brightness(1.06);
+}
+
+.btn.ghost {
+ background: transparent;
+}
+
+.main {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ flex: 1;
+ min-height: 0;
+}
+
+.editor-wrap {
+ background: var(--bg-elevated);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+ flex: 1 1 auto;
+ min-height: 120px;
+ display: flex;
+}
+
+.editor {
+ width: 100%;
+ height: 100%;
+ min-height: 120px;
+ border: 0;
+ resize: none;
+ padding: 12px 14px;
+ font: 14px/1.6 var(--font);
+ color: var(--ink);
+ background: transparent;
+ outline: none;
+}
+
+.editor::placeholder {
+ color: var(--ink-muted);
+ opacity: 0.75;
+}
+
+.stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.stats.secondary {
+ grid-template-columns: repeat(6, 1fr);
+}
+
+.stat-card {
+ background: var(--bg-elevated);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 10px 12px 9px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.stat-card.hero {
+ background: var(--hero);
+ border-color: transparent;
+ padding: 12px 14px 11px;
+}
+
+.stat-label {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--ink-muted);
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+}
+
+.stat-card.hero .stat-label {
+ color: color-mix(in srgb, var(--hero-ink) 72%, var(--ink-muted));
+}
+
+.stat-value {
+ font-size: 22px;
+ font-weight: 650;
+ line-height: 1.1;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.03em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.stat-card.hero .stat-value {
+ font-size: 28px;
+ color: var(--hero-ink);
+}
+
+.stat-value.reading {
+ font-size: 22px;
+}
+
+.stats.secondary .stat-value {
+ font-size: 16px;
+ font-weight: 600;
+}
+
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: 24px;
+ transform: translateX(-50%) translateY(8px);
+ background: var(--ink);
+ color: var(--bg);
+ padding: 8px 14px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 500;
+ box-shadow: var(--shadow);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease, transform 0.2s ease;
+ z-index: 20;
+}
+
+.toast.show {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+}
+
+@media (max-width: 720px) {
+ body {
+ overflow: auto;
+ }
+
+ .app {
+ height: auto;
+ min-height: 100vh;
+ }
+
+ .stats.secondary {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .stat-card.hero .stat-value {
+ font-size: 24px;
+ }
+
+ .stat-value.reading {
+ font-size: 18px;
+ }
+}
+
+@media (max-width: 420px) {
+ .app {
+ padding: 10px;
+ }
+
+ .stats {
+ grid-template-columns: 1fr 1fr 1fr;
+ }
+
+ .stats.secondary {
+ grid-template-columns: 1fr 1fr;
+ }
+}
diff --git a/plugins/word-count/index.html b/plugins/word-count/index.html
new file mode 100644
index 000000000..8489ba585
--- /dev/null
+++ b/plugins/word-count/index.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+ 字数统计
+
+
+
+
+
+
+
+
+
+
+
+
+ 汉字
+ 0
+
+
+ 英文
+ 0
+
+
+ 阅读
+ 0 秒
+
+
+
+
+
+ 总字符
+ 0
+
+
+ 不含空白
+ 0
+
+
+ 数字
+ 0
+
+
+ 标点
+ 0
+
+
+ 行
+ 0
+
+
+ 段落
+ 0
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/word-count/index.js b/plugins/word-count/index.js
new file mode 100644
index 000000000..f7862182f
--- /dev/null
+++ b/plugins/word-count/index.js
@@ -0,0 +1,221 @@
+(function () {
+ "use strict";
+
+ var input = document.getElementById("input-text");
+ var toastEl = document.getElementById("toast");
+ var btnCopy = document.getElementById("btn-copy");
+ var btnClear = document.getElementById("btn-clear");
+ var btnPaste = document.getElementById("btn-paste");
+
+ var lastStats = null;
+ var debounceTimer = null;
+ var toastTimer = null;
+
+ function z() {
+ return typeof window !== "undefined" && window.ztools ? window.ztools : null;
+ }
+
+ function setTheme(isDark) {
+ document.documentElement.setAttribute("data-theme", isDark ? "dark" : "light");
+ }
+
+ function applyThemeFromHost() {
+ var api = z();
+ if (!api) {
+ var prefersDark =
+ window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+ setTheme(prefersDark);
+ return;
+ }
+ try {
+ if (typeof api.isDarkColors === "function") {
+ setTheme(!!api.isDarkColors());
+ } else if (typeof api.getThemeInfo === "function") {
+ var info = api.getThemeInfo();
+ setTheme(!!(info && info.isDark));
+ }
+ if (typeof api.onThemeChange === "function") {
+ api.onThemeChange(function (themeInfo) {
+ setTheme(!!(themeInfo && themeInfo.isDark));
+ });
+ }
+ } catch (e) {
+ console.warn("theme init failed", e);
+ }
+ }
+
+ function renderStats(stats) {
+ lastStats = stats;
+ var keys = [
+ "han",
+ "latinWords",
+ "readingLabel",
+ "charCount",
+ "charNoSpace",
+ "numbers",
+ "punctuation",
+ "lines",
+ "paragraphs",
+ ];
+ keys.forEach(function (key) {
+ var el = document.querySelector('[data-key="' + key + '"]');
+ if (!el) return;
+ el.textContent = String(stats[key]);
+ });
+ }
+
+ function refresh(text) {
+ var stats = WordCount.countText(text || "");
+ renderStats(stats);
+ return stats;
+ }
+
+ function scheduleRefresh() {
+ if (debounceTimer) clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(function () {
+ refresh(input.value);
+ }, 80);
+ }
+
+ function showToast(message) {
+ var api = z();
+ if (api && typeof api.showToast === "function") {
+ try {
+ api.showToast(message);
+ return;
+ } catch (e) {
+ /* fall through */
+ }
+ }
+ if (!toastEl) return;
+ toastEl.textContent = message;
+ toastEl.hidden = false;
+ toastEl.classList.add("show");
+ if (toastTimer) clearTimeout(toastTimer);
+ toastTimer = setTimeout(function () {
+ toastEl.classList.remove("show");
+ setTimeout(function () {
+ toastEl.hidden = true;
+ }, 200);
+ }, 1600);
+ }
+
+ async function copyReport() {
+ var stats = lastStats || refresh(input.value);
+ var text = WordCount.formatFullReport(stats);
+ var api = z();
+ if (api && typeof api.copyText === "function") {
+ try {
+ var ok = api.copyText(text);
+ showToast(ok ? "已复制" : "复制失败");
+ return;
+ } catch (e) {
+ /* fall through */
+ }
+ }
+ try {
+ await navigator.clipboard.writeText(text);
+ showToast("已复制");
+ } catch (e) {
+ showToast("复制失败");
+ }
+ }
+
+ async function pasteFromClipboard() {
+ var api = z();
+ if (api && api.clipboard && typeof api.clipboard.getHistory === "function") {
+ try {
+ var history = await api.clipboard.getHistory(1, 1, "text");
+ var items = (history && (history.items || history.list || history)) || [];
+ if (Array.isArray(items) && items[0]) {
+ var content = items[0].content || items[0].text || items[0].data || "";
+ if (typeof content === "string" && content) {
+ input.value = content;
+ refresh(input.value);
+ showToast("已粘贴");
+ return;
+ }
+ }
+ } catch (e) {
+ /* fall through */
+ }
+ }
+ try {
+ var text = await navigator.clipboard.readText();
+ input.value = text || "";
+ refresh(input.value);
+ showToast(text ? "已粘贴" : "剪贴板为空");
+ } catch (e) {
+ showToast("无法读取剪贴板");
+ input.focus();
+ }
+ }
+
+ function clearAll() {
+ input.value = "";
+ refresh("");
+ input.focus();
+ }
+
+ function setText(text) {
+ input.value = typeof text === "string" ? text : "";
+ refresh(input.value);
+ }
+
+ function onPluginEnter(param) {
+ if (!param) return;
+ var payload = param.payload;
+ if (typeof payload === "string" && payload) {
+ setText(payload);
+ } else if (payload && typeof payload === "object") {
+ var maybe =
+ payload.text ||
+ payload.content ||
+ payload.payload ||
+ (typeof payload.data === "string" ? payload.data : "");
+ if (typeof maybe === "string" && maybe) setText(maybe);
+ }
+ }
+
+ function bindHostEvents() {
+ var api = z();
+ if (!api) return;
+ try {
+ if (typeof api.onPluginEnter === "function") {
+ api.onPluginEnter(onPluginEnter);
+ } else if (typeof api.onPluginReady === "function") {
+ api.onPluginReady(onPluginEnter);
+ }
+ } catch (e) {
+ console.warn("onPluginEnter bind failed", e);
+ }
+ }
+
+ input.addEventListener("input", scheduleRefresh);
+ btnCopy.addEventListener("click", function () {
+ copyReport();
+ });
+ btnClear.addEventListener("click", clearAll);
+ btnPaste.addEventListener("click", function () {
+ pasteFromClipboard();
+ });
+
+ document.addEventListener("keydown", function (e) {
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "enter") {
+ e.preventDefault();
+ copyReport();
+ }
+ });
+
+ applyThemeFromHost();
+ bindHostEvents();
+ refresh(input.value);
+
+ try {
+ var params = new URLSearchParams(window.location.search);
+ var q = params.get("text");
+ if (q) setText(q);
+ } catch (e) {
+ /* ignore */
+ }
+})();
diff --git a/plugins/word-count/logo.png b/plugins/word-count/logo.png
new file mode 100644
index 000000000..27e23a5aa
Binary files /dev/null and b/plugins/word-count/logo.png differ
diff --git a/plugins/word-count/plugin.json b/plugins/word-count/plugin.json
new file mode 100644
index 000000000..f65a31646
--- /dev/null
+++ b/plugins/word-count/plugin.json
@@ -0,0 +1,28 @@
+{
+ "name": "word-count",
+ "title": "字数统计",
+ "description": "写作向字数统计:汉字、英文单词、标点与预估阅读时长,支持粘贴输入与搜索框即时结果。",
+ "version": "1.0.1",
+ "author": "ZToolsWordCount",
+ "main": "index.html",
+ "logo": "logo.png",
+ "preload": "preload.js",
+ "features": [
+ {
+ "code": "word-count",
+ "explain": "统计汉字 / 英文单词 / 预估阅读时长",
+ "cmds": [
+ "字数统计",
+ "字数",
+ "wc",
+ "wordcount",
+ {
+ "type": "over",
+ "label": "字数统计",
+ "minLength": 1,
+ "maxLength": 10000
+ }
+ ]
+ }
+ ]
+}
diff --git a/plugins/word-count/preload.js b/plugins/word-count/preload.js
new file mode 100644
index 000000000..728e59bd1
--- /dev/null
+++ b/plugins/word-count/preload.js
@@ -0,0 +1,82 @@
+/**
+ * ZTools preload:注册主搜索即时字数结果
+ * 与 count.js 保持同一套统计口径(此处内联一份轻量实现,避免加载时序问题)
+ */
+(function () {
+ "use strict";
+
+ function countTextLite(text) {
+ if (typeof text !== "string") text = "";
+ var han = (text.match(/\p{Script=Han}/gu) || []).length;
+ var latinWords = (text.match(/\p{Script=Latin}+(?:['’]\p{Script=Latin}+)*/gu) || [])
+ .length;
+ var charCount = Array.from(text).length;
+ var readingMinutes = han / 400 + latinWords / 200;
+ if (charCount > 0 && readingMinutes < 1 / 60) readingMinutes = 1 / 60;
+ var readingLabel;
+ if (readingMinutes <= 0) readingLabel = "0 秒";
+ else if (readingMinutes < 1) readingLabel = Math.max(1, Math.round(readingMinutes * 60)) + " 秒";
+ else if (readingMinutes < 60) readingLabel = Math.round(readingMinutes) + " 分钟";
+ else {
+ var h = Math.floor(readingMinutes / 60);
+ var rest = Math.round(readingMinutes % 60);
+ readingLabel = rest > 0 ? h + " 小时 " + rest + " 分钟" : h + " 小时";
+ }
+ return {
+ charCount: charCount,
+ han: han,
+ latinWords: latinWords,
+ readingLabel: readingLabel,
+ };
+ }
+
+ function pickPayload(queryData) {
+ if (!queryData) return "";
+ if (typeof queryData === "string") return queryData;
+ return (
+ queryData.payload ||
+ queryData.text ||
+ queryData.query ||
+ queryData.content ||
+ (typeof queryData.data === "string" ? queryData.data : "") ||
+ ""
+ );
+ }
+
+ function registerMainPush() {
+ if (!window.ztools || typeof window.ztools.onMainPush !== "function") return;
+
+ window.ztools.onMainPush(
+ function (queryData) {
+ var text = pickPayload(queryData);
+ if (!text || !text.trim()) return [];
+ // 避免对极短指令词误报
+ if (text.trim().length < 2) return [];
+ var s = countTextLite(text);
+ return [
+ {
+ title: "字数统计",
+ description:
+ "汉字 " +
+ s.han +
+ " · 英文 " +
+ s.latinWords +
+ " · 阅读约 " +
+ s.readingLabel,
+ icon: "logo.png",
+ featureCode: "word-count",
+ },
+ ];
+ },
+ function (selectData) {
+ return true;
+ }
+ );
+ }
+
+ try {
+ registerMainPush();
+ } catch (e) {
+ console.warn("onMainPush register failed", e);
+ }
+})();
diff --git a/plugins/word-count/test-count.js b/plugins/word-count/test-count.js
new file mode 100644
index 000000000..5debffcc3
--- /dev/null
+++ b/plugins/word-count/test-count.js
@@ -0,0 +1,43 @@
+const { countText, formatFullReport, formatReadingTime } = require("./count.js");
+
+function assert(cond, msg) {
+ if (!cond) {
+ console.error("FAIL:", msg);
+ process.exitCode = 1;
+ } else {
+ console.log("OK:", msg);
+ }
+}
+
+let s = countText("");
+assert(
+ s.charCount === 0 && s.han === 0 && s.latinWords === 0 && s.lines === 0,
+ "empty"
+);
+
+s = countText("你好世界");
+assert(s.han === 4 && s.latinWords === 0 && s.charCount === 4, "pure han");
+
+s = countText("Hello world's test");
+assert(s.latinWords === 3 && s.han === 0, "latin words with apostrophe: " + s.latinWords);
+
+s = countText("Hello 世界 123!");
+assert(
+ s.han === 2 && s.latinWords === 1 && s.numbers === 1 && s.punctuation >= 1,
+ "mixed " + JSON.stringify({ han: s.han, words: s.latinWords, num: s.numbers, p: s.punctuation })
+);
+
+s = countText("😀字");
+assert(s.charCount === 2 && s.han === 1, "emoji code points: " + s.charCount);
+
+s = countText("a\nb\n\nc");
+assert(s.lines === 4 && s.paragraphs === 2, "lines/para " + s.lines + "/" + s.paragraphs);
+
+s = countText("一".repeat(400));
+assert(s.readingMinutes === 1, "400 han = 1 min, got " + s.readingMinutes);
+
+assert(formatReadingTime(0.01).includes("秒"), "seconds label");
+assert(formatReadingTime(1.2).includes("分钟"), "minutes label");
+
+console.log("report:", formatFullReport(countText("Hello 世界")));
+console.log("done");
diff --git a/plugins/word-count/test-ui-structure.js b/plugins/word-count/test-ui-structure.js
new file mode 100644
index 000000000..7c7ff4b5d
--- /dev/null
+++ b/plugins/word-count/test-ui-structure.js
@@ -0,0 +1,46 @@
+const fs = require("fs");
+const html = fs.readFileSync("index.html", "utf8");
+const js = fs.readFileSync("index.js", "utf8");
+const css = fs.readFileSync("index.css", "utf8");
+
+const keys = [...html.matchAll(/data-key="([^"]+)"/g)].map((m) => m[1]);
+const needed = [
+ "han",
+ "latinWords",
+ "readingLabel",
+ "charCount",
+ "charNoSpace",
+ "numbers",
+ "punctuation",
+ "lines",
+ "paragraphs",
+];
+const missing = needed.filter((k) => !keys.includes(k));
+console.log("data-keys missing:", missing.length ? missing.join(",") : "none");
+
+const ids = ["input-text", "btn-copy", "btn-clear", "btn-paste", "toast", "stats"];
+for (const id of ids) {
+ console.log("id", id, html.includes('id="' + id + '"') ? "OK" : "MISSING");
+}
+
+const banned = [
+ "AI生成",
+ "写作向",
+ "中文字符",
+ "拉丁词",
+ "400 汉字",
+ "实时统计",
+ "等待输入",
+ "口径:",
+ "stat-hint",
+ "footer-note",
+ "tagline",
+ "editor-meta",
+];
+for (const s of banned) {
+ const hit = html.includes(s) || css.includes(s) || js.includes(s);
+ console.log("ban", s, hit ? "FOUND" : "clean");
+}
+
+console.log("dark theme:", /html\[data-theme/.test(css));
+console.log("toast styles:", css.includes(".toast"));