@jjlmoya/utils-audiovisual 1.18.0 → 1.20.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/package.json +1 -1
- package/src/category/i18n/de.ts +1 -1
- package/src/category/i18n/fr.ts +1 -1
- package/src/category/i18n/ru.ts +1 -1
- package/src/category/index.ts +2 -0
- package/src/entries.ts +4 -1
- package/src/index.ts +1 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/chromaticLens/i18n/de.ts +6 -6
- package/src/tool/chromaticLens/i18n/fr.ts +3 -3
- package/src/tool/chromaticLens/i18n/pl.ts +1 -1
- package/src/tool/chromaticLens/i18n/ru.ts +10 -10
- package/src/tool/chromaticLens/i18n/zh.ts +2 -2
- package/src/tool/collageMaker/i18n/de.ts +6 -6
- package/src/tool/collageMaker/i18n/fr.ts +4 -4
- package/src/tool/collageMaker/i18n/pl.ts +5 -5
- package/src/tool/collageMaker/i18n/ru.ts +12 -12
- package/src/tool/collageMaker/i18n/sv.ts +3 -3
- package/src/tool/collageMaker/i18n/zh.ts +1 -1
- package/src/tool/depthOfFieldCalculator/i18n/de.ts +3 -3
- package/src/tool/depthOfFieldCalculator/i18n/en.ts +7 -7
- package/src/tool/depthOfFieldCalculator/i18n/es.ts +2 -2
- package/src/tool/depthOfFieldCalculator/i18n/fr.ts +6 -6
- package/src/tool/depthOfFieldCalculator/i18n/id.ts +2 -2
- package/src/tool/depthOfFieldCalculator/i18n/it.ts +2 -2
- package/src/tool/depthOfFieldCalculator/i18n/ja.ts +1 -1
- package/src/tool/depthOfFieldCalculator/i18n/ko.ts +1 -1
- package/src/tool/depthOfFieldCalculator/i18n/nl.ts +2 -2
- package/src/tool/depthOfFieldCalculator/i18n/pl.ts +2 -2
- package/src/tool/depthOfFieldCalculator/i18n/pt.ts +2 -2
- package/src/tool/depthOfFieldCalculator/i18n/ru.ts +6 -6
- package/src/tool/depthOfFieldCalculator/i18n/sv.ts +2 -2
- package/src/tool/depthOfFieldCalculator/i18n/tr.ts +1 -1
- package/src/tool/depthOfFieldCalculator/i18n/zh.ts +3 -3
- package/src/tool/exifCleaner/i18n/de.ts +8 -8
- package/src/tool/exifCleaner/i18n/fr.ts +11 -11
- package/src/tool/exifCleaner/i18n/pl.ts +6 -6
- package/src/tool/exifCleaner/i18n/ru.ts +14 -14
- package/src/tool/exifCleaner/i18n/zh.ts +3 -3
- package/src/tool/imageCompressor/i18n/de.ts +16 -16
- package/src/tool/imageCompressor/i18n/fr.ts +8 -8
- package/src/tool/imageCompressor/i18n/pl.ts +1 -1
- package/src/tool/imageCompressor/i18n/ru.ts +9 -9
- package/src/tool/imageCompressor/i18n/zh.ts +1 -1
- package/src/tool/printQualityCalculator/component.astro +118 -110
- package/src/tool/printQualityCalculator/i18n/de.ts +5 -5
- package/src/tool/printQualityCalculator/i18n/fr.ts +11 -11
- package/src/tool/printQualityCalculator/i18n/pl.ts +4 -4
- package/src/tool/printQualityCalculator/i18n/ru.ts +11 -11
- package/src/tool/printQualityCalculator/i18n/zh.ts +4 -4
- package/src/tool/printQualityCalculator/print-quality-calculator-pixels-to-cm-dpi.css +193 -40
- package/src/tool/privacyBlur/i18n/de.ts +5 -5
- package/src/tool/privacyBlur/i18n/fr.ts +9 -9
- package/src/tool/privacyBlur/i18n/ru.ts +6 -6
- package/src/tool/subtitleSync/i18n/de.ts +1 -1
- package/src/tool/subtitleSync/i18n/fr.ts +9 -9
- package/src/tool/subtitleSync/i18n/ru.ts +6 -6
- package/src/tool/subtitleSync/i18n/sv.ts +4 -4
- package/src/tool/timelapseCalculator/i18n/fr.ts +6 -6
- package/src/tool/timelapseCalculator/i18n/ru.ts +3 -3
- package/src/tool/timelapseCalculator/i18n/zh.ts +4 -4
- package/src/tool/tvDistance/i18n/fr.ts +2 -2
- package/src/tool/tvDistance/i18n/ru.ts +9 -9
- package/src/tool/tvDistance/i18n/zh.ts +4 -4
- package/src/tool/videoFrameExtractor/i18n/fr.ts +8 -8
- package/src/tool/videoFrameExtractor/i18n/ru.ts +2 -2
- package/src/tool/videoMerger/bibliography.astro +17 -0
- package/src/tool/videoMerger/bibliography.ts +16 -0
- package/src/tool/videoMerger/component.astro +400 -0
- package/src/tool/videoMerger/entry.ts +51 -0
- package/src/tool/videoMerger/i18n/de.ts +205 -0
- package/src/tool/videoMerger/i18n/en.ts +205 -0
- package/src/tool/videoMerger/i18n/es.ts +205 -0
- package/src/tool/videoMerger/i18n/fr.ts +205 -0
- package/src/tool/videoMerger/i18n/id.ts +205 -0
- package/src/tool/videoMerger/i18n/it.ts +205 -0
- package/src/tool/videoMerger/i18n/ja.ts +205 -0
- package/src/tool/videoMerger/i18n/ko.ts +205 -0
- package/src/tool/videoMerger/i18n/nl.ts +205 -0
- package/src/tool/videoMerger/i18n/pl.ts +205 -0
- package/src/tool/videoMerger/i18n/pt.ts +205 -0
- package/src/tool/videoMerger/i18n/ru.ts +205 -0
- package/src/tool/videoMerger/i18n/sv.ts +205 -0
- package/src/tool/videoMerger/i18n/tr.ts +205 -0
- package/src/tool/videoMerger/i18n/zh.ts +205 -0
- package/src/tool/videoMerger/index.ts +11 -0
- package/src/tool/videoMerger/logic.ts +263 -0
- package/src/tool/videoMerger/online-video-merger.css +440 -0
- package/src/tool/videoMerger/seo.astro +15 -0
- package/src/tools.ts +2 -0
package/package.json
CHANGED
package/src/category/i18n/de.ts
CHANGED
|
@@ -106,7 +106,7 @@ export const content: CategoryLocaleContent = {
|
|
|
106
106
|
type: 'proscons',
|
|
107
107
|
items: [
|
|
108
108
|
{
|
|
109
|
-
pro: '100% browserbasierte Verarbeitung
|
|
109
|
+
pro: '100% browserbasierte Verarbeitung - Ihre Daten werden niemals auf Server hochgeladen',
|
|
110
110
|
con: 'Erfordert einen modernen Browser mit JavaScript-Unterstützung'
|
|
111
111
|
},
|
|
112
112
|
{
|
package/src/category/i18n/fr.ts
CHANGED
|
@@ -155,7 +155,7 @@ export const content: CategoryLocaleContent = {
|
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
term: 'FPS (Images par seconde)',
|
|
158
|
-
definition: 'Nombre d\'images lues chaque seconde. Valeurs communes
|
|
158
|
+
definition: 'Nombre d\'images lues chaque seconde. Valeurs communes: 24 fps (cinéma), 25 fps (PAL), 30 fps (NTSC), 60 fps (vidéo fluide).'
|
|
159
159
|
},
|
|
160
160
|
{
|
|
161
161
|
term: 'DPI (Points par pouce)',
|
package/src/category/i18n/ru.ts
CHANGED
|
@@ -106,7 +106,7 @@ export const content: CategoryLocaleContent = {
|
|
|
106
106
|
type: 'proscons',
|
|
107
107
|
items: [
|
|
108
108
|
{
|
|
109
|
-
pro: '100% обработка в браузере
|
|
109
|
+
pro: '100% обработка в браузере - ваши данные никогда не загружаются на серверы',
|
|
110
110
|
con: 'Требуется современный браузер с поддержкой JavaScript'
|
|
111
111
|
},
|
|
112
112
|
{
|
package/src/category/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { imageCompressor } from '../tool/imageCompressor/index';
|
|
|
10
10
|
import { collageMaker } from '../tool/collageMaker/index';
|
|
11
11
|
import { videoFrameExtractor } from '../tool/videoFrameExtractor/index';
|
|
12
12
|
import { depthOfFieldCalculator } from '../tool/depthOfFieldCalculator/index';
|
|
13
|
+
import { videoMerger } from '../tool/videoMerger/index';
|
|
13
14
|
|
|
14
15
|
export const audiovisualCategory: AudiovisualCategoryEntry = {
|
|
15
16
|
icon: 'mdi:camera-iris',
|
|
@@ -25,6 +26,7 @@ export const audiovisualCategory: AudiovisualCategoryEntry = {
|
|
|
25
26
|
collageMaker as AudiovisualToolEntry,
|
|
26
27
|
videoFrameExtractor as AudiovisualToolEntry,
|
|
27
28
|
depthOfFieldCalculator as AudiovisualToolEntry,
|
|
29
|
+
videoMerger as AudiovisualToolEntry,
|
|
28
30
|
],
|
|
29
31
|
i18n: {
|
|
30
32
|
es: async () => (await import('./i18n/es')).content,
|
package/src/entries.ts
CHANGED
|
@@ -20,6 +20,8 @@ export { videoFrameExtractor } from './tool/videoFrameExtractor/entry';
|
|
|
20
20
|
export type { VideoFrameExtractorUI, VideoFrameExtractorLocaleContent } from './tool/videoFrameExtractor/entry';
|
|
21
21
|
export { depthOfFieldCalculator } from './tool/depthOfFieldCalculator/entry';
|
|
22
22
|
export type { DepthOfFieldUI, DepthOfFieldLocaleContent } from './tool/depthOfFieldCalculator/entry';
|
|
23
|
+
export { videoMerger } from './tool/videoMerger/entry';
|
|
24
|
+
export type { VideoMergerUI, VideoMergerLocaleContent } from './tool/videoMerger/entry';
|
|
23
25
|
export { audiovisualCategory, toolsCategory } from './category';
|
|
24
26
|
import { chromaticLens } from './tool/chromaticLens/entry';
|
|
25
27
|
import { collageMaker } from './tool/collageMaker/entry';
|
|
@@ -32,4 +34,5 @@ import { timelapseCalculator } from './tool/timelapseCalculator/entry';
|
|
|
32
34
|
import { tvDistance } from './tool/tvDistance/entry';
|
|
33
35
|
import { videoFrameExtractor } from './tool/videoFrameExtractor/entry';
|
|
34
36
|
import { depthOfFieldCalculator } from './tool/depthOfFieldCalculator/entry';
|
|
35
|
-
|
|
37
|
+
import { videoMerger } from './tool/videoMerger/entry';
|
|
38
|
+
export const ALL_ENTRIES = [chromaticLens, collageMaker, exifCleaner, imageCompressor, printQualityCalculator, privacyBlur, subtitleSync, timelapseCalculator, tvDistance, videoFrameExtractor, depthOfFieldCalculator, videoMerger];
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
|
|
5
|
+
|
|
6
|
+
const DIACRITIC_RULES = {
|
|
7
|
+
de: {
|
|
8
|
+
language: 'German',
|
|
9
|
+
expectedCharacters: 'ä ö ü ß',
|
|
10
|
+
characters: /[äöüÄÖÜß]/g,
|
|
11
|
+
minPerThousandLetters: 0.1,
|
|
12
|
+
},
|
|
13
|
+
es: {
|
|
14
|
+
language: 'Spanish',
|
|
15
|
+
expectedCharacters: 'á é í ó ú ü ñ',
|
|
16
|
+
characters: /[áéíóúüñÁÉÍÓÚÜÑ]/g,
|
|
17
|
+
minPerThousandLetters: 0.1,
|
|
18
|
+
},
|
|
19
|
+
fr: {
|
|
20
|
+
language: 'French',
|
|
21
|
+
expectedCharacters: 'à â æ ç é è ê ë î ï ô œ ù û ü ÿ',
|
|
22
|
+
characters: /[àâæçéèêëîïôœùûüÿÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸ]/g,
|
|
23
|
+
minPerThousandLetters: 0.1,
|
|
24
|
+
},
|
|
25
|
+
it: {
|
|
26
|
+
language: 'Italian',
|
|
27
|
+
expectedCharacters: 'à è é ì í î ò ó ù ú',
|
|
28
|
+
characters: /[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/g,
|
|
29
|
+
minPerThousandLetters: 0.1,
|
|
30
|
+
},
|
|
31
|
+
pl: {
|
|
32
|
+
language: 'Polish',
|
|
33
|
+
expectedCharacters: 'ą ć ę ł ń ó ś ź ż',
|
|
34
|
+
characters: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g,
|
|
35
|
+
minPerThousandLetters: 0.1,
|
|
36
|
+
},
|
|
37
|
+
pt: {
|
|
38
|
+
language: 'Portuguese',
|
|
39
|
+
expectedCharacters: 'á â ã à ç é ê í ó ô õ ú ü',
|
|
40
|
+
characters: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚÜ]/g,
|
|
41
|
+
minPerThousandLetters: 0.1,
|
|
42
|
+
},
|
|
43
|
+
sv: {
|
|
44
|
+
language: 'Swedish',
|
|
45
|
+
expectedCharacters: 'å ä ö',
|
|
46
|
+
characters: /[åäöÅÄÖ]/g,
|
|
47
|
+
minPerThousandLetters: 0.1,
|
|
48
|
+
},
|
|
49
|
+
tr: {
|
|
50
|
+
language: 'Turkish',
|
|
51
|
+
expectedCharacters: 'ç ğ ı İ ö ş ü',
|
|
52
|
+
characters: /[çğıöşüÇĞİÖŞÜ]/g,
|
|
53
|
+
minPerThousandLetters: 0.1,
|
|
54
|
+
},
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
const LETTERS = /\p{L}/gu;
|
|
58
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
59
|
+
|
|
60
|
+
function collectStrings(value: unknown): string[] {
|
|
61
|
+
if (typeof value === 'string') return [value];
|
|
62
|
+
if (!value || typeof value !== 'object') return [];
|
|
63
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
64
|
+
return Object.values(value).flatMap(collectStrings);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeText(value: unknown): string {
|
|
68
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
72
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function letterCount(text: string): number {
|
|
76
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
|
|
80
|
+
return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
|
|
84
|
+
const letters = letterCount(text);
|
|
85
|
+
if (letters === 0) return 0;
|
|
86
|
+
return diacriticCount(text, locale) / letters * 1000;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('Diacritics density validation', () => {
|
|
90
|
+
ALL_TOOLS.forEach((tool) => {
|
|
91
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
92
|
+
Object.keys(DIACRITIC_RULES).forEach((locale) => {
|
|
93
|
+
it(`${locale} keeps the expected accent and special-letter set`, async () => {
|
|
94
|
+
const typedLocale = locale as LocaleWithDiacritics;
|
|
95
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
96
|
+
if (!loader) return;
|
|
97
|
+
|
|
98
|
+
const content = await loader();
|
|
99
|
+
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
100
|
+
const rule = DIACRITIC_RULES[typedLocale];
|
|
101
|
+
const letters = letterCount(text);
|
|
102
|
+
const matches = diacriticCount(text, typedLocale);
|
|
103
|
+
const density = diacriticsPerThousandLetters(text, typedLocale);
|
|
104
|
+
|
|
105
|
+
expect(
|
|
106
|
+
density,
|
|
107
|
+
[
|
|
108
|
+
`Possible spelling or encoding issue detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
109
|
+
`The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
|
|
110
|
+
`This locale should include some of these characters: ${rule.expectedCharacters}.`,
|
|
111
|
+
'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
|
|
112
|
+
].join(' '),
|
|
113
|
+
).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
const INVERTED_PUNCTUATION_LOCALES = {
|
|
5
|
+
es: {
|
|
6
|
+
language: 'Spanish',
|
|
7
|
+
questionStart: '¿',
|
|
8
|
+
questionEnd: '?',
|
|
9
|
+
exclamationStart: '¡',
|
|
10
|
+
exclamationEnd: '!',
|
|
11
|
+
},
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
|
|
15
|
+
|
|
16
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
17
|
+
|
|
18
|
+
function collectStrings(value: unknown): string[] {
|
|
19
|
+
if (typeof value === 'string') return [value];
|
|
20
|
+
if (!value || typeof value !== 'object') return [];
|
|
21
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
22
|
+
return Object.values(value).flatMap(collectStrings);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function translatableStrings(content: Record<string, unknown>): string[] {
|
|
26
|
+
return TRANSLATABLE_KEYS.flatMap((key) => collectStrings(content[key]));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sentenceStart(text: string, endIndex: number): string {
|
|
30
|
+
const beforeMark = text.slice(0, endIndex).trimEnd();
|
|
31
|
+
const boundary = Math.max(
|
|
32
|
+
beforeMark.lastIndexOf('.'),
|
|
33
|
+
beforeMark.lastIndexOf(':'),
|
|
34
|
+
beforeMark.lastIndexOf(';'),
|
|
35
|
+
beforeMark.lastIndexOf('\n'),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return beforeMark.slice(boundary + 1).trimStart();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findMissingInvertedMarks(
|
|
42
|
+
text: string,
|
|
43
|
+
startMark: string,
|
|
44
|
+
endMark: string,
|
|
45
|
+
): string[] {
|
|
46
|
+
return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
|
|
47
|
+
.map((match) => sentenceStart(text, match.index ?? 0))
|
|
48
|
+
.filter((segment) => segment.length > 0 && !segment.includes(startMark))
|
|
49
|
+
.map((segment) => `${segment}${endMark}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('Inverted punctuation validation', () => {
|
|
53
|
+
ALL_TOOLS.forEach((tool) => {
|
|
54
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
55
|
+
Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
|
|
56
|
+
it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
|
|
57
|
+
const typedLocale = locale as InvertedPunctuationLocale;
|
|
58
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
59
|
+
if (!loader) return;
|
|
60
|
+
|
|
61
|
+
const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
|
|
62
|
+
const content = await loader();
|
|
63
|
+
const strings = translatableStrings(content as Record<string, unknown>);
|
|
64
|
+
const missingQuestions = strings.flatMap((text) =>
|
|
65
|
+
findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd)
|
|
66
|
+
);
|
|
67
|
+
const missingExclamations = strings.flatMap((text) =>
|
|
68
|
+
findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd)
|
|
69
|
+
);
|
|
70
|
+
const failures = [...missingQuestions, ...missingExclamations];
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
failures,
|
|
74
|
+
[
|
|
75
|
+
`Missing opening punctuation marks in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
76
|
+
`Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
|
|
77
|
+
`Examples: ${failures.slice(0, 5).join(' | ')}`,
|
|
78
|
+
].join(' '),
|
|
79
|
+
).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { ALL_TOOLS } from '../tools';
|
|
3
3
|
|
|
4
4
|
describe('Locale Completeness Validation', () => {
|
|
5
|
-
it('all
|
|
6
|
-
expect(ALL_TOOLS.length).toBe(
|
|
5
|
+
it('all 12 tools registered', () => {
|
|
6
|
+
expect(ALL_TOOLS.length).toBe(12);
|
|
7
7
|
});
|
|
8
8
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
function getFiles(dir: string): string[] {
|
|
6
|
+
const results: string[] = [];
|
|
7
|
+
if (!fs.existsSync(dir)) {
|
|
8
|
+
return results;
|
|
9
|
+
}
|
|
10
|
+
const list = fs.readdirSync(dir);
|
|
11
|
+
for (const file of list) {
|
|
12
|
+
const fullPath = path.join(dir, file);
|
|
13
|
+
const stat = fs.statSync(fullPath);
|
|
14
|
+
if (stat && stat.isDirectory()) {
|
|
15
|
+
if (file !== 'tests' && file !== 'node_modules' && file !== '.astro') {
|
|
16
|
+
results.push(...getFiles(fullPath));
|
|
17
|
+
}
|
|
18
|
+
} else {
|
|
19
|
+
results.push(fullPath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return results;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isContentFile(filePath: string): boolean {
|
|
26
|
+
return /\\i18n\\/.test(filePath) || filePath.endsWith('bibliography.ts');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const srcDir = path.join(process.cwd(), 'src');
|
|
30
|
+
const scriptsDir = path.join(process.cwd(), 'scripts');
|
|
31
|
+
const filesToTest = [
|
|
32
|
+
...getFiles(srcDir).filter(isContentFile),
|
|
33
|
+
...getFiles(scriptsDir).filter(isContentFile),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const aiTypographyGarbage = [
|
|
37
|
+
'\u2013',
|
|
38
|
+
'\u2014',
|
|
39
|
+
'\u2026',
|
|
40
|
+
'\u201C',
|
|
41
|
+
'\u201D',
|
|
42
|
+
'\u2018',
|
|
43
|
+
'\u2019',
|
|
44
|
+
'\u00AB',
|
|
45
|
+
'\u00BB',
|
|
46
|
+
'\u200B',
|
|
47
|
+
'\u201E',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
describe('Typography Garbage Character Validation', () => {
|
|
51
|
+
filesToTest.forEach((filePath) => {
|
|
52
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
53
|
+
it(`should not contain typography garbage characters in ${relativePath}`, () => {
|
|
54
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
55
|
+
const hasAiPatterns = aiTypographyGarbage.some(char => content.includes(char));
|
|
56
|
+
expect(hasAiPatterns).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it(`should not contain space before colon in ${relativePath}`, () => {
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const spaceBeforeColon = / : /.test(content);
|
|
62
|
+
expect(spaceBeforeColon).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it(`should not contain double hyphen in ${relativePath}`, () => {
|
|
66
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
67
|
+
expect(content).not.toContain('--');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_TOOLS } from '../tools';
|
|
3
|
+
|
|
4
|
+
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
5
|
+
|
|
6
|
+
const SCRIPT_RULES = {
|
|
7
|
+
ja: {
|
|
8
|
+
language: 'Japanese',
|
|
9
|
+
scriptName: 'kana/kanji',
|
|
10
|
+
scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu,
|
|
11
|
+
minScriptRatio: 0.45,
|
|
12
|
+
},
|
|
13
|
+
ko: {
|
|
14
|
+
language: 'Korean',
|
|
15
|
+
scriptName: 'hangul',
|
|
16
|
+
scriptCharacters: /\p{Script=Hangul}/gu,
|
|
17
|
+
minScriptRatio: 0.55,
|
|
18
|
+
},
|
|
19
|
+
ru: {
|
|
20
|
+
language: 'Russian',
|
|
21
|
+
scriptName: 'cyrillic',
|
|
22
|
+
scriptCharacters: /\p{Script=Cyrillic}/gu,
|
|
23
|
+
minScriptRatio: 0.65,
|
|
24
|
+
},
|
|
25
|
+
zh: {
|
|
26
|
+
language: 'Chinese',
|
|
27
|
+
scriptName: 'han',
|
|
28
|
+
scriptCharacters: /\p{Script=Han}/gu,
|
|
29
|
+
minScriptRatio: 0.45,
|
|
30
|
+
},
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
const LETTERS = /\p{L}/gu;
|
|
34
|
+
const TRANSLATABLE_KEYS = ['title', 'description', 'ui', 'seo', 'faq', 'howTo'] as const;
|
|
35
|
+
|
|
36
|
+
function collectStrings(value: unknown): string[] {
|
|
37
|
+
if (typeof value === 'string') return [value];
|
|
38
|
+
if (!value || typeof value !== 'object') return [];
|
|
39
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
40
|
+
return Object.values(value).flatMap(collectStrings);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeText(value: unknown): string {
|
|
44
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function translatableContent(content: Record<string, unknown>) {
|
|
48
|
+
return TRANSLATABLE_KEYS.map((key) => content[key]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function letterCount(text: string): number {
|
|
52
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
56
|
+
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
60
|
+
const letters = letterCount(text);
|
|
61
|
+
if (letters === 0) return 0;
|
|
62
|
+
return scriptCount(text, locale) / letters;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('Native script density validation', () => {
|
|
66
|
+
ALL_TOOLS.forEach((tool) => {
|
|
67
|
+
describe(`Tool: ${tool.entry.id}`, () => {
|
|
68
|
+
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
69
|
+
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
70
|
+
const typedLocale = locale as ScriptLocale;
|
|
71
|
+
const loader = tool.entry.i18n[typedLocale];
|
|
72
|
+
if (!loader) return;
|
|
73
|
+
|
|
74
|
+
const content = await loader();
|
|
75
|
+
const rule = SCRIPT_RULES[typedLocale];
|
|
76
|
+
const text = normalizeText(translatableContent(content as Record<string, unknown>));
|
|
77
|
+
const letters = letterCount(text);
|
|
78
|
+
const matches = scriptCount(text, typedLocale);
|
|
79
|
+
const ratio = scriptRatio(text, typedLocale);
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
ratio,
|
|
83
|
+
[
|
|
84
|
+
`Possible broken translation detected in ${tool.entry.id}/${typedLocale} (${rule.language}).`,
|
|
85
|
+
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
86
|
+
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
87
|
+
'Non-translatable fields such as slug, bibliography, and schemas are ignored to avoid false positives.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -4,8 +4,8 @@ import { audiovisualCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 12 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(12);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('audiovisualCategory should be defined', () => {
|
|
@@ -57,9 +57,9 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
57
57
|
title: 'Intelligente Extraktion von Farbpaletten',
|
|
58
58
|
items: [
|
|
59
59
|
'Professioneller Median-Cut-Algorithmus zur Farbanalyse',
|
|
60
|
-
'Extraktion von 3
|
|
60
|
+
'Extraktion von 3-12 dominanten Farben aus jedem Bild',
|
|
61
61
|
'HEX-Codes direkt in die Zwischenablage kopierbar',
|
|
62
|
-
'100% lokale Verarbeitung
|
|
62
|
+
'100% lokale Verarbeitung - ideal für Kreative'
|
|
63
63
|
]
|
|
64
64
|
},
|
|
65
65
|
{ type: 'title', text: 'Farbetraktion: Wissenschaft und Design', level: 2 },
|
|
@@ -138,15 +138,15 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
138
138
|
|
|
139
139
|
{ type: 'proscons', items: [
|
|
140
140
|
{
|
|
141
|
-
pro: 'Mathematische Präzision bei der Extraktion
|
|
141
|
+
pro: 'Mathematische Präzision bei der Extraktion - keine ungefähre visuelle Auswahl',
|
|
142
142
|
con: 'Kaum sichtbare Farben können enthalten sein, wenn sie viele Pixel haben'
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
|
-
pro: 'Sofortiges Kopieren in die Zwischenablage
|
|
145
|
+
pro: 'Sofortiges Kopieren in die Zwischenablage - perfekte Integration in den Workflow',
|
|
146
146
|
con: 'Benötigt einen modernen Browser, der mit der Canvas-API kompatibel ist'
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
|
-
pro: 'Vollständige Privatsphäre
|
|
149
|
+
pro: 'Vollständige Privatsphäre - 100% lokale Analyse ohne Datenübermittlung',
|
|
150
150
|
con: 'Kein Verlauf früherer Analysen gespeichert'
|
|
151
151
|
},
|
|
152
152
|
{
|
|
@@ -164,7 +164,7 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
term: 'RGB-Farbraum',
|
|
167
|
-
definition: 'Farbmodell basierend auf Rot, Grün und Blau. Jede Farbe wird als Kombination dieser drei Werte (0
|
|
167
|
+
definition: 'Farbmodell basierend auf Rot, Grün und Blau. Jede Farbe wird als Kombination dieser drei Werte (0-255) dargestellt. Standard für Bildschirme und Web.'
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
term: 'HEX-Code',
|
|
@@ -62,7 +62,7 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
62
62
|
'Traitement 100 % local - idéal pour les créatifs'
|
|
63
63
|
]
|
|
64
64
|
},
|
|
65
|
-
{ type: 'title', text: 'Extraction de Palettes de Couleurs
|
|
65
|
+
{ type: 'title', text: 'Extraction de Palettes de Couleurs: Science et Design', level: 2 },
|
|
66
66
|
{ type: 'paragraph', html: 'Vous êtes-vous déjà demandé pourquoi une photographie de film semble si harmonieuse ? Ce n\'est pas un hasard ; c\'est la théorie des couleurs en action. Lentille Chromatique vous permet d\'extraire cette harmonie directement du pixel, en la transformant en codes HEX utilisables dans vos projets de design.' },
|
|
67
67
|
|
|
68
68
|
{ type: 'stats', items: [
|
|
@@ -180,10 +180,10 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
180
180
|
}
|
|
181
181
|
] },
|
|
182
182
|
|
|
183
|
-
{ type: 'message', title: 'Analyse Chromatique Professionnelle', ariaLabel: 'Informations techniques sur l\'analyse des couleurs', html: 'Lentille Chromatique transforme l\'analyse visuelle manuelle en précision algorithmique. Elle ne se contente pas d\'extraire des couleurs
|
|
183
|
+
{ type: 'message', title: 'Analyse Chromatique Professionnelle', ariaLabel: 'Informations techniques sur l\'analyse des couleurs', html: 'Lentille Chromatique transforme l\'analyse visuelle manuelle en précision algorithmique. Elle ne se contente pas d\'extraire des couleurs: elle capture l\'essence émotionnelle et visuelle de toute image, la plaçant directement dans votre presse-papiers sous forme de codes HEX prêts à l\'emploi. Confidentialité totale, analyses illimitées.' },
|
|
184
184
|
|
|
185
185
|
{ type: 'title', text: 'Concevez Depuis l\'Inspiration Visuelle', level: 3 },
|
|
186
|
-
{ type: 'paragraph', html: 'La meilleure palette de couleurs est celle qui capture l\'intention visuelle de votre référence. Lentille Chromatique automatise ce qui était auparavant un processus manuel
|
|
186
|
+
{ type: 'paragraph', html: 'La meilleure palette de couleurs est celle qui capture l\'intention visuelle de votre référence. Lentille Chromatique automatise ce qui était auparavant un processus manuel: observer, analyser, noter. Désormais, faites simplement glisser une image et obtenez des codes HEX professionnels en quelques secondes.' }
|
|
187
187
|
];
|
|
188
188
|
|
|
189
189
|
const faqSchema: WithContext<FAQPage> = {
|
|
@@ -74,7 +74,7 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
74
74
|
{ type: 'title', text: 'Wyjaśnienie Algorytmu Median Cut', level: 3 },
|
|
75
75
|
{ type: 'paragraph', html: 'Inteligentna ekstrakcja palety nie jest prostym losowym próbkowaniem pikseli. Wykorzystuje algorytm Median Cut, technikę rekurencyjnego partycjonowania, która zapewnia wierne odwzorowanie:' },
|
|
76
76
|
{ type: 'list', items: [
|
|
77
|
-
'<strong>Podział Rekurencyjny:</strong>
|
|
77
|
+
'<strong>Podział Rekurencyjny:</strong> \"Kostka kolorów\" RGB obrazu jest rekurencyjnie dzielona na mniejsze pudełka.',
|
|
78
78
|
'<strong>Równowaga Objętości:</strong> Każda partycja dąży do pogrupowania pikseli z tej samej przestrzeni barw o podobnych objętościach.',
|
|
79
79
|
'<strong>Średnia Ważona:</strong> Wynikowy kolor każdego pudełka jest średnią wszystkich pikseli, które zawiera.',
|
|
80
80
|
'<strong>Wierne Odwzorowanie:</strong> Dominujące kolory odzwierciedlają rzeczywistą atmosferę wizualną obrazu, a nie prostą próbkę.'
|
|
@@ -20,7 +20,7 @@ const ui: ChromaticLensUI = {
|
|
|
20
20
|
const faq: ChromaticLensLocaleContent['faq'] = [
|
|
21
21
|
{
|
|
22
22
|
question: "Как работает извлечение цвета?",
|
|
23
|
-
answer: "Мы используем алгоритм
|
|
23
|
+
answer: "Мы используем алгоритм \"Median Cut\", который группирует пиксели изображения в соответствии с их близостью в цветовом пространстве RGB для поиска наиболее репрезентативных тонов.",
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
question: "Могу ли я копировать цвета в свой дизайнерский редактор?",
|
|
@@ -57,9 +57,9 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
57
57
|
title: 'Интеллектуальное извлечение цветовой палитры',
|
|
58
58
|
items: [
|
|
59
59
|
'Профессиональный алгоритм Median Cut для анализа цвета',
|
|
60
|
-
'Извлечение 3
|
|
60
|
+
'Извлечение 3-12 доминирующих цветов из любого изображения',
|
|
61
61
|
'HEX-коды копируются непосредственно в буфер обмена',
|
|
62
|
-
'100% локальная обработка
|
|
62
|
+
'100% локальная обработка - идеально для креативщиков'
|
|
63
63
|
]
|
|
64
64
|
},
|
|
65
65
|
{ type: 'title', text: 'Извлечение цветовой палитры: наука и дизайн', level: 2 },
|
|
@@ -72,9 +72,9 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
72
72
|
], columns: 3 },
|
|
73
73
|
|
|
74
74
|
{ type: 'title', text: 'Объяснение алгоритма Median Cut', level: 3 },
|
|
75
|
-
{ type: 'paragraph', html: 'Интеллектуальное извлечение палитры
|
|
75
|
+
{ type: 'paragraph', html: 'Интеллектуальное извлечение палитры - это не простая случайная выборка пикселей. Оно использует алгоритм Median Cut, метод рекурсивного разделения, который обеспечивает верное представление:' },
|
|
76
76
|
{ type: 'list', items: [
|
|
77
|
-
'<strong>Рекурсивное деление:</strong>
|
|
77
|
+
'<strong>Рекурсивное деление:</strong> \"Цветовой куб\" RGB изображения рекурсивно делится на более мелкие блоки.',
|
|
78
78
|
'<strong>Баланс объема:</strong> Каждый раздел стремится сгруппировать пиксели одного цветового пространства с похожими объемами.',
|
|
79
79
|
'<strong>Средневзвешенное значение:</strong> Результирующий цвет каждого блока является средним значением всех содержащихся в нем пикселей.',
|
|
80
80
|
'<strong>Точное представление:</strong> Доминирующие цвета отражают реальную визуальную атмосферу изображения, а не просто случайную пробу.'
|
|
@@ -138,15 +138,15 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
138
138
|
|
|
139
139
|
{ type: 'proscons', items: [
|
|
140
140
|
{
|
|
141
|
-
pro: 'Математическая точность при извлечении
|
|
141
|
+
pro: 'Математическая точность при извлечении - не приблизительный визуальный выбор',
|
|
142
142
|
con: 'Могут быть включены едва видимые цвета, если в них много пикселей'
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
|
-
pro: 'Мгновенное копирование в буфер обмена
|
|
145
|
+
pro: 'Мгновенное копирование в буфер обмена - идеальная интеграция с рабочим процессом',
|
|
146
146
|
con: 'Требуется современный браузер, совместимый с Canvas API'
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
|
-
pro: 'Полная конфиденциальность
|
|
149
|
+
pro: 'Полная конфиденциальность - 100% локальный анализ без отправки данных',
|
|
150
150
|
con: 'История предыдущих анализов не сохраняется'
|
|
151
151
|
},
|
|
152
152
|
{
|
|
@@ -164,7 +164,7 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
164
164
|
},
|
|
165
165
|
{
|
|
166
166
|
term: 'Цветовое пространство RGB',
|
|
167
|
-
definition: 'Цветовая модель, основанная на красном, зеленом и синем цветах. Каждый цвет представлен как комбинация этих трех значений (0
|
|
167
|
+
definition: 'Цветовая модель, основанная на красном, зеленом и синем цветах. Каждый цвет представлен как комбинация этих трех значений (0-255). Стандарт для цифровых экранов и веб-интерфейсов.'
|
|
168
168
|
},
|
|
169
169
|
{
|
|
170
170
|
term: 'HEX-код',
|
|
@@ -183,7 +183,7 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
183
183
|
{ type: 'message', title: 'Профессиональный хроматический анализ', ariaLabel: 'Техническая информация об анализе цвета', html: 'Chromatic Lens превращает ручной визуальный анализ в алгоритмическую точность. Он не просто извлекает цвета: он улавливает эмоциональную и визуальную суть любого изображения, помещая её прямо в ваш буфер обмена в виде готовых к использованию HEX-кодов. Полная конфиденциальность, без ограничений по объему анализа.' },
|
|
184
184
|
|
|
185
185
|
{ type: 'title', text: 'Дизайн на основе визуального вдохновения', level: 3 },
|
|
186
|
-
{ type: 'paragraph', html: 'Лучшая цветовая палитра
|
|
186
|
+
{ type: 'paragraph', html: 'Лучшая цветовая палитра - та, которая улавливает визуальный замысел вашего референса. Chromatic Lens автоматизирует то, что раньше было ручным процессом: наблюдение, анализ, запись. Теперь просто перетащите изображение и получите профессиональные HEX-коды за считанные секунды.' }
|
|
187
187
|
];
|
|
188
188
|
|
|
189
189
|
const faqSchema: WithContext<FAQPage> = {
|
|
@@ -20,7 +20,7 @@ const ui: ChromaticLensUI = {
|
|
|
20
20
|
const faq: ChromaticLensLocaleContent['faq'] = [
|
|
21
21
|
{
|
|
22
22
|
question: "色彩提取是如何工作的?",
|
|
23
|
-
answer: "
|
|
23
|
+
answer: "我们使用\"中位切分\"(Median Cut)算法。该算法根据图像像素在 RGB 色彩空间中的接近程度进行分组,从而找到最具代表性的色调。",
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
question: "我可以将颜色复制到我的设计编辑器吗?",
|
|
@@ -74,7 +74,7 @@ const seo: ChromaticLensLocaleContent['seo'] = [
|
|
|
74
74
|
{ type: 'title', text: '中位切分算法详解', level: 3 },
|
|
75
75
|
{ type: 'paragraph', html: '智能调色盘提取并非简单的随机采样。它使用中位切分(Median Cut)算法,这是一种确保忠实呈现的递归切分技术:' },
|
|
76
76
|
{ type: 'list', items: [
|
|
77
|
-
'<strong>递归细分:</strong> 图像的 RGB
|
|
77
|
+
'<strong>递归细分:</strong> 图像的 RGB \"色彩立方体\"被递归地细分为更小的方块。',
|
|
78
78
|
'<strong>体积平衡:</strong> 每个分区都力求将色彩空间中体积相似的像素归为一类。',
|
|
79
79
|
'<strong>加权平均:</strong> 每个方块最终产生的颜色是其包含的所有像素的平均值。',
|
|
80
80
|
'<strong>忠实呈现:</strong> 主导色彩反映了图像真实的视觉氛围,而不仅仅是简单的样本。'
|