Skip to main content

lindera_analysis/
character_filter.rs

1/// This module defines character filters and utilities for handling text transformations
2/// in the Lindera library. It includes various character filters such as Japanese iteration
3/// mark filter, mapping filter, regex filter, and unicode normalization filter. The module
4/// also provides functionality to load character filters from configuration values or CLI flags,
5/// and utilities to manage text offsets during transformations.
6///
7/// # Offset Mapping System
8///
9/// The offset mapping system tracks how character positions change during text filtering,
10/// allowing accurate mapping between filtered text positions and original text positions.
11/// This is essential for maintaining correct token byte offsets in tokenization.
12///
13/// ## How Offset Mapping Works
14///
15/// When text is transformed by character filters, each transformation is recorded as a
16/// `Transformation` that captures:
17/// - Original text byte range (before filtering)  
18/// - Filtered text byte range (after filtering)
19///
20/// ### Example: "10㍑" → "10リットル"
21///
22/// ```text
23/// Original:  "10㍑"
24/// Positions:  0-3  3-6  6-9     (byte positions)
25///              ↓    ↓    ↓
26/// Filtered:  "10リットル"  
27/// Positions:  0-1  1-2  2-14    (byte positions)
28/// ```
29///
30/// This creates three transformations:
31/// 1. "1" (0-3) → "1" (0-1)
32/// 2. "0" (3-6) → "0" (1-2)
33/// 3. "㍑" (6-9) → "リットル" (2-14)
34///
35/// ### Position Correction
36///
37/// To map a filtered text position back to the original:
38/// 1. Find which transformation range contains the position
39/// 2. Calculate the corresponding position in the original text
40/// 3. Return the original text position
41///
42/// ```rust,no_run
43/// # use lindera_analysis::character_filter::{OffsetMapping, Transformation};
44/// # let mut mapping = OffsetMapping::new();
45/// # mapping.add_transformation(Transformation::new(6, 9, 2, 14));
46/// # let text = "10リットル";
47/// // For filtered position 2 ("リットル" start):
48/// // → finds transformation[2]: filtered_range(2-14) contains position 2
49/// // → returns original_start: 6 (start of "㍑")
50/// let original_pos = mapping.correct_offset(2, text.len()); // returns 6
51/// ```
52///
53/// This ensures that tokenizer can provide accurate byte offsets relative to the original
54/// input text, even after multiple character transformations.
55///
56/// # Modules
57/// - `japanese_iteration_mark`: Contains the Japanese iteration mark character filter.
58/// - `mapping`: Contains the mapping character filter.
59/// - `regex`: Contains the regex character filter.
60/// - `unicode_normalize`: Contains the unicode normalization character filter.
61///
62/// # Traits
63/// - `CharacterFilter`: A trait for character filters that can be applied to text.
64/// - `CharacterFilterClone`: A trait for cloning character filters.
65///
66/// # Structs
67/// - `BoxCharacterFilter`: A boxed character filter that implements `Deref` to `CharacterFilter`.
68/// - `CharacterFilterLoader`: A loader for character filters from configuration values or CLI flags.
69/// - `OffsetMapping`: A modern structure for tracking position changes during text filtering.
70/// - `Transformation`: A record of text transformation with original and filtered positions.
71///
72/// # Functions
73/// No public utility functions are exposed, as all offset management is handled through OffsetMapping.
74pub mod japanese_iteration_mark;
75pub mod mapping;
76pub mod regex;
77pub mod unicode_normalize;
78
79use std::ops::Deref;
80
81use serde_json::Value;
82
83use crate::character_filter::japanese_iteration_mark::{
84    JAPANESE_ITERATION_MARK_CHARACTER_FILTER_NAME, JapaneseIterationMarkCharacterFilter,
85};
86use crate::character_filter::mapping::{MAPPING_CHARACTER_FILTER_NAME, MappingCharacterFilter};
87use crate::character_filter::regex::{REGEX_CHARACTER_FILTER_NAME, RegexCharacterFilter};
88use crate::character_filter::unicode_normalize::{
89    UNICODE_NORMALIZE_CHARACTER_FILTER_NAME, UnicodeNormalizeCharacterFilter,
90};
91use crate::parse_cli_flag;
92use lindera::LinderaResult;
93use lindera::error::LinderaErrorKind;
94
95/// A transformation record for offset mapping between original and filtered text.
96///
97/// This structure captures a single text transformation, recording how a specific
98/// segment of the original text maps to a segment in the filtered text.
99///
100/// # Example
101///
102/// For the transformation "㍑" → "リットル":
103/// ```rust
104/// # use lindera_analysis::character_filter::Transformation;
105/// let transformation = Transformation::new(
106///     6, 9,    // original: "㍑" at bytes 6-9
107///     2, 14    // filtered: "リットル" at bytes 2-14  
108/// );
109/// ```
110///
111/// This allows precise mapping between any position in the filtered text
112/// back to the corresponding position in the original text.
113#[derive(Debug, Clone, PartialEq)]
114pub struct Transformation {
115    /// Start position in the original text (in bytes)
116    pub original_start: usize,
117    /// End position in the original text (in bytes)
118    pub original_end: usize,
119    /// Start position in the filtered text (in bytes)
120    pub filtered_start: usize,
121    /// End position in the filtered text (in bytes)
122    pub filtered_end: usize,
123}
124
125impl Transformation {
126    pub fn new(
127        original_start: usize,
128        original_end: usize,
129        filtered_start: usize,
130        filtered_end: usize,
131    ) -> Self {
132        Self {
133            original_start,
134            original_end,
135            filtered_start,
136            filtered_end,
137        }
138    }
139}
140
141/// Offset mapping structure for tracking position changes during text filtering.
142///
143/// This structure maintains a list of all text transformations that occurred during
144/// character filtering, enabling accurate position mapping between filtered and original text.
145///
146/// # Usage Pattern
147///
148/// 1. **Record transformations** during filtering:
149/// ```rust
150/// # use lindera_analysis::character_filter::{OffsetMapping, Transformation};
151/// let mut mapping = OffsetMapping::new();
152/// // When "㍑" → "リットル" transformation occurs:
153/// mapping.add_transformation(Transformation::new(6, 9, 2, 14));
154/// ```
155///
156/// 2. **Correct positions** from filtered to original:
157/// ```rust,no_run
158/// # use lindera_analysis::character_filter::OffsetMapping;
159/// # let mapping = OffsetMapping::new();
160/// # let filtered_pos = 0;
161/// # let text = String::new();
162/// let original_pos = mapping.correct_offset(filtered_pos, text.len());
163/// ```
164///
165/// # Multi-Filter Support
166///
167/// When multiple character filters are applied, their mappings are composed:
168/// ```rust,no_run
169/// # use lindera_analysis::character_filter::OffsetMapping;
170/// # let mapping1 = OffsetMapping::new();
171/// # let mapping2 = OffsetMapping::new();
172/// let combined_mapping = mapping1.compose(mapping2);
173/// ```
174///
175/// This ensures accurate position tracking through complex filter chains.
176#[derive(Debug, Clone, Default, PartialEq)]
177pub struct OffsetMapping {
178    /// List of transformations applied to the text
179    pub transformations: Vec<Transformation>,
180    /// Cumulative `(original_len - filtered_len)` diff after each
181    /// transformation, i.e. `cumulative_diffs[i]` is the sum of diffs for
182    /// `transformations[0..=i]`. Kept in sync with `transformations` by
183    /// `add_transformation`/`with_transformations`/`compose`, and used by
184    /// `correct_offset` to answer in O(log n) instead of rescanning the
185    /// full transformation list.
186    cumulative_diffs: Vec<i64>,
187}
188
189/// Compute the cumulative diff sums for a transformation list, in the same
190/// order as `OffsetMapping::cumulative_diffs`.
191fn compute_cumulative_diffs(transformations: &[Transformation]) -> Vec<i64> {
192    let mut cumulative_diffs = Vec::with_capacity(transformations.len());
193    let mut cumulative = 0i64;
194    for t in transformations {
195        let original_len = (t.original_end - t.original_start) as i64;
196        let filtered_len = (t.filtered_end - t.filtered_start) as i64;
197        cumulative += original_len - filtered_len;
198        cumulative_diffs.push(cumulative);
199    }
200    cumulative_diffs
201}
202
203impl OffsetMapping {
204    pub fn new() -> Self {
205        Self::default()
206    }
207
208    pub fn with_transformations(transformations: Vec<Transformation>) -> Self {
209        let cumulative_diffs = compute_cumulative_diffs(&transformations);
210        Self {
211            transformations,
212            cumulative_diffs,
213        }
214    }
215
216    /// Add a transformation to the mapping
217    pub fn add_transformation(&mut self, transformation: Transformation) {
218        let original_len = (transformation.original_end - transformation.original_start) as i64;
219        let filtered_len = (transformation.filtered_end - transformation.filtered_start) as i64;
220        let diff = original_len - filtered_len;
221        let cumulative = self.cumulative_diffs.last().copied().unwrap_or(0) + diff;
222        self.cumulative_diffs.push(cumulative);
223        self.transformations.push(transformation);
224    }
225
226    /// Check if this mapping is empty (no transformations)
227    pub fn is_empty(&self) -> bool {
228        self.transformations.is_empty()
229    }
230
231    /// Correct a position in filtered text to the corresponding position in original text.
232    ///
233    /// This method maps a byte position in the filtered text back to the corresponding
234    /// byte position in the original text, accounting for all recorded transformations.
235    ///
236    /// # Arguments
237    ///
238    /// * `offset` - Byte position in the filtered text
239    /// * `text_len` - Length of the filtered text (used for boundary validation)
240    ///
241    /// # Returns
242    ///
243    /// The corresponding byte position in the original text.
244    ///
245    /// # Algorithm
246    ///
247    /// 1. If no transformations exist, return the offset unchanged
248    /// 2. Find the transformation whose filtered range contains the offset
249    /// 3. Map the offset to the corresponding position in the original range
250    /// 4. If offset is outside all transformation ranges, adjust by cumulative differences
251    ///
252    /// # Example
253    ///
254    /// ```rust,no_run
255    /// # use lindera_analysis::character_filter::{OffsetMapping, Transformation};
256    /// // For "10㍑" → "10リットル" with transformations recorded
257    /// let mut mapping = OffsetMapping::new();
258    /// mapping.add_transformation(Transformation::new(6, 9, 2, 14));
259    ///
260    /// // Position 2 in "10リットル" ("リットル" start)
261    /// let original_pos = mapping.correct_offset(2, 14); // returns 6
262    /// // This maps to position 6 in "10㍑" ("㍑" start)
263    /// ```
264    pub fn correct_offset(&self, offset: usize, text_len: usize) -> usize {
265        if self.transformations.is_empty() {
266            return offset;
267        }
268
269        // Boundary check: if offset is beyond text length, clamp to text length
270        let clamped_offset = offset.min(text_len);
271
272        // transformations are non-overlapping and appended in strictly
273        // increasing filtered_start/filtered_end order (an invariant the
274        // original linear scan below also depended on). Binary search for
275        // the first transformation whose filtered_end >= clamped_offset --
276        // this is exactly the transformation the old scan would have
277        // stopped at first, whether the offset falls inside it or before it.
278        let idx = self
279            .transformations
280            .partition_point(|t| t.filtered_end < clamped_offset);
281
282        if let Some(transformation) = self.transformations.get(idx) {
283            if clamped_offset >= transformation.filtered_start {
284                // Offset is within this transformation range
285                let filtered_offset = clamped_offset - transformation.filtered_start;
286                let original_len = transformation.original_end - transformation.original_start;
287                let filtered_len = transformation.filtered_end - transformation.filtered_start;
288
289                return if filtered_len == 0 {
290                    // Deletion case
291                    transformation.original_start
292                } else if original_len == 0 {
293                    // Insertion case
294                    transformation.original_start
295                } else {
296                    // Substitution case - proportionally map within the range
297                    let ratio = filtered_offset as f64 / filtered_len as f64;
298                    let original_offset = (ratio * original_len as f64).round() as usize;
299                    transformation.original_start + original_offset
300                };
301            }
302
303            // Offset is before this transformation: apply the cumulative
304            // diff of every transformation before it (O(1) lookup).
305            let prev_cumulative = if idx == 0 {
306                0
307            } else {
308                self.cumulative_diffs[idx - 1]
309            };
310            return (clamped_offset as i64 + prev_cumulative) as usize;
311        }
312
313        // Offset is after all transformations - apply the total cumulative diff.
314        let total_diff = *self.cumulative_diffs.last().unwrap();
315        let corrected = (clamped_offset as i64 + total_diff) as usize;
316
317        // Handle case where original offset was beyond text length
318        if offset > text_len {
319            // Preserve the overshoot in the original text space
320            let overshoot = offset - text_len;
321            corrected + overshoot
322        } else {
323            corrected
324        }
325    }
326
327    /// Compose this mapping with another mapping (for chaining filters)
328    pub fn compose(self, other: OffsetMapping) -> OffsetMapping {
329        if other.transformations.is_empty() {
330            return self;
331        }
332        if self.transformations.is_empty() {
333            return other;
334        }
335
336        let mut combined_transformations = self.transformations;
337        combined_transformations.extend(other.transformations);
338
339        OffsetMapping::with_transformations(combined_transformations)
340    }
341}
342
343/// The `CharacterFilter` trait defines an interface for filters that preprocess text before tokenization.
344///
345/// # Required Methods
346///
347/// - `name(&self) -> &str`:
348///   - Returns the name of the character filter. This can be used for identification or logging purposes.
349///
350/// - `apply_with_offset_mapping(&self, text: &mut String) -> LinderaResult<OffsetMapping>`:
351///   - Applies the character filter to the provided mutable string `text`.
352///   - It returns a result containing an `OffsetMapping` which tracks all text transformations
353///     performed by the filter, allowing precise position mapping between original and filtered text.
354///
355/// # Trait Bounds
356///
357/// - `'static`: The filter must have a `'static` lifetime, meaning it does not contain any references with shorter lifetimes.
358/// - `Send` and `Sync`: These bounds ensure that the filter can be safely used in multi-threaded contexts, allowing filters to be shared or sent across threads.
359///
360/// # Cloneability
361///
362/// - This trait requires the `CharacterFilterClone` trait, which is typically used to allow cloning of trait objects that implement `CharacterFilter`. This enables dynamic dispatch of cloned filters.
363pub trait CharacterFilter: 'static + Send + Sync + CharacterFilterClone {
364    fn name(&self) -> &str;
365    fn apply(&self, text: &mut String) -> LinderaResult<OffsetMapping>;
366}
367
368/// A struct that holds a boxed `CharacterFilter` trait object.
369///
370/// `BoxCharacterFilter` wraps a `Box<dyn CharacterFilter + 'static + Send + Sync>`, allowing for dynamic dispatch of character filters while ensuring they are thread-safe and have a `'static` lifetime.
371///
372/// # Fields
373///
374/// - `0: Box<dyn CharacterFilter + 'static + Send + Sync>`:
375///   - The boxed character filter trait object, which can be any type that implements the `CharacterFilter` trait. This allows for runtime polymorphism, meaning different character filter implementations can be stored in the same collection or passed around generically.
376///
377/// # Trait Bounds
378///
379/// - `CharacterFilter`: The wrapped object must implement the `CharacterFilter` trait, which defines the interface for applying filters to text.
380/// - `'static`: The wrapped object must have a `'static` lifetime, meaning it can live for the duration of the program and does not borrow from temporary data.
381/// - `Send` and `Sync`: These bounds ensure the filter can be shared between threads and sent across thread boundaries, making it safe for concurrent use.
382///
383/// # Example Usage
384///
385/// `BoxCharacterFilter` allows you to store and use different types of character filters dynamically, making it easier to apply multiple filters without needing to know their concrete types at compile time.
386pub struct BoxCharacterFilter(Box<dyn CharacterFilter + 'static + Send + Sync>);
387
388impl Deref for BoxCharacterFilter {
389    type Target = dyn CharacterFilter;
390
391    fn deref(&self) -> &dyn CharacterFilter {
392        &*self.0
393    }
394}
395
396impl<T: CharacterFilter> From<T> for BoxCharacterFilter {
397    fn from(character_filter: T) -> BoxCharacterFilter {
398        BoxCharacterFilter(Box::new(character_filter))
399    }
400}
401
402pub trait CharacterFilterClone {
403    fn box_clone(&self) -> BoxCharacterFilter;
404}
405
406impl<T: CharacterFilter + Clone + 'static> CharacterFilterClone for T {
407    fn box_clone(&self) -> BoxCharacterFilter {
408        BoxCharacterFilter::from(self.clone())
409    }
410}
411
412pub struct CharacterFilterLoader {}
413
414impl CharacterFilterLoader {
415    /// Loads a character filter based on the specified kind and configuration value.
416    ///
417    /// # Arguments
418    ///
419    /// * `kind` - A string slice representing the type of the character filter to be loaded. This string must match one of the supported filter types.
420    /// * `value` - A `serde_json::Value` that contains the configuration for the filter. The structure of this value depends on the filter type.
421    ///
422    /// # Returns
423    ///
424    /// Returns a `LinderaResult<BoxCharacterFilter>`, which is a boxed character filter, or an error if the filter type is unsupported or if the configuration fails to load.
425    ///
426    /// # Supported Filters
427    ///
428    /// - `JAPANESE_ITERATION_MARK_CHARACTER_FILTER_NAME`: Loads a `JapaneseIterationMarkCharacterFilter`.
429    /// - `MAPPING_CHARACTER_FILTER_NAME`: Loads a `MappingCharacterFilter`.
430    /// - `REGEX_CHARACTER_FILTER_NAME`: Loads a `RegexCharacterFilter`.
431    /// - `UNICODE_NORMALIZE_CHARACTER_FILTER_NAME`: Loads a `UnicodeNormalizeCharacterFilter`.
432    ///
433    /// # Errors
434    ///
435    /// - If the `kind` does not match any of the supported filters, an error is returned.
436    /// - If the configuration (`value`) for a filter is invalid, an error is returned during deserialization.
437    ///
438    /// # Details
439    ///
440    /// - This function uses the `kind` argument to determine which specific character filter to load. It matches the `kind` string to a filter name, deserializes the `value` into the appropriate filter configuration, and then constructs the corresponding filter.
441    /// - If the `kind` does not match any supported filters, the function returns a deserialization error with an appropriate error message.
442    pub fn load_from_value(kind: &str, value: &Value) -> LinderaResult<BoxCharacterFilter> {
443        let character_filter = match kind {
444            JAPANESE_ITERATION_MARK_CHARACTER_FILTER_NAME => {
445                BoxCharacterFilter::from(JapaneseIterationMarkCharacterFilter::from_config(value)?)
446            }
447            MAPPING_CHARACTER_FILTER_NAME => {
448                BoxCharacterFilter::from(MappingCharacterFilter::from_config(value)?)
449            }
450            REGEX_CHARACTER_FILTER_NAME => {
451                BoxCharacterFilter::from(RegexCharacterFilter::from_config(value)?)
452            }
453            UNICODE_NORMALIZE_CHARACTER_FILTER_NAME => {
454                BoxCharacterFilter::from(UnicodeNormalizeCharacterFilter::from_config(value)?)
455            }
456            _ => {
457                return Err(LinderaErrorKind::Deserialize
458                    .with_error(anyhow::anyhow!("unsupported character filter: {kind}")));
459            }
460        };
461
462        Ok(character_filter)
463    }
464
465    /// Loads a character filter based on a CLI flag string.
466    ///
467    /// # Arguments
468    ///
469    /// * `cli_flag` - A string slice representing the command-line interface (CLI) flag used to specify the character filter. The flag typically contains both the filter kind and its arguments.
470    ///
471    /// # Returns
472    ///
473    /// Returns a `LinderaResult<BoxCharacterFilter>`, which is a boxed character filter, or an error if the CLI flag is invalid or the filter configuration cannot be loaded.
474    ///
475    /// # Process
476    ///
477    /// 1. **Parse CLI flag**:
478    ///    - The `parse_cli_flag` function is called to extract the filter kind and its arguments from the `cli_flag` string.
479    /// 2. **Load filter from parsed values**:
480    ///    - The filter kind and arguments are passed to `load_from_value`, which constructs the appropriate character filter based on the parsed values.
481    ///
482    /// # Errors
483    ///
484    /// - If the CLI flag cannot be parsed, an error is returned.
485    /// - If the filter kind or its configuration is invalid, an error is returned during the filter loading process.
486    ///
487    /// # Details
488    ///
489    /// - The CLI flag is parsed into a filter kind and arguments. These are then used to load the appropriate character filter using the `load_from_value` function.
490    pub fn load_from_cli_flag(cli_flag: &str) -> LinderaResult<BoxCharacterFilter> {
491        let (kind, args) = parse_cli_flag(cli_flag)?;
492
493        let character_filter = Self::load_from_value(kind, &args)?;
494
495        Ok(character_filter)
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_transformation() {
505        let transformation = Transformation::new(0, 3, 0, 1);
506        assert_eq!(transformation.original_start, 0);
507        assert_eq!(transformation.original_end, 3);
508        assert_eq!(transformation.filtered_start, 0);
509        assert_eq!(transformation.filtered_end, 1);
510    }
511
512    #[test]
513    fn test_offset_mapping_empty() {
514        let mapping = OffsetMapping::new();
515        assert!(mapping.is_empty());
516
517        // Empty mapping should not change offsets
518        assert_eq!(5, mapping.correct_offset(5, 10));
519        assert_eq!(0, mapping.correct_offset(0, 10));
520    }
521
522    #[test]
523    fn test_offset_mapping_with_transformation() {
524        let mut mapping = OffsetMapping::new();
525        mapping.add_transformation(Transformation::new(0, 3, 0, 1));
526
527        assert!(!mapping.is_empty());
528
529        // Test offset correction for shortening transformation (0-3) -> (0-1)
530        assert_eq!(0, mapping.correct_offset(0, 8)); // Start maps to start
531        assert_eq!(3, mapping.correct_offset(1, 8)); // End of filtered maps to end of original
532        assert_eq!(5, mapping.correct_offset(3, 8)); // After transformation, add diff
533    }
534
535    #[test]
536    fn test_offset_mapping_correct_offset_multiple_transformations_boundaries() {
537        // Locks in the binary-search rewrite's boundary conditions against
538        // three transformations covering all four cases the linear scan
539        // used to handle: inside a transformation, before a transformation
540        // (but after a previous one), after all transformations (within
541        // and beyond text_len), and exactly on a filtered_start/filtered_end
542        // boundary.
543        let mut mapping = OffsetMapping::new();
544        // T0: original[0,2) "AB" -> filtered[0,1) "X" (shrink, diff = +1)
545        mapping.add_transformation(Transformation::new(0, 2, 0, 1));
546        // T1: original[4,5) "C" -> filtered[3,6) "YYY" (expand, diff = -2)
547        mapping.add_transformation(Transformation::new(4, 5, 3, 6));
548        // T2: original[7,9) "DE" -> filtered[8,9) "Z" (shrink, diff = +1)
549        mapping.add_transformation(Transformation::new(7, 9, 8, 9));
550
551        // filtered text is longer than the last transformation's end
552        let text_len = 12;
553
554        // Inside T0 (filtered_start and filtered_end boundaries)
555        assert_eq!(0, mapping.correct_offset(0, text_len));
556        assert_eq!(2, mapping.correct_offset(1, text_len));
557
558        // Between T0 and T1 (before T1, cumulative diff of T0 only)
559        assert_eq!(3, mapping.correct_offset(2, text_len));
560
561        // Inside T1 (filtered_start and filtered_end boundaries)
562        assert_eq!(4, mapping.correct_offset(3, text_len));
563        assert_eq!(5, mapping.correct_offset(6, text_len));
564
565        // Between T1 and T2 (before T2, cumulative diff of T0+T1)
566        assert_eq!(6, mapping.correct_offset(7, text_len));
567
568        // Inside T2 (filtered_start and filtered_end boundaries)
569        assert_eq!(7, mapping.correct_offset(8, text_len));
570        assert_eq!(9, mapping.correct_offset(9, text_len));
571
572        // After all transformations, within text_len (total cumulative diff = 0)
573        assert_eq!(10, mapping.correct_offset(10, text_len));
574        assert_eq!(12, mapping.correct_offset(12, text_len));
575
576        // Beyond text_len: clamped, then overshoot preserved
577        assert_eq!(15, mapping.correct_offset(15, text_len));
578    }
579
580    #[test]
581    fn test_offset_mapping_compose() {
582        let mut mapping1 = OffsetMapping::new();
583        mapping1.add_transformation(Transformation::new(0, 3, 0, 1));
584
585        let mut mapping2 = OffsetMapping::new();
586        mapping2.add_transformation(Transformation::new(1, 2, 1, 4));
587
588        let composed = mapping1.compose(mapping2);
589        assert_eq!(composed.transformations.len(), 2);
590    }
591}