diff --git a/README.md b/README.md index 3474242..808ac25 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Source Han Sans 与 Source Han Serif 各自的五个地区共享同一个字形 ```bash pnpm install -pnpm build:data # 生成字表与字体子集,首次会下载约 170MB 原始数据,之后走缓存 +pnpm build:data # 生成字表与字体子集,首次会下载约 210MB 原始数据,之后走缓存 pnpm dev pnpm test pnpm generate # 静态站点 @@ -79,7 +79,7 @@ pnpm generate # 静态站点 `public/_redirects` 把没有预渲染的单字页交回给应用(静态文件优先,预渲染的 4 千多页仍按文件直出),`public/_headers` 给带内容哈希的 `_nuxt/*` 设了长缓存。 -构建时 `pnpm build:data` 会下载约 **168 MB** 原始数据(其中 156 MB 是八份 Noto CJK 字体),CI 上每次构建都要重下。若嫌慢,可改为本地构建后直传: +构建时 `pnpm build:data` 会下载约 **210 MB** 原始数据(其中 195 MB 是十份 Noto CJK 字体),CI 上每次构建都要重下。若嫌慢,可改为本地构建后直传: ```bash pnpm build:data && pnpm generate diff --git a/app/components/FilterBar.vue b/app/components/FilterBar.vue index 1dee708..3ed3189 100644 --- a/app/components/FilterBar.vue +++ b/app/components/FilterBar.vue @@ -8,7 +8,7 @@ import { } from '~/composables/chars.ts' const chars = injectChars() -const { t } = useT() +const { t, locale } = useT() const { regionLabel, labelClass, visibleColumns, visibleRegions } = usePrefs() // Every label has to be computed, not built once: t() reads the active locale @@ -88,9 +88,11 @@ const varieties = computed(() => { label: variety === 1 ? t('filter.identical', { - n: hanNumber(visibleRegions.value.length), + n: hanNumber(visibleRegions.value.length, locale.value), }) - : t('filter.variety', { n: hanNumber(variety) }), + : t('filter.variety', { + n: hanNumber(variety, locale.value), + }), count: allPatterns.reduce( (total, pattern) => total + (chars.counts.value[pattern] ?? 0), 0, diff --git a/app/components/HeroOverprint.vue b/app/components/HeroOverprint.vue index 4b2cb27..02875e4 100644 --- a/app/components/HeroOverprint.vue +++ b/app/components/HeroOverprint.vue @@ -4,7 +4,7 @@ import { REGIONS, type CharRow } from '~~/shared/types.ts' const props = defineProps<{ row: CharRow }>() -const { t, list } = useT() +const { t, list, locale } = useT() const { regionLabel, labelClass, outlineOn, visibleRegions, regionIndices } = usePrefs() const split = ref(false) @@ -88,7 +88,7 @@ function toggle() {

- {{ t('hero.title', { n: hanNumber(groupCount) }) }} + {{ t('hero.title', { n: hanNumber(groupCount, locale) }) }}

{{ t('hero.body', { regions: places }) }} diff --git a/app/generated/face-marks.ts b/app/generated/face-marks.ts index 8d171c6..e4fb0a7 100644 --- a/app/generated/face-marks.ts +++ b/app/generated/face-marks.ts @@ -9,10 +9,12 @@ export const FACE_VIEW_BOX = "0 -880 1000 1000" export const FACE_MARKS: Record> = { "sans": { "黑": "M282 -696C311 -649 337 -586 346 -546L398 -567C390 -607 362 -667 332 -713ZM658 -714C641 -667 607 -598 581 -556L629 -536C656 -576 689 -638 717 -692ZM340 -90C351 -37 358 32 358 74L431 65C431 24 422 -44 410 -96ZM546 -88C568 -36 591 32 599 74L674 56C664 15 640 -52 616 -102ZM749 -92C797 -39 853 35 878 81L951 53C924 6 866 -66 818 -117ZM168 -117C144 -54 101 13 57 52L126 84C174 38 215 -34 240 -99ZM227 -739L461 -739L461 -521L227 -521ZM536 -739L766 -739L766 -521L536 -521ZM55 -224L55 -157L946 -157L946 -224L536 -224L536 -314L861 -314L861 -376L536 -376L536 -458L841 -458L841 -802L155 -802L155 -458L461 -458L461 -376L138 -376L138 -314L461 -314L461 -224Z", - "ゴ": "M734 -825L680 -802C705 -767 740 -709 759 -667L815 -692C795 -730 758 -791 734 -825ZM861 -854L806 -831C833 -796 865 -739 887 -698L943 -722C922 -760 885 -820 861 -854ZM140 -104L140 -13C167 -15 212 -17 253 -17L742 -17L740 39L830 39C829 23 826 -22 826 -58L826 -574C826 -598 828 -629 828 -652C809 -651 779 -650 754 -650L262 -650C230 -650 186 -652 152 -656L152 -567C176 -568 225 -570 263 -570L742 -570L742 -98L251 -98C209 -98 165 -101 140 -104Z" + "ゴ": "M734 -825L680 -802C705 -767 740 -709 759 -667L815 -692C795 -730 758 -791 734 -825ZM861 -854L806 -831C833 -796 865 -739 887 -698L943 -722C922 -760 885 -820 861 -854ZM140 -104L140 -13C167 -15 212 -17 253 -17L742 -17L740 39L830 39C829 23 826 -22 826 -58L826 -574C826 -598 828 -629 828 -652C809 -651 779 -650 754 -650L262 -650C230 -650 186 -652 152 -656L152 -567C176 -568 225 -570 263 -570L742 -570L742 -98L251 -98C209 -98 165 -101 140 -104Z", + "고": "M137 -736L137 -668L687 -668L687 -647C687 -538 687 -411 653 -238L737 -228C770 -411 770 -535 770 -647L770 -736ZM368 -441L368 -118L50 -118L50 -49L867 -49L867 -118L450 -118L450 -441Z" }, "serif": { "宋": "M437 -839L427 -832C463 -801 498 -746 504 -701C573 -650 636 -794 437 -839ZM169 -733L152 -732C157 -667 118 -609 79 -588C56 -575 42 -554 51 -531C63 -505 101 -505 127 -523C156 -543 183 -586 183 -651L836 -651C823 -613 802 -566 786 -534L800 -527C839 -556 892 -604 920 -639C941 -640 952 -642 959 -648L880 -725L835 -681L180 -681C178 -697 175 -715 169 -733ZM848 -489L801 -429L532 -429L532 -580C556 -584 565 -593 567 -607L466 -618L466 -429L72 -429L81 -399L430 -399C356 -230 218 -74 37 28L47 44C233 -40 375 -164 466 -315L466 78L479 78C504 78 532 63 532 54L532 -399C609 -206 742 -53 894 31C904 1 928 -17 955 -20L957 -31C801 -94 642 -236 556 -399L910 -399C923 -399 933 -404 935 -415C902 -447 848 -489 848 -489Z", - "明": "M837 -745L837 -545L580 -545L580 -745ZM516 -774L516 -454C516 -245 482 -70 301 68L315 80C480 -13 544 -143 567 -281L837 -281L837 -28C837 -10 831 -4 810 -4C785 -4 661 -13 661 -13L661 3C714 10 744 18 761 29C777 40 784 57 788 78C890 67 901 32 901 -20L901 -732C921 -736 938 -744 945 -753L860 -816L827 -774L591 -774L516 -807ZM837 -516L837 -310L572 -310C578 -358 580 -407 580 -455L580 -516ZM143 -728L331 -728L331 -504L143 -504ZM80 -758L80 -93L90 -93C122 -93 143 -111 143 -116L143 -213L331 -213L331 -133L340 -133C363 -133 393 -150 394 -157L394 -715C414 -720 431 -728 437 -736L357 -798L321 -758L155 -758L80 -789ZM143 -475L331 -475L331 -243L143 -243Z" + "明": "M837 -745L837 -545L580 -545L580 -745ZM516 -774L516 -454C516 -245 482 -70 301 68L315 80C480 -13 544 -143 567 -281L837 -281L837 -28C837 -10 831 -4 810 -4C785 -4 661 -13 661 -13L661 3C714 10 744 18 761 29C777 40 784 57 788 78C890 67 901 32 901 -20L901 -732C921 -736 938 -744 945 -753L860 -816L827 -774L591 -774L516 -807ZM837 -516L837 -310L572 -310C578 -358 580 -407 580 -455L580 -516ZM143 -728L331 -728L331 -504L143 -504ZM80 -758L80 -93L90 -93C122 -93 143 -111 143 -116L143 -213L331 -213L331 -133L340 -133C363 -133 393 -150 394 -157L394 -715C414 -720 431 -728 437 -736L357 -798L321 -758L155 -758L80 -789ZM143 -475L331 -475L331 -243L143 -243Z", + "명": "M805 -89C805 -198 715 -270 591 -270C466 -270 377 -198 377 -89C377 25 466 99 591 99C715 99 805 25 805 -89ZM730 -89C730 -5 672 44 591 44C509 44 451 -5 451 -89C451 -173 509 -222 591 -222C672 -222 730 -173 730 -89ZM541 -675L554 -708C556 -714 555 -718 552 -721C542 -727 519 -740 507 -746C503 -748 495 -748 491 -746L457 -727C393 -723 265 -721 202 -721C173 -727 115 -731 85 -731C79 -731 76 -730 75 -727L70 -705C90 -698 111 -686 122 -675L145 -360C146 -341 149 -319 153 -303L193 -303C200 -315 204 -328 206 -345L531 -349C536 -354 542 -366 542 -381C542 -386 539 -389 535 -392L519 -399C528 -470 536 -554 539 -628C553 -619 569 -611 585 -604C604 -611 629 -616 647 -618C667 -620 712 -620 744 -619L744 -481C700 -479 640 -478 612 -478C587 -478 560 -480 537 -483L531 -473C530 -470 531 -467 534 -464C552 -449 576 -436 601 -425C619 -431 644 -437 662 -439C679 -440 714 -441 744 -440L744 -396C744 -359 746 -280 750 -245L792 -245C806 -289 815 -368 815 -419L815 -755L818 -807C818 -817 814 -824 806 -827C777 -839 715 -852 679 -853C671 -853 668 -851 667 -848L660 -824C695 -812 726 -794 744 -773L744 -662C700 -659 629 -657 597 -657C578 -657 559 -658 540 -660ZM207 -388L201 -677C282 -680 382 -682 464 -682L461 -398L443 -393C389 -390 275 -388 207 -388Z" } } diff --git a/app/locales/all.ts b/app/locales/all.ts index 7d452c4..150d442 100644 --- a/app/locales/all.ts +++ b/app/locales/all.ts @@ -1,4 +1,5 @@ import { jaJP } from './ja-jp.ts' +import { koKR } from './ko-kr.ts' import { zhCN } from './zh-cn.ts' import { zhHK } from './zh-hk.ts' import { zhTW } from './zh-tw.ts' @@ -16,6 +17,7 @@ export const messages: Record = { 'zh-TW': zhTW, 'zh-HK': zhHK, 'ja-JP': jaJP, + 'ko-KR': koKR, } export { LOCALES, type Locale } diff --git a/app/locales/index.ts b/app/locales/index.ts index 86e2ebc..5dc74c0 100644 --- a/app/locales/index.ts +++ b/app/locales/index.ts @@ -6,7 +6,7 @@ import { zhCN } from './zh-cn.ts' * not indexed, and cannot be linked to -- hence no hreflang either. Switching * to locale-prefixed routes means changing this file and useT, nothing else. */ -export const LOCALES = ['zh-CN', 'zh-TW', 'zh-HK', 'ja-JP'] as const +export const LOCALES = ['zh-CN', 'zh-TW', 'zh-HK', 'ja-JP', 'ko-KR'] as const export type Locale = (typeof LOCALES)[number] /** What the prerendered HTML carries, and what anything unmatched falls back to. */ @@ -53,6 +53,11 @@ export const LOCALE_META: Record = { sans: "'Noto Sans JP'", uiFamily: 'UI ja-JP', }, + 'ko-KR': { + htmlLang: 'ko-KR', + sans: "'Noto Sans KR'", + uiFamily: 'UI ko-KR', + }, } /** @@ -73,20 +78,23 @@ export const LOADERS: Record Promise> = { 'zh-TW': async () => (await import('./zh-tw.ts')).zhTW, 'zh-HK': async () => (await import('./zh-hk.ts')).zhHK, 'ja-JP': async () => (await import('./ja-jp.ts')).jaJP, + 'ko-KR': async () => (await import('./ko-kr.ts')).koKR, } /** * The best locale for a browser's language list, in the browser's own order of - * preference. Japanese tags take the Japanese copy. A Chinese tag naming Hong - * Kong or Macao takes the Hong Kong copy, one naming Taiwan or the traditional - * script takes the Taiwanese copy, and any other Chinese tag takes the - * simplified. Anything else leaves it undecided, so the caller can fall back. + * preference. Japanese and Korean tags take their respective copy. A Chinese + * tag naming Hong Kong or Macao takes the Hong Kong copy, one naming Taiwan or + * the traditional script takes the Taiwanese copy, and any other Chinese tag + * takes the simplified. Anything else leaves it undecided, so the caller can + * fall back. */ export function matchLocale(tags: readonly string[]): Locale | undefined { for (const tag of tags) { const lower = tag.toLowerCase() const language = lower.split('-', 1)[0] if (language === 'ja') return 'ja-JP' + if (language === 'ko') return 'ko-KR' if (language !== 'zh') continue const parts = new Set(lower.split('-')) if (parts.has('hk') || parts.has('mo')) return 'zh-HK' diff --git a/app/locales/ko-kr.ts b/app/locales/ko-kr.ts new file mode 100644 index 0000000..e8f9646 --- /dev/null +++ b/app/locales/ko-kr.ts @@ -0,0 +1,174 @@ +import type { zhCN } from './zh-cn.ts' + +export const koKR: typeof zhCN = { + meta: { + title: '漢智', + name: 'Hanji', + description: + '중국 대륙, 홍콩, 대만, 일본 네 지역에서 상용되는 한자의 자형을 비교합니다. 같은 한자도 지역마다 쓰는 모양이 다른 경우가 많습니다.', + }, + region: { + cn: { short: '중', full: '중국 대륙' }, + hk: { short: '홍', full: '홍콩' }, + tw: { short: '대', full: '대만' }, + jp: { short: '일', full: '일본' }, + old: { short: '구자체', full: '일본 구자체' }, + }, + hero: { + title: '하나의 한자, {n} 가지 자형', + body: '{regions}에서는 같은 한자도 쓰는 모양이 다른 경우가 많습니다.', + split: '나눠 보기', + merge: '겹쳐 보기', + }, + nav: { + about: '사이트 소개', + github: 'GitHub 저장소', + theme: '밝은/어두운 테마 전환', + back: '한자표로 돌아가기', + options: '표시 설정', + }, + options: { + title: '표시 설정', + language: '인터페이스 언어', + emoji: '지역을 국기로 표시', + emojiHint: '‘중·홍·대·일’ 표기를 해당 국기 기호로 바꿉니다.', + outline: '겹쳐 보기에서 윤곽선 사용', + outlineHint: + '겹친 자형을 윤곽선으로 표시해 획이 한 덩어리로 뭉치지 않고 차이가 더 잘 보이게 합니다.', + columns: '비교할 자형', + columnsHint: + '해제한 열은 한자표와 상세 페이지에서 모두 숨겨지며, 남은 자형끼리 다시 그룹화됩니다.', + columnsLast: '지역을 하나 이상 선택해야 합니다.', + }, + style: { + label: '글꼴', + sans: '고', + serif: '명', + sansFull: '고딕체', + serifFull: '명조체', + }, + filter: { + dimension: '비교 기준', + glyph: '자형', + cp: '코드 포인트', + glyphHint: + '실제로 보이는 자형을 기준으로 비교합니다. 네 지역이 같은 코드 포인트를 사용해도 자형이 다르면 서로 다른 것으로 봅니다.', + cpHint: + 'Unicode 코드 포인트만 비교합니다. ‘骨’처럼 코드 포인트는 같지만 자형이 다른 한자도 같은 것으로 봅니다.', + search: '검색', + searchPlaceholder: '한자 / 독음 / U+9AA8', + strokes: '획수', + common: '상용 지역', + pattern: '차이 패턴', + tier: '수록 등급', + variety: '{n} 가지 자형', + identical: '{n} 개 지역에서 같은 자형', + clear: '필터 지우기', + matched: '{n}자', + }, + sort: { + label: '정렬', + common: '상용도', + strokes: '획수', + cp: '코드 포인트', + freq: '글자 빈도', + asc: '오름차순', + desc: '내림차순', + }, + table: { + strokes: '획수', + freq: '글자 빈도', + old: '구', + empty: + '조건에 맞는 한자가 없습니다. 획수 범위를 넓히거나 다른 차이 패턴을 선택해 보세요.', + page: '{total}페이지 중 {page}페이지', + prev: '이전 페이지', + next: '다음 페이지', + showAll: '{n}자 모두 보기', + paginate: '페이지로 나누어 보기', + }, + char: { + codePoint: 'Unicode 코드 포인트', + strokes: '획수', + freq: '중국 대륙 글자 빈도', + reading: '독음', + mandarin: '표준 중국어', + cantonese: '광둥어', + on: '일본어 음독', + kun: '일본어 훈독', + listed: '수록', + variety: '{n}가지 자형', + identical: '자형이 같음', + onePicked: '열을 하나만 선택해 비교할 대상이 없습니다', + also: '관련 항목', + alsoOut: '{region}에서 쓰는 ‘{char}’는 별도 항목이 있습니다', + alsoIn: '‘{char}’의 {region} 자형이기도 합니다', + alsoUncertain: '‘{char}’의 {region} 대응 관계는 아직 확인되지 않았습니다', + glossed: '괄호 병기 이체자', + unlistedFallback: '미수록 · 참고 자형 표시', + stacked: '겹쳐 보기', + split: '나란히 보기', + dict: '외부 사전', + tierCn: { 1: '1급 한자', 2: '2급 한자', 3: '3급 한자' }, + tierTw: { 1: '상용 국자', 2: '차상용 국자' }, + tierHk: { 1: '상용자' }, + tierJp: { 1: '상용한자', 2: '교육한자' }, + }, + about: { + title: '사이트 소개', + nameTitle: '이름의 유래', + name1: + '“Hanji”는 지역별 한자의 이름과 각각 알파벳 한 글자씩 다릅니다. 중국어 병음 hanzi와는 j/z, 일본어 kanji와는 h/k, 한국어 hanja와는 i/a가 다릅니다. 같은 것이라도 지역마다 조금씩 달라진다는 점이 바로 이 앱의 주제입니다.', + name2: + '또한 Hanji는 민난어에서 ‘한자(漢字)’를 실제로 읽는 말인 hàn-jī이기도 합니다. 즉 새로 만든 말이 아니라 또 다른 지역에서 쓰는 이름입니다. 중국어 이름 ‘漢智’도 이 발음에서 따왔습니다.', + methodTitle: '자형 차이를 판정하는 방법', + scopeTitle: '수록 범위', + sourcesTitle: '데이터 출처', + limitTitle: '이 데이터의 한계', + noticeTitle: '유의 사항', + thanksTitle: '감사의 말', + dataTitle: '데이터 이용', + use: '용도', + source: '출처', + license: '라이선스', + method1: + 'Source Han Sans와 Source Han Serif(Noto Sans CJK와 Noto Serif CJK)는 각각 다섯 지역이 공유하는 하나의 자형 풀과 지역별 ‘코드 포인트 → CID’ 매핑을 둡니다. 같은 풀에서 CID가 같으면 같은 자형입니다. 따라서 중국 대륙, 홍콩, 대만, 일본의 네 매핑을 비교해 하나의 한자가 네 지역에서 몇 가지 자형으로 나타나는지 판정합니다.', + method2: + '판정에는 두 글꼴의 합집합을 사용합니다. 고딕체와 명조체 중 어느 하나라도 두 지역의 자형을 같다고 보면 같은 자형으로 처리합니다. Source Han Sans는 일본 상용한자의 약 5분의 1에 일본 전용 자형을 제공하지만, 그중 약 200자는 Source Han Serif에서 분리되어 있지 않습니다. ‘了’, ‘人’, ‘子’, ‘水’, ‘金’ 등이 여기에 해당합니다. 한 글꼴에서만 구분되는 차이는 해당 글꼴의 디자인 판단이며 지역 규범의 차이로 보지 않습니다.', + method3: + '그 대신 통합된 칸은 같은 그룹의 대표 지역 글꼴로 렌더링되므로 Noto Sans JP 고유의 미세하게 다른 자형은 표시되지 않습니다. 이는 ‘같다고 간주한다’는 판정을 화면에도 일관되게 반영하기 위한 것입니다. 페이지에서 같다고 표시한 두 칸은 실제로도 같은 자형으로 그려져야 하기 때문입니다.', + scope1: + '중국 대륙의 《통용규범한자표》(通用規範漢字表, 2013), 대만의 《상용국자표준자체표》(常用國字標準字體表, 1982), 홍콩의 《상용자자형표》(常用字字形表), 일본의 《상용한자표》(常用漢字表, 2010) 네 목록의 합집합을 수록합니다. 간체자와 번체자, 일본 신자체와 구자체처럼 코드 포인트가 다른 대응 글자는 한 행으로 합쳐 총 {rows}행입니다.', + scope2: + '이 가운데 {identical}행은 네 지역의 자형이 완전히 같고, {allDiffer}행은 네 지역이 모두 다릅니다. 자형이 같은 한자도 수록합니다. 이 사이트는 네 지역의 한자표이며, 자형 차이는 여러 비교 기준 중 하나일 뿐입니다.', + scope3: + '대만의 《차상용국자표》(次常用國字表)는 기존 행의 2급 수록 상태와 후보만 보완하며 새 행을 만들지는 않습니다. 6,343개의 주 항목 가운데 이 표에만 있는 3,599개 항목은 제품 수록 범위에서 명시적으로 제외합니다.', + limit1: + '여기서 측정하는 것은 Source Han 계열의 지역별 자형 디자인이지 각 지역의 표준 자체가 아닙니다. 그래도 정확도가 높은 대리 지표입니다. Adobe의 지역별 자형이 각각 중국 대륙의 《인쇄통용한자자형표》(印刷通用漢字字形表), 대만 교육부의 《국자표준자체》(國字標準字體), 홍콩 교육국의 《상용자자형표》(常用字字形表), 일본의 JIS X 0208/0213(JIS2004 자형)을 바탕으로 하기 때문입니다.', + limit2: + 'Source Han의 홍콩용 자형은 지원 범위가 완전하지 않아 ‘홍콩만 다름’ 패턴이 실제보다 적게 집계될 수 있습니다.', + limit3: + '획수는 지역별로 가져옵니다. 먼저 Unihan의 kAlternateTotalStrokes를 사용합니다. IRG 출처별 값이 있어 가장 정확하지만 100여 개 코드 포인트만 다룹니다. 일본은 그다음 kRSAdobe_Japan1_6을 사용합니다. Adobe-Japan1-6에 수록된 일본 자형을 분석한 값이므로 ‘突’은 9획이 아닌 8획입니다. 둘 다 없으면 kTotalStrokes를 사용하지만, 이 값은 많아야 zh-Hans와 zh-Hant 두 종류만 구분하며 홍콩과 대만은 후자를 공유합니다.', + limit4: + '따라서 일본 열의 획수는 나머지 세 지역과 다른 경우가 많습니다. 그중 약 5분의 1은 자형 자체는 같고 획수를 세는 방식만 다릅니다. 일본에서는 ‘阝’를 3획, ‘ネ’를 5획으로 세기 때문에 ‘那’는 네 지역의 자형이 같아도 획수가 6/6/6/7이 됩니다.', + limit5: + 'OpenCC와 각 지역의 규범 한자표가 충돌하면 규범 한자표를 우선합니다. OpenCC의 이체자 표에는 ‘같은 글자의 다른 자형’과 ‘한 글자가 다른 글자를 대신하는 관계’가 섞여 있습니다. 어느 지역의 한자표가 두 글자를 별도로 수록하면 합치지 않습니다. 근거를 제공하는 지역이 하나뿐이고 후보가 다른 글자 그룹에도 속한다면 보수적으로 분리하고, 상세 페이지에서 양방향 ‘관계 미확인’ 링크로 알립니다.', + limit6: + '각 지역 칸은 한자표의 주 항목, 괄호 병기 이체자, 미수록 시 대체 표시를 구분합니다. 미수록 칸에도 전승된 참고 자형을 표시합니다. 이러한 상태는 상세 페이지에서만 설명하고 목록에는 별도 표시를 추가하지 않습니다.', + notice1: + '이 사이트의 데이터는 공적이거나 권위 있는 자료가 아닙니다. 한자의 수록 범위, 자형, 획수, 독음은 지역별 표준 사이에서도 다르며 모두가 동의할 유일한 데이터는 존재하지 않습니다.', + notice2: + '장기간 손으로 관리해야 하는 한자표가 되지 않도록, 이 프로젝트는 Unicode Unihan, 각 지역의 문화·교육 당국이 공개한 규범 한자표, Adobe와 Google의 공개 글꼴 자료 등 기존 공개 데이터를 가능한 한 그대로 사용합니다. 판정 규칙은 위와 같으며 개인적인 취사선택이나 입장을 나타내지 않습니다.', + notice3: + '공식적인 용도에는 원래 표준이나 사전을 참고하세요. 각 한자의 상세 페이지에는 해당 지역 사전으로 가는 링크가 있습니다.', + notice4: + '한자는 수가 방대해 오류를 완전히 피하기 어렵습니다. 데이터 오류를 발견하면 {issues}로 알려 주세요.', + data1: '이 사이트의 한자표 데이터는 JSON 형식으로 공개합니다:', + thanks: + 'OpenCC, Unicode Unihan, zispace/hanzi-chars, Jun Da의 《Modern Chinese Character Frequency List》, Adobe Source Han, Noto CJK 유지 관리자 여러분께 감사드립니다. 한 글자씩 비교할 수 있는 {tofu}는 이 프로젝트에 앞서 같은 방식으로 Noto 계열을 사용해 네 지역의 자형을 구분한 도구입니다.', + }, + footer: { + sources: '데이터 출처', + detail: '자세한 설명과 감사의 말', + }, +} diff --git a/app/styles/vars.css b/app/styles/vars.css index ae08bb7..d32129b 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -58,6 +58,12 @@ html[lang='ja-JP'] { 'Noto Sans SC', ui-sans-serif, system-ui, sans-serif; } +html[lang='ko-KR'] { + --font-ui: + 'UI Latin Sans', 'UI ko-KR Sans', 'Noto Sans', 'Noto Sans KR', + 'Noto Sans SC', ui-sans-serif, system-ui, sans-serif; +} + /* The typeface switch reaches the whole interface, not only the table. The language pairs stay more specific so they keep winning. */ html[data-style='serif'] { @@ -84,6 +90,12 @@ html[data-style='serif'][lang='ja-JP'] { 'Noto Serif SC', ui-serif, Georgia, serif; } +html[data-style='serif'][lang='ko-KR'] { + --font-ui: + 'UI Latin Serif', 'UI ko-KR Serif', 'Noto Serif', 'Noto Serif KR', + 'Noto Serif SC', ui-serif, Georgia, serif; +} + html.dark { --c-ink: #e9e7e2; --c-ink-soft: #a6a39d; diff --git a/app/utils/han-number.ts b/app/utils/han-number.ts index d021285..1927c10 100644 --- a/app/utils/han-number.ts +++ b/app/utils/han-number.ts @@ -1,14 +1,16 @@ /** * A small count as a Han numeral. * - * Every interface language here is written in Han script and spells these the - * same way, so the numeral is a formatting detail rather than a message of its - * own: 三地同形 reads in Chinese exactly as 三つの字形 reads in Japanese. + * Chinese and Japanese use Han numerals here. Korean needs the attributive + * native forms that precede its counters: 한 가지, 두 가지, and so on. */ const hanNumberFormat = new Intl.NumberFormat('zh', { numberingSystem: 'hanidec', useGrouping: false, }) +const KOREAN_COUNTERS = ['영', '한', '두', '세', '네', '다섯'] -export const hanNumber = (value: number): string => - hanNumberFormat.format(value) +export const hanNumber = (value: number, locale = 'zh-CN'): string => + locale.startsWith('ko') + ? (KOREAN_COUNTERS[value] ?? String(value)) + : hanNumberFormat.format(value) diff --git a/public/data/sources.json b/public/data/sources.json index 17cffd5..5c29402 100644 --- a/public/data/sources.json +++ b/public/data/sources.json @@ -5,11 +5,13 @@ "zh-CN": "判定四地字形差异", "zh-TW": "判定四地字形差異", "zh-HK": "判定四地字形差異", - "ja-JP": "4地域の字形差を判定" + "ja-JP": "4地域の字形差を判定", + "ko-KR": "네 지역의 자형 차이 판정" }, "name": "Adobe Source Han Sans / Serif(CMap 资源)", "localizedName": { - "ja-JP": "Adobe Source Han Sans / Serif(CMapリソース)" + "ja-JP": "Adobe Source Han Sans / Serif(CMapリソース)", + "ko-KR": "Adobe Source Han Sans / Serif(CMap 리소스)" }, "homepage": "https://github.com/adobe-fonts/source-han-sans", "license": "SIL OFL 1.1", @@ -18,7 +20,8 @@ "zh-CN": "每套字体的四份地区 CMap 给出「码点 → CID」映射,同一字形池内 CID 相同即同一字形。判定取黑体与宋体的并集。", "zh-TW": "每套字體的四份地區 CMap 給出「碼位 → CID」對映,同一字形池內 CID 相同即同一字形。判定取黑體與宋體的聯集。", "zh-HK": "每套字體的四份地區 CMap 給出「碼點 → CID」對應,同一字形池內 CID 相同即同一字形。判定取黑體與宋體的並集。", - "ja-JP": "各書体の4地域向けCMapには「コードポイント → CID」の対応があり、同じ字形プール内でCIDが同じなら同一字形です。判定にはゴシック体と明朝体の和集合を使います。" + "ja-JP": "各書体の4地域向けCMapには「コードポイント → CID」の対応があり、同じ字形プール内でCIDが同じなら同一字形です。判定にはゴシック体と明朝体の和集合を使います。", + "ko-KR": "각 글꼴의 네 지역용 CMap은 ‘코드 포인트 → CID’ 매핑을 제공합니다. 같은 자형 풀에서 CID가 같으면 같은 자형입니다. 판정에는 고딕체와 명조체의 합집합을 사용합니다." } }, { @@ -27,11 +30,13 @@ "zh-CN": "页面展示用字体", "zh-TW": "頁面顯示用字體", "zh-HK": "頁面顯示用字體", - "ja-JP": "画面表示用フォント" + "ja-JP": "画面表示用フォント", + "ko-KR": "화면 표시용 글꼴" }, "name": "Noto Sans / Noto Serif(含 CJK)", "localizedName": { - "ja-JP": "Noto Sans / Noto Serif(CJK対応)" + "ja-JP": "Noto Sans / Noto Serif(CJK対応)", + "ko-KR": "Noto Sans / Noto Serif(CJK 지원)" }, "homepage": "https://github.com/notofonts/noto-cjk", "license": "SIL OFL 1.1", @@ -40,7 +45,8 @@ "zh-CN": "汉字取自 CJK 版本,拉丁字母与数字取自拉丁版本,都按站内用字子集化后自托管,OFL 声明随附于 /fonts/OFL.txt。", "zh-TW": "漢字取自 CJK 版本,拉丁字母與數字取自拉丁版本,都按站內用字子集化後自行託管,OFL 聲明隨附於 /fonts/OFL.txt。", "zh-HK": "漢字取自 CJK 版本,拉丁字母與數字取自拉丁版本,都按站內用字子集化後自行託管,OFL 聲明隨附於 /fonts/OFL.txt。", - "ja-JP": "漢字はCJK版、ラテン文字と数字はラテン版を使用し、サイト内で使う文字だけにサブセット化してセルフホストしています。OFLの表記は/fonts/OFL.txtに同梱しています。" + "ja-JP": "漢字はCJK版、ラテン文字と数字はラテン版を使用し、サイト内で使う文字だけにサブセット化してセルフホストしています。OFLの表記は/fonts/OFL.txtに同梱しています。", + "ko-KR": "한자는 CJK 버전, 라틴 문자와 숫자는 라틴 버전을 사용합니다. 사이트에서 쓰는 문자만 서브셋으로 만들어 자체 호스팅하며, OFL 고지문은 /fonts/OFL.txt에 함께 제공합니다." } }, { @@ -49,11 +55,13 @@ "zh-CN": "简繁、港台异体、日本新旧字体对应", "zh-TW": "簡繁、港臺異體、日本新舊字體對應", "zh-HK": "簡繁、港台異體、日本新舊字體對應", - "ja-JP": "簡体字・繁体字、香港・台湾の異体字、日本の新字体・旧字体の対応" + "ja-JP": "簡体字・繁体字、香港・台湾の異体字、日本の新字体・旧字体の対応", + "ko-KR": "간체·번체, 홍콩·대만 이체자, 일본 신자체·구자체 대응" }, "name": "OpenCC 开放中文转换", "localizedName": { - "ja-JP": "OpenCC(Open Chinese Convert)" + "ja-JP": "OpenCC(Open Chinese Convert)", + "ko-KR": "OpenCC(Open Chinese Convert)" }, "homepage": "https://github.com/BYVoid/OpenCC", "license": "Apache-2.0", @@ -65,20 +73,23 @@ "zh-CN": "四地标准字表", "zh-TW": "四地標準字表", "zh-HK": "四地標準字表", - "ja-JP": "4地域の標準字表" + "ja-JP": "4地域の標準字表", + "ko-KR": "네 지역의 표준 한자표" }, "name": "zispace/hanzi-chars", "homepage": "https://github.com/zispace/hanzi-chars", "license": "仓库未声明", "localizedLicense": { - "ja-JP": "リポジトリに記載なし" + "ja-JP": "リポジトリに記載なし", + "ko-KR": "저장소에 명시되지 않음" }, "licenseUrl": "https://github.com/zispace/hanzi-chars", "note": { "zh-CN": "转录自各地官方规范:《通用规范汉字表》(2013)、臺灣《常用國字標準字體表》(1982)、香港《常用字字形表》、日本《常用漢字表》(2010)与《学年別漢字配当表》(2017)。", "zh-TW": "轉錄自各地官方規範:《通用規範漢字表》(2013)、臺灣《常用國字標準字體表》(1982)、香港《常用字字形表》、日本《常用漢字表》(2010)與《学年別漢字配当表》(2017)。", "zh-HK": "轉錄自各地官方規範:《通用規範漢字表》(2013)、臺灣《常用國字標準字體表》(1982)、香港《常用字字形表》、日本《常用漢字表》(2010)與《学年別漢字配当表》(2017)。", - "ja-JP": "各地域の公式規範である中国大陸の『通用規範漢字表』(2013年)、台湾の『常用國字標準字體表』(1982年)、香港の『常用字字形表』、日本の『常用漢字表』(2010年)と『学年別漢字配当表』(2017年)から転記されています。" + "ja-JP": "各地域の公式規範である中国大陸の『通用規範漢字表』(2013年)、台湾の『常用國字標準字體表』(1982年)、香港の『常用字字形表』、日本の『常用漢字表』(2010年)と『学年別漢字配当表』(2017年)から転記されています。", + "ko-KR": "각 지역의 공식 규범인 중국 대륙의 《통용규범한자표》(通用規範漢字表, 2013), 대만의 《상용국자표준자체표》(常用國字標準字體表, 1982), 홍콩의 《상용자자형표》(常用字字形表), 일본의 《상용한자표》(常用漢字表, 2010)와 《학년별한자배당표》(学年別漢字配当表, 2017)에서 옮긴 자료입니다." } }, { @@ -87,7 +98,8 @@ "zh-CN": "笔画数、读音", "zh-TW": "筆畫數、讀音", "zh-HK": "筆畫數、讀音", - "ja-JP": "画数・読み" + "ja-JP": "画数・読み", + "ko-KR": "획수와 독음" }, "name": "Unicode Han Database (Unihan)", "homepage": "https://www.unicode.org/reports/tr38/", @@ -100,11 +112,13 @@ "zh-CN": "大陆字频排名", "zh-TW": "大陸字頻排名", "zh-HK": "大陸字頻排名", - "ja-JP": "中国大陸の文字頻度順位" + "ja-JP": "中国大陸の文字頻度順位", + "ko-KR": "중국 대륙 글자 빈도 순위" }, "name": "hanziDB.csv(Jun Da《现代汉语单字频率列表》)", "localizedName": { - "ja-JP": "hanziDB.csv(Jun Da『Modern Chinese Character Frequency List』)" + "ja-JP": "hanziDB.csv(Jun Da『Modern Chinese Character Frequency List』)", + "ko-KR": "hanziDB.csv(Jun Da, 《Modern Chinese Character Frequency List》)" }, "homepage": "https://github.com/ruddfawcett/hanziDB.csv", "license": "MIT", diff --git a/scripts/build-fonts.ts b/scripts/build-fonts.ts index 7e5ead1..a0e1187 100644 --- a/scripts/build-fonts.ts +++ b/scripts/build-fonts.ts @@ -37,7 +37,13 @@ import { REGIONS, STYLES, type CharsData, type Style } from '../shared/types.ts' import { DATA_DIR, FONT_DIR, raw, ROOT, SOURCES } from './sources.ts' /** Region code as Noto names it. */ -const NOTO: Record = { cn: 'sc', hk: 'hk', tw: 'tc', jp: 'jp' } +const NOTO: Record = { + cn: 'sc', + hk: 'hk', + tw: 'tc', + jp: 'jp', + kr: 'kr', +} const otf = (style: Style, region: string) => `font/Noto${style === 'sans' ? 'Sans' : 'Serif'}CJK${NOTO[region]}-Regular.otf` @@ -188,6 +194,7 @@ const UI_FONT: Record string> = { 'zh-TW': (style) => otf(style, 'tw'), 'zh-HK': (style) => otf(style, 'hk'), 'ja-JP': (style) => otf(style, 'jp'), + 'ko-KR': (style) => otf(style, 'kr'), } /** @@ -294,7 +301,7 @@ async function faceMarks(): Promise { const char = messages[locale].style[style] // Labels shared by several locales only need one outline. Locale order // keeps the existing Chinese marks on the default face, while unique - // Japanese labels come from the Japanese face. + // Japanese and Korean labels come from their respective faces. if (marks[style]?.[char]) continue const source = UI_FONT[locale] if (!source) continue diff --git a/scripts/sources.ts b/scripts/sources.ts index 2211ccc..0b11c5c 100644 --- a/scripts/sources.ts +++ b/scripts/sources.ts @@ -49,10 +49,12 @@ export const SOURCES: Source[] = [ 'zh-TW': '判定四地字形差異', 'zh-HK': '判定四地字形差異', 'ja-JP': '4地域の字形差を判定', + 'ko-KR': '네 지역의 자형 차이 판정', }, name: 'Adobe Source Han Sans / Serif(CMap 资源)', localizedName: { 'ja-JP': 'Adobe Source Han Sans / Serif(CMapリソース)', + 'ko-KR': 'Adobe Source Han Sans / Serif(CMap 리소스)', }, homepage: 'https://github.com/adobe-fonts/source-han-sans', license: 'SIL OFL 1.1', @@ -66,6 +68,8 @@ export const SOURCES: Source[] = [ '每套字體的四份地區 CMap 給出「碼點 → CID」對應,同一字形池內 CID 相同即同一字形。判定取黑體與宋體的並集。', 'ja-JP': '各書体の4地域向けCMapには「コードポイント → CID」の対応があり、同じ字形プール内でCIDが同じなら同一字形です。判定にはゴシック体と明朝体の和集合を使います。', + 'ko-KR': + '각 글꼴의 네 지역용 CMap은 ‘코드 포인트 → CID’ 매핑을 제공합니다. 같은 자형 풀에서 CID가 같으면 같은 자형입니다. 판정에는 고딕체와 명조체의 합집합을 사용합니다.', }, }, { @@ -75,10 +79,12 @@ export const SOURCES: Source[] = [ 'zh-TW': '頁面顯示用字體', 'zh-HK': '頁面顯示用字體', 'ja-JP': '画面表示用フォント', + 'ko-KR': '화면 표시용 글꼴', }, name: 'Noto Sans / Noto Serif(含 CJK)', localizedName: { 'ja-JP': 'Noto Sans / Noto Serif(CJK対応)', + 'ko-KR': 'Noto Sans / Noto Serif(CJK 지원)', }, homepage: 'https://github.com/notofonts/noto-cjk', license: 'SIL OFL 1.1', @@ -92,6 +98,8 @@ export const SOURCES: Source[] = [ '漢字取自 CJK 版本,拉丁字母與數字取自拉丁版本,都按站內用字子集化後自行託管,OFL 聲明隨附於 /fonts/OFL.txt。', 'ja-JP': '漢字はCJK版、ラテン文字と数字はラテン版を使用し、サイト内で使う文字だけにサブセット化してセルフホストしています。OFLの表記は/fonts/OFL.txtに同梱しています。', + 'ko-KR': + '한자는 CJK 버전, 라틴 문자와 숫자는 라틴 버전을 사용합니다. 사이트에서 쓰는 문자만 서브셋으로 만들어 자체 호스팅하며, OFL 고지문은 /fonts/OFL.txt에 함께 제공합니다.', }, }, { @@ -101,10 +109,12 @@ export const SOURCES: Source[] = [ 'zh-TW': '簡繁、港臺異體、日本新舊字體對應', 'zh-HK': '簡繁、港台異體、日本新舊字體對應', 'ja-JP': '簡体字・繁体字、香港・台湾の異体字、日本の新字体・旧字体の対応', + 'ko-KR': '간체·번체, 홍콩·대만 이체자, 일본 신자체·구자체 대응', }, name: 'OpenCC 开放中文转换', localizedName: { 'ja-JP': 'OpenCC(Open Chinese Convert)', + 'ko-KR': 'OpenCC(Open Chinese Convert)', }, homepage: 'https://github.com/BYVoid/OpenCC', license: 'Apache-2.0', @@ -117,12 +127,14 @@ export const SOURCES: Source[] = [ 'zh-TW': '四地標準字表', 'zh-HK': '四地標準字表', 'ja-JP': '4地域の標準字表', + 'ko-KR': '네 지역의 표준 한자표', }, name: 'zispace/hanzi-chars', homepage: 'https://github.com/zispace/hanzi-chars', license: '仓库未声明', localizedLicense: { 'ja-JP': 'リポジトリに記載なし', + 'ko-KR': '저장소에 명시되지 않음', }, licenseUrl: 'https://github.com/zispace/hanzi-chars', note: { @@ -134,6 +146,8 @@ export const SOURCES: Source[] = [ '轉錄自各地官方規範:《通用規範漢字表》(2013)、臺灣《常用國字標準字體表》(1982)、香港《常用字字形表》、日本《常用漢字表》(2010)與《学年別漢字配当表》(2017)。', 'ja-JP': '各地域の公式規範である中国大陸の『通用規範漢字表』(2013年)、台湾の『常用國字標準字體表』(1982年)、香港の『常用字字形表』、日本の『常用漢字表』(2010年)と『学年別漢字配当表』(2017年)から転記されています。', + 'ko-KR': + '각 지역의 공식 규범인 중국 대륙의 《통용규범한자표》(通用規範漢字表, 2013), 대만의 《상용국자표준자체표》(常用國字標準字體表, 1982), 홍콩의 《상용자자형표》(常用字字形表), 일본의 《상용한자표》(常用漢字表, 2010)와 《학년별한자배당표》(学年別漢字配当表, 2017)에서 옮긴 자료입니다.', }, }, { @@ -143,6 +157,7 @@ export const SOURCES: Source[] = [ 'zh-TW': '筆畫數、讀音', 'zh-HK': '筆畫數、讀音', 'ja-JP': '画数・読み', + 'ko-KR': '획수와 독음', }, name: 'Unicode Han Database (Unihan)', homepage: 'https://www.unicode.org/reports/tr38/', @@ -156,11 +171,14 @@ export const SOURCES: Source[] = [ 'zh-TW': '大陸字頻排名', 'zh-HK': '大陸字頻排名', 'ja-JP': '中国大陸の文字頻度順位', + 'ko-KR': '중국 대륙 글자 빈도 순위', }, name: 'hanziDB.csv(Jun Da《现代汉语单字频率列表》)', localizedName: { 'ja-JP': 'hanziDB.csv(Jun Da『Modern Chinese Character Frequency List』)', + 'ko-KR': + 'hanziDB.csv(Jun Da, 《Modern Chinese Character Frequency List》)', }, homepage: 'https://github.com/ruddfawcett/hanziDB.csv', license: 'MIT', @@ -189,6 +207,7 @@ const NOTO_DIR: Record = { hk: 'TraditionalChineseHK', tc: 'TraditionalChinese', jp: 'Japanese', + kr: 'Korean', } const noto = (style: 'Sans' | 'Serif', region: string) => @@ -239,10 +258,12 @@ export const ASSETS: Record = { 'font/NotoSansCJKhk-Regular.otf': noto('Sans', 'hk'), 'font/NotoSansCJKtc-Regular.otf': noto('Sans', 'tc'), 'font/NotoSansCJKjp-Regular.otf': noto('Sans', 'jp'), + 'font/NotoSansCJKkr-Regular.otf': noto('Sans', 'kr'), 'font/NotoSerifCJKsc-Regular.otf': noto('Serif', 'sc'), 'font/NotoSerifCJKhk-Regular.otf': noto('Serif', 'hk'), 'font/NotoSerifCJKtc-Regular.otf': noto('Serif', 'tc'), 'font/NotoSerifCJKjp-Regular.otf': noto('Serif', 'jp'), + 'font/NotoSerifCJKkr-Regular.otf': noto('Serif', 'kr'), // Latin, digits and punctuation for the interface, plus the tone marks the // readings carry. Cut from faces designed for Latin rather than from the diff --git a/scripts/tests/i18n.test.ts b/scripts/tests/i18n.test.ts index 0e606d4..97a4a43 100644 --- a/scripts/tests/i18n.test.ts +++ b/scripts/tests/i18n.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { matchLocale } from '../../app/locales/index.ts' import { jaJP } from '../../app/locales/ja-jp.ts' +import { koKR } from '../../app/locales/ko-kr.ts' import { zhCN } from '../../app/locales/zh-cn.ts' +import { hanNumber } from '../../app/utils/han-number.ts' function leaves(value: unknown, prefix = ''): Record { if (typeof value === 'string') return { [prefix]: value } @@ -20,6 +22,10 @@ describe('browser locale matching', () => { expect(matchLocale([tag])).toBe('ja-JP') }) + it.each(['ko', 'ko-KR', 'ko-Kore-KR'])('matches %s to Korean', (tag) => { + expect(matchLocale([tag])).toBe('ko-KR') + }) + it('uses the first supported language in browser preference order', () => { expect(matchLocale(['en-US', 'ja-JP', 'zh-TW'])).toBe('ja-JP') }) @@ -31,6 +37,23 @@ describe('browser locale matching', () => { }) }) +describe('locale-aware small numbers', () => { + it('uses Korean counter forms in Korean copy', () => { + expect(hanNumber(1, 'ko-KR')).toBe('한') + expect(hanNumber(4, 'ko-KR')).toBe('네') + expect(koKR.filter.identical.replace('{n}', hanNumber(4, 'ko-KR'))).toBe( + '네 개 지역에서 같은 자형', + ) + expect(koKR.filter.variety.replace('{n}', hanNumber(3, 'ko-KR'))).toBe( + '세 가지 자형', + ) + }) + + it('keeps Han numerals for the existing locales', () => { + expect(hanNumber(3, 'ja-JP')).toBe('三') + }) +}) + describe('Japanese messages', () => { it('has every message and interpolation parameter in the default locale', () => { const base = leaves(zhCN) @@ -43,3 +66,16 @@ describe('Japanese messages', () => { expect(params(translated[key]!), key).toEqual(params(message)) }) }) + +describe('Korean messages', () => { + it('has every message and interpolation parameter in the default locale', () => { + const base = leaves(zhCN) + const translated = leaves(koKR) + + expect(Object.keys(translated).toSorted()).toEqual( + Object.keys(base).toSorted(), + ) + for (const [key, message] of Object.entries(base)) + expect(params(translated[key]!), key).toEqual(params(message)) + }) +})