Files
OSGKeyboard/OSGKeyboardShared/Typing/RimePinyinAnnotator.swift
T
Rocky 717902716a feat(typing): wire PersonalDictionary into Chinese Pinyin via Rime sidecar
Redeploy an osg_personal import table on dictionary add/delete/sync so
Chinese, English typing, and ASR share one curated lexicon (next keyboard open).
2026-08-05 22:12:30 +08:00

185 lines
6.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// RimePinyinAnnotator.swift
// OSGKeyboard · Shared
//
// Builds phrase / character pinyin maps from the bundled osg_pinyin
// dictionary so PersonalDictionary terms can be coded for Rime without
// shipping a second pronunciation dataset.
import Foundation
public struct RimePinyinAnnotator: Sendable {
private let phraseCodes: [String: String]
private let characterCodes: [String: String]
public init(phraseCodes: [String: String], characterCodes: [String: String]) {
self.phraseCodes = phraseCodes
self.characterCodes = characterCodes
}
/// Parses `osg_pinyin.dict.yaml` (text / code / weight columns).
/// When duplicate texts exist, keeps the highest-weight code.
public static func load(from dictYAML: URL) throws -> RimePinyinAnnotator {
let raw = try String(contentsOf: dictYAML, encoding: .utf8)
var phraseCodes: [String: (code: String, weight: Int)] = [:]
var characterCodes: [String: (code: String, weight: Int)] = [:]
var inBody = false
for line in raw.split(separator: "\n", omittingEmptySubsequences: false) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed == "..." {
inBody = true
continue
}
guard inBody, !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue }
let parts = trimmed.split(separator: "\t", omittingEmptySubsequences: false)
guard parts.count >= 2 else { continue }
let text = String(parts[0])
let code = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !code.isEmpty else { continue }
let weight = parts.count >= 3 ? Int(parts[2]) ?? 0 : 0
if let existing = phraseCodes[text] {
if weight >= existing.weight {
phraseCodes[text] = (code, weight)
}
} else {
phraseCodes[text] = (code, weight)
}
if text.count == 1, Self.isCJKIdeograph(text.unicodeScalars.first!) {
if let existing = characterCodes[text] {
if weight >= existing.weight {
characterCodes[text] = (code, weight)
}
} else {
characterCodes[text] = (code, weight)
}
}
}
return RimePinyinAnnotator(
phraseCodes: phraseCodes.mapValues(\.code),
characterCodes: characterCodes.mapValues(\.code)
)
}
/// Returns a Rime speller code (space-separated syllables / Latin tokens),
/// or `nil` when any CJK character cannot be annotated.
public func code(for term: String) -> String? {
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let exact = phraseCodes[trimmed] {
return exact
}
var parts: [String] = []
for run in Self.scriptRuns(in: trimmed) {
switch run.kind {
case .cjk:
if let phrase = phraseCodes[run.text] {
parts.append(phrase)
continue
}
var syllables: [String] = []
for character in run.text {
let key = String(character)
guard let syllable = characterCodes[key] else { return nil }
syllables.append(syllable)
}
parts.append(syllables.joined(separator: " "))
case .latin:
let latin = Self.latinSpellerCode(run.text)
guard !latin.isEmpty else { continue }
parts.append(latin)
case .other:
continue
}
}
let joined = parts.joined(separator: " ")
.split(separator: " ", omittingEmptySubsequences: true)
.joined(separator: " ")
return joined.isEmpty ? nil : joined
}
// MARK: - Script helpers
private enum RunKind {
case cjk
case latin
case other
}
private struct ScriptRun {
let kind: RunKind
let text: String
}
private static func scriptRuns(in term: String) -> [ScriptRun] {
var runs: [ScriptRun] = []
var currentKind: RunKind?
var buffer = ""
func flush() {
guard let kind = currentKind, !buffer.isEmpty else { return }
runs.append(ScriptRun(kind: kind, text: buffer))
buffer = ""
currentKind = nil
}
for scalar in term.unicodeScalars {
let kind: RunKind
if isCJKIdeograph(scalar) {
kind = .cjk
} else if scalar.isASCII, CharacterSet.letters.contains(scalar)
|| CharacterSet.decimalDigits.contains(scalar)
|| scalar == "-" || scalar == "'" || scalar == "_" {
kind = .latin
} else if scalar == " " || scalar == "\u{3000}" {
flush()
continue
} else {
kind = .other
}
if currentKind == nil {
currentKind = kind
buffer = String(scalar)
} else if currentKind == kind {
buffer.append(Character(scalar))
} else {
flush()
currentKind = kind
buffer = String(scalar)
}
}
flush()
return runs
}
/// Speller alphabet is az only; strip everything else and lowercase.
public static func latinSpellerCode(_ raw: String) -> String {
var output = ""
for scalar in raw.lowercased().unicodeScalars {
guard scalar.isASCII, CharacterSet.lowercaseLetters.contains(scalar) else { continue }
output.append(Character(scalar))
}
return output
}
public static func isCJKIdeograph(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF:
return true
default:
return false
}
}
public static func containsCJK(_ text: String) -> Bool {
text.unicodeScalars.contains(where: isCJKIdeograph)
}
}