Skip to main content

stamtools/
translate.rs

1use crate::align::print_transposition as print_translation;
2use serde::Deserialize;
3use stam::*;
4use std::borrow::Cow;
5use std::collections::HashMap;
6use toml;
7
8pub fn translate<'store>(
9    store: &'store mut AnnotationStore,
10    mut translation_queries: Vec<Query<'store>>,
11    queries: Vec<Query<'store>>,
12    use_translation_var: Option<&str>,
13    use_var: Option<&str>,
14    id_prefix: Option<String>,
15    idstrategy: IdStrategy,
16    ignore_errors: bool,
17    verbose: bool,
18    config: TranslateConfig,
19) -> Result<Vec<AnnotationHandle>, StamError> {
20    let mut builders = Vec::new();
21    while translation_queries.len() < queries.len() {
22        let query = translation_queries
23            .get(translation_queries.len() - 1)
24            .expect("there must be translation queries");
25        translation_queries.push(query.clone());
26    }
27    for (translation_query, query) in translation_queries.into_iter().zip(queries.into_iter()) {
28        let iter = store.query(translation_query)?;
29        let mut translation: Option<ResultItem<Annotation>> = None;
30        let translationdata = store
31            .find_data(
32                "https://w3id.org/stam/extensions/stam-translate/",
33                "Translation",
34                DataOperator::Null,
35            )
36            .next()
37            .ok_or_else(|| {
38                StamError::OtherError(
39                    "No translations at all were found in the annotation store (the STAM translate vocabulary is not present in the store)",
40                )
41            })?;
42        for resultrow in iter {
43            if let Ok(QueryResultItem::Annotation(annotation)) =
44                resultrow.get_by_name_or_last(use_translation_var)
45            {
46                if !annotation.has_data(&translationdata) {
47                    return Err(StamError::OtherError(
48                        "The retrieved annotation is not explicitly marked as a translation, refusing to use",
49                    ));
50                }
51                translation = Some(annotation.clone());
52                break;
53            }
54        }
55        if let Some(translation) = translation {
56            let iter = store.query(query)?;
57            let translationpivot: TranslationPivot<'_> = (&translation).try_into()?;
58            for resultrow in iter {
59                if let Ok(QueryResultItem::Annotation(annotation)) =
60                    resultrow.get_by_name_or_last(use_var)
61                {
62                    let mut config = config.clone();
63                    if let Some(id) = annotation.id() {
64                        let randomid = generate_id("", "");
65                        config.translation_id = if let Some(id_prefix) = &id_prefix {
66                            Some(format!("{}{}-translation-{}", id_prefix, id, randomid))
67                        } else {
68                            Some(format!("{}-translation-{}", id, randomid))
69                        };
70                        config.resegmentation_id = if let Some(id_prefix) = &id_prefix {
71                            Some(format!("{}{}-resegmentation-{}", id_prefix, id, randomid))
72                        } else {
73                            Some(format!("{}-resegmentation-{}", id, randomid))
74                        };
75                        config.source_side_id = Some(id.to_string());
76                        config.existing_source_side = true;
77                        config.target_side_ids = vec![if let Some(id_prefix) = &id_prefix {
78                            format!("{}{}", id_prefix, regenerate_id(id, &idstrategy))
79                        } else {
80                            regenerate_id(id, &idstrategy)
81                        }];
82                    } else {
83                        config.existing_source_side = false;
84                    }
85                    match annotation.translate(&translationpivot, config) {
86                        Ok(results) => builders.extend(results),
87                        Err(StamError::NoText(e)) => {
88                            eprintln!(
89                                "WARNING: Skipping translation of annotation {} that references no text: {}",
90                                annotation.id().unwrap_or("(no id)"),
91                                e
92                            );
93                        }
94                        Err(err) => {
95                            eprintln!(
96                                "WARNING: Failed to translate annotation {}: {}",
97                                annotation.id().unwrap_or("(no id)"),
98                                err
99                            );
100                            if !ignore_errors {
101                                return Err(StamError::OtherError(
102                                    "Failed to translate annotation",
103                                ));
104                            }
105                        }
106                    }
107                } else {
108                    return Err(StamError::OtherError(
109                            "Query should return instances of ANNOTATION to translate, got something else instead",
110                        ));
111                }
112            }
113        } else {
114            return Err(StamError::OtherError(
115                "Translation queries should return an ANNOTATION that is a translation, none found",
116            ));
117        }
118    }
119    let mut annotations = Vec::with_capacity(builders.len());
120    for builder in builders {
121        let annotation_handle = if !config.modify_existing {
122            //add new annotation
123            let annotation_handle = store.annotate(builder)?;
124            annotations.push(annotation_handle);
125            annotation_handle
126        } else {
127            //modify existing annotation
128            store.reannotate(builder, ReannotateMode::default())?
129        };
130        if verbose {
131            let annotation = store
132                .annotation(annotation_handle)
133                .expect("annotation was just added");
134            let translationdata = store
135                .find_data(
136                    "https://w3id.org/stam/extensions/stam-translate/",
137                    "Translation",
138                    DataOperator::Null,
139                )
140                .next()
141                .ok_or_else(|| {
142                    StamError::OtherError(
143                        "No translations at all were found in the annotation store (the STAM translate vocabulary is not present in the store)",
144                    )
145                })?;
146            if annotation.has_data(&translationdata) {
147                print_translation(&annotation);
148            } else if config.modify_existing {
149                eprintln!(
150                    "# updated annotation {}",
151                    annotation.id().expect("annotation must have ID")
152                );
153            } else {
154                eprintln!(
155                    "# added annotation {}",
156                    annotation.id().expect("annotation must have ID")
157                );
158            }
159        }
160    }
161    if verbose {
162        if !config.modify_existing {
163            eprintln!("{} annotations(s) created", annotations.len());
164        } else {
165            eprintln!("{} annotations(s) updated", annotations.len());
166        }
167    }
168    Ok(annotations)
169}
170
171#[derive(Clone, Default, Deserialize, Debug)]
172pub struct TranslateTextRule {
173    source: Option<String>,
174    target: String,
175    left: Option<String>,
176    right: Option<String>,
177
178    #[serde(default = "f_true")]
179    case_sensitive: bool,
180
181    #[serde(default)]
182    invert_context_match: bool,
183
184    #[serde(default)]
185    constraints: Vec<TranslateTextConstraint>,
186
187    #[serde(skip)]
188    source_regex: Option<Regex>,
189    #[serde(skip)]
190    left_regex: Option<Regex>,
191    #[serde(skip)]
192    right_regex: Option<Regex>,
193}
194
195// well, this is a bit silly, used in macro above
196fn f_true() -> bool {
197    true
198}
199
200#[derive(Clone, Default, Deserialize, Debug)]
201pub struct TranslateTextConstraint {
202    query: String,
203
204    #[serde(default)]
205    test: Option<String>,
206
207    #[serde(default)]
208    invert: bool,
209}
210
211pub struct MatchedRule<'a> {
212    source: &'a str,
213    target: Cow<'a, str>,
214}
215
216impl TranslateTextRule {
217    /// Tests whether this rule matches the text at the specified cursor
218    pub fn test<'a>(&'a self, text: &'a str, bytecursor: usize) -> Option<MatchedRule<'a>> {
219        if let Some(source_regex) = self.source_regex.as_ref() {
220            //check if text under cursor matches (regular expression test)
221            if let Some(m) = source_regex.find(&text[bytecursor..]) {
222                if self.test_context(text, bytecursor, m.len()) {
223                    return Some(MatchedRule {
224                        target: self.get_target(m.as_str()),
225                        source: m.as_str(),
226                    });
227                }
228            }
229        } else if let Some(source) = self.source.as_ref() {
230            //check if text under cursor matches (normal test)
231            if bytecursor + source.len() <= text.len() {
232                if let Some(candidate) = text.get(bytecursor..bytecursor + source.len()) {
233                    if ((self.case_sensitive && candidate == *source)
234                        || (!self.case_sensitive && candidate.to_lowercase() == *source))
235                        && self.test_context(text, bytecursor, source.len())
236                    {
237                        return Some(MatchedRule {
238                            target: self.get_target(source.as_str()),
239                            source: source.as_str().into(),
240                        });
241                    }
242                }
243            }
244        }
245        None
246    }
247
248    /// See if context constaints match
249    fn test_context(&self, text: &str, bytecursor: usize, matchbytelen: usize) -> bool {
250        if let Some(left_regex) = self.left_regex.as_ref() {
251            //match left context using regular expressiong
252            let leftcontext = &text[..bytecursor];
253            if !left_regex.is_match(leftcontext) {
254                if self.invert_context_match {
255                    return true;
256                } else {
257                    return false;
258                }
259            }
260        } else if let Some(left_pattern) = self.left.as_ref() {
261            //match left context normally
262            let leftcontext = &text[..bytecursor];
263            if (self.case_sensitive && !leftcontext.ends_with(left_pattern))
264                || (!self.case_sensitive
265                    && leftcontext[std::cmp::min(0, bytecursor - left_pattern.len())..]
266                        .to_lowercase()
267                        != left_pattern.to_lowercase())
268            {
269                if self.invert_context_match {
270                    return true;
271                } else {
272                    return false;
273                }
274            }
275        }
276        if let Some(right_regex) = self.right_regex.as_ref() {
277            //match right context using regular expression
278            let rightcontext = &text[bytecursor + matchbytelen..];
279            if !right_regex.is_match(rightcontext) {
280                if self.invert_context_match {
281                    return true;
282                } else {
283                    return false;
284                }
285            }
286        } else if let Some(right_pattern) = self.right.as_ref() {
287            //match right context normally
288            let rightcontext = &text[bytecursor + matchbytelen..];
289            if (self.case_sensitive && !rightcontext.starts_with(right_pattern))
290                || (!self.case_sensitive
291                    && rightcontext[..std::cmp::min(rightcontext.len(), right_pattern.len())]
292                        .to_lowercase()
293                        != right_pattern.to_lowercase())
294            {
295                if self.invert_context_match {
296                    return true;
297                } else {
298                    return false;
299                }
300            }
301        }
302        if self.invert_context_match {
303            return false;
304        } else {
305            return true;
306        }
307    }
308
309    fn get_target<'a>(&'a self, source: &'a str) -> Cow<'a, str> {
310        match self.target.as_str() {
311            "$UPPER" => source.to_uppercase().into(),
312            "$LOWER" => source.to_lowercase().into(),
313            "$REVERSED" => Cow::Owned(source.chars().rev().collect::<String>()),
314            _ => Cow::Borrowed(self.target.as_str()),
315        }
316    }
317}
318
319#[derive(Clone, Default, Deserialize, Debug)]
320pub struct TranslateTextConfig {
321    rules: Vec<TranslateTextRule>,
322
323    /// ID Suffix for translated resources
324    #[serde(default)]
325    id_suffix: Option<String>,
326
327    /// When no rules match, discard that part of the text entirely? By default it will just be copied and linked verbatim at character-level
328    #[serde(default)]
329    discard_unmatched: bool,
330
331    /// Do generate any annotations, use this if you just want the text copied and don't mind losing all ties with the original
332    #[serde(default)]
333    no_annotations: bool,
334
335    /// Create any texts and annotations even if the translation turn out exactly the same as the original
336    #[serde(default)]
337    force_when_unchanged: bool,
338
339    #[serde(default)]
340    debug: bool,
341}
342
343impl TranslateTextConfig {
344    /// Parse the configuration from a TOML string (load the data from file yourself).
345    pub fn from_toml_str(tomlstr: &str, debug: bool) -> Result<Self, String> {
346        let mut config: Self = toml::from_str(tomlstr).map_err(|e| format!("{}", e))?;
347        config.debug = debug;
348        config.compile_regexps()?;
349        Ok(config)
350    }
351
352    /// A suffix to assign when minting new IDs for resources and translations
353    pub fn with_id_suffix(mut self, suffix: impl Into<String>) -> Self {
354        self.id_suffix = Some(suffix.into());
355        self
356    }
357
358    /// Create any texts and annotations even if the translation turn out exactly the same as the original
359    pub fn with_force_when_unchanged(mut self) -> Self {
360        self.force_when_unchanged = true;
361        self
362    }
363
364    /// A suffix to assign when minting new IDs for resources and translations
365    pub fn with_debug(mut self, value: bool) -> Self {
366        self.debug = value;
367        self
368    }
369
370    fn compile_regexps<'a>(&'a mut self) -> Result<(), String> {
371        for rule in self.rules.iter_mut() {
372            if let Some(v) = rule.source.as_ref() {
373                if v.starts_with('/') && v.ends_with('/') && v.len() > 1 {
374                    let regex = format!("^{}", &v[1..v.len() - 1]);
375                    rule.source_regex = Some(
376                        RegexBuilder::new(&regex)
377                            .case_insensitive(!rule.case_sensitive)
378                            .build()
379                            .map_err(|e| {
380                                format!("Invalid regular expression for source: {}: {}", regex, e)
381                            })?,
382                    );
383                    if self.debug {
384                        eprintln!(
385                            "[stam translatetext] compiled source regex {:?}",
386                            rule.source_regex
387                        )
388                    }
389                }
390            }
391            if let Some(v) = rule.left.as_ref() {
392                if v.starts_with('/') && v.ends_with('/') && v.len() > 1 {
393                    let regex = format!(".*{}$", &v[1..v.len() - 1]);
394                    rule.left_regex = Some(
395                        RegexBuilder::new(&regex)
396                            .case_insensitive(!rule.case_sensitive)
397                            .build()
398                            .map_err(|e| {
399                                format!(
400                                    "Invalid regular expression for left context: {}: {}",
401                                    regex, e
402                                )
403                            })?,
404                    );
405                    if self.debug {
406                        eprintln!(
407                            "[stam translatetext] compiled left context regex {:?}",
408                            rule.left_regex
409                        )
410                    }
411                }
412            }
413            if let Some(v) = rule.right.as_ref() {
414                if v.starts_with('/') && v.ends_with('/') && v.len() > 1 {
415                    let regex = format!("^{}.*", &v[1..v.len() - 1]);
416                    rule.right_regex = Some(
417                        RegexBuilder::new(&regex)
418                            .case_insensitive(!rule.case_sensitive)
419                            .build()
420                            .map_err(|e| {
421                                format!(
422                                    "Invalid regular expression for right context: {}: {}",
423                                    regex, e
424                                )
425                            })?,
426                    );
427                    if self.debug {
428                        eprintln!(
429                            "[stam translatetext] compiled right context regex {:?}",
430                            rule.right_regex
431                        )
432                    }
433                }
434            }
435            if rule.source.is_none() {
436                return Err("Translation rules must have both a source".into());
437            }
438        }
439        if self.debug {
440            eprintln!("[stam translatetext] {} rules read", self.rules.len())
441        }
442        Ok(())
443    }
444
445    pub fn compile_queries<'a>(&'a self) -> Result<HashMap<String, Query<'a>>, String> {
446        let mut compiled_queries = HashMap::new();
447        for rule in self.rules.iter() {
448            for constraint in rule.constraints.iter() {
449                if !compiled_queries.contains_key(constraint.query.as_str()) {
450                    compiled_queries.insert(
451                        constraint.query.clone(),
452                        stam::Query::parse(constraint.query.as_str())
453                            .map_err(|err| format!("{}", err))?
454                            .0,
455                    );
456                }
457            }
458        }
459        Ok(compiled_queries)
460    }
461}
462
463/// Translates a text given a configuration containing translation rules, returns a vector of TextResourceBuilders and a vector of AnnotationBuilders which build a single annotation per resource that maps source to target.
464pub fn translate_text<'store>(
465    store: &'store AnnotationStore,
466    queries: Vec<Query<'store>>,
467    usevar: Option<&'store str>,
468    config: &TranslateTextConfig,
469) -> Result<(Vec<TextResourceBuilder>, Vec<AnnotationBuilder<'static>>), String> {
470    let mut annotations = Vec::new();
471    let mut resourcebuilders = Vec::new();
472    let constraint_queries = config.compile_queries()?;
473
474    let mut seqnr = 0;
475    for query in queries.into_iter() {
476        let iter = store.query(query).map_err(|e| format!("{}", e))?;
477        for resultrow in iter {
478            if let Ok(result) = resultrow.get_by_name_or_last(usevar) {
479                match result {
480                    QueryResultItem::TextResource(resource) => {
481                        let resource_id = resource.id().expect("resource must have ID");
482                        let new_resource_id = format!(
483                            "{}.{}{}",
484                            if resource_id.ends_with(".txt") {
485                                &resource_id[..resource_id.len() - 4]
486                            } else if resource_id.ends_with(".md") {
487                                &resource_id[..resource_id.len() - 3]
488                            } else {
489                                resource_id
490                            },
491                            config
492                                .id_suffix
493                                .as_ref()
494                                .map(|s| s.as_str())
495                                .unwrap_or("translation"),
496                            if resource_id.ends_with(".txt") {
497                                ".txt"
498                            } else if resource_id.ends_with(".md") {
499                                ".md"
500                            } else {
501                                ""
502                            }
503                        );
504                        let new_filename = if let Some(filename) = resource.as_ref().filename() {
505                            Some(format!(
506                                "{}.{}.txt",
507                                if filename.ends_with(".txt") {
508                                    &filename[..filename.len() - 4]
509                                } else if filename.ends_with(".md") {
510                                    &filename[..filename.len() - 3]
511                                } else {
512                                    filename
513                                },
514                                config
515                                    .id_suffix
516                                    .as_ref()
517                                    .map(|s| s.as_str())
518                                    .unwrap_or("translation")
519                            ))
520                        } else {
521                            None
522                        };
523                        translate_text_helper(
524                            config,
525                            store,
526                            resource.text(),
527                            resource,
528                            0,
529                            new_resource_id,
530                            new_filename,
531                            &mut resourcebuilders,
532                            &mut annotations,
533                            &constraint_queries,
534                        )?;
535                    }
536                    QueryResultItem::TextSelection(textselection) => {
537                        seqnr += 1;
538                        let resource = textselection.resource();
539                        let new_resource_id = format!(
540                            "{}.{}.{}",
541                            resource.id().expect("resource must have ID"),
542                            config
543                                .id_suffix
544                                .as_ref()
545                                .map(|s| s.as_str())
546                                .unwrap_or("translation"),
547                            seqnr
548                        );
549                        let new_filename = if let Some(filename) = resource.as_ref().filename() {
550                            Some(format!(
551                                "{}.{}.{}.txt",
552                                if filename.ends_with(".txt") {
553                                    &filename[..filename.len() - 4]
554                                } else if filename.ends_with(".md") {
555                                    &filename[..filename.len() - 3]
556                                } else {
557                                    filename
558                                },
559                                config
560                                    .id_suffix
561                                    .as_ref()
562                                    .map(|s| s.as_str())
563                                    .unwrap_or("translation"),
564                                seqnr
565                            ))
566                        } else {
567                            None
568                        };
569                        translate_text_helper(
570                            config,
571                            store,
572                            textselection.text(),
573                            &resource,
574                            textselection.begin(),
575                            new_resource_id,
576                            new_filename,
577                            &mut resourcebuilders,
578                            &mut annotations,
579                            &constraint_queries,
580                        )?;
581                    }
582                    _ => {
583                        return Err(
584                            "translatetext is only implemented for resources and text selections at the moment"
585                                .into(),
586                        );
587                    }
588                }
589            }
590        }
591    }
592
593    Ok((resourcebuilders, annotations))
594}
595
596fn translate_text_helper<'store, 'a>(
597    config: &TranslateTextConfig,
598    store: &'store AnnotationStore,
599    text: &'store str,
600    resource: &ResultItem<'store, TextResource>,
601    baseoffset: usize,
602    new_resource_id: String,
603    new_filename: Option<String>,
604    resourcebuilders: &mut Vec<TextResourceBuilder>,
605    annotations: &mut Vec<AnnotationBuilder<'static>>,
606    constraint_queries: &HashMap<String, Query<'a>>,
607) -> Result<(), String> {
608    let mut new_text =
609        String::with_capacity(text.len() + (0.1 * text.len() as f64).round() as usize); //reserve 10% extra capacity
610
611    let mut sourceselectors: Vec<SelectorBuilder<'static>> = Vec::new();
612    let mut targetselectors: Vec<SelectorBuilder<'static>> = Vec::new();
613
614    let mut skipbytes = 0;
615    let mut targetcharpos = 0;
616    for (charpos, (bytepos, c)) in text.char_indices().enumerate() {
617        if skipbytes > 0 {
618            skipbytes -= c.len_utf8();
619            continue;
620        }
621        let mut foundrule = false;
622        for rule in config.rules.iter().rev() {
623            if let Some(m) = rule.test(text, bytepos) {
624                if !rule.constraints.is_empty() {
625                    let mut constraints_match = true; //falsify
626                    let sourcecharlen = m.source.chars().count();
627                    let source = resource
628                        .textselection(&Offset::simple(charpos, charpos + sourcecharlen))
629                        .map_err(|e| format!("Failed to extract source: {}", e))?;
630                    let left = resource
631                        .textselection(&Offset::new(
632                            Cursor::BeginAligned(0),
633                            Cursor::BeginAligned(charpos),
634                        ))
635                        .map_err(|e| format!("Failed to extract left context: {}", e))?;
636                    let right = resource
637                        .textselection(&Offset::new(
638                            Cursor::BeginAligned(charpos + sourcecharlen), //MAYBE TODO: check if this holds for final char as well?
639                            Cursor::EndAligned(0),
640                        ))
641                        .map_err(|e| format!("Failed to extract right context: {}", e))?;
642                    for constraint in rule.constraints.iter() {
643                        //match constraint
644                        let mut query = constraint_queries
645                            .get(constraint.query.as_str())
646                            .expect("constraint query should have been compiled earlier")
647                            .clone();
648                        query.bind_resourcevar("resource", resource);
649                        query.bind_textvar("source", &source);
650                        query.bind_textvar("left", &left);
651                        query.bind_textvar("right", &right);
652                        let mut iter = store
653                            .query(query)
654                            .map_err(|e| format!("Constraint query failed: {}", e))?;
655                        if let Some(result) = iter.next() {
656                            //only one iteration suffices (for now)
657                            if let Some(testvar) = constraint.test.as_ref() {
658                                if result.get_by_name(testvar.as_str()).is_ok() {
659                                    if constraint.invert {
660                                        constraints_match = false;
661                                        break;
662                                    }
663                                } else if !constraint.invert {
664                                    constraints_match = false;
665                                    break;
666                                }
667                            } else if constraint.invert {
668                                //results (no specific test variable)
669                                constraints_match = false;
670                                break;
671                            }
672                        } else if !constraint.invert {
673                            //no results
674                            constraints_match = false;
675                            break;
676                        }
677                    }
678                    if !constraints_match {
679                        if config.debug {
680                            eprintln!(
681                                "[stam translatetext] @{} failed to matched rule {:?} -> {:?} because of unmet constraints",
682                                charpos, m.source, m.target
683                            )
684                        }
685                        continue; //skip to next rule
686                    }
687                }
688
689                skipbytes += m.source.len() - c.len_utf8(); //skip the remainder (everything except the char we're already covering)
690
691                if config.debug {
692                    eprintln!(
693                        "[stam translatetext] @{} (byte {}) matched rule {:?} -> {:?}",
694                        charpos, bytepos, m.source, m.target
695                    )
696                }
697
698                new_text += &m.target;
699
700                if !config.no_annotations {
701                    let sourcelen = m.source.chars().count();
702                    let targetlen = m.target.chars().count();
703                    if targetlen == 0 && sourcelen > 1 {
704                        //multichar deletion, delete each char individually (allows for matching on any prefix of the source-side in the translate step)
705                        for i in 0..sourcelen {
706                            sourceselectors.push(SelectorBuilder::TextSelector(
707                                resource.handle().into(),
708                                Offset::simple(
709                                    baseoffset + charpos + i,
710                                    baseoffset + charpos + i + 1,
711                                ),
712                            ));
713                            targetselectors.push(SelectorBuilder::TextSelector(
714                                new_resource_id.clone().into(),
715                                Offset::simple(targetcharpos, targetcharpos),
716                            ));
717                        }
718
719                        //MAYBE TODO: similar to this, we could make things more robust
720                        //by identifying if the source and target share a prefix and/or suffix and determine a segmentation based on that
721                    } else {
722                        //normal behaviour
723                        sourceselectors.push(SelectorBuilder::TextSelector(
724                            resource.handle().into(),
725                            Offset::simple(baseoffset + charpos, baseoffset + charpos + sourcelen),
726                        ));
727                        targetselectors.push(SelectorBuilder::TextSelector(
728                            new_resource_id.clone().into(),
729                            Offset::simple(targetcharpos, targetcharpos + targetlen),
730                        ));
731                    }
732                    targetcharpos += targetlen;
733                }
734
735                foundrule = true;
736                continue; //stop at first matching rule (last in config file as we reversed order)
737            }
738        }
739
740        if !foundrule && !config.discard_unmatched {
741            if config.debug {
742                eprintln!(
743                    "[stam translatetext] @{} (byte {}) no rule matches {:?}, falling back",
744                    charpos, bytepos, c
745                )
746            }
747            //no rule matches, translate character verbatim
748            new_text.push(c);
749            if !config.no_annotations {
750                sourceselectors.push(SelectorBuilder::TextSelector(
751                    resource.handle().into(),
752                    Offset::simple(baseoffset + charpos, baseoffset + charpos + 1),
753                ));
754                targetselectors.push(SelectorBuilder::TextSelector(
755                    new_resource_id.clone().into(),
756                    Offset::simple(targetcharpos, targetcharpos + 1),
757                ));
758            }
759            targetcharpos += 1;
760        }
761    }
762
763    if !config.force_when_unchanged && new_text.as_str() == text {
764        eprintln!(
765            "[stam translatetext] text for {} has not changed after translation, skipping..",
766            new_resource_id
767        );
768        return Ok(());
769    }
770
771    if !config.no_annotations {
772        let source_id = format!("{}.translation-source", new_resource_id.as_str());
773        let target_id = format!("{}.translation-target", new_resource_id.as_str());
774        annotations.push(
775            AnnotationBuilder::new()
776                .with_id(source_id.as_str())
777                .with_target(SelectorBuilder::DirectionalSelector(sourceselectors)),
778        );
779        annotations.push(
780            AnnotationBuilder::new()
781                .with_id(target_id.as_str())
782                .with_target(SelectorBuilder::DirectionalSelector(targetselectors)),
783        );
784        annotations.push(
785            AnnotationBuilder::new()
786                .with_id(format!("{}.translation", new_resource_id.as_str()))
787                .with_data(
788                    "https://w3id.org/stam/extensions/stam-translate/",
789                    "Translation",
790                    DataValue::Null,
791                )
792                .with_target(SelectorBuilder::DirectionalSelector(vec![
793                    SelectorBuilder::AnnotationSelector(source_id.into(), None),
794                    SelectorBuilder::AnnotationSelector(target_id.into(), None),
795                ])),
796        );
797    }
798
799    let mut resourcebuilder = TextResourceBuilder::new()
800        .with_text(new_text)
801        .with_id(new_resource_id);
802    if let Some(new_filename) = new_filename {
803        resourcebuilder = resourcebuilder.with_filename(new_filename);
804    }
805    resourcebuilders.push(resourcebuilder);
806    Ok(())
807}