@hanology/cham-browser 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +191 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline.d.ts +14 -0
- package/dist/pipeline.js +377 -0
- package/dist/pipeline.js.map +1 -0
- package/package.json +22 -3
- package/template/index.html +29 -0
- package/template/src/App.vue +29 -0
- package/template/src/components/AnnotationLayerSelector.vue +66 -0
- package/template/src/components/AnnotationTooltip.vue +189 -0
- package/template/src/components/BookCard.vue +85 -0
- package/template/src/components/HorizontalDisplay.vue +100 -0
- package/template/src/components/PoemCard.vue +131 -0
- package/template/src/components/PronunciationGroup.vue +45 -0
- package/template/src/components/ReadingToolbar.vue +131 -0
- package/template/src/components/SectionBlock.vue +142 -0
- package/template/src/components/SideNav.vue +291 -0
- package/template/src/components/VerticalScroll.vue +120 -0
- package/template/src/composables/useAnnotationRenderer.ts +158 -0
- package/template/src/composables/useBook.ts +93 -0
- package/template/src/composables/useData.ts +41 -0
- package/template/src/composables/useHorizontalScroll.ts +60 -0
- package/template/src/composables/useLibrary.ts +40 -0
- package/template/src/composables/usePageLayout.ts +25 -0
- package/template/src/composables/useReadingMode.ts +70 -0
- package/template/src/composables/useTitle.ts +5 -0
- package/template/src/main.ts +22 -0
- package/template/src/router.ts +29 -0
- package/template/src/shims-vue.d.ts +7 -0
- package/template/src/styles/main.css +136 -0
- package/template/src/types.ts +164 -0
- package/template/src/utils/annotationParser.ts +58 -0
- package/template/src/utils/chineseNumber.ts +41 -0
- package/template/src/views/AuthorView.vue +338 -0
- package/template/src/views/BookHome.vue +375 -0
- package/template/src/views/LibraryHome.vue +419 -0
- package/template/src/views/PieceView.vue +793 -0
- package/src/index.ts +0 -20
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* ===== THEME DEFINITIONS ===== */
|
|
2
|
+
:root,
|
|
3
|
+
[data-theme="light"] {
|
|
4
|
+
--ink: #1a1a1a;
|
|
5
|
+
--ink-mid: #4a3f35;
|
|
6
|
+
--ink-light: #7a6e5f;
|
|
7
|
+
--ink-faint: #a89b8a;
|
|
8
|
+
--paper: #faf6ee;
|
|
9
|
+
--paper-warm: #f5f0e5;
|
|
10
|
+
--paper-deep: #ebe4d4;
|
|
11
|
+
--surface: #ffffff;
|
|
12
|
+
--surface-warm: #fffdf8;
|
|
13
|
+
--vermillion: #c23a2b;
|
|
14
|
+
--vermillion-light: #d4554a;
|
|
15
|
+
--gold: #9a7d3a;
|
|
16
|
+
--gold-light: #c9a84c;
|
|
17
|
+
--jade: #3a6b5e;
|
|
18
|
+
--border: #d8cdb8;
|
|
19
|
+
--border-light: #e8e0d0;
|
|
20
|
+
--shadow-rgb: 26,26,26;
|
|
21
|
+
--selection-text: #fff;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
[data-theme="sepia"] {
|
|
25
|
+
--ink: #4a3f2e;
|
|
26
|
+
--ink-mid: #6b5d4a;
|
|
27
|
+
--ink-light: #8a7a66;
|
|
28
|
+
--ink-faint: #a8997f;
|
|
29
|
+
--paper: #f0e4c8;
|
|
30
|
+
--paper-warm: #e8d8b8;
|
|
31
|
+
--paper-deep: #dcc8a0;
|
|
32
|
+
--surface: #f8eed8;
|
|
33
|
+
--surface-warm: #f5ead0;
|
|
34
|
+
--vermillion: #a8321f;
|
|
35
|
+
--vermillion-light: #c44535;
|
|
36
|
+
--gold: #8a6d2a;
|
|
37
|
+
--gold-light: #b89540;
|
|
38
|
+
--jade: #2d5a4e;
|
|
39
|
+
--border: #c9b896;
|
|
40
|
+
--border-light: #d8cab0;
|
|
41
|
+
--shadow-rgb: 74,63,46;
|
|
42
|
+
--selection-text: #fff;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
[data-theme="dark"] {
|
|
46
|
+
--ink: #e8e0d0;
|
|
47
|
+
--ink-mid: #c8bfae;
|
|
48
|
+
--ink-light: #9a9185;
|
|
49
|
+
--ink-faint: #6a6258;
|
|
50
|
+
--paper: #1c1c1e;
|
|
51
|
+
--paper-warm: #2a2a2c;
|
|
52
|
+
--paper-deep: #3a3a3c;
|
|
53
|
+
--surface: #2c2c2e;
|
|
54
|
+
--surface-warm: #323234;
|
|
55
|
+
--vermillion: #e05545;
|
|
56
|
+
--vermillion-light: #e87060;
|
|
57
|
+
--gold: #c9a84c;
|
|
58
|
+
--gold-light: #d8b860;
|
|
59
|
+
--jade: #5aaa98;
|
|
60
|
+
--border: #48484a;
|
|
61
|
+
--border-light: #555557;
|
|
62
|
+
--shadow-rgb: 0,0,0;
|
|
63
|
+
--selection-text: #fff;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
[data-theme="oled"] {
|
|
67
|
+
--ink: #ffffff;
|
|
68
|
+
--ink-mid: #d0d0d0;
|
|
69
|
+
--ink-light: #909090;
|
|
70
|
+
--ink-faint: #606060;
|
|
71
|
+
--paper: #000000;
|
|
72
|
+
--paper-warm: #0a0a0a;
|
|
73
|
+
--paper-deep: #141414;
|
|
74
|
+
--surface: #0a0a0a;
|
|
75
|
+
--surface-warm: #101010;
|
|
76
|
+
--vermillion: #ff4444;
|
|
77
|
+
--vermillion-light: #ff6666;
|
|
78
|
+
--gold: #e8c840;
|
|
79
|
+
--gold-light: #f0d860;
|
|
80
|
+
--jade: #40c8a8;
|
|
81
|
+
--border: #333333;
|
|
82
|
+
--border-light: #444444;
|
|
83
|
+
--shadow-rgb: 0,0,0;
|
|
84
|
+
--selection-text: #000;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ===== RESET ===== */
|
|
88
|
+
:root {
|
|
89
|
+
--serif: 'Noto Serif TC', '宋體-繁', serif;
|
|
90
|
+
--sans: 'Noto Sans TC', 'PingFang TC', sans-serif;
|
|
91
|
+
--nav-width: 56px;
|
|
92
|
+
}
|
|
93
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
94
|
+
html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; }
|
|
95
|
+
body {
|
|
96
|
+
font-family: var(--serif);
|
|
97
|
+
background: var(--paper);
|
|
98
|
+
color: var(--ink);
|
|
99
|
+
line-height: 1.8;
|
|
100
|
+
min-height: 100vh;
|
|
101
|
+
}
|
|
102
|
+
::selection { background: var(--vermillion); color: var(--selection-text); }
|
|
103
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
104
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
105
|
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
106
|
+
|
|
107
|
+
a { color: inherit; text-decoration: none; }
|
|
108
|
+
|
|
109
|
+
/* ===== LOADING SCREEN ===== */
|
|
110
|
+
#app-loading {
|
|
111
|
+
position: fixed; inset: 0;
|
|
112
|
+
background: var(--paper);
|
|
113
|
+
display: flex; flex-direction: column;
|
|
114
|
+
align-items: center; justify-content: center;
|
|
115
|
+
z-index: 9999;
|
|
116
|
+
}
|
|
117
|
+
#app-loading .char {
|
|
118
|
+
font-family: var(--serif);
|
|
119
|
+
font-size: 64px; font-weight: 900;
|
|
120
|
+
color: var(--ink);
|
|
121
|
+
animation: pulse 1.2s ease-in-out infinite;
|
|
122
|
+
}
|
|
123
|
+
#app-loading .text {
|
|
124
|
+
font-family: var(--sans);
|
|
125
|
+
font-size: 13px; color: var(--ink-faint);
|
|
126
|
+
letter-spacing: 4px; margin-top: 16px;
|
|
127
|
+
}
|
|
128
|
+
@keyframes pulse {
|
|
129
|
+
0%, 100% { opacity: 0.3; }
|
|
130
|
+
50% { opacity: 1; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ===== RESPONSIVE ===== */
|
|
134
|
+
@media (max-width: 768px) {
|
|
135
|
+
:root { --nav-width: 44px; }
|
|
136
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// ─── Library ────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type LibraryScale = 'single-piece' | 'single-book' | 'library'
|
|
4
|
+
export type BookGenre = 'poetry' | 'prose' | 'mixed' | 'drama'
|
|
5
|
+
|
|
6
|
+
export interface BookLayer {
|
|
7
|
+
id: string
|
|
8
|
+
label: string
|
|
9
|
+
shortLabel?: string
|
|
10
|
+
contributor: string
|
|
11
|
+
role?: string
|
|
12
|
+
nature?: string
|
|
13
|
+
displayOrder?: number
|
|
14
|
+
enabled?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BookAnnotationDefaults {
|
|
18
|
+
defaultLabel?: string
|
|
19
|
+
defaultShortLabel?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BookMeta {
|
|
23
|
+
id: string
|
|
24
|
+
title: string
|
|
25
|
+
subtitle?: string
|
|
26
|
+
titleEn?: string
|
|
27
|
+
publisher?: string
|
|
28
|
+
genre: BookGenre
|
|
29
|
+
count: number
|
|
30
|
+
hero?: string[]
|
|
31
|
+
layers?: BookLayer[]
|
|
32
|
+
annotation?: BookAnnotationDefaults
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CrossRef {
|
|
36
|
+
focusedBookId: string
|
|
37
|
+
focusedNum: number
|
|
38
|
+
fullBookId: string
|
|
39
|
+
fullNum?: number
|
|
40
|
+
relation: 'section' | 'excerpt'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LibraryIndex {
|
|
44
|
+
scale: LibraryScale
|
|
45
|
+
books: BookMeta[]
|
|
46
|
+
crossRefs?: CrossRef[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BookData {
|
|
50
|
+
meta: BookMeta
|
|
51
|
+
pieces: Piece[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Piece ──────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface TextRange {
|
|
57
|
+
type: 'point' | 'range' | 'full'
|
|
58
|
+
scope: 'verse' | 'title' | 'section' | 'full_text'
|
|
59
|
+
verseIndex?: number
|
|
60
|
+
sectionKey?: string
|
|
61
|
+
start?: number
|
|
62
|
+
end?: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Annotation {
|
|
66
|
+
id: string
|
|
67
|
+
range: TextRange
|
|
68
|
+
kind: 'pronunciation' | 'semantic' | 'etymology' | 'note' | 'definition' | 'commentary' | 'translation'
|
|
69
|
+
lang?: string
|
|
70
|
+
text: string
|
|
71
|
+
source: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PronSegment {
|
|
75
|
+
lang: 'yue' | 'cmn'
|
|
76
|
+
label: string
|
|
77
|
+
parts: string[]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AnnotationEntry {
|
|
81
|
+
num: number
|
|
82
|
+
numDisplay: string
|
|
83
|
+
term: string
|
|
84
|
+
pronSegments: PronSegment[]
|
|
85
|
+
definition: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface VerseLine {
|
|
89
|
+
text: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AnnotationLayer {
|
|
93
|
+
id: string
|
|
94
|
+
label: string
|
|
95
|
+
shortLabel: string
|
|
96
|
+
contributor: string
|
|
97
|
+
role: string
|
|
98
|
+
nature: string
|
|
99
|
+
displayOrder: number
|
|
100
|
+
enabled: boolean
|
|
101
|
+
annotations: Annotation[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PieceContributor {
|
|
105
|
+
id: string
|
|
106
|
+
name: string
|
|
107
|
+
role: string
|
|
108
|
+
title?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PieceSource { text?: string
|
|
112
|
+
textRef?: string
|
|
113
|
+
pieceRef?: number
|
|
114
|
+
relation: 'section' | 'excerpt' | 'standalone'
|
|
115
|
+
range?: { start: string; end: string }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ProseSection {
|
|
119
|
+
key: string
|
|
120
|
+
title: string
|
|
121
|
+
filename: string
|
|
122
|
+
body: string
|
|
123
|
+
order: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface Piece {
|
|
127
|
+
bookId: string
|
|
128
|
+
num: number
|
|
129
|
+
title: string
|
|
130
|
+
author: string
|
|
131
|
+
authorId: string
|
|
132
|
+
dynasty: string
|
|
133
|
+
genre: BookGenre
|
|
134
|
+
verses: VerseLine[]
|
|
135
|
+
sections: Record<string, string>
|
|
136
|
+
structuredSections?: ProseSection[]
|
|
137
|
+
annotations: Annotation[]
|
|
138
|
+
layers?: Record<string, Annotation[]>
|
|
139
|
+
annotationLayers?: AnnotationLayer[]
|
|
140
|
+
source?: PieceSource
|
|
141
|
+
contributors?: PieceContributor[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Backward compatibility alias
|
|
145
|
+
export type Poem = Piece
|
|
146
|
+
|
|
147
|
+
// ─── Author & Dynasty ───────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
export interface Author {
|
|
150
|
+
'@id': string
|
|
151
|
+
'@type': string
|
|
152
|
+
name: string
|
|
153
|
+
dynasty: string
|
|
154
|
+
poemCount: number
|
|
155
|
+
bio?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface Dynasty {
|
|
159
|
+
'@id': string
|
|
160
|
+
'@type': string
|
|
161
|
+
name: string
|
|
162
|
+
authors: string[]
|
|
163
|
+
poemCount: number
|
|
164
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Annotation, AnnotationEntry, PronSegment } from '../types'
|
|
2
|
+
import { toChineseNumber } from './chineseNumber'
|
|
3
|
+
|
|
4
|
+
function esc(s: string): string {
|
|
5
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parsePronSegments(def: string): { segments: PronSegment[], remaining: string } {
|
|
9
|
+
const parts = def.split('○').filter(s => s.trim())
|
|
10
|
+
if (!parts.length) return { segments: [], remaining: '' }
|
|
11
|
+
|
|
12
|
+
const segments: PronSegment[] = []
|
|
13
|
+
const remaining: string[] = []
|
|
14
|
+
|
|
15
|
+
for (const raw of parts) {
|
|
16
|
+
const s = raw.replace(/[;;]/g, ' ').trim()
|
|
17
|
+
if (s.startsWith('粵')) {
|
|
18
|
+
segments.push({ lang: 'yue', label: '粵', parts: s.slice(1).trim().split(/\s+/).filter(Boolean) })
|
|
19
|
+
} else if (s.startsWith('漢')) {
|
|
20
|
+
segments.push({ lang: 'cmn', label: '普', parts: s.slice(1).trim().split(/\s+/).filter(Boolean) })
|
|
21
|
+
} else {
|
|
22
|
+
remaining.push(s)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { segments, remaining: remaining.join('\n').trim() }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseAnnotationBlock(raw: string): AnnotationEntry[] {
|
|
30
|
+
const parts = raw.split(/(\d{1,2})\.\s*/)
|
|
31
|
+
const entries: AnnotationEntry[] = []
|
|
32
|
+
|
|
33
|
+
for (let i = 1; i < parts.length; i += 2) {
|
|
34
|
+
const num = parseInt(parts[i], 10)
|
|
35
|
+
const body = parts[i + 1]?.trim() || ''
|
|
36
|
+
const colonIdx = body.indexOf(':')
|
|
37
|
+
const term = colonIdx >= 0 ? body.slice(0, colonIdx) : ''
|
|
38
|
+
const def = colonIdx >= 0 ? body.slice(colonIdx + 1) : body
|
|
39
|
+
const { segments, remaining } = parsePronSegments(def)
|
|
40
|
+
entries.push({
|
|
41
|
+
num,
|
|
42
|
+
numDisplay: toChineseNumber(num),
|
|
43
|
+
term,
|
|
44
|
+
pronSegments: segments,
|
|
45
|
+
definition: esc(remaining),
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return entries.length ? entries : []
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function annotationToPronSegment(ann: Annotation): PronSegment | null {
|
|
53
|
+
if (ann.kind !== 'pronunciation') return null
|
|
54
|
+
const parts = ann.text.replace(/[;;]/g, ' ').trim().split(/\s+/).filter(Boolean)
|
|
55
|
+
if (!parts.length) return null
|
|
56
|
+
const lang = ann.lang === 'yue' ? 'yue' : 'cmn'
|
|
57
|
+
return { lang, label: lang === 'yue' ? '粵' : '普', parts }
|
|
58
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const DIGITS = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']
|
|
2
|
+
|
|
3
|
+
export function toChineseNumber(n: number): string {
|
|
4
|
+
if (n < 0) return '負' + toChineseNumber(-n)
|
|
5
|
+
if (n === 0) return '零'
|
|
6
|
+
if (n < 10) return DIGITS[n]
|
|
7
|
+
|
|
8
|
+
if (n >= 10000) {
|
|
9
|
+
const wan = Math.floor(n / 10000)
|
|
10
|
+
const rest = n % 10000
|
|
11
|
+
return (wan === 1 ? '' : toChineseNumber(wan)) + '萬' + (rest ? toChineseNumber(rest) : '')
|
|
12
|
+
}
|
|
13
|
+
if (n >= 1000) {
|
|
14
|
+
const qian = Math.floor(n / 1000)
|
|
15
|
+
const rest = n % 1000
|
|
16
|
+
return (qian === 1 ? '' : DIGITS[qian]) + '千' + (rest ? formatUnder1000(rest) : '')
|
|
17
|
+
}
|
|
18
|
+
if (n >= 100) {
|
|
19
|
+
const bai = Math.floor(n / 100)
|
|
20
|
+
const rest = n % 100
|
|
21
|
+
return (bai === 1 ? '' : DIGITS[bai]) + '百' + (rest ? formatTens(rest) : '')
|
|
22
|
+
}
|
|
23
|
+
return formatTens(n)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatTens(n: number): string {
|
|
27
|
+
if (n < 10) return DIGITS[n]
|
|
28
|
+
const tens = Math.floor(n / 10)
|
|
29
|
+
const ones = n % 10
|
|
30
|
+
const T: Record<number, string> = { 1: '十', 2: '廿', 3: '卅', 4: '卌' }
|
|
31
|
+
if (T[tens]) return T[tens] + (ones ? DIGITS[ones] : '')
|
|
32
|
+
return DIGITS[tens] + (ones ? DIGITS[ones] : '十')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatUnder1000(n: number): string {
|
|
36
|
+
if (n === 0) return ''
|
|
37
|
+
if (n < 100) return formatTens(n)
|
|
38
|
+
const bai = Math.floor(n / 100)
|
|
39
|
+
const rest = n % 100
|
|
40
|
+
return DIGITS[bai] + '百' + (rest ? formatTens(rest) : '')
|
|
41
|
+
}
|