1#![deny(missing_docs)]
40
41mod pinyin;
42
43use std::borrow::Cow;
44use std::collections::{btree_map::Entry, BTreeMap, BTreeSet, HashMap};
45use std::error::Error;
46use std::fmt;
47use std::fmt::Write as _;
48use std::fs::File;
49use std::io::{BufRead, BufReader, Read, Write};
50use std::path::Path;
51use std::string::FromUtf8Error;
52use std::sync::Arc;
53
54use fst::{Map, MapBuilder, Streamer};
55use moine_core::{
56 levenshtein_str, normalized_similarity_from_distance, try_damerau_distance,
57 try_damerau_levenshtein_str, try_distance, DistanceError, Lattice, StringDistanceWorkspace,
58};
59use serde::{Deserialize, Serialize};
60use sha2::{Digest, Sha256};
61
62use pinyin::{
63 can_build_direct_pinyin_input, direct_pinyin_lattice, normalize_artifact_reading,
64 normalize_direct_pinyin_input,
65};
66pub use pinyin::{normalize_pinyin, pinyin_lattice_from_reading_paths};
67
68const ARTIFACT_PAYLOAD_SCHEMA_VERSION: u32 = 1;
69const ARTIFACT_PAYLOAD_TYPE: &str = "moine.zh.reading-index.surface-readings";
70const INDEXED_ARTIFACT_MAGIC: &[u8; 8] = b"MOINEZ01";
71const INDEXED_ARTIFACT_VERSION: u32 = 1;
72const INDEXED_ARTIFACT_HEADER_LEN: usize = 40;
73const MAX_ARTIFACT_PAYLOAD_BYTES: u64 = 512 * 1024 * 1024;
74const MAX_ARTIFACT_ENTRIES: usize = 2_000_000;
75const MAX_ARTIFACT_READINGS_PER_ENTRY: usize = 256;
76const MAX_ARTIFACT_STRING_BYTES: usize = 16 * 1024;
77const DEFAULT_QUERY_SPAN_CHARS: usize = 8;
78const DEFAULT_QUERY_PATHS: usize = 1024;
79const MAX_QUERY_SPAN_CHARS: usize = DEFAULT_QUERY_SPAN_CHARS * 32;
82const MAX_QUERY_PATHS: usize = DEFAULT_QUERY_PATHS * 16;
83const MAX_QUERY_READINGS_PER_SEGMENT: usize = MAX_ARTIFACT_READINGS_PER_ENTRY;
84pub const ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM: &str = "sha256-canonical-v1";
86pub const ARTIFACT_PAYLOAD_FILE_DIGEST_ALGORITHM: &str = "sha256-file-v1";
88
89#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
91pub enum PinyinView {
92 #[default]
94 NoTone,
95 Tone3,
97}
98
99#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub struct CedictIndexOptions {
102 pub pinyin_view: PinyinView,
104 pub max_readings_per_surface: Option<usize>,
106}
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub struct PinyinReadingOptions {
111 pub max_span_chars: usize,
113 pub max_paths: usize,
115 pub longest_match_only: bool,
117 pub max_readings_per_segment: Option<usize>,
119}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct PinyinReadingSegment {
124 pub surface: String,
126 pub reading: String,
128 pub source: PinyinReadingSegmentSource,
130}
131
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub enum PinyinReadingSegmentSource {
135 Dictionary,
137 Direct,
139}
140
141#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct PinyinReadingPath {
144 pub segments: Vec<PinyinReadingSegment>,
146 pub joined_reading: String,
148}
149
150#[derive(Clone, Debug, Default, Eq, PartialEq)]
152pub struct PinyinReadingExpansion {
153 pub paths: Vec<PinyinReadingPath>,
155 pub stats: PinyinReadingStats,
157}
158
159#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
161pub struct PinyinReadingStats {
162 pub matched_spans: usize,
164 pub direct_fallback_spans: usize,
166 pub longest_match_pruned_spans: usize,
168 pub raw_segment_readings: usize,
170 pub used_segment_readings: usize,
172 pub pruned_segment_readings: usize,
174 pub candidate_combinations: usize,
176 pub unique_paths: usize,
178 pub duplicate_joined_readings: usize,
180 pub max_paths_hit_count: usize,
182}
183
184#[derive(Clone, Copy, Debug, Eq, PartialEq)]
186pub struct ChineseDistance {
187 pub surface_levenshtein: usize,
189 pub surface_damerau: usize,
191 pub lattice: usize,
193 pub lattice_damerau: usize,
195 pub combined: usize,
201}
202
203pub type ZhReadingIndex = CedictReadingIndex;
205
206#[derive(Clone, Debug)]
208pub struct CedictReadingIndex {
209 storage: ZhReadingStorage,
210 pinyin_view: PinyinView,
211}
212
213#[derive(Clone, Debug)]
214enum ZhReadingStorage {
215 Eager(HashMap<String, Vec<String>>),
216 Indexed(IndexedZhPayload),
217}
218
219impl Default for CedictReadingIndex {
220 fn default() -> Self {
221 Self {
222 storage: ZhReadingStorage::Eager(HashMap::new()),
223 pinyin_view: PinyinView::default(),
224 }
225 }
226}
227
228impl PartialEq for CedictReadingIndex {
229 fn eq(&self, other: &Self) -> bool {
230 self.pinyin_view == other.pinyin_view && self.artifact_payload() == other.artifact_payload()
231 }
232}
233
234impl Eq for CedictReadingIndex {}
235
236#[derive(Clone, Debug)]
237struct IndexedZhPayload {
238 bytes: Arc<[u8]>,
239 map: Map<Vec<u8>>,
240 readings_start: usize,
241 entries: usize,
242}
243
244#[derive(Clone, Copy, Debug, Eq, PartialEq)]
246pub struct ZhIndexedArtifactPayloadHeader {
247 pub version: u32,
249 pub pinyin_view: PinyinView,
251 pub entries: usize,
253 pub fst_len: usize,
255 pub readings_len: usize,
257}
258
259#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
261pub struct ZhArtifactMetadata {
262 pub schema_version: u32,
264 pub artifact_type: String,
266 pub artifact_name: String,
268 pub generator: String,
270 pub payload: ZhArtifactPayload,
272 pub source: ZhArtifactSource,
274 pub build: ZhArtifactBuild,
276 pub query_defaults: ZhArtifactQueryDefaults,
278 pub license: ZhArtifactLicense,
280}
281
282#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
284pub struct ZhArtifactPayload {
285 pub path: String,
287 pub format: String,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub file_digest_algorithm: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub file_digest: Option<String>,
295 pub checksum_algorithm: String,
297 pub checksum: String,
299}
300
301#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
303pub struct ZhArtifactSource {
304 pub name: String,
306 pub version: String,
308 pub cedict: String,
310}
311
312#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
314pub struct ZhArtifactBuild {
315 pub pinyin_view: String,
317 pub max_readings_per_surface: Option<usize>,
319 pub entries: usize,
321}
322
323#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
325pub struct ZhArtifactQueryDefaults {
326 pub max_span_chars: usize,
328 pub max_paths: usize,
330 pub longest_match_only: bool,
332 pub max_readings_per_segment: Option<usize>,
334}
335
336#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
338pub struct ZhArtifactLicense {
339 pub selected_license: String,
341 pub references: Vec<ZhArtifactLicenseReference>,
343}
344
345#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
347pub struct ZhArtifactLicenseReference {
348 pub label: String,
350 pub path: String,
352}
353
354#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
356pub struct ZhReadingIndexPayload {
357 pub schema_version: u32,
359 pub payload_type: String,
361 pub pinyin_view: String,
363 pub entries: Vec<ZhReadingIndexPayloadEntry>,
365}
366
367#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
369pub struct ZhReadingIndexPayloadEntry {
370 pub surface: String,
372 pub readings: Vec<String>,
374}
375
376#[derive(Clone, Debug, Eq, PartialEq)]
378pub struct ZhArtifactMetadataOptions {
379 pub artifact_name: String,
381 pub generator: String,
383 pub payload_file_name: String,
385 pub payload_format: String,
387 pub source_name: String,
389 pub source_version: String,
391 pub source_cedict: String,
393 pub index_options: CedictIndexOptions,
395 pub query_defaults: PinyinReadingOptions,
397 pub license: ZhArtifactLicense,
399}
400
401#[derive(Debug)]
403pub enum CedictError {
404 Io(std::io::Error),
406 InvalidEntry {
408 line: usize,
410 message: String,
412 },
413}
414
415#[derive(Debug)]
417pub enum ZhArtifactPayloadError {
418 Io(std::io::Error),
420 Yaml(serde_yaml::Error),
422 InvalidIndexedMagic {
424 magic: [u8; 8],
426 },
427 UnsupportedIndexedVersion {
429 version: u32,
431 },
432 UnsupportedIndexedPinyinView {
434 value: u32,
436 },
437 IndexedSectionTooLarge {
439 field: &'static str,
441 len: u64,
443 },
444 ArtifactLimitExceeded {
446 field: &'static str,
448 len: u64,
450 max: u64,
452 },
453 NonZeroIndexedReserved {
455 value: u32,
457 },
458 TruncatedIndexed {
460 field: &'static str,
462 },
463 InvalidIndexedFst {
465 message: String,
467 },
468 IndexedEntryCountMismatch {
470 header_entries: usize,
472 fst_entries: usize,
474 },
475 InvalidIndexedOffset {
477 offset: u64,
479 },
480 InvalidIndexedUtf8 {
482 field: &'static str,
484 source: FromUtf8Error,
486 },
487 UnsupportedSchemaVersion {
489 version: u32,
491 },
492 UnsupportedPayloadType {
494 payload_type: String,
496 },
497 UnsupportedPinyinView {
499 pinyin_view: String,
501 },
502 EmptySurface {
504 entry_index: usize,
506 },
507 DuplicateSurface {
509 surface: String,
511 },
512 EmptyReadings {
514 surface: String,
516 },
517 EmptyReading {
519 surface: String,
521 reading_index: usize,
523 },
524 DuplicateReading {
526 surface: String,
528 reading: String,
530 },
531 ReadingNotNormalized {
533 surface: String,
535 reading: String,
537 normalized: String,
539 },
540}
541
542#[derive(Debug, Eq, PartialEq)]
544pub enum CnLatticeError {
545 EmptyReadings,
547 UnsupportedDirectInput {
549 surface: String,
551 },
552 ArtifactPayload(String),
554 Distance(DistanceError),
556}
557
558impl PinyinView {
559 pub fn as_str(self) -> &'static str {
561 match self {
562 Self::NoTone => "no-tone",
563 Self::Tone3 => "tone3",
564 }
565 }
566}
567
568impl TryFrom<&str> for PinyinView {
569 type Error = ();
570
571 fn try_from(value: &str) -> Result<Self, Self::Error> {
572 match value {
573 "no-tone" | "notone" | "normal" => Ok(Self::NoTone),
574 "tone3" => Ok(Self::Tone3),
575 _ => Err(()),
576 }
577 }
578}
579
580impl Default for CedictIndexOptions {
581 fn default() -> Self {
582 Self {
583 pinyin_view: PinyinView::NoTone,
584 max_readings_per_surface: None,
585 }
586 }
587}
588
589impl Default for PinyinReadingOptions {
590 fn default() -> Self {
591 Self {
592 max_span_chars: DEFAULT_QUERY_SPAN_CHARS,
593 max_paths: DEFAULT_QUERY_PATHS,
594 longest_match_only: false,
595 max_readings_per_segment: None,
596 }
597 }
598}
599
600impl PinyinReadingOptions {
601 pub fn validate(self) -> Result<Self, ZhArtifactPayloadError> {
604 check_limit("max_span_chars", self.max_span_chars, MAX_QUERY_SPAN_CHARS)?;
605 check_limit("max_paths", self.max_paths, MAX_QUERY_PATHS)?;
606 if let Some(max_readings) = self.max_readings_per_segment {
607 check_limit(
608 "max_readings_per_segment",
609 max_readings,
610 MAX_QUERY_READINGS_PER_SEGMENT,
611 )?;
612 }
613 Ok(self)
614 }
615}
616
617impl Default for ZhArtifactLicense {
618 fn default() -> Self {
619 Self {
620 selected_license: "CC BY-SA 4.0".to_string(),
621 references: vec![ZhArtifactLicenseReference {
622 label: "CC-CEDICT".to_string(),
623 path: "license/CC-CEDICT.md".to_string(),
624 }],
625 }
626 }
627}
628
629impl fmt::Display for CedictError {
630 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
631 match self {
632 Self::Io(err) => write!(f, "failed to read CC-CEDICT: {err}"),
633 Self::InvalidEntry { line, message } => {
634 write!(f, "invalid CC-CEDICT entry at line {line}: {message}")
635 }
636 }
637 }
638}
639
640impl Error for CedictError {
641 fn source(&self) -> Option<&(dyn Error + 'static)> {
642 match self {
643 Self::Io(err) => Some(err),
644 Self::InvalidEntry { .. } => None,
645 }
646 }
647}
648
649impl fmt::Display for ZhArtifactPayloadError {
650 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
651 match self {
652 Self::Io(err) => write!(f, "failed to read zh artifact payload: {err}"),
653 Self::Yaml(err) => write!(f, "invalid zh artifact payload YAML: {err}"),
654 Self::InvalidIndexedMagic { magic } => {
655 write!(f, "invalid zh indexed artifact magic {magic:?}")
656 }
657 Self::UnsupportedIndexedVersion { version } => {
658 write!(f, "unsupported zh indexed artifact version {version}")
659 }
660 Self::UnsupportedIndexedPinyinView { value } => {
661 write!(f, "unsupported zh indexed artifact pinyin view {value}")
662 }
663 Self::IndexedSectionTooLarge { field, len } => {
664 write!(f, "zh indexed artifact {field} length {len} exceeds usize::MAX")
665 }
666 Self::ArtifactLimitExceeded { field, len, max } => {
667 write!(f, "zh artifact {field} length/count {len} exceeds limit {max}")
668 }
669 Self::NonZeroIndexedReserved { value } => {
670 write!(f, "zh indexed artifact reserved header field is {value}")
671 }
672 Self::TruncatedIndexed { field } => {
673 write!(f, "truncated zh indexed artifact while reading {field}")
674 }
675 Self::InvalidIndexedFst { message } => {
676 write!(f, "invalid zh indexed artifact FST: {message}")
677 }
678 Self::IndexedEntryCountMismatch {
679 header_entries,
680 fst_entries,
681 } => write!(
682 f,
683 "zh indexed artifact header entry count {header_entries} does not match FST entry count {fst_entries}"
684 ),
685 Self::InvalidIndexedOffset { offset } => {
686 write!(f, "invalid zh indexed artifact readings offset {offset}")
687 }
688 Self::InvalidIndexedUtf8 { field, source } => {
689 write!(f, "invalid UTF-8 in zh indexed artifact {field}: {source}")
690 }
691 Self::UnsupportedSchemaVersion { version } => {
692 write!(f, "unsupported zh artifact payload schema version {version}")
693 }
694 Self::UnsupportedPayloadType { payload_type } => {
695 write!(f, "unsupported zh artifact payload type {payload_type:?}")
696 }
697 Self::UnsupportedPinyinView { pinyin_view } => {
698 write!(f, "unsupported zh artifact pinyin view {pinyin_view:?}")
699 }
700 Self::EmptySurface { entry_index } => {
701 write!(f, "zh artifact payload entry {entry_index} has an empty surface")
702 }
703 Self::DuplicateSurface { surface } => {
704 write!(f, "zh artifact payload has duplicate surface {surface:?}")
705 }
706 Self::EmptyReadings { surface } => {
707 write!(f, "zh artifact payload surface {surface:?} has no readings")
708 }
709 Self::EmptyReading {
710 surface,
711 reading_index,
712 } => write!(
713 f,
714 "zh artifact payload surface {surface:?} has an empty reading at index {reading_index}"
715 ),
716 Self::DuplicateReading { surface, reading } => write!(
717 f,
718 "zh artifact payload surface {surface:?} has duplicate reading {reading:?}"
719 ),
720 Self::ReadingNotNormalized {
721 surface,
722 reading,
723 normalized,
724 } => write!(
725 f,
726 "zh artifact payload surface {surface:?} has non-normalized reading {reading:?}; expected {normalized:?}"
727 ),
728 }
729 }
730}
731
732impl Error for ZhArtifactPayloadError {
733 fn source(&self) -> Option<&(dyn Error + 'static)> {
734 match self {
735 Self::Io(err) => Some(err),
736 Self::Yaml(err) => Some(err),
737 Self::InvalidIndexedUtf8 { source, .. } => Some(source),
738 _ => None,
739 }
740 }
741}
742
743impl From<std::io::Error> for CedictError {
744 fn from(err: std::io::Error) -> Self {
745 Self::Io(err)
746 }
747}
748
749impl From<std::io::Error> for ZhArtifactPayloadError {
750 fn from(err: std::io::Error) -> Self {
751 Self::Io(err)
752 }
753}
754
755impl From<serde_yaml::Error> for ZhArtifactPayloadError {
756 fn from(err: serde_yaml::Error) -> Self {
757 Self::Yaml(err)
758 }
759}
760
761impl fmt::Display for CnLatticeError {
762 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
763 match self {
764 Self::EmptyReadings => write!(f, "at least one pinyin reading is required"),
765 Self::UnsupportedDirectInput { surface } => {
766 write!(f, "unsupported direct pinyin input {surface:?}")
767 }
768 Self::ArtifactPayload(err) => write!(f, "{err}"),
769 Self::Distance(err) => write!(f, "{err}"),
770 }
771 }
772}
773
774impl Error for CnLatticeError {
775 fn source(&self) -> Option<&(dyn Error + 'static)> {
776 match self {
777 Self::Distance(err) => Some(err),
778 Self::EmptyReadings
779 | Self::UnsupportedDirectInput { .. }
780 | Self::ArtifactPayload(_) => None,
781 }
782 }
783}
784
785impl From<DistanceError> for CnLatticeError {
786 fn from(value: DistanceError) -> Self {
787 Self::Distance(value)
788 }
789}
790
791impl CedictReadingIndex {
792 pub fn from_cedict_path(path: impl AsRef<Path>) -> Result<Self, CedictError> {
794 Self::from_cedict_path_with_options(path, CedictIndexOptions::default())
795 }
796
797 pub fn from_cedict_path_with_options(
799 path: impl AsRef<Path>,
800 options: CedictIndexOptions,
801 ) -> Result<Self, CedictError> {
802 let file = File::open(path)?;
803 Self::from_cedict_reader_with_options(file, options)
804 }
805
806 pub fn from_cedict_reader(reader: impl Read) -> Result<Self, CedictError> {
808 Self::from_cedict_reader_with_options(reader, CedictIndexOptions::default())
809 }
810
811 pub fn from_cedict_reader_with_options(
813 reader: impl Read,
814 options: CedictIndexOptions,
815 ) -> Result<Self, CedictError> {
816 let mut by_surface = HashMap::<String, BTreeSet<String>>::new();
817 let reader = BufReader::new(reader);
818 for (line_index, line) in reader.lines().enumerate() {
819 let line_number = line_index + 1;
820 let line = line?;
821 let line = line.trim_end_matches('\r');
822 if line.is_empty() || line.starts_with('#') {
823 continue;
824 }
825
826 let entry = parse_cedict_entry(line, line_number)?;
827 let reading = normalize_pinyin(entry.pinyin, options.pinyin_view);
828 if reading.is_empty() {
829 continue;
830 }
831
832 by_surface
833 .entry(entry.traditional.to_string())
834 .or_default()
835 .insert(reading.clone());
836 by_surface
837 .entry(entry.simplified.to_string())
838 .or_default()
839 .insert(reading);
840 }
841
842 let readings_by_surface = by_surface
843 .into_iter()
844 .map(|(surface, readings)| {
845 let mut readings = readings.into_iter().collect::<Vec<_>>();
846 if let Some(max_readings) = options.max_readings_per_surface {
847 readings.truncate(max_readings);
848 }
849 (surface, readings)
850 })
851 .filter(|(_, readings)| !readings.is_empty())
852 .collect();
853
854 Ok(Self {
855 storage: ZhReadingStorage::Eager(readings_by_surface),
856 pinyin_view: options.pinyin_view,
857 })
858 }
859
860 pub fn from_artifact_payload_path(
862 path: impl AsRef<Path>,
863 ) -> Result<Self, ZhArtifactPayloadError> {
864 let path = path.as_ref();
865 check_payload_file_size(path)?;
866 let file = File::open(path)?;
867 Self::from_artifact_payload_reader(file)
868 }
869
870 pub fn from_artifact_payload_reader(reader: impl Read) -> Result<Self, ZhArtifactPayloadError> {
872 let payload = serde_yaml::from_reader(reader)?;
873 Self::from_artifact_payload(payload)
874 }
875
876 pub fn from_artifact_payload(
878 payload: ZhReadingIndexPayload,
879 ) -> Result<Self, ZhArtifactPayloadError> {
880 validate_artifact_payload_header(&payload)?;
881 let pinyin_view = PinyinView::try_from(payload.pinyin_view.as_str()).map_err(|()| {
882 ZhArtifactPayloadError::UnsupportedPinyinView {
883 pinyin_view: payload.pinyin_view.clone(),
884 }
885 })?;
886 check_limit("entry_count", payload.entries.len(), MAX_ARTIFACT_ENTRIES)?;
887
888 let mut readings_by_surface = HashMap::new();
889 for (entry_index, entry) in payload.entries.into_iter().enumerate() {
890 check_limit(
891 "surface_bytes",
892 entry.surface.len(),
893 MAX_ARTIFACT_STRING_BYTES,
894 )?;
895 check_limit(
896 "reading_count",
897 entry.readings.len(),
898 MAX_ARTIFACT_READINGS_PER_ENTRY,
899 )?;
900 if entry.surface.is_empty() {
901 return Err(ZhArtifactPayloadError::EmptySurface { entry_index });
902 }
903 if entry.readings.is_empty() {
904 return Err(ZhArtifactPayloadError::EmptyReadings {
905 surface: entry.surface,
906 });
907 }
908
909 let mut seen_readings = BTreeSet::new();
910 for (reading_index, reading) in entry.readings.iter().enumerate() {
911 check_limit("reading_bytes", reading.len(), MAX_ARTIFACT_STRING_BYTES)?;
912 if reading.is_empty() {
913 return Err(ZhArtifactPayloadError::EmptyReading {
914 surface: entry.surface,
915 reading_index,
916 });
917 }
918 let normalized = normalize_artifact_reading(reading, pinyin_view);
919 if normalized != *reading {
920 return Err(ZhArtifactPayloadError::ReadingNotNormalized {
921 surface: entry.surface,
922 reading: reading.clone(),
923 normalized,
924 });
925 }
926 if !seen_readings.insert(reading) {
927 return Err(ZhArtifactPayloadError::DuplicateReading {
928 surface: entry.surface,
929 reading: reading.clone(),
930 });
931 }
932 }
933
934 if readings_by_surface
935 .insert(entry.surface.clone(), entry.readings)
936 .is_some()
937 {
938 return Err(ZhArtifactPayloadError::DuplicateSurface {
939 surface: entry.surface,
940 });
941 }
942 }
943
944 Ok(Self {
945 storage: ZhReadingStorage::Eager(readings_by_surface),
946 pinyin_view,
947 })
948 }
949
950 pub fn from_indexed_artifact_payload_path(
956 path: impl AsRef<Path>,
957 ) -> Result<Self, ZhArtifactPayloadError> {
958 let path = path.as_ref();
959 let bytes = read_payload_file_bounded(path)?;
960 Self::from_indexed_owned_bytes(bytes)
961 }
962
963 pub fn from_indexed_artifact_payload_bytes(
974 bytes: &[u8],
975 ) -> Result<Self, ZhArtifactPayloadError> {
976 if bytes.len() as u64 > MAX_ARTIFACT_PAYLOAD_BYTES {
977 return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
978 field: "payload_bytes",
979 len: bytes.len() as u64,
980 max: MAX_ARTIFACT_PAYLOAD_BYTES,
981 });
982 }
983 Self::from_indexed_owned_bytes(bytes.to_vec())
984 }
985
986 fn from_indexed_owned_bytes(bytes: Vec<u8>) -> Result<Self, ZhArtifactPayloadError> {
987 let bytes: Arc<[u8]> = Arc::from(bytes);
988 if bytes.len() as u64 > MAX_ARTIFACT_PAYLOAD_BYTES {
989 return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
990 field: "payload_bytes",
991 len: bytes.len() as u64,
992 max: MAX_ARTIFACT_PAYLOAD_BYTES,
993 });
994 }
995 let header = read_indexed_artifact_payload_header_bytes(bytes.as_ref())?;
996 let fst_start = INDEXED_ARTIFACT_HEADER_LEN;
997 let fst_end = fst_start.checked_add(header.fst_len).ok_or(
998 ZhArtifactPayloadError::TruncatedIndexed {
999 field: "fst_section",
1000 },
1001 )?;
1002 let readings_end = fst_end.checked_add(header.readings_len).ok_or(
1003 ZhArtifactPayloadError::TruncatedIndexed {
1004 field: "readings_section",
1005 },
1006 )?;
1007 if bytes.len() < readings_end {
1008 return Err(ZhArtifactPayloadError::TruncatedIndexed {
1009 field: "indexed_payload",
1010 });
1011 }
1012
1013 let map = Map::new(bytes[fst_start..fst_end].to_vec()).map_err(|err| {
1014 ZhArtifactPayloadError::InvalidIndexedFst {
1015 message: err.to_string(),
1016 }
1017 })?;
1018 let fst_entries = map.len();
1019 if fst_entries != header.entries {
1020 return Err(ZhArtifactPayloadError::IndexedEntryCountMismatch {
1021 header_entries: header.entries,
1022 fst_entries,
1023 });
1024 }
1025 let indexed = IndexedZhPayload {
1026 bytes,
1027 map,
1028 readings_start: fst_end,
1029 entries: header.entries,
1030 };
1031 indexed.validate(header.pinyin_view)?;
1032 Ok(Self {
1033 storage: ZhReadingStorage::Indexed(indexed),
1034 pinyin_view: header.pinyin_view,
1035 })
1036 }
1037
1038 pub fn pinyin_view(&self) -> PinyinView {
1040 self.pinyin_view
1041 }
1042
1043 pub fn readings(&self, surface: &str) -> Option<Cow<'_, [String]>> {
1049 self.try_readings(surface).ok().flatten()
1050 }
1051
1052 pub fn try_readings(
1055 &self,
1056 surface: &str,
1057 ) -> Result<Option<Cow<'_, [String]>>, ZhArtifactPayloadError> {
1058 match &self.storage {
1059 ZhReadingStorage::Eager(readings_by_surface) => Ok(readings_by_surface
1060 .get(surface)
1061 .map(|readings| Cow::Borrowed(readings.as_slice()))),
1062 ZhReadingStorage::Indexed(indexed) => indexed
1063 .readings(surface)
1064 .map(|readings| readings.map(Cow::Owned)),
1065 }
1066 }
1067
1068 pub fn len(&self) -> usize {
1070 match &self.storage {
1071 ZhReadingStorage::Eager(readings_by_surface) => readings_by_surface.len(),
1072 ZhReadingStorage::Indexed(indexed) => indexed.entries,
1073 }
1074 }
1075
1076 pub fn is_empty(&self) -> bool {
1078 self.len() == 0
1079 }
1080
1081 pub fn artifact_metadata(&self, options: ZhArtifactMetadataOptions) -> ZhArtifactMetadata {
1087 ZhArtifactMetadata {
1088 schema_version: 1,
1089 artifact_type: "moine.zh.reading-index".to_string(),
1090 artifact_name: options.artifact_name,
1091 generator: options.generator,
1092 payload: ZhArtifactPayload {
1093 path: options.payload_file_name,
1094 format: options.payload_format,
1095 file_digest_algorithm: None,
1096 file_digest: None,
1097 checksum_algorithm: ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM.to_string(),
1098 checksum: self.artifact_payload_checksum(),
1099 },
1100 source: ZhArtifactSource {
1101 name: options.source_name,
1102 version: options.source_version,
1103 cedict: options.source_cedict,
1104 },
1105 build: ZhArtifactBuild {
1106 pinyin_view: options.index_options.pinyin_view.as_str().to_string(),
1107 max_readings_per_surface: options.index_options.max_readings_per_surface,
1108 entries: self.len(),
1109 },
1110 query_defaults: ZhArtifactQueryDefaults {
1111 max_span_chars: options.query_defaults.max_span_chars,
1112 max_paths: options.query_defaults.max_paths,
1113 longest_match_only: options.query_defaults.longest_match_only,
1114 max_readings_per_segment: options.query_defaults.max_readings_per_segment,
1115 },
1116 license: options.license,
1117 }
1118 }
1119
1120 pub fn artifact_payload(&self) -> ZhReadingIndexPayload {
1125 let entries = match &self.storage {
1126 ZhReadingStorage::Eager(readings_by_surface) => {
1127 let mut entries = readings_by_surface
1128 .iter()
1129 .map(|(surface, readings)| ZhReadingIndexPayloadEntry {
1130 surface: surface.clone(),
1131 readings: readings.clone(),
1132 })
1133 .collect::<Vec<_>>();
1134 entries.sort_by(|left, right| left.surface.cmp(&right.surface));
1135 entries
1136 }
1137 ZhReadingStorage::Indexed(indexed) => indexed
1138 .entries()
1139 .expect("validated indexed artifact should decode"),
1140 };
1141
1142 ZhReadingIndexPayload {
1143 schema_version: ARTIFACT_PAYLOAD_SCHEMA_VERSION,
1144 payload_type: ARTIFACT_PAYLOAD_TYPE.to_string(),
1145 pinyin_view: self.pinyin_view.as_str().to_string(),
1146 entries,
1147 }
1148 }
1149
1150 pub fn artifact_payload_checksum(&self) -> String {
1152 self.artifact_payload_checksum_for_algorithm(ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM)
1153 .expect("default artifact checksum algorithm should be supported")
1154 }
1155
1156 pub fn artifact_payload_checksum_for_algorithm(&self, algorithm: &str) -> Option<String> {
1160 let payload = self.artifact_payload();
1161 let bytes = canonical_payload_bytes(&payload);
1162 match algorithm {
1163 ARTIFACT_PAYLOAD_CHECKSUM_ALGORITHM => Some(sha256_hex(&bytes)),
1164 _ => None,
1165 }
1166 }
1167
1168 pub fn write_indexed_artifact_payload(
1174 &self,
1175 mut writer: impl Write,
1176 ) -> Result<(), ZhArtifactPayloadError> {
1177 let payload = self.artifact_payload();
1178 let mut fst_bytes = Vec::new();
1179 let mut readings_bytes = Vec::new();
1180 {
1181 let mut builder = MapBuilder::new(&mut fst_bytes).map_err(|err| {
1182 ZhArtifactPayloadError::InvalidIndexedFst {
1183 message: err.to_string(),
1184 }
1185 })?;
1186 for entry in &payload.entries {
1187 let offset = readings_bytes.len() as u64;
1188 builder.insert(&entry.surface, offset).map_err(|err| {
1189 ZhArtifactPayloadError::InvalidIndexedFst {
1190 message: err.to_string(),
1191 }
1192 })?;
1193 write_indexed_reading_block(&mut readings_bytes, &entry.readings)?;
1194 }
1195 builder
1196 .finish()
1197 .map_err(|err| ZhArtifactPayloadError::InvalidIndexedFst {
1198 message: err.to_string(),
1199 })?;
1200 }
1201
1202 writer.write_all(INDEXED_ARTIFACT_MAGIC)?;
1203 writer.write_all(&INDEXED_ARTIFACT_VERSION.to_le_bytes())?;
1204 writer.write_all(&pinyin_view_header_value(self.pinyin_view).to_le_bytes())?;
1205 writer.write_all(&(payload.entries.len() as u64).to_le_bytes())?;
1206 writer.write_all(&(fst_bytes.len() as u64).to_le_bytes())?;
1207 writer.write_all(&(readings_bytes.len() as u64).to_le_bytes())?;
1208 writer.write_all(&fst_bytes)?;
1209 writer.write_all(&readings_bytes)?;
1210 Ok(())
1211 }
1212
1213 pub fn reading_sequences(&self, text: &str, options: PinyinReadingOptions) -> Vec<String> {
1219 self.reading_paths(text, options)
1220 .into_iter()
1221 .map(|path| path.joined_reading)
1222 .collect()
1223 }
1224
1225 pub fn reading_paths(
1232 &self,
1233 text: &str,
1234 options: PinyinReadingOptions,
1235 ) -> Vec<PinyinReadingPath> {
1236 self.reading_paths_with_stats(text, options).paths
1237 }
1238
1239 pub fn reading_paths_with_stats(
1246 &self,
1247 text: &str,
1248 options: PinyinReadingOptions,
1249 ) -> PinyinReadingExpansion {
1250 self.try_reading_paths_with_stats(text, options)
1251 .unwrap_or_default()
1252 }
1253
1254 pub fn try_reading_paths_with_stats(
1257 &self,
1258 text: &str,
1259 options: PinyinReadingOptions,
1260 ) -> Result<PinyinReadingExpansion, ZhArtifactPayloadError> {
1261 self.reading_paths_with_stats_inner(text, options, false)
1262 }
1263
1264 pub fn hybrid_reading_paths(
1270 &self,
1271 text: &str,
1272 options: PinyinReadingOptions,
1273 ) -> Vec<PinyinReadingPath> {
1274 self.hybrid_reading_paths_with_stats(text, options).paths
1275 }
1276
1277 pub fn hybrid_reading_paths_with_stats(
1284 &self,
1285 text: &str,
1286 options: PinyinReadingOptions,
1287 ) -> PinyinReadingExpansion {
1288 self.try_hybrid_reading_paths_with_stats(text, options)
1289 .unwrap_or_default()
1290 }
1291
1292 pub fn try_hybrid_reading_paths_with_stats(
1295 &self,
1296 text: &str,
1297 options: PinyinReadingOptions,
1298 ) -> Result<PinyinReadingExpansion, ZhArtifactPayloadError> {
1299 self.reading_paths_with_stats_inner(text, options, true)
1300 }
1301
1302 pub fn pinyin_lattice(
1308 &self,
1309 text: &str,
1310 options: PinyinReadingOptions,
1311 ) -> Result<Option<Lattice>, CnLatticeError> {
1312 let paths = self
1313 .try_reading_paths_with_stats(text, options)
1314 .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))?
1315 .paths;
1316 if paths.is_empty() {
1317 return Ok(None);
1318 }
1319 pinyin_lattice_from_reading_paths(&paths).map(Some)
1320 }
1321
1322 pub fn hybrid_pinyin_lattice(
1328 &self,
1329 text: &str,
1330 options: PinyinReadingOptions,
1331 ) -> Result<Option<Lattice>, CnLatticeError> {
1332 let paths = self
1333 .try_hybrid_reading_paths_with_stats(text, options)
1334 .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))?
1335 .paths;
1336 if paths.is_empty() {
1337 return Ok(None);
1338 }
1339 pinyin_lattice_from_reading_paths(&paths).map(Some)
1340 }
1341
1342 fn reading_paths_with_stats_inner(
1343 &self,
1344 text: &str,
1345 options: PinyinReadingOptions,
1346 allow_direct_fallback: bool,
1347 ) -> Result<PinyinReadingExpansion, ZhArtifactPayloadError> {
1348 let options = options.validate()?;
1349 if text.is_empty() || options.max_span_chars == 0 || options.max_paths == 0 {
1350 return Ok(PinyinReadingExpansion::default());
1351 }
1352
1353 let mut stats = PinyinReadingStats::default();
1354 let boundaries = char_boundaries(text);
1355 let char_len = boundaries.len() - 1;
1356 let mut suffix_paths = vec![Vec::<PinyinReadingPath>::new(); char_len + 1];
1357 suffix_paths[char_len].push(PinyinReadingPath {
1358 segments: Vec::new(),
1359 joined_reading: String::new(),
1360 });
1361
1362 for start in (0..char_len).rev() {
1363 let mut paths_by_reading = BTreeMap::new();
1364 let end_limit = char_len.min(start.saturating_add(options.max_span_chars));
1365 let mut matching_ends = Vec::new();
1366
1367 for end in start + 1..=end_limit {
1368 let surface = &text[boundaries[start]..boundaries[end]];
1369 if !suffix_paths[end].is_empty() {
1370 if let Some(surface_readings) = self.try_readings(surface)? {
1371 matching_ends.push((end, surface_readings));
1372 }
1373 }
1374 }
1375 stats.matched_spans += matching_ends.len();
1376
1377 if options.longest_match_only {
1378 let pruned_spans = matching_ends.len().saturating_sub(1);
1379 if let Some(longest_match) = matching_ends.pop() {
1380 stats.longest_match_pruned_spans += pruned_spans;
1381 matching_ends.clear();
1382 matching_ends.push(longest_match);
1383 }
1384 }
1385
1386 for (end, surface_readings) in matching_ends {
1387 let surface = &text[boundaries[start]..boundaries[end]];
1388
1389 stats.raw_segment_readings += surface_readings.len();
1390 let raw_surface_reading_count = surface_readings.len();
1391 let surface_readings = limited_surface_readings(surface_readings.as_ref(), options);
1392 stats.used_segment_readings += surface_readings.len();
1393 stats.pruned_segment_readings += raw_surface_reading_count - surface_readings.len();
1394
1395 for surface_reading in surface_readings {
1396 for suffix in &suffix_paths[end] {
1397 stats.candidate_combinations += 1;
1398 let mut reading = String::with_capacity(
1399 surface_reading.len() + suffix.joined_reading.len(),
1400 );
1401 reading.push_str(surface_reading);
1402 reading.push_str(&suffix.joined_reading);
1403
1404 let mut segments = Vec::with_capacity(suffix.segments.len() + 1);
1405 segments.push(PinyinReadingSegment {
1406 surface: surface.to_string(),
1407 reading: surface_reading.to_string(),
1408 source: PinyinReadingSegmentSource::Dictionary,
1409 });
1410 segments.extend(suffix.segments.iter().cloned());
1411
1412 match paths_by_reading.entry(reading.clone()) {
1413 Entry::Vacant(entry) => {
1414 entry.insert(PinyinReadingPath {
1415 segments,
1416 joined_reading: reading,
1417 });
1418 stats.unique_paths += 1;
1419 }
1420 Entry::Occupied(_) => {
1421 stats.duplicate_joined_readings += 1;
1422 }
1423 }
1424
1425 if paths_by_reading.len() >= options.max_paths {
1426 stats.max_paths_hit_count += 1;
1427 break;
1428 }
1429 }
1430
1431 if paths_by_reading.len() >= options.max_paths {
1432 break;
1433 }
1434 }
1435
1436 if paths_by_reading.len() >= options.max_paths {
1437 break;
1438 }
1439 }
1440
1441 if allow_direct_fallback && paths_by_reading.len() < options.max_paths {
1442 if let Some(end) = direct_fallback_end(text, &boundaries, start, char_len) {
1443 if !suffix_paths[end].is_empty() {
1444 stats.direct_fallback_spans += 1;
1445 let surface = &text[boundaries[start]..boundaries[end]];
1446 let reading = normalize_direct_pinyin_input(surface);
1447 for suffix in &suffix_paths[end] {
1448 stats.candidate_combinations += 1;
1449 let mut joined =
1450 String::with_capacity(reading.len() + suffix.joined_reading.len());
1451 joined.push_str(&reading);
1452 joined.push_str(&suffix.joined_reading);
1453
1454 let mut segments = Vec::with_capacity(suffix.segments.len() + 1);
1455 segments.push(PinyinReadingSegment {
1456 surface: surface.to_string(),
1457 reading: reading.clone(),
1458 source: PinyinReadingSegmentSource::Direct,
1459 });
1460 segments.extend(suffix.segments.iter().cloned());
1461
1462 match paths_by_reading.entry(joined.clone()) {
1463 Entry::Vacant(entry) => {
1464 entry.insert(PinyinReadingPath {
1465 segments,
1466 joined_reading: joined,
1467 });
1468 stats.unique_paths += 1;
1469 }
1470 Entry::Occupied(_) => {
1471 stats.duplicate_joined_readings += 1;
1472 }
1473 }
1474
1475 if paths_by_reading.len() >= options.max_paths {
1476 stats.max_paths_hit_count += 1;
1477 break;
1478 }
1479 }
1480 }
1481 }
1482 }
1483
1484 suffix_paths[start] = paths_by_reading.into_values().collect();
1485 }
1486
1487 Ok(PinyinReadingExpansion {
1488 paths: suffix_paths.remove(0),
1489 stats,
1490 })
1491 }
1492}
1493
1494pub fn compare_with_cedict_index(
1496 left: &str,
1497 right: &str,
1498 index: &CedictReadingIndex,
1499 options: PinyinReadingOptions,
1500) -> Result<ChineseDistance, CnLatticeError> {
1501 compare_with_zh_index(left, right, index, options)
1502}
1503
1504pub fn compare_with_zh_index(
1506 left: &str,
1507 right: &str,
1508 index: &ZhReadingIndex,
1509 options: PinyinReadingOptions,
1510) -> Result<ChineseDistance, CnLatticeError> {
1511 let options = validate_pinyin_options(options)?;
1512 let left_lattice = cedict_or_direct_lattice(left, index, options)?;
1513 let right_lattice = cedict_or_direct_lattice(right, index, options)?;
1514 compare_lattices(left, right, &left_lattice, &right_lattice)
1515}
1516
1517pub fn normalized_similarity_with_zh_index(
1519 left: &str,
1520 right: &str,
1521 index: &ZhReadingIndex,
1522 options: PinyinReadingOptions,
1523) -> Result<f64, CnLatticeError> {
1524 let options = validate_pinyin_options(options)?;
1525 let left_paths = zh_or_direct_pinyin_paths(left, index, options)?;
1526 let right_paths = zh_or_direct_pinyin_paths(right, index, options)?;
1527 Ok(max_normalized_similarity(&left_paths, &right_paths))
1528}
1529
1530pub fn cedict_or_direct_lattice(
1532 input: &str,
1533 index: &CedictReadingIndex,
1534 options: PinyinReadingOptions,
1535) -> Result<Lattice, CnLatticeError> {
1536 zh_or_direct_lattice(input, index, options)
1537}
1538
1539pub fn zh_or_direct_lattice(
1541 input: &str,
1542 index: &ZhReadingIndex,
1543 options: PinyinReadingOptions,
1544) -> Result<Lattice, CnLatticeError> {
1545 let options = validate_pinyin_options(options)?;
1546 if let Some(lattice) = direct_pinyin_lattice(input) {
1547 return Ok(lattice);
1548 }
1549
1550 if let Some(lattice) = index.pinyin_lattice(input, options)? {
1551 return Ok(lattice);
1552 }
1553
1554 if let Some(lattice) = index.hybrid_pinyin_lattice(input, options)? {
1555 return Ok(lattice);
1556 }
1557
1558 direct_pinyin_lattice(input).ok_or_else(|| CnLatticeError::UnsupportedDirectInput {
1559 surface: input.to_string(),
1560 })
1561}
1562
1563pub fn zh_or_direct_pinyin_paths(
1565 input: &str,
1566 index: &ZhReadingIndex,
1567 options: PinyinReadingOptions,
1568) -> Result<Vec<String>, CnLatticeError> {
1569 let options = validate_pinyin_options(options)?;
1570 if can_build_direct_pinyin_input(input) {
1571 return Ok(vec![normalize_direct_pinyin_input(input)]);
1572 }
1573
1574 let paths = index
1575 .try_reading_paths_with_stats(input, options)
1576 .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))?
1577 .paths;
1578 if !paths.is_empty() {
1579 return Ok(paths.into_iter().map(|path| path.joined_reading).collect());
1580 }
1581
1582 let paths = index
1583 .try_hybrid_reading_paths_with_stats(input, options)
1584 .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))?
1585 .paths;
1586 if !paths.is_empty() {
1587 return Ok(paths.into_iter().map(|path| path.joined_reading).collect());
1588 }
1589
1590 Err(CnLatticeError::UnsupportedDirectInput {
1591 surface: input.to_string(),
1592 })
1593}
1594
1595fn validate_pinyin_options(
1596 options: PinyinReadingOptions,
1597) -> Result<PinyinReadingOptions, CnLatticeError> {
1598 options
1599 .validate()
1600 .map_err(|err| CnLatticeError::ArtifactPayload(err.to_string()))
1601}
1602
1603fn max_normalized_similarity(left_paths: &[String], right_paths: &[String]) -> f64 {
1604 let left_paths = char_paths(left_paths);
1605 let right_paths = char_paths(right_paths);
1606 let mut workspace = StringDistanceWorkspace::new();
1607 max_normalized_similarity_chars(&left_paths, &right_paths, &mut workspace)
1608}
1609
1610fn char_paths(paths: &[String]) -> Vec<Vec<char>> {
1611 paths.iter().map(|path| path.chars().collect()).collect()
1612}
1613
1614fn max_normalized_similarity_chars(
1615 left_paths: &[Vec<char>],
1616 right_paths: &[Vec<char>],
1617 workspace: &mut StringDistanceWorkspace,
1618) -> f64 {
1619 let mut best = 0.0;
1620 for left in left_paths {
1621 for right in right_paths {
1622 let distance = workspace.levenshtein(left, right);
1623 let similarity = normalized_similarity_from_distance(distance, left.len(), right.len());
1624 best = f64::max(best, similarity);
1625 if best == 1.0 {
1626 return best;
1627 }
1628 }
1629 }
1630 best
1631}
1632
1633fn compare_lattices(
1634 left: &str,
1635 right: &str,
1636 left_lattice: &Lattice,
1637 right_lattice: &Lattice,
1638) -> Result<ChineseDistance, CnLatticeError> {
1639 let lattice = try_distance(left_lattice, right_lattice)?;
1640 let lattice_damerau = try_damerau_distance(left_lattice, right_lattice)?;
1641 let surface_levenshtein = levenshtein_str(left, right);
1642 let surface_damerau = try_damerau_levenshtein_str(left, right)?;
1643
1644 Ok(ChineseDistance {
1645 surface_levenshtein,
1646 surface_damerau,
1647 lattice,
1648 lattice_damerau,
1649 combined: surface_damerau.min(lattice),
1650 })
1651}
1652
1653fn char_boundaries(text: &str) -> Vec<usize> {
1654 text.char_indices()
1655 .map(|(index, _)| index)
1656 .chain(std::iter::once(text.len()))
1657 .collect()
1658}
1659
1660fn limited_surface_readings(readings: &[String], options: PinyinReadingOptions) -> &[String] {
1661 if let Some(max_readings) = options.max_readings_per_segment {
1662 &readings[..readings.len().min(max_readings)]
1663 } else {
1664 readings
1665 }
1666}
1667
1668fn direct_fallback_end(
1669 text: &str,
1670 boundaries: &[usize],
1671 start: usize,
1672 char_len: usize,
1673) -> Option<usize> {
1674 let mut end = start;
1675 while end < char_len {
1676 let surface = &text[boundaries[start]..boundaries[end + 1]];
1677 if !can_build_direct_pinyin_input(surface) {
1678 break;
1679 }
1680 end += 1;
1681 }
1682
1683 (end > start).then_some(end)
1684}
1685
1686fn pinyin_view_header_value(view: PinyinView) -> u32 {
1687 match view {
1688 PinyinView::NoTone => 0,
1689 PinyinView::Tone3 => 1,
1690 }
1691}
1692
1693fn pinyin_view_from_header_value(value: u32) -> Result<PinyinView, ZhArtifactPayloadError> {
1694 match value {
1695 0 => Ok(PinyinView::NoTone),
1696 1 => Ok(PinyinView::Tone3),
1697 _ => Err(ZhArtifactPayloadError::UnsupportedIndexedPinyinView { value }),
1698 }
1699}
1700
1701fn write_binary_string(
1702 writer: &mut impl Write,
1703 field: &'static str,
1704 value: &str,
1705) -> Result<(), ZhArtifactPayloadError> {
1706 write_u32_len(writer, field, value.len())?;
1707 writer.write_all(value.as_bytes())?;
1708 Ok(())
1709}
1710
1711fn write_u32_len(
1712 writer: &mut impl Write,
1713 field: &'static str,
1714 len: usize,
1715) -> Result<(), ZhArtifactPayloadError> {
1716 let len = u32::try_from(len).map_err(|_| ZhArtifactPayloadError::IndexedSectionTooLarge {
1717 field,
1718 len: len as u64,
1719 })?;
1720 writer.write_all(&len.to_le_bytes())?;
1721 Ok(())
1722}
1723
1724fn read_indexed_artifact_payload_header_bytes(
1725 bytes: &[u8],
1726) -> Result<ZhIndexedArtifactPayloadHeader, ZhArtifactPayloadError> {
1727 if bytes.len() < INDEXED_ARTIFACT_HEADER_LEN {
1728 return Err(ZhArtifactPayloadError::TruncatedIndexed { field: "header" });
1729 }
1730 let mut magic = [0_u8; 8];
1731 magic.copy_from_slice(&bytes[..8]);
1732 if &magic != INDEXED_ARTIFACT_MAGIC {
1733 return Err(ZhArtifactPayloadError::InvalidIndexedMagic { magic });
1734 }
1735
1736 let version = read_u32_le_bytes(bytes, 8, "version")?;
1737 if version != INDEXED_ARTIFACT_VERSION {
1738 return Err(ZhArtifactPayloadError::UnsupportedIndexedVersion { version });
1739 }
1740 let pinyin_view = pinyin_view_from_header_value(read_u32_le_bytes(bytes, 12, "pinyin_view")?)?;
1741 let entry_count = read_u64_le_bytes(bytes, 16, "entry_count")?;
1742 let fst_len = read_u64_le_bytes(bytes, 24, "fst_len")?;
1743 let readings_len = read_u64_le_bytes(bytes, 32, "readings_len")?;
1744 let entries = checked_indexed_usize("entry_count", entry_count)?;
1745 check_limit("entry_count", entries, MAX_ARTIFACT_ENTRIES)?;
1746 Ok(ZhIndexedArtifactPayloadHeader {
1747 version,
1748 pinyin_view,
1749 entries,
1750 fst_len: checked_indexed_usize("fst_len", fst_len)?,
1751 readings_len: checked_indexed_usize("readings_len", readings_len)?,
1752 })
1753}
1754
1755fn read_u32_le_bytes(
1756 bytes: &[u8],
1757 offset: usize,
1758 field: &'static str,
1759) -> Result<u32, ZhArtifactPayloadError> {
1760 let end = offset
1761 .checked_add(4)
1762 .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field })?;
1763 let chunk = bytes
1764 .get(offset..end)
1765 .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field })?;
1766 Ok(u32::from_le_bytes(
1767 chunk.try_into().expect("slice length is 4"),
1768 ))
1769}
1770
1771fn read_u64_le_bytes(
1772 bytes: &[u8],
1773 offset: usize,
1774 field: &'static str,
1775) -> Result<u64, ZhArtifactPayloadError> {
1776 let end = offset
1777 .checked_add(8)
1778 .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field })?;
1779 let chunk = bytes
1780 .get(offset..end)
1781 .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field })?;
1782 Ok(u64::from_le_bytes(
1783 chunk.try_into().expect("slice length is 8"),
1784 ))
1785}
1786
1787fn checked_indexed_usize(field: &'static str, len: u64) -> Result<usize, ZhArtifactPayloadError> {
1788 usize::try_from(len).map_err(|_| ZhArtifactPayloadError::IndexedSectionTooLarge { field, len })
1789}
1790
1791fn check_payload_file_size(path: &Path) -> Result<(), ZhArtifactPayloadError> {
1792 let len = std::fs::metadata(path)?.len();
1793 if len > MAX_ARTIFACT_PAYLOAD_BYTES {
1794 return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
1795 field: "payload_bytes",
1796 len,
1797 max: MAX_ARTIFACT_PAYLOAD_BYTES,
1798 });
1799 }
1800 Ok(())
1801}
1802
1803fn read_payload_file_bounded(path: &Path) -> Result<Vec<u8>, ZhArtifactPayloadError> {
1804 let metadata = std::fs::metadata(path)?;
1805 if !metadata.file_type().is_file() {
1806 return Err(std::io::Error::new(
1807 std::io::ErrorKind::InvalidInput,
1808 "artifact payload path is not a regular file",
1809 )
1810 .into());
1811 }
1812 if metadata.len() > MAX_ARTIFACT_PAYLOAD_BYTES {
1813 return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
1814 field: "payload_bytes",
1815 len: metadata.len(),
1816 max: MAX_ARTIFACT_PAYLOAD_BYTES,
1817 });
1818 }
1819 let file = File::open(path)?;
1820 read_payload_reader_bounded(file, MAX_ARTIFACT_PAYLOAD_BYTES)
1821}
1822
1823fn read_payload_reader_bounded(
1824 reader: impl Read,
1825 max: u64,
1826) -> Result<Vec<u8>, ZhArtifactPayloadError> {
1827 let mut reader = reader.take(max.saturating_add(1));
1828 let mut bytes = Vec::new();
1829 let mut chunk = [0_u8; 8192];
1830 loop {
1831 let len = reader.read(&mut chunk)?;
1832 if len == 0 {
1833 return Ok(bytes);
1834 }
1835 let next_len = bytes.len() as u64 + len as u64;
1836 if next_len > max {
1837 return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
1838 field: "payload_bytes",
1839 len: next_len,
1840 max,
1841 });
1842 }
1843 bytes.extend_from_slice(&chunk[..len]);
1844 }
1845}
1846
1847fn check_limit(field: &'static str, len: usize, max: usize) -> Result<(), ZhArtifactPayloadError> {
1848 if len > max {
1849 return Err(ZhArtifactPayloadError::ArtifactLimitExceeded {
1850 field,
1851 len: len as u64,
1852 max: max as u64,
1853 });
1854 }
1855 Ok(())
1856}
1857
1858fn write_indexed_reading_block(
1859 writer: &mut Vec<u8>,
1860 readings: &[String],
1861) -> Result<(), ZhArtifactPayloadError> {
1862 write_u32_len(writer, "reading_count", readings.len())?;
1863 for reading in readings {
1864 write_binary_string(writer, "reading", reading)?;
1865 }
1866 Ok(())
1867}
1868
1869impl IndexedZhPayload {
1870 fn validate(&self, pinyin_view: PinyinView) -> Result<(), ZhArtifactPayloadError> {
1871 let mut stream = self.map.stream();
1872 while let Some((surface, offset)) = stream.next() {
1873 let surface = String::from_utf8(surface.to_vec()).map_err(|source| {
1874 ZhArtifactPayloadError::InvalidIndexedUtf8 {
1875 field: "surface",
1876 source,
1877 }
1878 })?;
1879 if surface.is_empty() {
1880 return Err(ZhArtifactPayloadError::EmptySurface { entry_index: 0 });
1881 }
1882 check_limit("surface_bytes", surface.len(), MAX_ARTIFACT_STRING_BYTES)?;
1883 let readings = self.readings_at(offset)?;
1884 if readings.is_empty() {
1885 return Err(ZhArtifactPayloadError::EmptyReadings { surface });
1886 }
1887 let mut seen = BTreeSet::new();
1888 for (reading_index, reading) in readings.iter().enumerate() {
1889 if reading.is_empty() {
1890 return Err(ZhArtifactPayloadError::EmptyReading {
1891 surface: surface.clone(),
1892 reading_index,
1893 });
1894 }
1895 let normalized = normalize_artifact_reading(reading, pinyin_view);
1896 if normalized != *reading {
1897 return Err(ZhArtifactPayloadError::ReadingNotNormalized {
1898 surface: surface.clone(),
1899 reading: reading.clone(),
1900 normalized,
1901 });
1902 }
1903 if !seen.insert(reading) {
1904 return Err(ZhArtifactPayloadError::DuplicateReading {
1905 surface: surface.clone(),
1906 reading: reading.clone(),
1907 });
1908 }
1909 }
1910 }
1911 Ok(())
1912 }
1913
1914 fn readings(&self, surface: &str) -> Result<Option<Vec<String>>, ZhArtifactPayloadError> {
1915 self.map
1916 .get(surface)
1917 .map(|offset| self.readings_at(offset))
1918 .transpose()
1919 }
1920
1921 fn entries(&self) -> Result<Vec<ZhReadingIndexPayloadEntry>, ZhArtifactPayloadError> {
1922 let mut entries = Vec::with_capacity(self.entries);
1923 let mut stream = self.map.stream();
1924 while let Some((surface, offset)) = stream.next() {
1925 let surface = String::from_utf8(surface.to_vec()).map_err(|source| {
1926 ZhArtifactPayloadError::InvalidIndexedUtf8 {
1927 field: "surface",
1928 source,
1929 }
1930 })?;
1931 let readings = self.readings_at(offset)?;
1932 entries.push(ZhReadingIndexPayloadEntry { surface, readings });
1933 }
1934 Ok(entries)
1935 }
1936
1937 fn readings_at(&self, offset: u64) -> Result<Vec<String>, ZhArtifactPayloadError> {
1938 read_indexed_readings_at_bytes(self.bytes.as_ref(), self.readings_start, offset)
1939 }
1940}
1941
1942fn read_indexed_readings_at_bytes(
1943 bytes: &[u8],
1944 readings_start: usize,
1945 offset: u64,
1946) -> Result<Vec<String>, ZhArtifactPayloadError> {
1947 let offset = usize::try_from(offset)
1948 .map_err(|_| ZhArtifactPayloadError::InvalidIndexedOffset { offset })?;
1949 let start =
1950 readings_start
1951 .checked_add(offset)
1952 .ok_or(ZhArtifactPayloadError::InvalidIndexedOffset {
1953 offset: offset as u64,
1954 })?;
1955 if start >= bytes.len() {
1956 return Err(ZhArtifactPayloadError::InvalidIndexedOffset {
1957 offset: offset as u64,
1958 });
1959 }
1960 let mut cursor = start;
1961 let reading_count = read_u32_le_bytes(bytes, cursor, "reading_count")? as usize;
1962 check_limit(
1963 "reading_count",
1964 reading_count,
1965 MAX_ARTIFACT_READINGS_PER_ENTRY,
1966 )?;
1967 cursor += 4;
1968 let mut readings = Vec::with_capacity(reading_count);
1969 for _ in 0..reading_count {
1970 let len = read_u32_le_bytes(bytes, cursor, "reading_len")? as usize;
1971 check_limit("reading_bytes", len, MAX_ARTIFACT_STRING_BYTES)?;
1972 cursor += 4;
1973 let end = cursor
1974 .checked_add(len)
1975 .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field: "reading" })?;
1976 let reading_bytes = bytes
1977 .get(cursor..end)
1978 .ok_or(ZhArtifactPayloadError::TruncatedIndexed { field: "reading" })?;
1979 let reading = String::from_utf8(reading_bytes.to_vec()).map_err(|source| {
1980 ZhArtifactPayloadError::InvalidIndexedUtf8 {
1981 field: "reading",
1982 source,
1983 }
1984 })?;
1985 readings.push(reading);
1986 cursor = end;
1987 }
1988 Ok(readings)
1989}
1990
1991pub fn artifact_file_digest_path(path: impl AsRef<Path>) -> Result<String, std::io::Error> {
1993 let file = File::open(path)?;
1994 artifact_file_digest_reader(file)
1995}
1996
1997pub fn artifact_file_digest_reader(mut reader: impl Read) -> Result<String, std::io::Error> {
1999 let mut hasher = Sha256::new();
2000 let mut buffer = [0_u8; 64 * 1024];
2001 loop {
2002 let read = reader.read(&mut buffer)?;
2003 if read == 0 {
2004 break;
2005 }
2006 hasher.update(&buffer[..read]);
2007 }
2008 Ok(sha256_digest_hex(hasher.finalize()))
2009}
2010
2011fn validate_artifact_payload_header(
2012 payload: &ZhReadingIndexPayload,
2013) -> Result<(), ZhArtifactPayloadError> {
2014 if payload.schema_version != ARTIFACT_PAYLOAD_SCHEMA_VERSION {
2015 return Err(ZhArtifactPayloadError::UnsupportedSchemaVersion {
2016 version: payload.schema_version,
2017 });
2018 }
2019 if payload.payload_type != ARTIFACT_PAYLOAD_TYPE {
2020 return Err(ZhArtifactPayloadError::UnsupportedPayloadType {
2021 payload_type: payload.payload_type.clone(),
2022 });
2023 }
2024 Ok(())
2025}
2026
2027fn canonical_payload_bytes(payload: &ZhReadingIndexPayload) -> Vec<u8> {
2028 let mut bytes = Vec::new();
2029 bytes.extend_from_slice(b"moine.zh.reading-index.surface-readings/v1\n");
2030 push_len_prefixed(&mut bytes, b"V", &payload.pinyin_view);
2031 for entry in &payload.entries {
2032 push_len_prefixed(&mut bytes, b"S", &entry.surface);
2033 bytes.extend_from_slice(format!("R{}\n", entry.readings.len()).as_bytes());
2034 for reading in &entry.readings {
2035 push_len_prefixed(&mut bytes, b"r", reading);
2036 }
2037 }
2038 bytes
2039}
2040
2041fn push_len_prefixed(bytes: &mut Vec<u8>, tag: &[u8], value: &str) {
2042 bytes.extend_from_slice(tag);
2043 bytes.extend_from_slice(value.len().to_string().as_bytes());
2044 bytes.push(b'\n');
2045 bytes.extend_from_slice(value.as_bytes());
2046 bytes.push(b'\n');
2047}
2048
2049fn sha256_hex(bytes: &[u8]) -> String {
2050 sha256_digest_hex(Sha256::digest(bytes))
2051}
2052
2053fn sha256_digest_hex(digest: impl IntoIterator<Item = u8>) -> String {
2054 let mut output = String::with_capacity(64);
2055 for byte in digest {
2056 write!(&mut output, "{byte:02x}").expect("writing to String should not fail");
2057 }
2058 output
2059}
2060
2061struct CedictEntry<'a> {
2062 traditional: &'a str,
2063 simplified: &'a str,
2064 pinyin: &'a str,
2065}
2066
2067fn parse_cedict_entry(line: &str, line_number: usize) -> Result<CedictEntry<'_>, CedictError> {
2068 let (traditional, rest) = take_token(line)
2069 .ok_or_else(|| invalid_entry(line_number, "missing traditional surface"))?;
2070 let (simplified, rest) = take_token(rest.trim_start())
2071 .ok_or_else(|| invalid_entry(line_number, "missing simplified surface"))?;
2072 let rest = rest.trim_start();
2073
2074 let (pinyin, rest) = if let Some(after_open) = rest.strip_prefix("[[") {
2075 let Some(end) = after_open.find("]]") else {
2076 return Err(invalid_entry(line_number, "missing closing ]] for pinyin"));
2077 };
2078 (&after_open[..end], &after_open[end + 2..])
2079 } else if let Some(after_open) = rest.strip_prefix('[') {
2080 let Some(end) = after_open.find(']') else {
2081 return Err(invalid_entry(line_number, "missing closing ] for pinyin"));
2082 };
2083 (&after_open[..end], &after_open[end + 1..])
2084 } else {
2085 return Err(invalid_entry(line_number, "missing pinyin bracket"));
2086 };
2087
2088 if pinyin.is_empty() {
2089 return Err(invalid_entry(line_number, "empty pinyin field"));
2090 }
2091 if !rest.trim_start().starts_with('/') {
2092 return Err(invalid_entry(line_number, "missing definition slash"));
2093 }
2094
2095 Ok(CedictEntry {
2096 traditional,
2097 simplified,
2098 pinyin,
2099 })
2100}
2101
2102fn invalid_entry(line: usize, message: impl Into<String>) -> CedictError {
2103 CedictError::InvalidEntry {
2104 line,
2105 message: message.into(),
2106 }
2107}
2108
2109fn take_token(input: &str) -> Option<(&str, &str)> {
2110 let input = input.trim_start();
2111 if input.is_empty() {
2112 return None;
2113 }
2114 for (index, ch) in input.char_indices() {
2115 if ch.is_whitespace() {
2116 return Some((&input[..index], &input[index..]));
2117 }
2118 }
2119 Some((input, ""))
2120}
2121
2122#[cfg(test)]
2123mod tests {
2124 use super::*;
2125
2126 fn indexed_payload_with_surface(
2127 surface: &str,
2128 readings: &[String],
2129 ) -> Result<Vec<u8>, ZhArtifactPayloadError> {
2130 let mut fst_bytes = Vec::new();
2131 let mut readings_bytes = Vec::new();
2132 {
2133 let mut builder = MapBuilder::new(&mut fst_bytes).map_err(|err| {
2134 ZhArtifactPayloadError::InvalidIndexedFst {
2135 message: err.to_string(),
2136 }
2137 })?;
2138 builder.insert(surface, 0).map_err(|err| {
2139 ZhArtifactPayloadError::InvalidIndexedFst {
2140 message: err.to_string(),
2141 }
2142 })?;
2143 builder
2144 .finish()
2145 .map_err(|err| ZhArtifactPayloadError::InvalidIndexedFst {
2146 message: err.to_string(),
2147 })?;
2148 }
2149 write_indexed_reading_block(&mut readings_bytes, readings)?;
2150
2151 let mut bytes = Vec::new();
2152 bytes.extend_from_slice(INDEXED_ARTIFACT_MAGIC);
2153 bytes.extend_from_slice(&INDEXED_ARTIFACT_VERSION.to_le_bytes());
2154 bytes.extend_from_slice(&pinyin_view_header_value(PinyinView::NoTone).to_le_bytes());
2155 bytes.extend_from_slice(&1_u64.to_le_bytes());
2156 bytes.extend_from_slice(&(fst_bytes.len() as u64).to_le_bytes());
2157 bytes.extend_from_slice(&(readings_bytes.len() as u64).to_le_bytes());
2158 bytes.extend_from_slice(&fst_bytes);
2159 bytes.extend_from_slice(&readings_bytes);
2160 Ok(bytes)
2161 }
2162
2163 #[test]
2164 fn bounded_payload_reader_rejects_data_past_limit() {
2165 let err = read_payload_reader_bounded(std::io::Cursor::new([0_u8; 4]), 3).unwrap_err();
2166
2167 assert!(matches!(
2168 err,
2169 ZhArtifactPayloadError::ArtifactLimitExceeded {
2170 field: "payload_bytes",
2171 len: 4,
2172 max: 3
2173 }
2174 ));
2175 }
2176
2177 #[test]
2178 fn normalizes_pinyin_views() {
2179 assert_eq!(
2180 normalize_pinyin("Wei1 shi4 ji4", PinyinView::NoTone),
2181 "weishiji"
2182 );
2183 assert_eq!(
2184 normalize_pinyin("Wei1 shi4 ji4", PinyinView::Tone3),
2185 "wei1shi4ji4"
2186 );
2187 assert_eq!(normalize_pinyin("nu:3 er2", PinyinView::NoTone), "nver");
2188 assert_eq!(normalize_pinyin("nu:3 er2", PinyinView::Tone3), "nv3er2");
2189 assert_eq!(normalize_pinyin("hua1 r5", PinyinView::NoTone), "huar");
2190 assert_eq!(normalize_pinyin("11 Qu1", PinyinView::NoTone), "11qu");
2191 assert_eq!(normalize_pinyin("Shuang1 11", PinyinView::NoTone), "shuang");
2192 assert_eq!(
2193 normalize_pinyin("D N A jian4 ding4", PinyinView::NoTone),
2194 "dnajianding"
2195 );
2196 }
2197
2198 #[test]
2199 fn builds_no_tone_index_from_cedict() {
2200 let cedict = "\
2201# CC-CEDICT
2202威士忌 威士忌 [Wei1 shi4 ji4] /whisky/
2203布納哈本 布纳哈本 [Bu4 na4 ha1 ben3] /Bunnahabhain/
2204女兒 女儿 [nu:3 er2] /daughter/
2205";
2206 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2207
2208 assert_eq!(index.pinyin_view(), PinyinView::NoTone);
2209 assert_eq!(
2210 index.readings("威士忌").as_deref(),
2211 Some(&["weishiji".to_string()][..])
2212 );
2213 assert_eq!(
2214 index.readings("布纳哈本").as_deref(),
2215 Some(&["bunahaben".to_string()][..])
2216 );
2217 assert_eq!(
2218 index.readings("女儿").as_deref(),
2219 Some(&["nver".to_string()][..])
2220 );
2221 }
2222
2223 #[test]
2224 fn builds_tone3_index_when_requested() {
2225 let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2226 let index = CedictReadingIndex::from_cedict_reader_with_options(
2227 cedict.as_bytes(),
2228 CedictIndexOptions {
2229 pinyin_view: PinyinView::Tone3,
2230 ..CedictIndexOptions::default()
2231 },
2232 )
2233 .unwrap();
2234
2235 assert_eq!(index.pinyin_view(), PinyinView::Tone3);
2236 assert_eq!(
2237 index.readings("威士忌").as_deref(),
2238 Some(&["wei1shi4ji4".to_string()][..])
2239 );
2240 }
2241
2242 #[test]
2243 fn deduplicates_after_normalization() {
2244 let cedict = "\
2245樂 乐 [Le4] /surname Le/
2246樂 乐 [le4] /happy/
2247樂 乐 [Yue4] /surname Yue/
2248";
2249 let no_tone = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2250 let tone3 = CedictReadingIndex::from_cedict_reader_with_options(
2251 cedict.as_bytes(),
2252 CedictIndexOptions {
2253 pinyin_view: PinyinView::Tone3,
2254 ..CedictIndexOptions::default()
2255 },
2256 )
2257 .unwrap();
2258
2259 assert_eq!(
2260 no_tone.readings("乐").as_deref(),
2261 Some(&["le".to_string(), "yue".to_string()][..])
2262 );
2263 assert_eq!(
2264 tone3.readings("乐").as_deref(),
2265 Some(&["le4".to_string(), "yue4".to_string()][..])
2266 );
2267 }
2268
2269 #[test]
2270 fn rejects_malformed_entries() {
2271 let err = CedictReadingIndex::from_cedict_reader(
2272 "威士忌 威士忌 Wei1 shi4 ji4 /whisky/\n".as_bytes(),
2273 )
2274 .unwrap_err();
2275
2276 assert!(matches!(err, CedictError::InvalidEntry { line: 1, .. }));
2277 }
2278
2279 #[test]
2280 fn computes_dictionary_paths_and_stats() {
2281 let cedict = "\
2282威 威 [wei1] /power/
2283士忌 士忌 [shi4 ji4] /whisky transcription tail/
2284威士忌 威士忌 [Wei1 shi4 ji4] /whisky/
2285";
2286 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2287 let expansion = index.reading_paths_with_stats(
2288 "威士忌",
2289 PinyinReadingOptions {
2290 longest_match_only: true,
2291 ..PinyinReadingOptions::default()
2292 },
2293 );
2294
2295 assert_eq!(expansion.paths.len(), 1);
2296 assert_eq!(expansion.paths[0].joined_reading, "weishiji");
2297 assert_eq!(
2298 expansion.paths[0].segments,
2299 vec![PinyinReadingSegment {
2300 surface: "威士忌".to_string(),
2301 reading: "weishiji".to_string(),
2302 source: PinyinReadingSegmentSource::Dictionary,
2303 }]
2304 );
2305 assert_eq!(expansion.stats.longest_match_pruned_spans, 1);
2306 }
2307
2308 #[test]
2309 fn hybrid_paths_allow_ascii_prefix_and_dictionary_tail() {
2310 let cedict = "忌 忌 [ji4] /whisky transcription character/\n";
2311 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2312 let paths = index.hybrid_reading_paths("weishi忌", PinyinReadingOptions::default());
2313
2314 assert_eq!(paths.len(), 1);
2315 assert_eq!(paths[0].joined_reading, "weishiji");
2316 }
2317
2318 #[test]
2319 fn hybrid_paths_allow_dictionary_text_with_chinese_punctuation() {
2320 let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2321 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2322 let paths = index.hybrid_reading_paths("威士忌。", PinyinReadingOptions::default());
2323
2324 assert_eq!(paths.len(), 1);
2325 assert_eq!(paths[0].joined_reading, "weishiji。");
2326 }
2327
2328 #[test]
2329 fn compare_matches_pinyin_input_to_chinese_surface() {
2330 let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2331 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2332 let distances = compare_with_cedict_index(
2333 "weishiji",
2334 "威士忌",
2335 &index,
2336 PinyinReadingOptions::default(),
2337 )
2338 .unwrap();
2339
2340 assert_eq!(distances.lattice, 0);
2341 assert_eq!(distances.lattice_damerau, 0);
2342 assert!(distances.surface_damerau > distances.lattice);
2343 }
2344
2345 #[test]
2346 fn compare_handles_chinese_punctuation_between_pinyin_and_dictionary_spans() {
2347 let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2348 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2349 let distances = compare_with_cedict_index(
2350 "weishiji,威士忌。",
2351 "威士忌,weishiji。",
2352 &index,
2353 PinyinReadingOptions::default(),
2354 )
2355 .unwrap();
2356
2357 assert_eq!(distances.lattice, 0);
2358 }
2359
2360 #[test]
2361 fn direct_pinyin_normalizes_unicode_whitespace() {
2362 let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2363 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2364 for whitespace in [' ', '\u{00a0}', '\u{2003}', '\u{2009}', '\u{3000}'] {
2365 let input = format!("weishi{whitespace}ji");
2366 let distances = compare_with_cedict_index(
2367 &input,
2368 "威士忌",
2369 &index,
2370 PinyinReadingOptions::default(),
2371 )
2372 .unwrap();
2373
2374 assert_eq!(distances.lattice, 0);
2375 }
2376 }
2377
2378 #[test]
2379 fn lattice_damerau_counts_adjacent_pinyin_transposition() {
2380 let distances = compare_with_cedict_index(
2381 "weishiji",
2382 "wieshiji",
2383 &CedictReadingIndex::default(),
2384 PinyinReadingOptions::default(),
2385 )
2386 .unwrap();
2387
2388 assert_eq!(distances.lattice, 2);
2389 assert_eq!(distances.lattice_damerau, 1);
2390 }
2391
2392 #[test]
2393 fn normalized_similarity_matches_pinyin_input_to_chinese_surface() {
2394 let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2395 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2396 let similarity = normalized_similarity_with_zh_index(
2397 "weishiji",
2398 "威士忌",
2399 &index,
2400 PinyinReadingOptions::default(),
2401 )
2402 .unwrap();
2403
2404 assert_eq!(similarity, 1.0);
2405 }
2406
2407 #[test]
2408 fn emits_and_loads_artifact_payload() {
2409 let cedict = "\
2410威士忌 威士忌 [Wei1 shi4 ji4] /whisky/
2411布納哈本 布纳哈本 [Bu4 na4 ha1 ben3] /Bunnahabhain/
2412";
2413 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2414 let payload = index.artifact_payload();
2415 let loaded = ZhReadingIndex::from_artifact_payload(payload).unwrap();
2416
2417 assert_eq!(loaded.pinyin_view(), PinyinView::NoTone);
2418 assert_eq!(
2419 loaded.readings("威士忌").as_deref(),
2420 Some(&["weishiji".to_string()][..])
2421 );
2422 assert_eq!(
2423 loaded.readings("布纳哈本").as_deref(),
2424 Some(&["bunahaben".to_string()][..])
2425 );
2426 assert_eq!(
2427 loaded.artifact_payload_checksum(),
2428 index.artifact_payload_checksum()
2429 );
2430 }
2431
2432 #[test]
2433 fn indexed_artifact_payload_round_trips_and_supports_lookup() {
2434 let cedict = "\
2435威士忌 威士忌 [Wei1 shi4 ji4] /whisky/
2436布納哈本 布纳哈本 [Bu4 na4 ha1 ben3] /Bunnahabhain/
2437";
2438 let index = CedictReadingIndex::from_cedict_reader(cedict.as_bytes()).unwrap();
2439 let mut bytes = Vec::new();
2440 index.write_indexed_artifact_payload(&mut bytes).unwrap();
2441 let path = std::env::temp_dir().join(format!(
2442 "moine-zh-indexed-test-{}-{}.moineidx",
2443 std::process::id(),
2444 std::time::SystemTime::now()
2445 .duration_since(std::time::UNIX_EPOCH)
2446 .unwrap()
2447 .as_nanos()
2448 ));
2449 std::fs::write(&path, &bytes).unwrap();
2450 let loaded = ZhReadingIndex::from_indexed_artifact_payload_path(&path).unwrap();
2451 std::fs::write(&path, b"truncated after load").unwrap();
2452 assert_eq!(
2453 loaded.readings("威士忌").as_deref(),
2454 Some(&["weishiji".to_string()][..])
2455 );
2456 std::fs::remove_file(&path).unwrap();
2457 let loaded_from_bytes = ZhReadingIndex::from_indexed_artifact_payload_bytes(&bytes)
2458 .expect("indexed payload bytes should load");
2459
2460 assert_eq!(loaded.pinyin_view(), PinyinView::NoTone);
2461 assert_eq!(
2462 loaded.readings("威士忌").as_deref(),
2463 Some(&["weishiji".to_string()][..])
2464 );
2465 assert_eq!(
2466 loaded_from_bytes.artifact_payload(),
2467 index.artifact_payload()
2468 );
2469 assert_eq!(
2470 loaded.readings("布纳哈本").as_deref(),
2471 Some(&["bunahaben".to_string()][..])
2472 );
2473 assert_eq!(
2474 loaded.artifact_payload_checksum(),
2475 index.artifact_payload_checksum()
2476 );
2477 }
2478
2479 #[test]
2480 fn artifact_metadata_records_build_and_license() {
2481 let cedict = "威士忌 威士忌 [Wei1 shi4 ji4] /whisky/\n";
2482 let options = CedictIndexOptions {
2483 pinyin_view: PinyinView::Tone3,
2484 max_readings_per_surface: Some(4),
2485 };
2486 let index = CedictReadingIndex::from_cedict_reader_with_options(cedict.as_bytes(), options)
2487 .unwrap();
2488 let metadata = index.artifact_metadata(ZhArtifactMetadataOptions {
2489 artifact_name: "moine-cedict-test".to_string(),
2490 generator: "test".to_string(),
2491 payload_file_name: "payload.yaml".to_string(),
2492 payload_format: "yaml.surface-readings.v1".to_string(),
2493 source_name: "CC-CEDICT".to_string(),
2494 source_version: "2026-05-20".to_string(),
2495 source_cedict: "cedict.txt".to_string(),
2496 index_options: options,
2497 query_defaults: PinyinReadingOptions {
2498 longest_match_only: true,
2499 ..PinyinReadingOptions::default()
2500 },
2501 license: ZhArtifactLicense::default(),
2502 });
2503
2504 assert_eq!(metadata.artifact_type, "moine.zh.reading-index");
2505 assert_eq!(metadata.build.pinyin_view, "tone3");
2506 assert_eq!(metadata.build.max_readings_per_surface, Some(4));
2507 assert!(metadata.query_defaults.longest_match_only);
2508 assert_eq!(metadata.license.selected_license, "CC BY-SA 4.0");
2509 }
2510
2511 #[test]
2512 fn rejects_duplicate_artifact_surface() {
2513 let payload = ZhReadingIndexPayload {
2514 schema_version: 1,
2515 payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2516 pinyin_view: "no-tone".to_string(),
2517 entries: vec![
2518 ZhReadingIndexPayloadEntry {
2519 surface: "威士忌".to_string(),
2520 readings: vec!["weishiji".to_string()],
2521 },
2522 ZhReadingIndexPayloadEntry {
2523 surface: "威士忌".to_string(),
2524 readings: vec!["weishiji".to_string()],
2525 },
2526 ],
2527 };
2528 let err = ZhReadingIndex::from_artifact_payload(payload).unwrap_err();
2529
2530 assert!(matches!(
2531 err,
2532 ZhArtifactPayloadError::DuplicateSurface { .. }
2533 ));
2534 }
2535
2536 #[test]
2537 fn rejects_artifact_payload_excessive_reading_count() {
2538 let payload = ZhReadingIndexPayload {
2539 schema_version: 1,
2540 payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2541 pinyin_view: "no-tone".to_string(),
2542 entries: vec![ZhReadingIndexPayloadEntry {
2543 surface: "威士忌".to_string(),
2544 readings: vec!["weishiji".to_string(); MAX_ARTIFACT_READINGS_PER_ENTRY + 1],
2545 }],
2546 };
2547 let err = ZhReadingIndex::from_artifact_payload(payload).unwrap_err();
2548
2549 assert!(matches!(
2550 err,
2551 ZhArtifactPayloadError::ArtifactLimitExceeded {
2552 field: "reading_count",
2553 ..
2554 }
2555 ));
2556 }
2557
2558 #[test]
2559 fn rejects_indexed_artifact_payload_excessive_surface_bytes() {
2560 let bytes = indexed_payload_with_surface(
2561 &"威".repeat(MAX_ARTIFACT_STRING_BYTES + 1),
2562 &["wei".to_string()],
2563 )
2564 .unwrap();
2565
2566 let err = ZhReadingIndex::from_indexed_artifact_payload_bytes(&bytes).unwrap_err();
2567
2568 assert!(matches!(
2569 err,
2570 ZhArtifactPayloadError::ArtifactLimitExceeded {
2571 field: "surface_bytes",
2572 ..
2573 }
2574 ));
2575 }
2576
2577 #[test]
2578 fn rejects_excessive_pinyin_reading_options() {
2579 let index = ZhReadingIndex::default();
2580 let err = index
2581 .try_reading_paths_with_stats(
2582 "威士忌",
2583 PinyinReadingOptions {
2584 max_span_chars: MAX_QUERY_SPAN_CHARS + 1,
2585 ..PinyinReadingOptions::default()
2586 },
2587 )
2588 .unwrap_err();
2589
2590 assert!(matches!(
2591 err,
2592 ZhArtifactPayloadError::ArtifactLimitExceeded {
2593 field: "max_span_chars",
2594 ..
2595 }
2596 ));
2597
2598 let err = index
2599 .try_reading_paths_with_stats(
2600 "威士忌",
2601 PinyinReadingOptions {
2602 max_paths: MAX_QUERY_PATHS + 1,
2603 ..PinyinReadingOptions::default()
2604 },
2605 )
2606 .unwrap_err();
2607
2608 assert!(matches!(
2609 err,
2610 ZhArtifactPayloadError::ArtifactLimitExceeded {
2611 field: "max_paths",
2612 ..
2613 }
2614 ));
2615
2616 let err = index
2617 .try_reading_paths_with_stats(
2618 "威士忌",
2619 PinyinReadingOptions {
2620 max_readings_per_segment: Some(MAX_QUERY_READINGS_PER_SEGMENT + 1),
2621 ..PinyinReadingOptions::default()
2622 },
2623 )
2624 .unwrap_err();
2625
2626 assert!(matches!(
2627 err,
2628 ZhArtifactPayloadError::ArtifactLimitExceeded {
2629 field: "max_readings_per_segment",
2630 ..
2631 }
2632 ));
2633 }
2634
2635 #[test]
2636 fn rejects_non_normalized_artifact_reading() {
2637 let payload = ZhReadingIndexPayload {
2638 schema_version: 1,
2639 payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2640 pinyin_view: "no-tone".to_string(),
2641 entries: vec![ZhReadingIndexPayloadEntry {
2642 surface: "威士忌".to_string(),
2643 readings: vec!["Wei1shi4ji4".to_string()],
2644 }],
2645 };
2646 let err = ZhReadingIndex::from_artifact_payload(payload).unwrap_err();
2647
2648 assert!(matches!(
2649 err,
2650 ZhArtifactPayloadError::ReadingNotNormalized { .. }
2651 ));
2652 }
2653
2654 #[test]
2655 fn no_tone_artifact_rejects_tone_digits_after_letters() {
2656 let payload = ZhReadingIndexPayload {
2657 schema_version: 1,
2658 payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2659 pinyin_view: "no-tone".to_string(),
2660 entries: vec![ZhReadingIndexPayloadEntry {
2661 surface: "威士忌".to_string(),
2662 readings: vec!["wei1shi4ji4".to_string()],
2663 }],
2664 };
2665 let err = ZhReadingIndex::from_artifact_payload(payload).unwrap_err();
2666
2667 assert!(matches!(
2668 err,
2669 ZhArtifactPayloadError::ReadingNotNormalized { .. }
2670 ));
2671 }
2672
2673 #[test]
2674 fn artifact_validation_keeps_numeric_tokens_in_no_tone_view() {
2675 let payload = ZhReadingIndexPayload {
2676 schema_version: 1,
2677 payload_type: "moine.zh.reading-index.surface-readings".to_string(),
2678 pinyin_view: "no-tone".to_string(),
2679 entries: vec![ZhReadingIndexPayloadEntry {
2680 surface: "11区".to_string(),
2681 readings: vec!["11qu".to_string()],
2682 }],
2683 };
2684 let index = ZhReadingIndex::from_artifact_payload(payload).unwrap();
2685
2686 assert_eq!(
2687 index.readings("11区").as_deref(),
2688 Some(&["11qu".to_string()][..])
2689 );
2690 }
2691
2692 #[test]
2693 fn tone3_view_preserves_tone_digits() {
2694 let cedict = "重 重 [chong2] /again/\n重 重 [zhong4] /heavy/\n";
2695 let index = CedictReadingIndex::from_cedict_reader_with_options(
2696 cedict.as_bytes(),
2697 CedictIndexOptions {
2698 pinyin_view: PinyinView::Tone3,
2699 ..CedictIndexOptions::default()
2700 },
2701 )
2702 .unwrap();
2703 let distances =
2704 compare_with_cedict_index("zhong4", "重", &index, PinyinReadingOptions::default())
2705 .unwrap();
2706
2707 assert_eq!(distances.lattice, 0);
2708 }
2709
2710 #[test]
2711 fn unknown_han_without_dictionary_path_is_rejected() {
2712 let index = CedictReadingIndex::default();
2713 let err =
2714 cedict_or_direct_lattice("印", &index, PinyinReadingOptions::default()).unwrap_err();
2715
2716 assert!(matches!(err, CnLatticeError::UnsupportedDirectInput { .. }));
2717 }
2718}