[READ-ONLY] Mirror of https://github.com/sxzz/hanji. 一字之間,照見五地字形 hanji.sxzz.moe
chinese-characters cjk han hanja hanji hanzi kanji
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323import { existsSync, readdirSync, readFileSync } from 'node:fs'import { join } from 'node:path'import { describe, expect, it } from 'vitest'import { ANIMCJK_SOURCE_BASELINE, ANIMCJK_SOURCE_SIZE, ANIMCJK_TRANSFORM, ANIMCJK_VIEW_BOX, animCJKMedianPath, unpackAnimCJK,} from '../app/utils/animcjk.ts'import { parseAnimCJKGraphics } from '../scripts/build-strokes.ts'import { DATA_DIR, NOTICES_DIR, STROKE_DIR } from '../scripts/sources.ts'import { setStrokeVariant, STROKE_SHARD_COUNT, strokeDataRef, strokeShardId, type AnimCJKRegion, type PackedStrokeShard,} from '../shared/strokes.ts'import { COLUMNS, REGIONS, type CharRow, type CharsData,} from '../shared/types.ts'
describe('stroke source parsing', () => { it('keeps selected AnimCJK outlines and flattens their medians', () => { const graphics = parseAnimCJKGraphics( [ JSON.stringify({ character: '一', strokes: ['M0 500L1024 500'], medians: [ [ [0, 512], [1024, 512], ], ], }), JSON.stringify({ character: '二', strokes: ['M0 0L1 1'], medians: [ [ [0, 0], [1, 1], ], ], }), ].join('\n'), new Set(['一']), ) expect(graphics).toEqual( new Map([ [ '一', { medians: [[0, 512, 1024, 512]], outlines: ['M0 500L1024 500'], }, ], ]), ) })
it('restores source medians and official clipped-animation geometry', () => { expect(animCJKMedianPath([0, 900, 1024, 0])).toBe('M0 900L1024 0') expect(() => animCJKMedianPath([0, 1, 2])).toThrow('invalid') expect( unpackAnimCJK({ medians: [[0, 512, 1024, 512]], outlines: ['M0 500L1024 500'], }), ).toEqual({ revealWidth: 128, transform: ANIMCJK_TRANSFORM, viewBox: ANIMCJK_VIEW_BOX, strokes: [ { d: 'M0 512L1024 512', order: 1, outline: 'M0 500L1024 500', start: [0, 512], }, ], }) })
it('resolves HK stroke sources in TW, CN, JP, KR priority order', () => { const row: CharRow = { key: '港', chars: ['陆', '港', '臺', '日', '韓'], cp: '01234', glyph: '00000', strokes: [1, 1, 1, 1, 1], tier: [0, 0, 0, 0, 0], listing: ['unlisted', 'unlisted', 'unlisted', 'unlisted', 'unlisted'], common: 0, } const useSources = (regions: readonly AnimCJKRegion[]) => { let strokeMap = 0 for (const [variant, region] of regions.entries()) strokeMap = setStrokeVariant(strokeMap, region, variant) row.strokeMap = setStrokeVariant(strokeMap, 'hk', 0) }
useSources(['tw', 'cn', 'jp', 'kr']) expect(strokeDataRef(row, 'hk')).toEqual({ char: '臺', region: 'tw', variant: 0, })
useSources(['cn', 'jp', 'kr']) expect(strokeDataRef(row, 'hk')).toEqual({ char: '陆', region: 'cn', variant: 0, }) useSources(['jp', 'kr']) expect(strokeDataRef(row, 'hk')).toEqual({ char: '日', region: 'jp', variant: 0, }) useSources(['kr']) expect(strokeDataRef(row, 'hk')).toEqual({ char: '韓', region: 'kr', variant: 0, }) })})
describe('generated stroke shards', () => { const files = readdirSync(STROKE_DIR) .filter((file) => file.endsWith('.json')) .toSorted() const shards = files.map( (file) => JSON.parse( readFileSync(join(STROKE_DIR, file), 'utf8'), ) as PackedStrokeShard, ) const charsData = JSON.parse( readFileSync(join(DATA_DIR, 'chars.json'), 'utf8'), ) as CharsData const rows = new Map(charsData.rows.map((row) => [row.key, row]))
it('uses a fixed small number of files', () => { expect(files).toHaveLength(STROKE_SHARD_COUNT) expect(files.toSorted()).toEqual( Array.from( { length: STROKE_SHARD_COUNT }, (_, index) => `${index.toString(16).padStart(2, '0')}.json`, ), ) })
it('puts every group in its deterministic shard with the AnimCJK notice', () => { for (const [index, shard] of shards.entries()) { for (const groupKey of Object.keys(shard.groups)) expect(Number.parseInt(strokeShardId(groupKey), 16)).toBe(index) } expect(existsSync(join(NOTICES_DIR, 'animcjk-APL.txt'))).toBe(true) expect(existsSync(join(NOTICES_DIR, 'animcjk-COPYING.txt'))).toBe(true) })
it('keeps every transformed AnimCJK median inside its source viewBox', () => { const outside: { character: string; x: number; y: number }[] = [] for (const shard of shards) for (const [character, group] of Object.entries(shard.groups)) for (const packed of group.variants) for (const points of packed.medians) for (let index = 0; index < points.length; index += 2) { const x = points[index]! const y = ANIMCJK_SOURCE_BASELINE - points[index + 1]! if ( x < 0 || x > ANIMCJK_SOURCE_SIZE || y < 0 || y > ANIMCJK_SOURCE_SIZE ) outside.push({ character, x, y }) } expect(outside).toEqual([]) })
it('stores usable AnimCJK outline and median geometry for every region', () => { for (const shard of shards) for (const group of Object.values(shard.groups)) for (const packed of group.variants) { expect(packed.medians.length).toBeGreaterThan(0) expect(packed.outlines).toHaveLength(packed.medians.length) expect(packed.outlines.every((path) => /^\s*m/i.test(path))).toBe( true, ) } })
it('covers every character listed by the Japanese source lists', () => { const missing = charsData.rows .filter( (row) => row.listing[3] !== 'unlisted' && !strokeDataRef(row, 'jp'), ) .map((row) => row.chars[3]) expect(missing).toEqual([]) })
it('deduplicates ordered outlines and maps every available column to them', () => { let packedGroups = 0 for (const shard of shards) for (const [groupKey, group] of Object.entries(shard.groups)) { packedGroups++ const row = rows.get(groupKey) expect(row).toBeDefined() expect( new Set(group.variants.map((data) => JSON.stringify(data.outlines))) .size, ).toBe(group.variants.length) const refs = COLUMNS.flatMap((column) => { const ref = strokeDataRef(row!, column) return ref ? [ref] : [] }) expect(new Set(refs.map((ref) => ref.variant)).size).toBe( group.variants.length, ) for (const ref of refs) expect(group.variants[ref.variant]).toBeDefined() }
expect(packedGroups).toBeGreaterThan(0) expect(packedGroups).toBeLessThanOrEqual(charsData.rows.length)
const timeShard = shards[Number.parseInt(strokeShardId('時'), 16)]! const time = timeShard.groups['時']! const timeRow = rows.get('時')! const cn = strokeDataRef(timeRow, 'cn')! const hk = strokeDataRef(timeRow, 'hk')! const tw = strokeDataRef(timeRow, 'tw')! const jp = strokeDataRef(timeRow, 'jp')! expect(time.variants[cn.variant]).toBeDefined() expect(tw.variant).toBe(jp.variant) expect(hk.variant).toBe(tw.variant) expect(cn.variant).not.toBe(tw.variant) })
it('stores resolved stroke-order counts in the unified regional values', () => { let strokeOrderValues = 0 let fallbackValues = 0
for (const row of charsData.rows) { const group = shards[Number.parseInt(strokeShardId(row.key), 16)]!.groups[row.key]
for (const [index, region] of REGIONS.entries()) { const ref = strokeDataRef(row, region) if (!ref) { fallbackValues++ expect(row.strokes[index]).toBeGreaterThan(0) continue }
strokeOrderValues++ const count = group!.variants[ref.variant]!.outlines.length expect(row.strokes[index]).toBe(count) }
if (row.old) { const ref = strokeDataRef(row, 'old') if (ref) { strokeOrderValues++ expect(row.old.strokes).toBe( group!.variants[ref.variant]!.outlines.length, ) } else { fallbackValues++ expect(row.old.strokes).toBeGreaterThan(0) } } }
expect(strokeOrderValues).toBeGreaterThan(0) expect(fallbackValues).toBeGreaterThan(0) expect(charsData.rows.some((row) => 'comparisonStrokes' in row)).toBe(false) })
it('merges median differences but preserves differences in stroke order', () => { const group = (key: string) => shards[Number.parseInt(strokeShardId(key), 16)]!.groups[key]!
const wantRow = rows.get('要')! const wantVariants = COLUMNS.flatMap((column) => { const ref = strokeDataRef(wantRow, column) return ref ? [ref.variant] : [] }) expect(group('要').variants).toHaveLength(1) expect(new Set(wantVariants)).toEqual(new Set([0]))
const haveRow = rows.get('有')! const haveCn = strokeDataRef(haveRow, 'cn')! const haveJp = strokeDataRef(haveRow, 'jp')! const haveCnOutlines = group('有').variants[haveCn.variant]!.outlines const haveJpOutlines = group('有').variants[haveJp.variant]!.outlines expect(group('有').variants).toHaveLength(2) expect(haveCn.variant).not.toBe(haveJp.variant) expect(haveCnOutlines).not.toEqual(haveJpOutlines) expect(haveCnOutlines.toSorted()).toEqual(haveJpOutlines.toSorted()) })
it('records direct availability and hides regions without data', () => { const one = rows.get('一')! expect(strokeDataRef(one, 'hk')).toMatchObject({ char: '一', region: 'tw', }) expect(strokeDataRef(rows.get('骨')!, 'tw')).toBeUndefined() })})